Compare commits

...
Author SHA1 Message Date
Aaryan Khandelwal 540cafb13a fix: editor tsconfig 2025-07-18 15:47:47 +05:30
Anmol Singh BhatiaandGitHub 07c80bb02c [WEB-4525] fix: project create/update error handling (#7429)
* chore: project create/update error handling

* chore: code refactor
2025-07-18 14:25:28 +05:30
Prateek ShouryaandGitHub 1ad792b4bb [WEB-4528] fix: calendar options interactions (#7437) 2025-07-18 14:25:08 +05:30
sriram veeraghantaandGitHub 0eb4af9d19 fix: on-headers vulnerability (#7436) 2025-07-18 13:27:53 +05:30
Aaryan KhandelwalandGitHub 7136b3129b [WIKI-543] fix: unable to delete last cell of a table (#7394)
* fix: delete last cell of a table

* refactor: last cell selection logic
2025-07-17 21:19:54 +05:30
Aaryan KhandelwalandGitHub 48f1999c95 [WIKI-557] fix: keyboard navigation in tables (#7428) 2025-07-17 15:15:18 +05:30
Akshat JainandGitHub 3783e34ae8 [INFRA-213] Fix aio dockerfile with new file structure (#7427)
* fix: update paths in Dockerfile and supervisor.conf for application directories

* fix: update live command in supervisor.conf to use server.js
2025-07-17 14:59:13 +05:30
SangeethaandGitHub e313aee3df [WEB-4346] chore: refactor copy_s3_object bg task (#7298) 2025-07-17 13:10:34 +05:30
Vipin ChaudharyandGitHub 6bb79df0eb [WIKI-547] fix: update find suggestion logic for emoji extension (#7411)
* fix: update find suggestion logic

* refactor: remove logs

* refactor : make logic simpler

* feat: check for one char to show suggestion

* refactor : import types from extension

* refactor: add early return

* refactor : put custom suggestion in helper

* fix : char

* fix: types
2025-07-17 13:07:12 +05:30
SangeethaandGitHub ec0ef98c1b [WEB-4281] chore: error code on project updation endpoint (#7218) 2025-07-17 13:05:24 +05:30
Vamsi KrishnaandGitHub 9523c28c3e [WEB-4514]chore: moved EIssueLayoutTypes enum to types #7416 2025-07-17 13:01:24 +05:30
Anmol Singh BhatiaandGitHub 156ed329ac [WEB-4510] fix: peek view ui layout (#7422) 2025-07-17 12:53:37 +05:30
Bavisetti NarayanandGitHub ef0e3dca12 [WEB-4520] chore: changed the serializer for cycle issues #7423 2025-07-17 12:53:04 +05:30
Aaryan KhandelwalandGitHub d8f2c97810 [WIKI-554] feat: delete multiple rows/columns from a table (#7426) 2025-07-17 12:52:33 +05:30
Anmol Singh BhatiaandGitHub 89983b06d2 [WEB-4515] fix: duplicate work item quick action #7421 2025-07-16 15:19:55 +05:30
Bavisetti NarayanandGitHub 3224122df0 [WEB-1643] chore: support cyrillic characters in subject #7336 2025-07-16 01:04:59 +05:30
99127ff8e4 [WEB-4479] feat: enable/disable SMTP configuration (#7393)
* feat: api update instance configuration

* chore: add enable_smtp key

* fix: empty string for enable_smtp key

* chore: update email_port and email_from

* fix: handled smtp enable disable

* fix: error handling

* fix: refactor

* fix: removed enabled toast

* fix: refactor

---------

Co-authored-by: gakshita <akshitagoyal1516@gmail.com>
Co-authored-by: Akshita Goyal <36129505+gakshita@users.noreply.github.com>
2025-07-16 01:04:18 +05:30
Anmol Singh BhatiaandGitHub da5390fa03 [WEB-4497] chore: reduce breadcrumb height (#7413)
* chore: reduce breadcrumb height from 3.75rem to 3.25rem

* chore: code refactor

* chore: code refactor
2025-07-16 01:03:31 +05:30
Anmol Singh BhatiaandGitHub ac22df3f88 [WEB-4451] chore: work item header quick action enhancements (#7414)
* chore: work item header quick action enhancements

* chore: code refactor
2025-07-16 00:52:30 +05:30
Aaryan KhandelwalandGitHub df762afaef [WIKI-551] refactor: work item activity logic #7417 2025-07-15 20:49:20 +05:30
Aaryan KhandelwalandGitHub 2058f06b8a [WIKI-549] fix: comment scroll logic #7412 2025-07-15 15:12:27 +05:30
Anmol Singh BhatiaandGitHub bfd4bd5e75 [WEB-4506] fix: date range modal z-index #7410 2025-07-15 13:51:16 +05:30
NikhilandGitHub ac928f263a [WIKI-520] fix: update content disposition handling in asset download endpoints (#7380) 2025-07-15 13:24:17 +05:30
Bavisetti NarayanandGitHub 62065a6ebb [WEB-4504] chore: added migration for app rail and reactions #7408 2025-07-14 20:57:29 +05:30
67b62dcbe3 [WEB-4498] improvement: remove workspace details from workspace members list API (#7407)
* [WEB-4498] improvement: remove workspace details from workspace members list API

* refactor: update select_related usage in workspace invitation and member views

---------

Co-authored-by: Dheeraj Kumar Ketireddy <dheeru0198@gmail.com>
2025-07-14 20:45:23 +05:30
Aaryan KhandelwalandGitHub a427367720 [WIKI-277] fix: unable to delete last table column/row in editor (#7389)
* fix: delete column not working

* refactor: delete column commad

* fix: delete last row logic

* refactor: row count logic

* refactor: isCellSelection utility function
2025-07-14 17:27:09 +05:30
Aaryan KhandelwalandGitHub c067eaa1ed [WIKI-542] refactor: editor extensions (#7345)
* refactor: editor extensions

* fix: placeholder extension
2025-07-14 17:10:32 +05:30
Aaryan KhandelwalandGitHub 2c70c1aaa8 [WIKI-509] feat: comment copy link option (#7385)
* feat: comment copy link option

* chore: add translations

* chore: update block position

* chore: rename use id scroll hook

* refactor: setTimeout function

* refactor: use-hash-scroll hook
2025-07-14 17:07:44 +05:30
Akshat JainandGitHub f90e553881 [INFRA-209] Remove nginx related configurations from plane community (#7406)
* Remove deprecated Nginx configuration files and scripts, including Dockerfiles, environment scripts, and configuration templates, to streamline the project structure.

* Update environment configuration and Docker setup for proxy services

- Added LISTEN_PORT and LISTEN_SSL_PORT variables to .env.example and related files.
- Updated Docker Compose files to reference new port variables instead of deprecated NGINX_PORT.
- Adjusted README and variable documentation to reflect changes in port configuration.
- Changed build context for proxy services to use the new directory structure.

* Refactor port configuration in environment and Docker files

- Renamed LISTEN_PORT and LISTEN_SSL_PORT to LISTEN_HTTP_PORT and LISTEN_HTTPS_PORT in .env.example and related files.
- Updated Docker Compose configurations to reflect the new port variable names.
- Adjusted documentation in README and variables.env to ensure consistency with the new naming conventions.
2025-07-14 16:38:27 +05:30
Aaryan KhandelwalandGitHub 0af0e52275 [WIKI-545] fix: shrinking headings in page table of contents #7404 2025-07-14 16:30:19 +05:30
Anmol Singh BhatiaandGitHub 7829e3adf5 [WEB-4494] fix: kanban delete modal ux #7405 2025-07-14 16:29:26 +05:30
Anmol Singh BhatiaandGitHub 47354f0e91 [WEB-4468] fix: setting sidebar truncate #7403 2025-07-14 16:26:04 +05:30
M. PalanikannanandGitHub db2f783d33 [WEB-4407] chore: add project page creation event tracking #7378 2025-07-14 15:30:45 +05:30
6d01622663 [INFRA-208] Reorganize deployment structure and update build workflows (#7391)
* refactor: reorganize deployment structure and update build workflows

- Restructure deployment directories from deploy/ to deployments/
- Move selfhost files to deployments/cli/community/
- Add new AIO community deployment setup
- Update GitHub Actions workflows for new directory structure
- Add Caddy proxy configuration for CE deployment
- Remove deprecated AIO build files and workflows
- Update build context paths in install scripts

* chore: update Dockerfile and supervisor configuration

- Changed `apk add` command in Dockerfile to use `--no-cache` for better image size management.
- Updated `build.sh` to ensure proper directory navigation with quotes around `dirname "$0"`.
- Modified `supervisor.conf` to set `stderr_logfile_maxbytes` to 50MB and added `stderr_logfile_backups` for better log management across multiple services.

* chore: consistent node and python version

---------

Co-authored-by: sriramveeraghanta <veeraghanta.sriram@gmail.com>
2025-07-14 14:38:43 +05:30
Anmol Singh BhatiaandGitHub dff2f8ae12 [WEB-4482] fix: kanban drag and delete #7402 2025-07-14 14:18:38 +05:30
sriram veeraghantaandGitHub 4c57ed4336 fix: local dev environment setup using yarn v4 (#7400)
* chore: adding typescript dependency to constants.

* chore: package deps update
2025-07-14 13:08:41 +05:30
NikhilandGitHub c8dab1cc9c [WEB-4484]chore: streamline issue saving process with advisory locks for sequence management (#7396)
* chore: streamline issue saving process with advisory locks for sequence management

* fix: update advisory lock usage in issue model for improved concurrency management
2025-07-14 13:08:26 +05:30
Aaron HeckmannandGitHub 17c90a9d93 [WEB-4485] - fix(dev): get local dev migrator working again #7397 2025-07-12 19:45:45 +05:30
Masoom WahidandGitHub a3714c8e3e feat : added Kabul to the list of timezones (#7370) 2025-07-12 19:30:39 +05:30
NikhilandGitHub 71cd36865b [WEB-4484]chore: streamline issue saving process with advisory locks for sequence management #7395 2025-07-11 21:18:31 +05:30
Dheeraj Kumar KetireddyandGitHub 4501e44702 [WEB-4481] optimize workspace member view by prefetching related assets (#7390) 2025-07-11 21:17:33 +05:30
Vamsi KrishnaandGitHub f661e06582 [WEB-4480]fix: full screen modals (#7392) 2025-07-11 16:41:27 +05:30
Vamsi KrishnaandGitHub 86f3ff1bd2 [WEB-4471]fix: full screen view visibility (#7387)
* fix: full screen mode for analytics, work items peek and timeline chart

* chore: added null check for portal elements
2025-07-11 11:50:26 +05:30
Aaryan KhandelwalandGitHub fce4729f22 [WIKI-497] refactor: table insert handles size (#7376)
* refactor: table insert handles size

* fix: insert handles z-idnex
2025-07-11 04:17:04 +05:30
Anmol Singh BhatiaandGitHub bb48250467 [WEB-4477] fix: sidebar resize behaviour #7386 2025-07-11 04:16:36 +05:30
Prateek ShouryaandGitHub 5fbe411e04 [WEB-4475] fix: add default theme for subscription colors (#7382) 2025-07-10 18:32:17 +05:30
2e75ff7f1c [WEB-4405] chore: project settings events (#7362)
* chore: workspace events

* fix: refactor

---------

Co-authored-by: sriramveeraghanta <veeraghanta.sriram@gmail.com>
2025-07-10 17:05:30 +05:30
eb4239417a [WEB-4405] chore: profile settings events (#7313)
* feat: event tracker helper

* feat: track click events for `data-ph-element`

* fix: handled click events

* fix: handled name

* chore: tracker element updates

* chore: remove export

* chore: tracker element type

* chore: track element and event helper.

* chore: minor improvements

* chore: minor refactors

* fix: workspace events

* fix: added slug

* fix: changes nomenclature

* fix: nomenclature

* chore: update event tracker helper types

* fix: data id

* refactor: cycle events (#7290)

* chore: update event tracker helper types

* refactor: cycle events

* refactor: cycle events

* refactor: cycle event tracker

* chore: update tracker elements

* chore: check for closest element with data-ph-element attribute

---------

Co-authored-by: Prateek Shourya <prateekshourya@Prateeks-MacBook-Pro.local>

* Refactor module events (#7291)

* chore: update event tracker helper types

* refactor: cycle events

* refactor: cycle events

* refactor: cycle event tracker

* refactor: module tracker event and element

* chore: update tracker element

* chore: revert unnecessary changes

---------

Co-authored-by: Prateek Shourya <prateekshourya@Prateeks-MacBook-Pro.local>

* refactor: global views, product tour, notifications, onboarding, users and sidebar related events

* chore: member tracker events (#7302)

* chore: member-tracker-events

* fix: constants

* refactor: update event tracker constants

* refactor: auth related event trackers (#7306)

* Chore: state events (#7307)

* chore: state events

* fix: refactor

* chore: project events (#7305)

* chore: project-events

* fix: refactor

* fix: removed hardcoded values

* fix: github redirection event

* chore: project page tracker events (#7304)

* added events for most page events

* refactor: simplify lock button event handling in PageLockControl

---------

Co-authored-by: Palanikannan M <akashmalinimurugu@gmail.com>
Co-authored-by: M. Palanikannan <73993394+Palanikannan1437@users.noreply.github.com>

* chore: minor cleanup and import fixes

* refactor: added tracker elements for buttons (#7308)

Co-authored-by: Prateek Shourya <prateekshourya29@gmail.com>

* fix: event type

* refactor: posthog group event

* chore: removed instances of event tracker (#7309)

* refactor: remove event tracker stores and hooks

* refactor: remove event tracker store

* fix: build errors

* clean up event tracker payloads

* chore: profile settings events

* fix: refactor

---------

Co-authored-by: Prateek Shourya <prateekshourya@Prateeks-MacBook-Pro.local>
Co-authored-by: Prateek Shourya <prateekshourya29@gmail.com>
Co-authored-by: Palanikannan M <akashmalinimurugu@gmail.com>
Co-authored-by: M. Palanikannan <73993394+Palanikannan1437@users.noreply.github.com>
Co-authored-by: Vamsi Krishna <46787868+vamsikrishnamathala@users.noreply.github.com>
Co-authored-by: sriramveeraghanta <veeraghanta.sriram@gmail.com>
2025-07-10 17:02:06 +05:30
c6fbbfd8cc [WEB-4405] chore: workspace settings events (#7312)
* feat: event tracker helper

* feat: track click events for `data-ph-element`

* fix: handled click events

* fix: handled name

* chore: tracker element updates

* chore: remove export

* chore: tracker element type

* chore: track element and event helper.

* chore: minor improvements

* chore: minor refactors

* fix: workspace events

* fix: added slug

* fix: changes nomenclature

* fix: nomenclature

* chore: update event tracker helper types

* fix: data id

* refactor: cycle events (#7290)

* chore: update event tracker helper types

* refactor: cycle events

* refactor: cycle events

* refactor: cycle event tracker

* chore: update tracker elements

* chore: check for closest element with data-ph-element attribute

---------

Co-authored-by: Prateek Shourya <prateekshourya@Prateeks-MacBook-Pro.local>

* Refactor module events (#7291)

* chore: update event tracker helper types

* refactor: cycle events

* refactor: cycle events

* refactor: cycle event tracker

* refactor: module tracker event and element

* chore: update tracker element

* chore: revert unnecessary changes

---------

Co-authored-by: Prateek Shourya <prateekshourya@Prateeks-MacBook-Pro.local>

* refactor: global views, product tour, notifications, onboarding, users and sidebar related events

* chore: member tracker events (#7302)

* chore: member-tracker-events

* fix: constants

* refactor: update event tracker constants

* refactor: auth related event trackers (#7306)

* Chore: state events (#7307)

* chore: state events

* fix: refactor

* chore: project events (#7305)

* chore: project-events

* fix: refactor

* fix: removed hardcoded values

* fix: github redirection event

* chore: project page tracker events (#7304)

* added events for most page events

* refactor: simplify lock button event handling in PageLockControl

---------

Co-authored-by: Palanikannan M <akashmalinimurugu@gmail.com>
Co-authored-by: M. Palanikannan <73993394+Palanikannan1437@users.noreply.github.com>

* chore: minor cleanup and import fixes

* refactor: added tracker elements for buttons (#7308)

Co-authored-by: Prateek Shourya <prateekshourya29@gmail.com>

* fix: event type

* refactor: posthog group event

* chore: removed instances of event tracker (#7309)

* refactor: remove event tracker stores and hooks

* refactor: remove event tracker store

* fix: build errors

* clean up event tracker payloads

* chore: workspace-settings-events

* fix: refactor

---------

Co-authored-by: Prateek Shourya <prateekshourya@Prateeks-MacBook-Pro.local>
Co-authored-by: Prateek Shourya <prateekshourya29@gmail.com>
Co-authored-by: Palanikannan M <akashmalinimurugu@gmail.com>
Co-authored-by: M. Palanikannan <73993394+Palanikannan1437@users.noreply.github.com>
Co-authored-by: Vamsi Krishna <46787868+vamsikrishnamathala@users.noreply.github.com>
2025-07-10 16:54:06 +05:30
Anmol Singh BhatiaandGitHub 4d0a7e4658 [WEB-4470] fix: sidebar custom theme bg (#7379) 2025-07-10 15:35:13 +05:30
sriram veeraghantaandGitHub 1cb49cacae fix: packages dev command failing due to missing dependency mapping (#7381)
* fix: packages dev command failing due to missing dependency mapping

* fix: typescript version to match with other packages
2025-07-10 15:26:43 +05:30
Vamsi KrishnaandGitHub 3618ffc2c3 [WEB-4469] fix: full screen mode visibility for Gnatt and Analytics 2025-07-10 14:23:08 +05:30
Aaryan KhandelwalandGitHub fcb6e269a0 [WIKI-497] feat: table insert column and row handles (#7286)
* refactor: tables width and selection UI

* fix: drag handle position

* refactor: selection decorator logic

* refactor: adjacent cells logic

* refactor: folder structure

* chore: default column width for new columns

* refactor: plugin location

* feat: table insert handlers

* refactor: css rules

* refactor: plugins folder structure

* chore: add aria labels
2025-07-09 17:59:04 +05:30
853423608c [INFRA-206] fix: update build context and dockerfile paths in build.yml in deploy/selfhost (#7368)
* chore: update build context and dockerfile paths in build.yml for self-hosted deployment

* fix: build context

---------

Co-authored-by: sriramveeraghanta <veeraghanta.sriram@gmail.com>
2025-07-09 16:00:16 +05:30
258 changed files with 5968 additions and 4085 deletions
+13 -3
View File
@@ -15,12 +15,15 @@ RABBITMQ_USER="plane"
RABBITMQ_PASSWORD="plane"
RABBITMQ_VHOST="plane"
LISTEN_HTTP_PORT=80
LISTEN_HTTPS_PORT=443
# AWS Settings
AWS_REGION=""
AWS_ACCESS_KEY_ID="access-key"
AWS_SECRET_ACCESS_KEY="secret-key"
AWS_S3_ENDPOINT_URL="http://plane-minio:9000"
# Changing this requires change in the nginx.conf for uploads if using minio setup
# Changing this requires change in the proxy config for uploads if using minio setup
AWS_S3_BUCKET_NAME="uploads"
# Maximum file upload limit
FILE_SIZE_LIMIT=5242880
@@ -36,8 +39,15 @@ DOCKERIZED=1 # deprecated
# set to 1 If using the pre-configured minio setup
USE_MINIO=1
# Nginx Configuration
NGINX_PORT=80
# If SSL Cert to be generated, set CERT_EMAIl="email <EMAIL_ADDRESS>"
CERT_ACME_CA=https://acme-v02.api.letsencrypt.org/directory
TRUSTED_PROXIES=0.0.0.0/0
SITE_ADDRESS=:80
CERT_EMAIL=
# For DNS Challenge based certificate generation, set the CERT_ACME_DNS, CERT_EMAIL
# CERT_ACME_DNS="acme_dns <CERT_DNS_PROVIDER> <CERT_DNS_PROVIDER_API_KEY>"
CERT_ACME_DNS=
# Force HTTPS for handling SSL Termination
MINIO_ENDPOINT_SSL=0
-139
View File
@@ -1,139 +0,0 @@
name: Build AIO Base Image
on:
workflow_dispatch:
inputs:
base_tag_name:
description: 'Base Tag Name'
required: false
default: ''
env:
TARGET_BRANCH: ${{ github.ref_name }}
jobs:
base_build_setup:
name: Build Preparation
runs-on: ubuntu-latest
outputs:
gh_branch_name: ${{ steps.set_env_variables.outputs.TARGET_BRANCH }}
gh_buildx_driver: ${{ steps.set_env_variables.outputs.BUILDX_DRIVER }}
gh_buildx_version: ${{ steps.set_env_variables.outputs.BUILDX_VERSION }}
gh_buildx_platforms: ${{ steps.set_env_variables.outputs.BUILDX_PLATFORMS }}
gh_buildx_endpoint: ${{ steps.set_env_variables.outputs.BUILDX_ENDPOINT }}
image_tag: ${{ steps.set_env_variables.outputs.IMAGE_TAG }}
steps:
- id: set_env_variables
name: Set Environment Variables
run: |
echo "TARGET_BRANCH=${{ env.TARGET_BRANCH }}" >> $GITHUB_OUTPUT
if [ "${{ github.event.inputs.base_tag_name }}" != "" ]; then
echo "IMAGE_TAG=${{ github.event.inputs.base_tag_name }}" >> $GITHUB_OUTPUT
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
echo "IMAGE_TAG=latest" >> $GITHUB_OUTPUT
elif [ "${{ env.TARGET_BRANCH }}" == "preview" ]; then
echo "IMAGE_TAG=preview" >> $GITHUB_OUTPUT
else
echo "IMAGE_TAG=develop" >> $GITHUB_OUTPUT
fi
if [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
echo "BUILDX_DRIVER=cloud" >> $GITHUB_OUTPUT
echo "BUILDX_VERSION=lab:latest" >> $GITHUB_OUTPUT
echo "BUILDX_PLATFORMS=linux/amd64,linux/arm64" >> $GITHUB_OUTPUT
echo "BUILDX_ENDPOINT=makeplane/plane-dev" >> $GITHUB_OUTPUT
else
echo "BUILDX_DRIVER=docker-container" >> $GITHUB_OUTPUT
echo "BUILDX_VERSION=latest" >> $GITHUB_OUTPUT
echo "BUILDX_PLATFORMS=linux/amd64" >> $GITHUB_OUTPUT
echo "BUILDX_ENDPOINT=" >> $GITHUB_OUTPUT
fi
- id: checkout_files
name: Checkout Files
uses: actions/checkout@v4
full_base_build_push:
runs-on: ubuntu-latest
needs: [base_build_setup]
env:
BASE_IMG_TAG: makeplane/plane-aio-base:full-${{ needs.base_build_setup.outputs.image_tag }}
BUILDX_DRIVER: ${{ needs.base_build_setup.outputs.gh_buildx_driver }}
BUILDX_VERSION: ${{ needs.base_build_setup.outputs.gh_buildx_version }}
BUILDX_PLATFORMS: ${{ needs.base_build_setup.outputs.gh_buildx_platforms }}
BUILDX_ENDPOINT: ${{ needs.base_build_setup.outputs.gh_buildx_endpoint }}
steps:
- name: Check out the repo
uses: actions/checkout@v4
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver: ${{ env.BUILDX_DRIVER }}
version: ${{ env.BUILDX_VERSION }}
endpoint: ${{ env.BUILDX_ENDPOINT }}
- name: Build and Push to Docker Hub
uses: docker/build-push-action@v6.9.0
with:
context: ./aio
file: ./aio/Dockerfile-base-full
platforms: ${{ env.BUILDX_PLATFORMS }}
tags: ${{ env.BASE_IMG_TAG }}
push: true
cache-from: type=gha
cache-to: type=gha,mode=max
env:
DOCKER_BUILDKIT: 1
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
slim_base_build_push:
runs-on: ubuntu-latest
needs: [base_build_setup]
env:
BASE_IMG_TAG: makeplane/plane-aio-base:slim-${{ needs.base_build_setup.outputs.image_tag }}
BUILDX_DRIVER: ${{ needs.base_build_setup.outputs.gh_buildx_driver }}
BUILDX_VERSION: ${{ needs.base_build_setup.outputs.gh_buildx_version }}
BUILDX_PLATFORMS: ${{ needs.base_build_setup.outputs.gh_buildx_platforms }}
BUILDX_ENDPOINT: ${{ needs.base_build_setup.outputs.gh_buildx_endpoint }}
steps:
- name: Check out the repo
uses: actions/checkout@v4
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver: ${{ env.BUILDX_DRIVER }}
version: ${{ env.BUILDX_VERSION }}
endpoint: ${{ env.BUILDX_ENDPOINT }}
- name: Build and Push to Docker Hub
uses: docker/build-push-action@v6.9.0
with:
context: ./aio
file: ./aio/Dockerfile-base-slim
platforms: ${{ env.BUILDX_PLATFORMS }}
tags: ${{ env.BASE_IMG_TAG }}
push: true
cache-from: type=gha
cache-to: type=gha,mode=max
env:
DOCKER_BUILDKIT: 1
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
-207
View File
@@ -1,207 +0,0 @@
name: Branch Build AIO
on:
workflow_dispatch:
inputs:
full:
description: 'Run full build'
type: boolean
required: false
default: false
slim:
description: 'Run slim build'
type: boolean
required: false
default: false
base_tag_name:
description: 'Base Tag Name'
required: false
default: ''
release:
types: [released, prereleased]
env:
TARGET_BRANCH: ${{ github.ref_name || github.event.release.target_commitish }}
FULL_BUILD_INPUT: ${{ github.event.inputs.full }}
SLIM_BUILD_INPUT: ${{ github.event.inputs.slim }}
jobs:
branch_build_setup:
name: Build Setup
runs-on: ubuntu-latest
outputs:
gh_branch_name: ${{ steps.set_env_variables.outputs.TARGET_BRANCH }}
flat_branch_name: ${{ steps.set_env_variables.outputs.FLAT_BRANCH_NAME }}
gh_buildx_driver: ${{ steps.set_env_variables.outputs.BUILDX_DRIVER }}
gh_buildx_version: ${{ steps.set_env_variables.outputs.BUILDX_VERSION }}
gh_buildx_platforms: ${{ steps.set_env_variables.outputs.BUILDX_PLATFORMS }}
gh_buildx_endpoint: ${{ steps.set_env_variables.outputs.BUILDX_ENDPOINT }}
aio_base_tag: ${{ steps.set_env_variables.outputs.AIO_BASE_TAG }}
do_full_build: ${{ steps.set_env_variables.outputs.DO_FULL_BUILD }}
do_slim_build: ${{ steps.set_env_variables.outputs.DO_SLIM_BUILD }}
steps:
- id: set_env_variables
name: Set Environment Variables
run: |
if [ "${{ env.TARGET_BRANCH }}" == "master" ] || [ "${{ github.event_name }}" == "release" ]; then
echo "BUILDX_DRIVER=cloud" >> $GITHUB_OUTPUT
echo "BUILDX_VERSION=lab:latest" >> $GITHUB_OUTPUT
echo "BUILDX_PLATFORMS=linux/amd64,linux/arm64" >> $GITHUB_OUTPUT
echo "BUILDX_ENDPOINT=makeplane/plane-dev" >> $GITHUB_OUTPUT
echo "AIO_BASE_TAG=latest" >> $GITHUB_OUTPUT
else
echo "BUILDX_DRIVER=docker-container" >> $GITHUB_OUTPUT
echo "BUILDX_VERSION=latest" >> $GITHUB_OUTPUT
echo "BUILDX_PLATFORMS=linux/amd64" >> $GITHUB_OUTPUT
echo "BUILDX_ENDPOINT=" >> $GITHUB_OUTPUT
if [ "${{ github.event_name}}" == "workflow_dispatch" ] && [ "${{ github.event.inputs.base_tag_name }}" != "" ]; then
echo "AIO_BASE_TAG=${{ github.event.inputs.base_tag_name }}" >> $GITHUB_OUTPUT
elif [ "${{ env.TARGET_BRANCH }}" == "preview" ]; then
echo "AIO_BASE_TAG=preview" >> $GITHUB_OUTPUT
else
echo "AIO_BASE_TAG=develop" >> $GITHUB_OUTPUT
fi
fi
echo "TARGET_BRANCH=${{ env.TARGET_BRANCH }}" >> $GITHUB_OUTPUT
if [ "${{ env.FULL_BUILD_INPUT }}" == "true" ] || [ "${{github.event_name}}" == "push" ] || [ "${{github.event_name}}" == "release" ]; then
echo "DO_FULL_BUILD=true" >> $GITHUB_OUTPUT
else
echo "DO_FULL_BUILD=false" >> $GITHUB_OUTPUT
fi
if [ "${{ env.SLIM_BUILD_INPUT }}" == "true" ] || [ "${{github.event_name}}" == "push" ] || [ "${{github.event_name}}" == "release" ]; then
echo "DO_SLIM_BUILD=true" >> $GITHUB_OUTPUT
else
echo "DO_SLIM_BUILD=false" >> $GITHUB_OUTPUT
fi
FLAT_BRANCH_NAME=$(echo "${{ env.TARGET_BRANCH }}" | sed 's/[^a-zA-Z0-9]/-/g')
echo "FLAT_BRANCH_NAME=$FLAT_BRANCH_NAME" >> $GITHUB_OUTPUT
- id: checkout_files
name: Checkout Files
uses: actions/checkout@v4
full_build_push:
if: ${{ needs.branch_build_setup.outputs.do_full_build == 'true' }}
runs-on: ubuntu-22.04
needs: [branch_build_setup]
env:
BUILD_TYPE: full
AIO_BASE_TAG: ${{ needs.branch_build_setup.outputs.aio_base_tag }}
AIO_IMAGE_TAGS: makeplane/plane-aio:full-${{ needs.branch_build_setup.outputs.flat_branch_name }}
TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }}
BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
BUILDX_PLATFORMS: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }}
BUILDX_ENDPOINT: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
steps:
- name: Set Docker Tag
run: |
if [ "${{ github.event_name }}" == "release" ]; then
TAG=makeplane/plane-aio:${{env.BUILD_TYPE}}-stable,makeplane/plane-aio:${{env.BUILD_TYPE}}-${{ github.event.release.tag_name }}
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
TAG=makeplane/plane-aio:${{env.BUILD_TYPE}}-latest
else
TAG=${{ env.AIO_IMAGE_TAGS }}
fi
echo "AIO_IMAGE_TAGS=${TAG}" >> $GITHUB_ENV
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver: ${{ env.BUILDX_DRIVER }}
version: ${{ env.BUILDX_VERSION }}
endpoint: ${{ env.BUILDX_ENDPOINT }}
- name: Check out the repo
uses: actions/checkout@v4
- name: Build and Push to Docker Hub
uses: docker/build-push-action@v6.9.0
with:
context: .
file: ./aio/Dockerfile-app
platforms: ${{ env.BUILDX_PLATFORMS }}
tags: ${{ env.AIO_IMAGE_TAGS }}
push: true
build-args: |
BASE_TAG=${{ env.AIO_BASE_TAG }}
BUILD_TYPE=${{env.BUILD_TYPE}}
cache-from: type=gha
cache-to: type=gha,mode=max
env:
DOCKER_BUILDKIT: 1
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
slim_build_push:
if: ${{ needs.branch_build_setup.outputs.do_slim_build == 'true' }}
runs-on: ubuntu-22.04
needs: [branch_build_setup]
env:
BUILD_TYPE: slim
AIO_BASE_TAG: ${{ needs.branch_build_setup.outputs.aio_base_tag }}
AIO_IMAGE_TAGS: makeplane/plane-aio:slim-${{ needs.branch_build_setup.outputs.flat_branch_name }}
TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }}
BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
BUILDX_PLATFORMS: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }}
BUILDX_ENDPOINT: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
steps:
- name: Set Docker Tag
run: |
if [ "${{ github.event_name }}" == "release" ]; then
TAG=makeplane/plane-aio:${{env.BUILD_TYPE}}-stable,makeplane/plane-aio:${{env.BUILD_TYPE}}-${{ github.event.release.tag_name }}
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
TAG=makeplane/plane-aio:${{env.BUILD_TYPE}}-latest
else
TAG=${{ env.AIO_IMAGE_TAGS }}
fi
echo "AIO_IMAGE_TAGS=${TAG}" >> $GITHUB_ENV
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver: ${{ env.BUILDX_DRIVER }}
version: ${{ env.BUILDX_VERSION }}
endpoint: ${{ env.BUILDX_ENDPOINT }}
- name: Check out the repo
uses: actions/checkout@v4
- name: Build and Push to Docker Hub
uses: docker/build-push-action@v6.9.0
with:
context: .
file: ./aio/Dockerfile-app
platforms: ${{ env.BUILDX_PLATFORMS }}
tags: ${{ env.AIO_IMAGE_TAGS }}
push: true
build-args: |
BASE_TAG=${{ env.AIO_BASE_TAG }}
BUILD_TYPE=${{env.BUILD_TYPE}}
cache-from: type=gha
cache-to: type=gha,mode=max
env:
DOCKER_BUILDKIT: 1
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
+116 -11
View File
@@ -25,6 +25,11 @@ on:
required: false
default: false
type: boolean
aio_build:
description: "Build for AIO docker image"
required: false
default: false
type: boolean
push:
branches:
- preview
@@ -36,6 +41,7 @@ env:
BUILD_TYPE: ${{ github.event.inputs.build_type }}
RELEASE_VERSION: ${{ github.event.inputs.releaseVersion }}
IS_PRERELEASE: ${{ github.event.inputs.isPrerelease }}
AIO_BUILD: ${{ github.event.inputs.aio_build }}
jobs:
branch_build_setup:
@@ -54,11 +60,13 @@ jobs:
dh_img_live: ${{ steps.set_env_variables.outputs.DH_IMG_LIVE }}
dh_img_backend: ${{ steps.set_env_variables.outputs.DH_IMG_BACKEND }}
dh_img_proxy: ${{ steps.set_env_variables.outputs.DH_IMG_PROXY }}
dh_img_aio: ${{ steps.set_env_variables.outputs.DH_IMG_AIO }}
build_type: ${{steps.set_env_variables.outputs.BUILD_TYPE}}
build_release: ${{ steps.set_env_variables.outputs.BUILD_RELEASE }}
build_prerelease: ${{ steps.set_env_variables.outputs.BUILD_PRERELEASE }}
release_version: ${{ steps.set_env_variables.outputs.RELEASE_VERSION }}
aio_build: ${{ steps.set_env_variables.outputs.AIO_BUILD }}
steps:
- id: set_env_variables
@@ -84,12 +92,15 @@ jobs:
echo "DH_IMG_LIVE=plane-live" >> $GITHUB_OUTPUT
echo "DH_IMG_BACKEND=plane-backend" >> $GITHUB_OUTPUT
echo "DH_IMG_PROXY=plane-proxy" >> $GITHUB_OUTPUT
echo "DH_IMG_AIO=plane-aio-community" >> $GITHUB_OUTPUT
echo "BUILD_TYPE=${{env.BUILD_TYPE}}" >> $GITHUB_OUTPUT
BUILD_RELEASE=false
BUILD_PRERELEASE=false
RELVERSION="latest"
BUILD_AIO=${{ env.AIO_BUILD }}
if [ "${{ env.BUILD_TYPE }}" == "Release" ]; then
FLAT_RELEASE_VERSION=$(echo "${{ env.RELEASE_VERSION }}" | sed 's/[^a-zA-Z0-9.-]//g')
echo "FLAT_RELEASE_VERSION=${FLAT_RELEASE_VERSION}" >> $GITHUB_OUTPUT
@@ -108,10 +119,14 @@ jobs:
if [ "${{ env.IS_PRERELEASE }}" == "true" ]; then
BUILD_PRERELEASE=true
fi
BUILD_AIO=true
fi
echo "BUILD_RELEASE=${BUILD_RELEASE}" >> $GITHUB_OUTPUT
echo "BUILD_PRERELEASE=${BUILD_PRERELEASE}" >> $GITHUB_OUTPUT
echo "RELEASE_VERSION=${RELVERSION}" >> $GITHUB_OUTPUT
echo "AIO_BUILD=${BUILD_AIO}" >> $GITHUB_OUTPUT
- id: checkout_files
name: Checkout Files
@@ -242,13 +257,102 @@ jobs:
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
docker-image-owner: makeplane
docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_proxy }}
build-context: ./nginx
dockerfile-path: ./nginx/Dockerfile
build-context: ./apps/proxy
dockerfile-path: ./apps/proxy/Dockerfile.ce
buildx-driver: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
buildx-version: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
buildx-platforms: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }}
buildx-endpoint: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
branch_build_push_aio:
if: ${{ needs.branch_build_setup.outputs.aio_build == 'true' }}
name: Build-Push AIO Docker Image
runs-on: ubuntu-22.04
needs: [
branch_build_setup,
branch_build_push_admin,
branch_build_push_web,
branch_build_push_space,
branch_build_push_live,
branch_build_push_api,
branch_build_push_proxy
]
steps:
- name: Checkout Files
uses: actions/checkout@v4
- name: Prepare AIO Assets
id: prepare_aio_assets
run: |
cd deployments/aio/community
if [ "${{ needs.branch_build_setup.outputs.build_type }}" == "Release" ]; then
aio_version=${{ needs.branch_build_setup.outputs.release_version }}
else
aio_version=${{ needs.branch_build_setup.outputs.gh_branch_name }}
fi
bash ./build.sh --release $aio_version
echo "AIO_BUILD_VERSION=${aio_version}" >> $GITHUB_OUTPUT
- name: Upload AIO Assets
uses: actions/upload-artifact@v4
with:
path: ./deployments/aio/community/dist
name: aio-assets-dist
- name: AIO Build and Push
uses: makeplane/actions/build-push@v1.1.0
with:
build-release: ${{ needs.branch_build_setup.outputs.build_release }}
build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }}
release-version: ${{ needs.branch_build_setup.outputs.release_version }}
dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }}
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
docker-image-owner: makeplane
docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_aio }}
build-context: ./deployments/aio/community
dockerfile-path: ./deployments/aio/community/Dockerfile
buildx-driver: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
buildx-version: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
buildx-platforms: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }}
buildx-endpoint: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
additional-assets: aio-assets-dist
additional-assets-dir: ./deployments/aio/community/dist
build-args: |
PLANE_VERSION=${{ steps.prepare_aio_assets.outputs.AIO_BUILD_VERSION }}
upload_build_assets:
name: Upload Build Assets
runs-on: ubuntu-22.04
needs: [branch_build_setup, branch_build_push_admin, branch_build_push_web, branch_build_push_space, branch_build_push_live, branch_build_push_api, branch_build_push_proxy]
steps:
- name: Checkout Files
uses: actions/checkout@v4
- name: Update Assets
run: |
if [ "${{ needs.branch_build_setup.outputs.build_type }}" == "Release" ]; then
REL_VERSION=${{ needs.branch_build_setup.outputs.release_version }}
else
REL_VERSION=${{ needs.branch_build_setup.outputs.gh_branch_name }}
fi
cp ./deployments/cli/community/install.sh deployments/cli/community/setup.sh
sed -i 's/${APP_RELEASE:-stable}/${APP_RELEASE:-'${REL_VERSION}'}/g' deployments/cli/community/docker-compose.yml
# sed -i 's/APP_RELEASE=stable/APP_RELEASE='${REL_VERSION}'/g' deployments/cli/community/variables.env
- name: Upload Assets
uses: actions/upload-artifact@v4
with:
name: community-assets
path: |
./deployments/cli/community/setup.sh
./deployments/cli/community/restore.sh
./deployments/cli/community/restore-airgapped.sh
./deployments/cli/community/docker-compose.yml
./deployments/cli/community/variables.env
./deployments/swarm/community/swarm.sh
publish_release:
if: ${{ needs.branch_build_setup.outputs.build_type == 'Release' }}
name: Build Release
@@ -271,9 +375,9 @@ jobs:
- name: Update Assets
run: |
cp ./deploy/selfhost/install.sh deploy/selfhost/setup.sh
sed -i 's/${APP_RELEASE:-stable}/${APP_RELEASE:-'${REL_VERSION}'}/g' deploy/selfhost/docker-compose.yml
# sed -i 's/APP_RELEASE=stable/APP_RELEASE='${REL_VERSION}'/g' deploy/selfhost/variables.env
cp ./deployments/cli/community/install.sh deployments/cli/community/setup.sh
sed -i 's/${APP_RELEASE:-stable}/${APP_RELEASE:-'${REL_VERSION}'}/g' deployments/cli/community/docker-compose.yml
# sed -i 's/APP_RELEASE=stable/APP_RELEASE='${REL_VERSION}'/g' deployments/cli/community/variables.env
- name: Create Release
id: create_release
@@ -287,9 +391,10 @@ jobs:
prerelease: ${{ env.IS_PRERELEASE }}
generate_release_notes: true
files: |
${{ github.workspace }}/deploy/selfhost/setup.sh
${{ github.workspace }}/deploy/selfhost/swarm.sh
${{ github.workspace }}/deploy/selfhost/restore.sh
${{ github.workspace }}/deploy/selfhost/restore-airgapped.sh
${{ github.workspace }}/deploy/selfhost/docker-compose.yml
${{ github.workspace }}/deploy/selfhost/variables.env
${{ github.workspace }}/deployments/cli/community/setup.sh
${{ github.workspace }}/deployments/cli/community/restore.sh
${{ github.workspace }}/deployments/cli/community/restore-airgapped.sh
${{ github.workspace }}/deployments/cli/community/docker-compose.yml
${{ github.workspace }}/deployments/cli/community/variables.env
${{ github.workspace }}/deployments/swarm/community/swarm.sh
-182
View File
@@ -1,182 +0,0 @@
ARG BASE_TAG=develop
ARG BUILD_TYPE=full
# *****************************************************************************
# STAGE 1: Build the project
# *****************************************************************************
FROM node:18-alpine AS builder
RUN apk add --no-cache libc6-compat
# Set working directory
WORKDIR /app
RUN yarn global add turbo
COPY . .
RUN turbo prune --scope=web --scope=space --scope=admin --docker
# *****************************************************************************
# STAGE 2: Install dependencies & build the project
# *****************************************************************************
# Add lockfile and package.json's of isolated subworkspace
FROM node:18-alpine AS installer
RUN apk add --no-cache libc6-compat
WORKDIR /app
# First install the dependencies (as they change less often)
COPY .gitignore .gitignore
COPY --from=builder /app/out/json/ .
COPY --from=builder /app/out/yarn.lock ./yarn.lock
RUN yarn install
# # Build the project
COPY --from=builder /app/out/full/ .
COPY turbo.json turbo.json
ARG NEXT_PUBLIC_API_BASE_URL=""
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
ARG NEXT_PUBLIC_ADMIN_BASE_URL=""
ENV NEXT_PUBLIC_ADMIN_BASE_URL=$NEXT_PUBLIC_ADMIN_BASE_URL
ARG NEXT_PUBLIC_ADMIN_BASE_PATH="/god-mode"
ENV NEXT_PUBLIC_ADMIN_BASE_PATH=$NEXT_PUBLIC_ADMIN_BASE_PATH
ARG NEXT_PUBLIC_SPACE_BASE_URL=""
ENV NEXT_PUBLIC_SPACE_BASE_URL=$NEXT_PUBLIC_SPACE_BASE_URL
ARG NEXT_PUBLIC_SPACE_BASE_PATH="/spaces"
ENV NEXT_PUBLIC_SPACE_BASE_PATH=$NEXT_PUBLIC_SPACE_BASE_PATH
ARG NEXT_PUBLIC_WEB_BASE_URL=""
ENV NEXT_PUBLIC_WEB_BASE_URL=$NEXT_PUBLIC_WEB_BASE_URL
ENV NEXT_TELEMETRY_DISABLED=1
ENV TURBO_TELEMETRY_DISABLED=1
RUN yarn turbo run build --filter=web --filter=space --filter=admin
# *****************************************************************************
# STAGE 3: Copy the project and start it
# *****************************************************************************
FROM makeplane/plane-aio-base:${BUILD_TYPE}-${BASE_TAG} AS runner
WORKDIR /app
SHELL [ "/bin/bash", "-c" ]
# PYTHON APPLICATION SETUP
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
ENV PIP_DISABLE_PIP_VERSION_CHECK=1
COPY apps/api/requirements.txt ./api/
COPY apps/api/requirements ./api/requirements
RUN pip install -r ./api/requirements.txt --compile --no-cache-dir
# Add in Django deps and generate Django's static files
COPY apps/api/manage.py ./api/manage.py
COPY apps/api/plane ./api/plane/
COPY apps/api/templates ./api/templates/
COPY package.json ./api/package.json
COPY apps/api/bin ./api/bin/
RUN chmod +x ./api/bin/*
RUN chmod -R 777 ./api/
# NEXTJS BUILDS
COPY --from=installer /app/web/next.config.js ./web/
COPY --from=installer /app/web/package.json ./web/
COPY --from=installer /app/web/.next/standalone ./web
COPY --from=installer /app/web/.next/static ./web/web/.next/static
COPY --from=installer /app/web/public ./web/web/public
COPY --from=installer /app/space/next.config.js ./space/
COPY --from=installer /app/space/package.json ./space/
COPY --from=installer /app/space/.next/standalone ./space
COPY --from=installer /app/space/.next/static ./space/space/.next/static
COPY --from=installer /app/space/public ./space/space/public
COPY --from=installer /app/admin/next.config.js ./admin/
COPY --from=installer /app/admin/package.json ./admin/
COPY --from=installer /app/admin/.next/standalone ./admin
COPY --from=installer /app/admin/.next/static ./admin/admin/.next/static
COPY --from=installer /app/admin/public ./admin/admin/public
ARG NEXT_PUBLIC_API_BASE_URL=""
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
ARG NEXT_PUBLIC_ADMIN_BASE_URL=""
ENV NEXT_PUBLIC_ADMIN_BASE_URL=$NEXT_PUBLIC_ADMIN_BASE_URL
ARG NEXT_PUBLIC_ADMIN_BASE_PATH="/god-mode"
ENV NEXT_PUBLIC_ADMIN_BASE_PATH=$NEXT_PUBLIC_ADMIN_BASE_PATH
ARG NEXT_PUBLIC_SPACE_BASE_URL=""
ENV NEXT_PUBLIC_SPACE_BASE_URL=$NEXT_PUBLIC_SPACE_BASE_URL
ARG NEXT_PUBLIC_SPACE_BASE_PATH="/spaces"
ENV NEXT_PUBLIC_SPACE_BASE_PATH=$NEXT_PUBLIC_SPACE_BASE_PATH
ARG NEXT_PUBLIC_WEB_BASE_URL=""
ENV NEXT_PUBLIC_WEB_BASE_URL=$NEXT_PUBLIC_WEB_BASE_URL
ENV NEXT_TELEMETRY_DISABLED=1
ENV TURBO_TELEMETRY_DISABLED=1
ARG BUILD_TYPE=full
ENV BUILD_TYPE=$BUILD_TYPE
COPY aio/supervisord-${BUILD_TYPE}-base /app/supervisord.conf
COPY aio/supervisord-app /app/supervisord-app
RUN cat /app/supervisord-app >> /app/supervisord.conf && \
rm /app/supervisord-app
COPY ./aio/nginx.conf /etc/nginx/nginx.conf.template
# if build type is full, run the below copy pg-setup.sh
COPY aio/postgresql.conf /etc/postgresql/postgresql.conf
COPY aio/pg-setup.sh /app/pg-setup.sh
RUN chmod +x /app/pg-setup.sh
# *****************************************************************************
# APPLICATION ENVIRONMENT SETTINGS
# *****************************************************************************
ENV APP_DOMAIN=localhost
ENV WEB_URL=http://${APP_DOMAIN}
ENV DEBUG=0
ENV CORS_ALLOWED_ORIGINS=http://${APP_DOMAIN},https://${APP_DOMAIN}
# Secret Key
ENV SECRET_KEY=60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5
# Gunicorn Workers
ENV GUNICORN_WORKERS=1
ENV POSTGRES_USER="plane"
ENV POSTGRES_PASSWORD="plane"
ENV POSTGRES_DB="plane"
ENV POSTGRES_HOST="localhost"
ENV POSTGRES_PORT="5432"
ENV DATABASE_URL="postgresql://plane:plane@localhost:5432/plane"
ENV REDIS_HOST="localhost"
ENV REDIS_PORT="6379"
ENV REDIS_URL="redis://localhost:6379"
ENV USE_MINIO="1"
ENV AWS_REGION=""
ENV AWS_ACCESS_KEY_ID="access-key"
ENV AWS_SECRET_ACCESS_KEY="secret-key"
ENV AWS_S3_ENDPOINT_URL="http://localhost:9000"
ENV AWS_S3_BUCKET_NAME="uploads"
ENV MINIO_ROOT_USER="access-key"
ENV MINIO_ROOT_PASSWORD="secret-key"
ENV BUCKET_NAME="uploads"
ENV FILE_SIZE_LIMIT="5242880"
# *****************************************************************************
RUN /app/pg-setup.sh
CMD ["/usr/bin/supervisord", "-c", "/app/supervisord.conf"]
-73
View File
@@ -1,73 +0,0 @@
FROM --platform=$BUILDPLATFORM tonistiigi/binfmt AS binfmt
FROM python:3.12-slim
# Set environment variables to non-interactive for apt
ENV DEBIAN_FRONTEND=noninteractive
ENV BUILD_TYPE=full
SHELL [ "/bin/bash", "-c" ]
WORKDIR /app
RUN mkdir -p /app/{data,logs} && \
mkdir -p /app/data/{redis,pg,minio,nginx} && \
mkdir -p /app/logs/{access,error} && \
mkdir -p /etc/supervisor/conf.d
# Update the package list and install prerequisites
RUN apt-get update && \
apt-get install -y \
gnupg2 curl ca-certificates lsb-release software-properties-common \
build-essential libssl-dev zlib1g-dev libbz2-dev libreadline-dev \
libsqlite3-dev wget llvm libncurses5-dev libncursesw5-dev xz-utils \
tk-dev libffi-dev liblzma-dev supervisor nginx nano vim ncdu \
sudo lsof net-tools libpq-dev procps gettext
# Install Redis 7.2
RUN echo "deb http://deb.debian.org/debian $(lsb_release -cs)-backports main" > /etc/apt/sources.list.d/backports.list && \
curl -fsSL https://packages.redis.io/gpg | gpg --dearmor -o /usr/share/keyrings/redis-archive-keyring.gpg && \
echo "deb [signed-by=/usr/share/keyrings/redis-archive-keyring.gpg] https://packages.redis.io/deb $(lsb_release -cs) main" > /etc/apt/sources.list.d/redis.list && \
apt-get update && \
apt-get install -y redis-server
# Install PostgreSQL 15
ENV POSTGRES_VERSION=15
RUN curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor -o /usr/share/keyrings/pgdg-archive-keyring.gpg && \
echo "deb [signed-by=/usr/share/keyrings/pgdg-archive-keyring.gpg] http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list && \
apt-get update && \
apt-get install -y postgresql-$POSTGRES_VERSION postgresql-client-$POSTGRES_VERSION && \
mkdir -p /var/lib/postgresql/data && \
chown -R postgres:postgres /var/lib/postgresql
COPY postgresql.conf /etc/postgresql/postgresql.conf
RUN sudo -u postgres /usr/lib/postgresql/$POSTGRES_VERSION/bin/initdb -D /var/lib/postgresql/data
# Install MinIO
ARG TARGETARCH
RUN if [ "$TARGETARCH" = "amd64" ]; then \
curl -fSl https://dl.min.io/server/minio/release/linux-amd64/minio -o /usr/local/bin/minio; \
elif [ "$TARGETARCH" = "arm64" ]; then \
curl -fSl https://dl.min.io/server/minio/release/linux-arm64/minio -o /usr/local/bin/minio; \
else \
echo "Unsupported architecture: $TARGETARCH"; exit 1; \
fi && \
chmod +x /usr/local/bin/minio
# Install Node.js 18
RUN curl -fsSL https://deb.nodesource.com/setup_18.x | bash - && \
apt-get install -y nodejs && \
python -m pip install --upgrade pip && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
# Create Supervisor configuration file
COPY supervisord-full-base /app/supervisord.conf
COPY nginx.conf /etc/nginx/nginx.conf.template
COPY env.sh /app/nginx-start.sh
RUN chmod +x /app/nginx-start.sh
# Expose ports for Redis, PostgreSQL, and MinIO
EXPOSE 6379 5432 9000 80 443
# Start Supervisor
CMD ["/usr/bin/supervisord", "-c", "/app/supervisord.conf"]
-45
View File
@@ -1,45 +0,0 @@
FROM --platform=$BUILDPLATFORM tonistiigi/binfmt AS binfmt
FROM python:3.12-slim
# Set environment variables to non-interactive for apt
ENV DEBIAN_FRONTEND=noninteractive
ENV BUILD_TYPE=slim
SHELL [ "/bin/bash", "-c" ]
WORKDIR /app
RUN mkdir -p /app/{data,logs} && \
mkdir -p /app/data/{nginx} && \
mkdir -p /app/logs/{access,error} && \
mkdir -p /etc/supervisor/conf.d
# Update the package list and install prerequisites
RUN apt-get update && \
apt-get install -y \
gnupg2 curl ca-certificates lsb-release software-properties-common \
build-essential libssl-dev zlib1g-dev libbz2-dev libreadline-dev \
libsqlite3-dev wget llvm libncurses5-dev libncursesw5-dev xz-utils \
tk-dev libffi-dev liblzma-dev supervisor nginx nano vim ncdu \
sudo lsof net-tools libpq-dev procps gettext
# Install Node.js 18
RUN curl -fsSL https://deb.nodesource.com/setup_18.x | bash - && \
apt-get install -y nodejs
RUN python -m pip install --upgrade pip && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
# Create Supervisor configuration file
COPY supervisord-slim-base /app/supervisord.conf
COPY nginx.conf /etc/nginx/nginx.conf.template
COPY env.sh /app/nginx-start.sh
RUN chmod +x /app/nginx-start.sh
# Expose ports for Redis, PostgreSQL, and MinIO
EXPOSE 80 443
# Start Supervisor
CMD ["/usr/bin/supervisord", "-c", "/app/supervisord.conf"]
-7
View File
@@ -1,7 +0,0 @@
#!/bin/bash
export dollar="$"
export http_upgrade="http_upgrade"
export scheme="scheme"
envsubst < /etc/nginx/nginx.conf.template > /etc/nginx/nginx.conf
exec nginx -g 'daemon off;'
-72
View File
@@ -1,72 +0,0 @@
events {
}
http {
sendfile on;
server {
listen 80;
root /www/data/;
access_log /var/log/nginx/access.log;
client_max_body_size ${FILE_SIZE_LIMIT};
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;
add_header Permissions-Policy "interest-cohort=()" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Forwarded-Proto "${dollar}scheme";
add_header X-Forwarded-Host "${dollar}host";
add_header X-Forwarded-For "${dollar}proxy_add_x_forwarded_for";
add_header X-Real-IP "${dollar}remote_addr";
location / {
proxy_http_version 1.1;
proxy_set_header Upgrade ${dollar}http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host ${dollar}http_host;
proxy_pass http://localhost:3001/;
}
location /spaces/ {
rewrite ^/spaces/?$ /spaces/login break;
proxy_http_version 1.1;
proxy_set_header Upgrade ${dollar}http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host ${dollar}http_host;
proxy_pass http://localhost:3002/spaces/;
}
location /god-mode/ {
proxy_http_version 1.1;
proxy_set_header Upgrade ${dollar}http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host ${dollar}http_host;
proxy_pass http://localhost:3003/god-mode/;
}
location /api/ {
proxy_http_version 1.1;
proxy_set_header Upgrade ${dollar}http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host ${dollar}http_host;
proxy_pass http://localhost:8000/api/;
}
location /auth/ {
proxy_http_version 1.1;
proxy_set_header Upgrade ${dollar}http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host ${dollar}http_host;
proxy_pass http://localhost:8000/auth/;
}
location /${BUCKET_NAME}/ {
proxy_http_version 1.1;
proxy_set_header Upgrade ${dollar}http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host ${dollar}http_host;
proxy_pass http://localhost:9000/uploads/;
}
}
}
-14
View File
@@ -1,14 +0,0 @@
#!/bin/bash
if [ "$BUILD_TYPE" == "full" ]; then
export PGHOST=localhost
sudo -u postgres "/usr/lib/postgresql/${POSTGRES_VERSION}/bin/pg_ctl" -D /var/lib/postgresql/data start
sudo -u postgres "/usr/lib/postgresql/${POSTGRES_VERSION}/bin/psql" --command "CREATE USER $POSTGRES_USER WITH SUPERUSER PASSWORD '$POSTGRES_PASSWORD';" && \
sudo -u postgres "/usr/lib/postgresql/${POSTGRES_VERSION}/bin/createdb" -O "$POSTGRES_USER" "$POSTGRES_DB" && \
sudo -u postgres "/usr/lib/postgresql/${POSTGRES_VERSION}/bin/psql" --command "GRANT ALL PRIVILEGES ON DATABASE $POSTGRES_DB TO $POSTGRES_USER;" && \
sudo -u postgres "/usr/lib/postgresql/${POSTGRES_VERSION}/bin/pg_ctl" -D /var/lib/postgresql/data stop
fi
-815
View File
@@ -1,815 +0,0 @@
# -----------------------------
# PostgreSQL configuration file
# -----------------------------
#
# This file consists of lines of the form:
#
# name = value
#
# (The "=" is optional.) Whitespace may be used. Comments are introduced with
# "#" anywhere on a line. The complete list of parameter names and allowed
# values can be found in the PostgreSQL documentation.
#
# The commented-out settings shown in this file represent the default values.
# Re-commenting a setting is NOT sufficient to revert it to the default value;
# you need to reload the server.
#
# This file is read on server startup and when the server receives a SIGHUP
# signal. If you edit the file on a running system, you have to SIGHUP the
# server for the changes to take effect, run "pg_ctl reload", or execute
# "SELECT pg_reload_conf()". Some parameters, which are marked below,
# require a server shutdown and restart to take effect.
#
# Any parameter can also be given as a command-line option to the server, e.g.,
# "postgres -c log_connections=on". Some parameters can be changed at run time
# with the "SET" SQL command.
#
# Memory units: B = bytes Time units: us = microseconds
# kB = kilobytes ms = milliseconds
# MB = megabytes s = seconds
# GB = gigabytes min = minutes
# TB = terabytes h = hours
# d = days
#------------------------------------------------------------------------------
# FILE LOCATIONS
#------------------------------------------------------------------------------
# The default values of these variables are driven from the -D command-line
# option or PGDATA environment variable, represented here as ConfigDir.
data_directory = '/var/lib/postgresql/data' # use data in another directory
# (change requires restart)
hba_file = '/etc/postgresql/15/main/pg_hba.conf' # host-based authentication file
# (change requires restart)
ident_file = '/etc/postgresql/15/main/pg_ident.conf' # ident configuration file
# (change requires restart)
# If external_pid_file is not explicitly set, no extra PID file is written.
external_pid_file = '/var/run/postgresql/15-main.pid' # write an extra PID file
# (change requires restart)
#------------------------------------------------------------------------------
# CONNECTIONS AND AUTHENTICATION
#------------------------------------------------------------------------------
# - Connection Settings -
listen_addresses = 'localhost' # what IP address(es) to listen on;
# comma-separated list of addresses;
# defaults to 'localhost'; use '*' for all
# (change requires restart)
port = 5432 # (change requires restart)
max_connections = 200 # (change requires restart)
#superuser_reserved_connections = 3 # (change requires restart)
unix_socket_directories = '/var/run/postgresql' # comma-separated list of directories
# (change requires restart)
#unix_socket_group = '' # (change requires restart)
#unix_socket_permissions = 0777 # begin with 0 to use octal notation
# (change requires restart)
#bonjour = off # advertise server via Bonjour
# (change requires restart)
#bonjour_name = '' # defaults to the computer name
# (change requires restart)
# - TCP settings -
# see "man tcp" for details
#tcp_keepalives_idle = 0 # TCP_KEEPIDLE, in seconds;
# 0 selects the system default
#tcp_keepalives_interval = 0 # TCP_KEEPINTVL, in seconds;
# 0 selects the system default
#tcp_keepalives_count = 0 # TCP_KEEPCNT;
# 0 selects the system default
#tcp_user_timeout = 0 # TCP_USER_TIMEOUT, in milliseconds;
# 0 selects the system default
#client_connection_check_interval = 0 # time between checks for client
# disconnection while running queries;
# 0 for never
# - Authentication -
#authentication_timeout = 1min # 1s-600s
#password_encryption = scram-sha-256 # scram-sha-256 or md5
#db_user_namespace = off
# GSSAPI using Kerberos
#krb_server_keyfile = 'FILE:${sysconfdir}/krb5.keytab'
#krb_caseins_users = off
# - SSL -
ssl = on
#ssl_ca_file = ''
ssl_cert_file = '/etc/ssl/certs/ssl-cert-snakeoil.pem'
#ssl_crl_file = ''
#ssl_crl_dir = ''
ssl_key_file = '/etc/ssl/private/ssl-cert-snakeoil.key'
#ssl_ciphers = 'HIGH:MEDIUM:+3DES:!aNULL' # allowed SSL ciphers
#ssl_prefer_server_ciphers = on
#ssl_ecdh_curve = 'prime256v1'
#ssl_min_protocol_version = 'TLSv1.2'
#ssl_max_protocol_version = ''
#ssl_dh_params_file = ''
#ssl_passphrase_command = ''
#ssl_passphrase_command_supports_reload = off
#------------------------------------------------------------------------------
# RESOURCE USAGE (except WAL)
#------------------------------------------------------------------------------
# - Memory -
shared_buffers = 256MB # min 128kB
# (change requires restart)
#huge_pages = try # on, off, or try
# (change requires restart)
#huge_page_size = 0 # zero for system default
# (change requires restart)
#temp_buffers = 8MB # min 800kB
#max_prepared_transactions = 0 # zero disables the feature
# (change requires restart)
# Caution: it is not advisable to set max_prepared_transactions nonzero unless
# you actively intend to use prepared transactions.
#work_mem = 4MB # min 64kB
#hash_mem_multiplier = 2.0 # 1-1000.0 multiplier on hash table work_mem
#maintenance_work_mem = 64MB # min 1MB
#autovacuum_work_mem = -1 # min 1MB, or -1 to use maintenance_work_mem
#logical_decoding_work_mem = 64MB # min 64kB
#max_stack_depth = 2MB # min 100kB
#shared_memory_type = mmap # the default is the first option
# supported by the operating system:
# mmap
# sysv
# windows
# (change requires restart)
dynamic_shared_memory_type = posix # the default is usually the first option
# supported by the operating system:
# posix
# sysv
# windows
# mmap
# (change requires restart)
#min_dynamic_shared_memory = 0MB # (change requires restart)
# - Disk -
#temp_file_limit = -1 # limits per-process temp file space
# in kilobytes, or -1 for no limit
# - Kernel Resources -
#max_files_per_process = 1000 # min 64
# (change requires restart)
# - Cost-Based Vacuum Delay -
#vacuum_cost_delay = 0 # 0-100 milliseconds (0 disables)
#vacuum_cost_page_hit = 1 # 0-10000 credits
#vacuum_cost_page_miss = 2 # 0-10000 credits
#vacuum_cost_page_dirty = 20 # 0-10000 credits
#vacuum_cost_limit = 200 # 1-10000 credits
# - Background Writer -
#bgwriter_delay = 200ms # 10-10000ms between rounds
#bgwriter_lru_maxpages = 100 # max buffers written/round, 0 disables
#bgwriter_lru_multiplier = 2.0 # 0-10.0 multiplier on buffers scanned/round
#bgwriter_flush_after = 512kB # measured in pages, 0 disables
# - Asynchronous Behavior -
#backend_flush_after = 0 # measured in pages, 0 disables
#effective_io_concurrency = 1 # 1-1000; 0 disables prefetching
#maintenance_io_concurrency = 10 # 1-1000; 0 disables prefetching
#max_worker_processes = 8 # (change requires restart)
#max_parallel_workers_per_gather = 2 # limited by max_parallel_workers
#max_parallel_maintenance_workers = 2 # limited by max_parallel_workers
#max_parallel_workers = 8 # number of max_worker_processes that
# can be used in parallel operations
#parallel_leader_participation = on
#old_snapshot_threshold = -1 # 1min-60d; -1 disables; 0 is immediate
# (change requires restart)
#------------------------------------------------------------------------------
# WRITE-AHEAD LOG
#------------------------------------------------------------------------------
# - Settings -
#wal_level = replica # minimal, replica, or logical
# (change requires restart)
#fsync = on # flush data to disk for crash safety
# (turning this off can cause
# unrecoverable data corruption)
#synchronous_commit = on # synchronization level;
# off, local, remote_write, remote_apply, or on
#wal_sync_method = fsync # the default is the first option
# supported by the operating system:
# open_datasync
# fdatasync (default on Linux and FreeBSD)
# fsync
# fsync_writethrough
# open_sync
#full_page_writes = on # recover from partial page writes
#wal_log_hints = off # also do full page writes of non-critical updates
# (change requires restart)
#wal_compression = off # enables compression of full-page writes;
# off, pglz, lz4, zstd, or on
#wal_init_zero = on # zero-fill new WAL files
#wal_recycle = on # recycle WAL files
#wal_buffers = -1 # min 32kB, -1 sets based on shared_buffers
# (change requires restart)
#wal_writer_delay = 200ms # 1-10000 milliseconds
#wal_writer_flush_after = 1MB # measured in pages, 0 disables
#wal_skip_threshold = 2MB
#commit_delay = 0 # range 0-100000, in microseconds
#commit_siblings = 5 # range 1-1000
# - Checkpoints -
#checkpoint_timeout = 5min # range 30s-1d
#checkpoint_completion_target = 0.9 # checkpoint target duration, 0.0 - 1.0
#checkpoint_flush_after = 256kB # measured in pages, 0 disables
#checkpoint_warning = 30s # 0 disables
max_wal_size = 1GB
min_wal_size = 80MB
# - Prefetching during recovery -
#recovery_prefetch = try # prefetch pages referenced in the WAL?
#wal_decode_buffer_size = 512kB # lookahead window used for prefetching
# (change requires restart)
# - Archiving -
#archive_mode = off # enables archiving; off, on, or always
# (change requires restart)
#archive_library = '' # library to use to archive a logfile segment
# (empty string indicates archive_command should
# be used)
#archive_command = '' # command to use to archive a logfile segment
# placeholders: %p = path of file to archive
# %f = file name only
# e.g. 'test ! -f /mnt/server/archivedir/%f && cp %p /mnt/server/archivedir/%f'
#archive_timeout = 0 # force a logfile segment switch after this
# number of seconds; 0 disables
# - Archive Recovery -
# These are only used in recovery mode.
#restore_command = '' # command to use to restore an archived logfile segment
# placeholders: %p = path of file to restore
# %f = file name only
# e.g. 'cp /mnt/server/archivedir/%f %p'
#archive_cleanup_command = '' # command to execute at every restartpoint
#recovery_end_command = '' # command to execute at completion of recovery
# - Recovery Target -
# Set these only when performing a targeted recovery.
#recovery_target = '' # 'immediate' to end recovery as soon as a
# consistent state is reached
# (change requires restart)
#recovery_target_name = '' # the named restore point to which recovery will proceed
# (change requires restart)
#recovery_target_time = '' # the time stamp up to which recovery will proceed
# (change requires restart)
#recovery_target_xid = '' # the transaction ID up to which recovery will proceed
# (change requires restart)
#recovery_target_lsn = '' # the WAL LSN up to which recovery will proceed
# (change requires restart)
#recovery_target_inclusive = on # Specifies whether to stop:
# just after the specified recovery target (on)
# just before the recovery target (off)
# (change requires restart)
#recovery_target_timeline = 'latest' # 'current', 'latest', or timeline ID
# (change requires restart)
#recovery_target_action = 'pause' # 'pause', 'promote', 'shutdown'
# (change requires restart)
#------------------------------------------------------------------------------
# REPLICATION
#------------------------------------------------------------------------------
# - Sending Servers -
# Set these on the primary and on any standby that will send replication data.
#max_wal_senders = 10 # max number of walsender processes
# (change requires restart)
#max_replication_slots = 10 # max number of replication slots
# (change requires restart)
#wal_keep_size = 0 # in megabytes; 0 disables
#max_slot_wal_keep_size = -1 # in megabytes; -1 disables
#wal_sender_timeout = 60s # in milliseconds; 0 disables
#track_commit_timestamp = off # collect timestamp of transaction commit
# (change requires restart)
# - Primary Server -
# These settings are ignored on a standby server.
#synchronous_standby_names = '' # standby servers that provide sync rep
# method to choose sync standbys, number of sync standbys,
# and comma-separated list of application_name
# from standby(s); '*' = all
#vacuum_defer_cleanup_age = 0 # number of xacts by which cleanup is delayed
# - Standby Servers -
# These settings are ignored on a primary server.
#primary_conninfo = '' # connection string to sending server
#primary_slot_name = '' # replication slot on sending server
#promote_trigger_file = '' # file name whose presence ends recovery
#hot_standby = on # "off" disallows queries during recovery
# (change requires restart)
#max_standby_archive_delay = 30s # max delay before canceling queries
# when reading WAL from archive;
# -1 allows indefinite delay
#max_standby_streaming_delay = 30s # max delay before canceling queries
# when reading streaming WAL;
# -1 allows indefinite delay
#wal_receiver_create_temp_slot = off # create temp slot if primary_slot_name
# is not set
#wal_receiver_status_interval = 10s # send replies at least this often
# 0 disables
#hot_standby_feedback = off # send info from standby to prevent
# query conflicts
#wal_receiver_timeout = 60s # time that receiver waits for
# communication from primary
# in milliseconds; 0 disables
#wal_retrieve_retry_interval = 5s # time to wait before retrying to
# retrieve WAL after a failed attempt
#recovery_min_apply_delay = 0 # minimum delay for applying changes during recovery
# - Subscribers -
# These settings are ignored on a publisher.
#max_logical_replication_workers = 4 # taken from max_worker_processes
# (change requires restart)
#max_sync_workers_per_subscription = 2 # taken from max_logical_replication_workers
#------------------------------------------------------------------------------
# QUERY TUNING
#------------------------------------------------------------------------------
# - Planner Method Configuration -
#enable_async_append = on
#enable_bitmapscan = on
#enable_gathermerge = on
#enable_hashagg = on
#enable_hashjoin = on
#enable_incremental_sort = on
#enable_indexscan = on
#enable_indexonlyscan = on
#enable_material = on
#enable_memoize = on
#enable_mergejoin = on
#enable_nestloop = on
#enable_parallel_append = on
#enable_parallel_hash = on
#enable_partition_pruning = on
#enable_partitionwise_join = off
#enable_partitionwise_aggregate = off
#enable_seqscan = on
#enable_sort = on
#enable_tidscan = on
# - Planner Cost Constants -
#seq_page_cost = 1.0 # measured on an arbitrary scale
#random_page_cost = 4.0 # same scale as above
#cpu_tuple_cost = 0.01 # same scale as above
#cpu_index_tuple_cost = 0.005 # same scale as above
#cpu_operator_cost = 0.0025 # same scale as above
#parallel_setup_cost = 1000.0 # same scale as above
#parallel_tuple_cost = 0.1 # same scale as above
#min_parallel_table_scan_size = 8MB
#min_parallel_index_scan_size = 512kB
#effective_cache_size = 4GB
#jit_above_cost = 100000 # perform JIT compilation if available
# and query more expensive than this;
# -1 disables
#jit_inline_above_cost = 500000 # inline small functions if query is
# more expensive than this; -1 disables
#jit_optimize_above_cost = 500000 # use expensive JIT optimizations if
# query is more expensive than this;
# -1 disables
# - Genetic Query Optimizer -
#geqo = on
#geqo_threshold = 12
#geqo_effort = 5 # range 1-10
#geqo_pool_size = 0 # selects default based on effort
#geqo_generations = 0 # selects default based on effort
#geqo_selection_bias = 2.0 # range 1.5-2.0
#geqo_seed = 0.0 # range 0.0-1.0
# - Other Planner Options -
#default_statistics_target = 100 # range 1-10000
#constraint_exclusion = partition # on, off, or partition
#cursor_tuple_fraction = 0.1 # range 0.0-1.0
#from_collapse_limit = 8
#jit = on # allow JIT compilation
#join_collapse_limit = 8 # 1 disables collapsing of explicit
# JOIN clauses
#plan_cache_mode = auto # auto, force_generic_plan or
# force_custom_plan
#recursive_worktable_factor = 10.0 # range 0.001-1000000
#------------------------------------------------------------------------------
# REPORTING AND LOGGING
#------------------------------------------------------------------------------
# - Where to Log -
#log_destination = 'stderr' # Valid values are combinations of
# stderr, csvlog, jsonlog, syslog, and
# eventlog, depending on platform.
# csvlog and jsonlog require
# logging_collector to be on.
# This is used when logging to stderr:
#logging_collector = off # Enable capturing of stderr, jsonlog,
# and csvlog into log files. Required
# to be on for csvlogs and jsonlogs.
# (change requires restart)
# These are only used if logging_collector is on:
#log_directory = 'log' # directory where log files are written,
# can be absolute or relative to PGDATA
#log_filename = 'postgresql-%Y-%m-%d_%H%M%S.log' # log file name pattern,
# can include strftime() escapes
#log_file_mode = 0600 # creation mode for log files,
# begin with 0 to use octal notation
#log_rotation_age = 1d # Automatic rotation of logfiles will
# happen after that time. 0 disables.
#log_rotation_size = 10MB # Automatic rotation of logfiles will
# happen after that much log output.
# 0 disables.
#log_truncate_on_rotation = off # If on, an existing log file with the
# same name as the new log file will be
# truncated rather than appended to.
# But such truncation only occurs on
# time-driven rotation, not on restarts
# or size-driven rotation. Default is
# off, meaning append to existing files
# in all cases.
# These are relevant when logging to syslog:
#syslog_facility = 'LOCAL0'
#syslog_ident = 'postgres'
#syslog_sequence_numbers = on
#syslog_split_messages = on
# This is only relevant when logging to eventlog (Windows):
# (change requires restart)
#event_source = 'PostgreSQL'
# - When to Log -
#log_min_messages = warning # values in order of decreasing detail:
# debug5
# debug4
# debug3
# debug2
# debug1
# info
# notice
# warning
# error
# log
# fatal
# panic
#log_min_error_statement = error # values in order of decreasing detail:
# debug5
# debug4
# debug3
# debug2
# debug1
# info
# notice
# warning
# error
# log
# fatal
# panic (effectively off)
#log_min_duration_statement = -1 # -1 is disabled, 0 logs all statements
# and their durations, > 0 logs only
# statements running at least this number
# of milliseconds
#log_min_duration_sample = -1 # -1 is disabled, 0 logs a sample of statements
# and their durations, > 0 logs only a sample of
# statements running at least this number
# of milliseconds;
# sample fraction is determined by log_statement_sample_rate
#log_statement_sample_rate = 1.0 # fraction of logged statements exceeding
# log_min_duration_sample to be logged;
# 1.0 logs all such statements, 0.0 never logs
#log_transaction_sample_rate = 0.0 # fraction of transactions whose statements
# are logged regardless of their duration; 1.0 logs all
# statements from all transactions, 0.0 never logs
#log_startup_progress_interval = 10s # Time between progress updates for
# long-running startup operations.
# 0 disables the feature, > 0 indicates
# the interval in milliseconds.
# - What to Log -
#debug_print_parse = off
#debug_print_rewritten = off
#debug_print_plan = off
#debug_pretty_print = on
#log_autovacuum_min_duration = 10min # log autovacuum activity;
# -1 disables, 0 logs all actions and
# their durations, > 0 logs only
# actions running at least this number
# of milliseconds.
#log_checkpoints = on
#log_connections = off
#log_disconnections = off
#log_duration = off
#log_error_verbosity = default # terse, default, or verbose messages
#log_hostname = off
log_line_prefix = '%m [%p] %q%u@%d ' # special values:
# %a = application name
# %u = user name
# %d = database name
# %r = remote host and port
# %h = remote host
# %b = backend type
# %p = process ID
# %P = process ID of parallel group leader
# %t = timestamp without milliseconds
# %m = timestamp with milliseconds
# %n = timestamp with milliseconds (as a Unix epoch)
# %Q = query ID (0 if none or not computed)
# %i = command tag
# %e = SQL state
# %c = session ID
# %l = session line number
# %s = session start timestamp
# %v = virtual transaction ID
# %x = transaction ID (0 if none)
# %q = stop here in non-session
# processes
# %% = '%'
# e.g. '<%u%%%d> '
#log_lock_waits = off # log lock waits >= deadlock_timeout
#log_recovery_conflict_waits = off # log standby recovery conflict waits
# >= deadlock_timeout
#log_parameter_max_length = -1 # when logging statements, limit logged
# bind-parameter values to N bytes;
# -1 means print in full, 0 disables
#log_parameter_max_length_on_error = 0 # when logging an error, limit logged
# bind-parameter values to N bytes;
# -1 means print in full, 0 disables
#log_statement = 'none' # none, ddl, mod, all
#log_replication_commands = off
#log_temp_files = -1 # log temporary files equal or larger
# than the specified size in kilobytes;
# -1 disables, 0 logs all temp files
log_timezone = 'Etc/UTC'
#------------------------------------------------------------------------------
# PROCESS TITLE
#------------------------------------------------------------------------------
cluster_name = '15/main' # added to process titles if nonempty
# (change requires restart)
#update_process_title = on
#------------------------------------------------------------------------------
# STATISTICS
#------------------------------------------------------------------------------
# - Cumulative Query and Index Statistics -
#track_activities = on
#track_activity_query_size = 1024 # (change requires restart)
#track_counts = on
#track_io_timing = off
#track_wal_io_timing = off
#track_functions = none # none, pl, all
#stats_fetch_consistency = cache
# - Monitoring -
#compute_query_id = auto
#log_statement_stats = off
#log_parser_stats = off
#log_planner_stats = off
#log_executor_stats = off
#------------------------------------------------------------------------------
# AUTOVACUUM
#------------------------------------------------------------------------------
#autovacuum = on # Enable autovacuum subprocess? 'on'
# requires track_counts to also be on.
#autovacuum_max_workers = 3 # max number of autovacuum subprocesses
# (change requires restart)
#autovacuum_naptime = 1min # time between autovacuum runs
#autovacuum_vacuum_threshold = 50 # min number of row updates before
# vacuum
#autovacuum_vacuum_insert_threshold = 1000 # min number of row inserts
# before vacuum; -1 disables insert
# vacuums
#autovacuum_analyze_threshold = 50 # min number of row updates before
# analyze
#autovacuum_vacuum_scale_factor = 0.2 # fraction of table size before vacuum
#autovacuum_vacuum_insert_scale_factor = 0.2 # fraction of inserts over table
# size before insert vacuum
#autovacuum_analyze_scale_factor = 0.1 # fraction of table size before analyze
#autovacuum_freeze_max_age = 200000000 # maximum XID age before forced vacuum
# (change requires restart)
#autovacuum_multixact_freeze_max_age = 400000000 # maximum multixact age
# before forced vacuum
# (change requires restart)
#autovacuum_vacuum_cost_delay = 2ms # default vacuum cost delay for
# autovacuum, in milliseconds;
# -1 means use vacuum_cost_delay
#autovacuum_vacuum_cost_limit = -1 # default vacuum cost limit for
# autovacuum, -1 means use
# vacuum_cost_limit
#------------------------------------------------------------------------------
# CLIENT CONNECTION DEFAULTS
#------------------------------------------------------------------------------
# - Statement Behavior -
#client_min_messages = notice # values in order of decreasing detail:
# debug5
# debug4
# debug3
# debug2
# debug1
# log
# notice
# warning
# error
#search_path = '"$user", public' # schema names
#row_security = on
#default_table_access_method = 'heap'
#default_tablespace = '' # a tablespace name, '' uses the default
#default_toast_compression = 'pglz' # 'pglz' or 'lz4'
#temp_tablespaces = '' # a list of tablespace names, '' uses
# only default tablespace
#check_function_bodies = on
#default_transaction_isolation = 'read committed'
#default_transaction_read_only = off
#default_transaction_deferrable = off
#session_replication_role = 'origin'
#statement_timeout = 0 # in milliseconds, 0 is disabled
#lock_timeout = 0 # in milliseconds, 0 is disabled
#idle_in_transaction_session_timeout = 0 # in milliseconds, 0 is disabled
#idle_session_timeout = 0 # in milliseconds, 0 is disabled
#vacuum_freeze_table_age = 150000000
#vacuum_freeze_min_age = 50000000
#vacuum_failsafe_age = 1600000000
#vacuum_multixact_freeze_table_age = 150000000
#vacuum_multixact_freeze_min_age = 5000000
#vacuum_multixact_failsafe_age = 1600000000
#bytea_output = 'hex' # hex, escape
#xmlbinary = 'base64'
#xmloption = 'content'
#gin_pending_list_limit = 4MB
# - Locale and Formatting -
datestyle = 'iso, mdy'
#intervalstyle = 'postgres'
timezone = 'Etc/UTC'
#timezone_abbreviations = 'Default' # Select the set of available time zone
# abbreviations. Currently, there are
# Default
# Australia (historical usage)
# India
# You can create your own file in
# share/timezonesets/.
#extra_float_digits = 1 # min -15, max 3; any value >0 actually
# selects precise output mode
#client_encoding = sql_ascii # actually, defaults to database
# encoding
# These settings are initialized by initdb, but they can be changed.
lc_messages = 'C.UTF-8' # locale for system error message
# strings
lc_monetary = 'C.UTF-8' # locale for monetary formatting
lc_numeric = 'C.UTF-8' # locale for number formatting
lc_time = 'C.UTF-8' # locale for time formatting
# default configuration for text search
default_text_search_config = 'pg_catalog.english'
# - Shared Library Preloading -
#local_preload_libraries = ''
#session_preload_libraries = ''
#shared_preload_libraries = '' # (change requires restart)
#jit_provider = 'llvmjit' # JIT library to use
# - Other Defaults -
#dynamic_library_path = '$libdir'
#extension_destdir = '' # prepend path when loading extensions
# and shared objects (added by Debian)
#gin_fuzzy_search_limit = 0
#------------------------------------------------------------------------------
# LOCK MANAGEMENT
#------------------------------------------------------------------------------
#deadlock_timeout = 1s
#max_locks_per_transaction = 64 # min 10
# (change requires restart)
#max_pred_locks_per_transaction = 64 # min 10
# (change requires restart)
#max_pred_locks_per_relation = -2 # negative values mean
# (max_pred_locks_per_transaction
# / -max_pred_locks_per_relation) - 1
#max_pred_locks_per_page = 2 # min 0
#------------------------------------------------------------------------------
# VERSION AND PLATFORM COMPATIBILITY
#------------------------------------------------------------------------------
# - Previous PostgreSQL Versions -
#array_nulls = on
#backslash_quote = safe_encoding # on, off, or safe_encoding
#escape_string_warning = on
#lo_compat_privileges = off
#quote_all_identifiers = off
#standard_conforming_strings = on
#synchronize_seqscans = on
# - Other Platforms and Clients -
#transform_null_equals = off
#------------------------------------------------------------------------------
# ERROR HANDLING
#------------------------------------------------------------------------------
#exit_on_error = off # terminate session on any error?
#restart_after_crash = on # reinitialize after backend crash?
#data_sync_retry = off # retry or panic on failure to fsync
# data?
# (change requires restart)
#recovery_init_sync_method = fsync # fsync, syncfs (Linux 5.8+)
#------------------------------------------------------------------------------
# CONFIG FILE INCLUDES
#------------------------------------------------------------------------------
# These options allow settings to be loaded from files other than the
# default postgresql.conf. Note that these are directives, not variable
# assignments, so they can usefully be given more than once.
# include_dir = 'conf.d' # include files ending in '.conf' from
# a directory, e.g., 'conf.d'
#include_if_exists = '...' # include file only if it exists
#include = '...' # include file
#------------------------------------------------------------------------------
# CUSTOMIZED OPTIONS
#------------------------------------------------------------------------------
# Add settings for extensions here
-71
View File
@@ -1,71 +0,0 @@
[program:web]
command=node /app/web/web/server.js
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stdout
stderr_logfile_maxbytes=0
environment=PORT=3001,HOSTNAME=0.0.0.0
[program:space]
command=node /app/space/space/server.js
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stdout
stderr_logfile_maxbytes=0
environment=PORT=3002,HOSTNAME=0.0.0.0
[program:admin]
command=node /app/admin/admin/server.js
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stdout
stderr_logfile_maxbytes=0
environment=PORT=3003,HOSTNAME=0.0.0.0
[program:migrator]
directory=/app/api
command=sh -c "./bin/docker-entrypoint-migrator.sh"
autostart=true
autorestart=false
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stdout
stderr_logfile_maxbytes=0
[program:api]
directory=/app/api
command=sh -c "./bin/docker-entrypoint-api.sh"
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stdout
stderr_logfile_maxbytes=0
[program:worker]
directory=/app/api
command=sh -c "./bin/docker-entrypoint-worker.sh"
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stdout
stderr_logfile_maxbytes=0
[program:beat]
directory=/app/api
command=sh -c "./bin/docker-entrypoint-beat.sh"
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stdout
stderr_logfile_maxbytes=0
-38
View File
@@ -1,38 +0,0 @@
[supervisord]
user=root
nodaemon=true
stderr_logfile=/app/logs/error/supervisor.err.log
stdout_logfile=/app/logs/access/supervisor.log
[program:redis]
directory=/app/data/redis
command=redis-server
autostart=true
autorestart=true
stderr_logfile=/app/logs/error/redis.err.log
stdout_logfile=/app/logs/access/redis.log
[program:postgresql]
user=postgres
command=/usr/lib/postgresql/15/bin/postgres --config-file=/etc/postgresql/postgresql.conf
autostart=true
autorestart=true
stderr_logfile=/app/logs/error/postgresql.err.log
stdout_logfile=/app/logs/access/postgresql.log
[program:minio]
directory=/app/data/minio
command=minio server /app/data/minio
autostart=true
autorestart=true
stderr_logfile=/app/logs/error/minio.err.log
stdout_logfile=/app/logs/access/minio.log
[program:nginx]
directory=/app/data/nginx
command=/app/nginx-start.sh
autostart=true
autorestart=true
stderr_logfile=/app/logs/error/nginx.err.log
stdout_logfile=/app/logs/access/nginx.log
-14
View File
@@ -1,14 +0,0 @@
[supervisord]
user=root
nodaemon=true
stderr_logfile=/app/logs/error/supervisor.err.log
stdout_logfile=/app/logs/access/supervisor.log
[program:nginx]
directory=/app/data/nginx
command=/app/nginx-start.sh
autostart=true
autorestart=true
stderr_logfile=/app/logs/error/nginx.err.log
stdout_logfile=/app/logs/access/nginx.log
@@ -49,9 +49,9 @@ export const InstanceEmailForm: FC<IInstanceEmailForm> = (props) => {
EMAIL_USE_TLS: config["EMAIL_USE_TLS"],
EMAIL_USE_SSL: config["EMAIL_USE_SSL"],
EMAIL_FROM: config["EMAIL_FROM"],
ENABLE_SMTP: config["ENABLE_SMTP"],
},
});
const emailFormFields: TControllerInputFormField[] = [
{
key: "EMAIL_HOST",
@@ -101,7 +101,7 @@ export const InstanceEmailForm: FC<IInstanceEmailForm> = (props) => {
];
const onSubmit = async (formData: EmailFormValues) => {
const payload: Partial<EmailFormValues> = { ...formData };
const payload: Partial<EmailFormValues> = { ...formData, ENABLE_SMTP: "1" };
await updateInstanceConfigurations(payload)
.then(() =>
+65 -20
View File
@@ -1,8 +1,9 @@
"use client";
import { useEffect, useState } from "react";
import { observer } from "mobx-react";
import useSWR from "swr";
import { Loader } from "@plane/ui";
import { Loader, setToast, TOAST_TYPE, ToggleSwitch } from "@plane/ui";
// hooks
import { useInstance } from "@/hooks/store";
// components
@@ -10,36 +11,80 @@ import { InstanceEmailForm } from "./email-config-form";
const InstanceEmailPage = observer(() => {
// store
const { fetchInstanceConfigurations, formattedConfig } = useInstance();
const { fetchInstanceConfigurations, formattedConfig, disableEmail } = useInstance();
useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations());
const { isLoading } = useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations());
const [isSubmitting, setIsSubmitting] = useState(false);
const [isSMTPEnabled, setIsSMTPEnabled] = useState(false);
const handleToggle = async () => {
if (isSMTPEnabled) {
setIsSubmitting(true);
try {
await disableEmail();
setIsSMTPEnabled(false);
setToast({
title: "Email feature disabled",
message: "Email feature has been disabled",
type: TOAST_TYPE.SUCCESS,
});
} catch (error) {
setToast({
title: "Error disabling email",
message: "Failed to disable email feature. Please try again.",
type: TOAST_TYPE.ERROR,
});
} finally {
setIsSubmitting(false);
}
return;
}
setIsSMTPEnabled(true);
};
useEffect(() => {
if (formattedConfig) {
setIsSMTPEnabled(formattedConfig.ENABLE_SMTP === "1");
}
}, [formattedConfig]);
return (
<>
<div className="relative container mx-auto w-full h-full p-4 py-4 space-y-6 flex flex-col">
<div className="border-b border-custom-border-100 mx-4 py-4 space-y-1 flex-shrink-0">
<div className="text-xl font-medium text-custom-text-100">Secure emails from your own instance</div>
<div className="text-sm font-normal text-custom-text-300">
Plane can send useful emails to you and your users from your own instance without talking to the Internet.
<div className="flex items-center justify-between gap-4 border-b border-custom-border-100 mx-4 py-4 space-y-1 flex-shrink-0">
<div className="py-4 space-y-1 flex-shrink-0">
<div className="text-xl font-medium text-custom-text-100">Secure emails from your own instance</div>
<div className="text-sm font-normal text-custom-text-300">
Set it up below and please test your settings before you save them.&nbsp;
<span className="text-red-400">Misconfigs can lead to email bounces and errors.</span>
Plane can send useful emails to you and your users from your own instance without talking to the Internet.
<div className="text-sm font-normal text-custom-text-300">
Set it up below and please test your settings before you save them.&nbsp;
<span className="text-red-400">Misconfigs can lead to email bounces and errors.</span>
</div>
</div>
</div>
</div>
<div className="flex-grow overflow-hidden overflow-y-scroll vertical-scrollbar scrollbar-md px-4">
{formattedConfig ? (
<InstanceEmailForm config={formattedConfig} />
) : (
<Loader className="space-y-10">
<Loader.Item height="50px" width="75%" />
<Loader.Item height="50px" width="75%" />
<Loader.Item height="50px" width="40%" />
<Loader.Item height="50px" width="40%" />
<Loader.Item height="50px" width="20%" />
{isLoading ? (
<Loader>
<Loader.Item width="24px" height="16px" className="rounded-full" />
</Loader>
) : (
<ToggleSwitch value={isSMTPEnabled} onChange={handleToggle} size="sm" disabled={isSubmitting} />
)}
</div>
{isSMTPEnabled && !isLoading && (
<div className="flex-grow overflow-hidden overflow-y-scroll vertical-scrollbar scrollbar-md px-4">
{formattedConfig ? (
<InstanceEmailForm config={formattedConfig} />
) : (
<Loader className="space-y-10">
<Loader.Item height="50px" width="75%" />
<Loader.Item height="50px" width="75%" />
<Loader.Item height="50px" width="40%" />
<Loader.Item height="50px" width="40%" />
<Loader.Item height="50px" width="20%" />
</Loader>
)}
</div>
)}
</div>
</>
);
+1 -1
View File
@@ -72,7 +72,7 @@ export const AdminHeader: FC = observer(() => {
const breadcrumbItems = generateBreadcrumbItems(pathName);
return (
<div className="relative z-10 flex h-[3.75rem] w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 border-b border-custom-sidebar-border-200 bg-custom-sidebar-background-100 p-4">
<div className="relative z-10 flex h-header w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 border-b border-custom-sidebar-border-200 bg-custom-sidebar-background-100 p-4">
<div className="flex w-full flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap">
<HamburgerToggle />
{breadcrumbItems.length >= 0 && (
@@ -77,7 +77,7 @@ export const AdminSidebarDropdown = observer(() => {
}, [csrfToken]);
return (
<div className="flex max-h-[3.75rem] items-center gap-x-5 gap-y-2 border-b border-custom-sidebar-border-200 px-4 py-3.5">
<div className="flex max-h-header items-center gap-x-5 gap-y-2 border-b border-custom-sidebar-border-200 px-4 py-3.5">
<div className="h-full w-full truncate">
<div
className={`flex flex-grow items-center gap-x-2 truncate rounded py-1 ${
+27
View File
@@ -32,6 +32,7 @@ export interface IInstanceStore {
fetchInstanceAdmins: () => Promise<IInstanceAdmin[] | undefined>;
fetchInstanceConfigurations: () => Promise<IInstanceConfiguration[] | undefined>;
updateInstanceConfigurations: (data: Partial<IFormattedInstanceConfiguration>) => Promise<IInstanceConfiguration[]>;
disableEmail: () => Promise<void>;
}
export class InstanceStore implements IInstanceStore {
@@ -187,4 +188,30 @@ export class InstanceStore implements IInstanceStore {
throw error;
}
};
disableEmail = async () => {
const instanceConfigurations = this.instanceConfigurations;
try {
runInAction(() => {
this.instanceConfigurations = this.instanceConfigurations?.map((config) => {
if (
[
"EMAIL_HOST",
"EMAIL_PORT",
"EMAIL_HOST_USER",
"EMAIL_HOST_PASSWORD",
"EMAIL_FROM",
"ENABLE_SMTP",
].includes(config.key)
)
return { ...config, value: "" };
return config;
});
});
await this.instanceService.disableEmail();
} catch (error) {
console.error("Error disabling the email");
this.instanceConfigurations = instanceConfigurations;
}
};
}
+2 -3
View File
@@ -28,7 +28,7 @@ AWS_REGION=""
AWS_ACCESS_KEY_ID="access-key"
AWS_SECRET_ACCESS_KEY="secret-key"
AWS_S3_ENDPOINT_URL="http://localhost:9000"
# Changing this requires change in the nginx.conf for uploads if using minio setup
# Changing this requires change in the proxy config for uploads if using minio setup
AWS_S3_BUCKET_NAME="uploads"
# Maximum file upload limit
FILE_SIZE_LIMIT=5242880
@@ -39,8 +39,7 @@ DOCKERIZED=1 # deprecated
# set to 1 If using the pre-configured minio setup
USE_MINIO=0
# Nginx Configuration
NGINX_PORT=80
# Email redirections and minio domain settings
WEB_URL="http://localhost:8000"
+2 -2
View File
@@ -25,7 +25,7 @@ from rest_framework import status
from rest_framework.response import Response
# Module imports
from plane.api.serializers import CycleIssueSerializer, CycleSerializer
from plane.api.serializers import CycleIssueSerializer, CycleSerializer, IssueSerializer
from plane.app.permissions import ProjectEntityPermission
from plane.bgtasks.issue_activities_task import issue_activity
from plane.db.models import (
@@ -639,7 +639,7 @@ class CycleIssueAPIEndpoint(BaseAPIView):
return self.paginate(
request=request,
queryset=(issues),
on_results=lambda issues: CycleSerializer(
on_results=lambda issues: IssueSerializer(
issues, many=True, fields=self.fields, expand=self.expand
).data,
)
+40 -44
View File
@@ -24,55 +24,51 @@ class ProjectSerializer(BaseSerializer):
fields = "__all__"
read_only_fields = ["workspace", "deleted_at"]
def validate_name(self, name):
project_id = self.instance.id if self.instance else None
workspace_id = self.context["workspace_id"]
project = Project.objects.filter(name=name, workspace_id=workspace_id)
if project_id:
project = project.exclude(id=project_id)
if project.exists():
raise serializers.ValidationError(
detail="PROJECT_NAME_ALREADY_EXIST",
)
return name
def validate_identifier(self, identifier):
project_id = self.instance.id if self.instance else None
workspace_id = self.context["workspace_id"]
project = Project.objects.filter(
identifier=identifier, workspace_id=workspace_id
)
if project_id:
project = project.exclude(id=project_id)
if project.exists():
raise serializers.ValidationError(
detail="PROJECT_IDENTIFIER_ALREADY_EXIST",
)
return identifier
def create(self, validated_data):
identifier = validated_data.get("identifier", "").strip().upper()
if identifier == "":
raise serializers.ValidationError(detail="Project Identifier is required")
workspace_id = self.context["workspace_id"]
if ProjectIdentifier.objects.filter(
name=identifier, workspace_id=self.context["workspace_id"]
).exists():
raise serializers.ValidationError(detail="Project Identifier is taken")
project = Project.objects.create(
**validated_data, workspace_id=self.context["workspace_id"]
)
_ = ProjectIdentifier.objects.create(
name=project.identifier,
project=project,
workspace_id=self.context["workspace_id"],
project = Project.objects.create(**validated_data, workspace_id=workspace_id)
ProjectIdentifier.objects.create(
name=project.identifier, project=project, workspace_id=workspace_id
)
return project
def update(self, instance, validated_data):
identifier = validated_data.get("identifier", "").strip().upper()
# If identifier is not passed update the project and return
if identifier == "":
project = super().update(instance, validated_data)
return project
# If no Project Identifier is found create it
project_identifier = ProjectIdentifier.objects.filter(
name=identifier, workspace_id=instance.workspace_id
).first()
if project_identifier is None:
project = super().update(instance, validated_data)
project_identifier = ProjectIdentifier.objects.filter(
project=project
).first()
if project_identifier is not None:
project_identifier.name = identifier
project_identifier.save()
return project
# If found check if the project_id to be updated and identifier project id is same
if project_identifier.project_id == instance.id:
# If same pass update
project = super().update(instance, validated_data)
return project
# If not same fail update
raise serializers.ValidationError(detail="Project Identifier is already taken")
class ProjectLiteSerializer(BaseSerializer):
class Meta:
@@ -76,7 +76,6 @@ class WorkspaceLiteSerializer(BaseSerializer):
class WorkSpaceMemberSerializer(DynamicBaseSerializer):
member = UserLiteSerializer(read_only=True)
workspace = WorkspaceLiteSerializer(read_only=True)
class Meta:
model = WorkspaceMember
@@ -93,7 +92,6 @@ class WorkspaceMemberMeSerializer(BaseSerializer):
class WorkspaceMemberAdminSerializer(DynamicBaseSerializer):
member = UserAdminLiteSerializer(read_only=True)
workspace = WorkspaceLiteSerializer(read_only=True)
class Meta:
model = WorkspaceMember
+4 -2
View File
@@ -740,7 +740,8 @@ class WorkspaceAssetDownloadEndpoint(BaseAPIView):
storage = S3Storage(request=request)
signed_url = storage.generate_presigned_url(
object_name=asset.asset.name,
disposition=f"attachment; filename={asset.asset.name}",
disposition="attachment",
filename=asset.attributes.get("name", uuid.uuid4().hex),
)
return HttpResponseRedirect(signed_url)
@@ -767,7 +768,8 @@ class ProjectAssetDownloadEndpoint(BaseAPIView):
storage = S3Storage(request=request)
signed_url = storage.generate_presigned_url(
object_name=asset.asset.name,
disposition=f"attachment; filename={asset.asset.name}",
disposition="attachment",
filename=asset.attributes.get("name", uuid.uuid4().hex),
)
return HttpResponseRedirect(signed_url)
+2 -2
View File
@@ -40,7 +40,7 @@ from ..base import BaseAPIView, BaseViewSet
from plane.bgtasks.page_transaction_task import page_transaction
from plane.bgtasks.page_version_task import page_version
from plane.bgtasks.recent_visited_task import recent_visited_task
from plane.bgtasks.copy_s3_object import copy_s3_objects
from plane.bgtasks.copy_s3_object import copy_s3_objects_of_description_and_assets
def unarchive_archive_page_and_descendants(page_id, archived_at):
@@ -606,7 +606,7 @@ class PageDuplicateEndpoint(BaseAPIView):
)
# Copy the s3 objects uploaded in the page
copy_s3_objects.delay(
copy_s3_objects_of_description_and_assets.delay(
entity_name="PAGE",
entity_identifier=page.id,
project_id=project_id,
+142 -182
View File
@@ -239,205 +239,165 @@ class ProjectViewSet(BaseViewSet):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
def create(self, request, slug):
try:
workspace = Workspace.objects.get(slug=slug)
workspace = Workspace.objects.get(slug=slug)
serializer = ProjectSerializer(
data={**request.data}, context={"workspace_id": workspace.id}
serializer = ProjectSerializer(
data={**request.data}, context={"workspace_id": workspace.id}
)
if serializer.is_valid():
serializer.save()
# Add the user as Administrator to the project
_ = ProjectMember.objects.create(
project_id=serializer.data["id"], member=request.user, role=20
)
# Also create the issue property for the user
_ = IssueUserProperty.objects.create(
project_id=serializer.data["id"], user=request.user
)
if serializer.is_valid():
serializer.save()
# Add the user as Administrator to the project
_ = ProjectMember.objects.create(
project_id=serializer.data["id"], member=request.user, role=20
if serializer.data["project_lead"] is not None and str(
serializer.data["project_lead"]
) != str(request.user.id):
ProjectMember.objects.create(
project_id=serializer.data["id"],
member_id=serializer.data["project_lead"],
role=20,
)
# Also create the issue property for the user
_ = IssueUserProperty.objects.create(
project_id=serializer.data["id"], user=request.user
IssueUserProperty.objects.create(
project_id=serializer.data["id"],
user_id=serializer.data["project_lead"],
)
if serializer.data["project_lead"] is not None and str(
serializer.data["project_lead"]
) != str(request.user.id):
ProjectMember.objects.create(
project_id=serializer.data["id"],
member_id=serializer.data["project_lead"],
role=20,
)
# Also create the issue property for the user
IssueUserProperty.objects.create(
project_id=serializer.data["id"],
user_id=serializer.data["project_lead"],
)
# Default states
states = [
{
"name": "Backlog",
"color": "#60646C",
"sequence": 15000,
"group": "backlog",
"default": True,
},
{
"name": "Todo",
"color": "#60646C",
"sequence": 25000,
"group": "unstarted",
},
{
"name": "In Progress",
"color": "#F59E0B",
"sequence": 35000,
"group": "started",
},
{
"name": "Done",
"color": "#46A758",
"sequence": 45000,
"group": "completed",
},
{
"name": "Cancelled",
"color": "#9AA4BC",
"sequence": 55000,
"group": "cancelled",
},
]
State.objects.bulk_create(
[
State(
name=state["name"],
color=state["color"],
project=serializer.instance,
sequence=state["sequence"],
workspace=serializer.instance.workspace,
group=state["group"],
default=state.get("default", False),
created_by=request.user,
)
for state in states
]
)
project = self.get_queryset().filter(pk=serializer.data["id"]).first()
# Create the model activity
model_activity.delay(
model_name="project",
model_id=str(project.id),
requested_data=request.data,
current_instance=None,
actor_id=request.user.id,
slug=slug,
origin=base_host(request=request, is_app=True),
)
serializer = ProjectListSerializer(project)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
except IntegrityError as e:
if "already exists" in str(e):
return Response(
{
"name": "The project name is already taken",
"code": "PROJECT_NAME_ALREADY_EXIST",
},
status=status.HTTP_409_CONFLICT,
)
except Workspace.DoesNotExist:
return Response(
{"error": "Workspace does not exist"}, status=status.HTTP_404_NOT_FOUND
)
except serializers.ValidationError:
return Response(
# Default states
states = [
{
"identifier": "The project identifier is already taken",
"code": "PROJECT_IDENTIFIER_ALREADY_EXIST",
"name": "Backlog",
"color": "#60646C",
"sequence": 15000,
"group": "backlog",
"default": True,
},
status=status.HTTP_409_CONFLICT,
{
"name": "Todo",
"color": "#60646C",
"sequence": 25000,
"group": "unstarted",
},
{
"name": "In Progress",
"color": "#F59E0B",
"sequence": 35000,
"group": "started",
},
{
"name": "Done",
"color": "#46A758",
"sequence": 45000,
"group": "completed",
},
{
"name": "Cancelled",
"color": "#9AA4BC",
"sequence": 55000,
"group": "cancelled",
},
]
State.objects.bulk_create(
[
State(
name=state["name"],
color=state["color"],
project=serializer.instance,
sequence=state["sequence"],
workspace=serializer.instance.workspace,
group=state["group"],
default=state.get("default", False),
created_by=request.user,
)
for state in states
]
)
project = self.get_queryset().filter(pk=serializer.data["id"]).first()
# Create the model activity
model_activity.delay(
model_name="project",
model_id=str(project.id),
requested_data=request.data,
current_instance=None,
actor_id=request.user.id,
slug=slug,
origin=base_host(request=request, is_app=True),
)
serializer = ProjectListSerializer(project)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def partial_update(self, request, slug, pk=None):
try:
if not ProjectMember.objects.filter(
member=request.user,
workspace__slug=slug,
project_id=pk,
role=20,
is_active=True,
).exists():
return Response(
{"error": "You don't have the required permissions."},
status=status.HTTP_403_FORBIDDEN,
)
workspace = Workspace.objects.get(slug=slug)
project = Project.objects.get(pk=pk)
intake_view = request.data.get("inbox_view", project.intake_view)
current_instance = json.dumps(
ProjectSerializer(project).data, cls=DjangoJSONEncoder
)
if project.archived_at:
return Response(
{"error": "Archived projects cannot be updated"},
status=status.HTTP_400_BAD_REQUEST,
)
serializer = ProjectSerializer(
project,
data={**request.data, "intake_view": intake_view},
context={"workspace_id": workspace.id},
partial=True,
)
if serializer.is_valid():
serializer.save()
if intake_view:
intake = Intake.objects.filter(
project=project, is_default=True
).first()
if not intake:
Intake.objects.create(
name=f"{project.name} Intake",
project=project,
is_default=True,
)
project = self.get_queryset().filter(pk=serializer.data["id"]).first()
model_activity.delay(
model_name="project",
model_id=str(project.id),
requested_data=request.data,
current_instance=current_instance,
actor_id=request.user.id,
slug=slug,
origin=base_host(request=request, is_app=True),
)
serializer = ProjectListSerializer(project)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
except IntegrityError as e:
if "already exists" in str(e):
return Response(
{"name": "The project name is already taken"},
status=status.HTTP_409_CONFLICT,
)
except (Project.DoesNotExist, Workspace.DoesNotExist):
# try:
if not ProjectMember.objects.filter(
member=request.user,
workspace__slug=slug,
project_id=pk,
role=20,
is_active=True,
).exists():
return Response(
{"error": "Project does not exist"}, status=status.HTTP_404_NOT_FOUND
{"error": "You don't have the required permissions."},
status=status.HTTP_403_FORBIDDEN,
)
except serializers.ValidationError:
workspace = Workspace.objects.get(slug=slug)
project = Project.objects.get(pk=pk)
intake_view = request.data.get("inbox_view", project.intake_view)
current_instance = json.dumps(
ProjectSerializer(project).data, cls=DjangoJSONEncoder
)
if project.archived_at:
return Response(
{"identifier": "The project identifier is already taken"},
status=status.HTTP_409_CONFLICT,
{"error": "Archived projects cannot be updated"},
status=status.HTTP_400_BAD_REQUEST,
)
serializer = ProjectSerializer(
project,
data={**request.data, "intake_view": intake_view},
context={"workspace_id": workspace.id},
partial=True,
)
if serializer.is_valid():
serializer.save()
if intake_view:
intake = Intake.objects.filter(project=project, is_default=True).first()
if not intake:
Intake.objects.create(
name=f"{project.name} Intake",
project=project,
is_default=True,
)
project = self.get_queryset().filter(pk=serializer.data["id"]).first()
model_activity.delay(
model_name="project",
model_id=str(project.id),
requested_data=request.data,
current_instance=current_instance,
actor_id=request.user.id,
slug=slug,
origin=base_host(request=request, is_app=True),
)
serializer = ProjectListSerializer(project)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def destroy(self, request, slug, pk):
if (
WorkspaceMember.objects.filter(
@@ -116,6 +116,7 @@ class TimezoneEndpoint(APIView):
("Astrakhan", "Europe/Astrakhan"), # UTC+04:00
("Tbilisi", "Asia/Tbilisi"), # UTC+04:00
("Mauritius", "Indian/Mauritius"), # UTC+04:00
("Kabul", "Asia/Kabul"), # UTC+04:30
("Islamabad", "Asia/Karachi"), # UTC+05:00
("Karachi", "Asia/Karachi"), # UTC+05:00
("Tashkent", "Asia/Tashkent"), # UTC+05:00
+1 -1
View File
@@ -80,7 +80,7 @@ class WorkspaceInvitationsViewset(BaseViewSet):
workspace_id=workspace.id,
member__email__in=[email.get("email") for email in emails],
is_active=True,
).select_related("member", "workspace", "workspace__owner")
).select_related("member", "member__avatar_asset")
if workspace_members:
return Response(
+1 -2
View File
@@ -34,8 +34,7 @@ class WorkSpaceMemberViewSet(BaseViewSet):
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"), is_active=True)
.select_related("workspace", "workspace__owner")
.select_related("member")
.select_related("member", "member__avatar_asset")
)
@allow_permission(
+55 -44
View File
@@ -83,8 +83,52 @@ def sync_with_external_service(entity_name, description_html):
return {}
def copy_assets(entity, entity_identifier, project_id, asset_ids, user_id):
duplicated_assets = []
workspace = entity.workspace
storage = S3Storage()
original_assets = FileAsset.objects.filter(
workspace=workspace, project_id=project_id, id__in=asset_ids
)
for original_asset in original_assets:
destination_key = (
f"{workspace.id}/{uuid.uuid4().hex}-{original_asset.attributes.get('name')}"
)
duplicated_asset = FileAsset.objects.create(
attributes={
"name": original_asset.attributes.get("name"),
"type": original_asset.attributes.get("type"),
"size": original_asset.attributes.get("size"),
},
asset=destination_key,
size=original_asset.size,
workspace=workspace,
created_by_id=user_id,
entity_type=original_asset.entity_type,
project_id=project_id,
storage_metadata=original_asset.storage_metadata,
**get_entity_id_field(original_asset.entity_type, entity_identifier),
)
storage.copy_object(original_asset.asset, destination_key)
duplicated_assets.append(
{
"new_asset_id": str(duplicated_asset.id),
"old_asset_id": str(original_asset.id),
}
)
if duplicated_assets:
FileAsset.objects.filter(
pk__in=[item["new_asset_id"] for item in duplicated_assets]
).update(is_uploaded=True)
return duplicated_assets
@shared_task
def copy_s3_objects(entity_name, entity_identifier, project_id, slug, user_id):
def copy_s3_objects_of_description_and_assets(
entity_name, entity_identifier, project_id, slug, user_id
):
"""
Step 1: Extract asset ids from the description_html of the entity
Step 2: Duplicate the assets
@@ -100,53 +144,20 @@ def copy_s3_objects(entity_name, entity_identifier, project_id, slug, user_id):
entity = model_class.objects.get(id=entity_identifier)
asset_ids = extract_asset_ids(entity.description_html, "image-component")
duplicated_assets = []
workspace = entity.workspace
storage = S3Storage()
original_assets = FileAsset.objects.filter(
workspace=workspace, project_id=project_id, id__in=asset_ids
duplicated_assets = copy_assets(
entity, entity_identifier, project_id, asset_ids, user_id
)
for original_asset in original_assets:
destination_key = f"{workspace.id}/{uuid.uuid4().hex}-{original_asset.attributes.get('name')}"
duplicated_asset = FileAsset.objects.create(
attributes={
"name": original_asset.attributes.get("name"),
"type": original_asset.attributes.get("type"),
"size": original_asset.attributes.get("size"),
},
asset=destination_key,
size=original_asset.size,
workspace=workspace,
created_by_id=user_id,
entity_type=original_asset.entity_type,
project_id=project_id,
storage_metadata=original_asset.storage_metadata,
**get_entity_id_field(original_asset.entity_type, entity_identifier),
)
storage.copy_object(original_asset.asset, destination_key)
duplicated_assets.append(
{
"new_asset_id": str(duplicated_asset.id),
"old_asset_id": str(original_asset.id),
}
)
updated_html = update_description(entity, duplicated_assets, "image-component")
if duplicated_assets:
FileAsset.objects.filter(
pk__in=[item["new_asset_id"] for item in duplicated_assets]
).update(is_uploaded=True)
updated_html = update_description(
entity, duplicated_assets, "image-component"
)
external_data = sync_with_external_service(entity_name, updated_html)
external_data = sync_with_external_service(entity_name, updated_html)
if external_data:
entity.description = external_data.get("description")
entity.description_binary = base64.b64decode(
external_data.get("description_binary")
)
entity.save()
if external_data:
entity.description = external_data.get("description")
entity.description_binary = base64.b64decode(
external_data.get("description_binary")
)
entity.save()
return
except Exception as e:
@@ -21,8 +21,8 @@ from plane.utils.exception_logger import log_exception
def remove_unwanted_characters(input_text):
# Keep only alphanumeric characters, spaces, and dashes.
processed_text = re.sub(r"[^a-zA-Z0-9 \-]", "", input_text)
# Remove only control characters and potentially problematic characters for email subjects
processed_text = re.sub(r"[\x00-\x1F\x7F-\x9F]", "", input_text)
return processed_text
@@ -0,0 +1,28 @@
# Generated by Django 4.2.21 on 2025-07-14 10:47
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('db', '0097_project_external_id_project_external_source'),
]
operations = [
migrations.AddField(
model_name='profile',
name='is_app_rail_docked',
field=models.BooleanField(default=True),
),
migrations.AlterField(
model_name='commentreaction',
name='reaction',
field=models.TextField(),
),
migrations.AlterField(
model_name='issuereaction',
name='reaction',
field=models.TextField(),
),
]
+30 -23
View File
@@ -215,30 +215,37 @@ class Issue(ProjectBaseModel):
with connection.cursor() as cursor:
# Get an exclusive lock using the project ID as the lock key
cursor.execute("SELECT pg_advisory_xact_lock(%s)", [lock_key])
cursor.execute("SELECT pg_advisory_lock(%s)", [lock_key])
# Get the last sequence for the project
last_sequence = IssueSequence.objects.filter(
project=self.project
).aggregate(largest=models.Max("sequence"))["largest"]
self.sequence_id = last_sequence + 1 if last_sequence else 1
# Strip the html tags using html parser
self.description_stripped = (
None
if (self.description_html == "" or self.description_html is None)
else strip_tags(self.description_html)
)
largest_sort_order = Issue.objects.filter(
project=self.project, state=self.state
).aggregate(largest=models.Max("sort_order"))["largest"]
if largest_sort_order is not None:
self.sort_order = largest_sort_order + 10000
try:
# Get the last sequence for the project
last_sequence = IssueSequence.objects.filter(
project=self.project
).aggregate(largest=models.Max("sequence"))["largest"]
self.sequence_id = last_sequence + 1 if last_sequence else 1
# Strip the html tags using html parser
self.description_stripped = (
None
if (
self.description_html == "" or self.description_html is None
)
else strip_tags(self.description_html)
)
largest_sort_order = Issue.objects.filter(
project=self.project, state=self.state
).aggregate(largest=models.Max("sort_order"))["largest"]
if largest_sort_order is not None:
self.sort_order = largest_sort_order + 10000
super(Issue, self).save(*args, **kwargs)
super(Issue, self).save(*args, **kwargs)
IssueSequence.objects.create(
issue=self, sequence=self.sequence_id, project=self.project
)
IssueSequence.objects.create(
issue=self, sequence=self.sequence_id, project=self.project
)
finally:
# Release the lock
with connection.cursor() as cursor:
cursor.execute("SELECT pg_advisory_unlock(%s)", [lock_key])
else:
# Strip the html tags using html parser
self.description_stripped = (
@@ -594,7 +601,7 @@ class IssueReaction(ProjectBaseModel):
issue = models.ForeignKey(
Issue, on_delete=models.CASCADE, related_name="issue_reactions"
)
reaction = models.CharField(max_length=20)
reaction = models.TextField()
class Meta:
unique_together = ["issue", "actor", "reaction", "deleted_at"]
@@ -623,7 +630,7 @@ class CommentReaction(ProjectBaseModel):
comment = models.ForeignKey(
IssueComment, on_delete=models.CASCADE, related_name="comment_reactions"
)
reaction = models.CharField(max_length=20)
reaction = models.TextField()
class Meta:
unique_together = ["comment", "actor", "reaction", "deleted_at"]
+1
View File
@@ -197,6 +197,7 @@ class Profile(TimeAuditModel):
)
# General
theme = models.JSONField(default=dict)
is_app_rail_docked = models.BooleanField(default=True)
# Onboarding
is_tour_completed = models.BooleanField(default=False)
onboarding_step = models.JSONField(default=get_default_onboarding)
+5 -1
View File
@@ -1,7 +1,11 @@
from .instance import InstanceEndpoint, SignUpScreenVisitedEndpoint
from .configuration import EmailCredentialCheckEndpoint, InstanceConfigurationEndpoint
from .configuration import (
EmailCredentialCheckEndpoint,
InstanceConfigurationEndpoint,
DisableEmailFeatureEndpoint,
)
from .admin import (
@@ -9,6 +9,7 @@ from smtplib import (
# Django imports
from django.core.mail import BadHeaderError, EmailMultiAlternatives, get_connection
from django.db.models import Q, Case, When, Value
# Third party imports
from rest_framework import status
@@ -57,6 +58,34 @@ class InstanceConfigurationEndpoint(BaseAPIView):
return Response(serializer.data, status=status.HTTP_200_OK)
class DisableEmailFeatureEndpoint(BaseAPIView):
permission_classes = [InstanceAdminPermission]
@invalidate_cache(path="/api/instances/", user=False)
def delete(self, request):
try:
InstanceConfiguration.objects.filter(
Q(
key__in=[
"EMAIL_HOST",
"EMAIL_HOST_USER",
"EMAIL_HOST_PASSWORD",
"ENABLE_SMTP",
"EMAIL_PORT",
"EMAIL_FROM",
]
)
).update(
value=Case(When(key="ENABLE_SMTP", then=Value("0")), default=Value(""))
)
return Response(status=status.HTTP_200_OK)
except Exception:
return Response(
{"error": "Failed to disable email configuration"},
status=status.HTTP_400_BAD_REQUEST,
)
class EmailCredentialCheckEndpoint(BaseAPIView):
def post(self, request):
receiver_email = request.data.get("receiver_email", False)
@@ -89,6 +89,12 @@ class Command(BaseCommand):
"category": "GITLAB",
"is_encrypted": False,
},
{
"key": "ENABLE_SMTP",
"value": os.environ.get("ENABLE_SMTP", "0"),
"category": "SMTP",
"is_encrypted": False,
},
{
"key": "GITLAB_CLIENT_SECRET",
"value": os.environ.get("GITLAB_CLIENT_SECRET"),
+6
View File
@@ -6,6 +6,7 @@ from plane.license.api.views import (
InstanceAdminSignInEndpoint,
InstanceAdminSignUpEndpoint,
InstanceConfigurationEndpoint,
DisableEmailFeatureEndpoint,
InstanceEndpoint,
SignUpScreenVisitedEndpoint,
InstanceAdminUserMeEndpoint,
@@ -35,6 +36,11 @@ urlpatterns = [
InstanceConfigurationEndpoint.as_view(),
name="instance-configuration",
),
path(
"configurations/disable-email-feature/",
DisableEmailFeatureEndpoint.as_view(),
name="disable-email-configuration",
),
path(
"admins/sign-in/",
InstanceAdminSignInEndpoint.as_view(),
+4
View File
@@ -1,5 +1,6 @@
# Python imports
import os
import uuid
# Third party imports
import boto3
@@ -101,6 +102,9 @@ class S3Storage(S3Boto3Storage):
def _get_content_disposition(self, disposition, filename=None):
"""Helper method to generate Content-Disposition header value"""
if filename is None:
filename = uuid.uuid4().hex
if filename:
# Encode the filename to handle special characters
encoded_filename = quote(filename)
@@ -0,0 +1,182 @@
import pytest
from plane.db.models import Project, ProjectMember, Issue, FileAsset
from unittest.mock import patch, MagicMock
from plane.bgtasks.copy_s3_object import (
copy_s3_objects_of_description_and_assets,
copy_assets,
)
import base64
@pytest.mark.unit
class TestCopyS3Objects:
"""Test the copy_s3_objects_of_description_and_assets function"""
@pytest.fixture
def project(self, create_user, workspace):
project = Project.objects.create(
name="Test Project", identifier="test-project", workspace=workspace
)
ProjectMember.objects.create(project=project, member=create_user)
return project
@pytest.fixture
def issue(self, workspace, project):
return Issue.objects.create(
name="Test Issue",
workspace=workspace,
project_id=project.id,
description_html=f'<div><image-component src="35e8b958-6ee5-43ce-ae56-fb0e776f421e"></image-component><image-component src="97988198-274f-4dfe-aa7a-4c0ffc684214"></image-component></div>',
)
@pytest.fixture
def file_asset(self, workspace, project, issue):
return FileAsset.objects.create(
issue=issue,
workspace=workspace,
project=project,
asset="workspace1/test-asset-1.jpg",
attributes={
"name": "test-asset-1.jpg",
"size": 100,
"type": "image/jpeg",
},
id="35e8b958-6ee5-43ce-ae56-fb0e776f421e",
entity_type="ISSUE_DESCRIPTION",
)
@pytest.mark.django_db
@patch("plane.bgtasks.copy_s3_object.S3Storage")
def test_copy_s3_objects_of_description_and_assets(
self, mock_s3_storage, create_user, workspace, project, issue, file_asset
):
FileAsset.objects.create(
issue=issue,
workspace=workspace,
project=project,
asset="workspace1/test-asset-2.pdf",
attributes={
"name": "test-asset-2.pdf",
"size": 100,
"type": "application/pdf",
},
id="97988198-274f-4dfe-aa7a-4c0ffc684214",
entity_type="ISSUE_DESCRIPTION",
)
issue.save()
# Set up mock S3 storage
mock_storage_instance = MagicMock()
mock_s3_storage.return_value = mock_storage_instance
# Mock the external service call to avoid actual HTTP requests
with patch(
"plane.bgtasks.copy_s3_object.sync_with_external_service"
) as mock_sync:
mock_sync.return_value = {
"description": "test description",
"description_binary": base64.b64encode(b"test binary").decode(),
}
# Call the actual function (not .delay())
copy_s3_objects_of_description_and_assets(
"ISSUE", issue.id, project.id, "test-workspace", create_user.id
)
# Assert that copy_object was called for each asset
assert mock_storage_instance.copy_object.call_count == 2
# Get the updated issue and its new assets
updated_issue = Issue.objects.get(id=issue.id)
new_assets = FileAsset.objects.filter(
issue=updated_issue,
entity_type="ISSUE_DESCRIPTION",
)
# Verify new assets were created
assert new_assets.count() == 4 # 2 original + 2 copied
@pytest.mark.django_db
@patch("plane.bgtasks.copy_s3_object.S3Storage")
def test_copy_assets_successful(
self, mock_s3_storage, workspace, project, issue, file_asset
):
"""Test successful copying of assets"""
# Arrange
mock_storage_instance = MagicMock()
mock_s3_storage.return_value = mock_storage_instance
# Act
result = copy_assets(
entity=issue,
entity_identifier=issue.id,
project_id=project.id,
asset_ids=[file_asset.id],
user_id=issue.created_by_id,
)
# Assert
# Verify S3 copy was called
mock_storage_instance.copy_object.assert_called_once()
# Verify new asset was created
assert len(result) == 1
new_asset_id = result[0]["new_asset_id"]
new_asset = FileAsset.objects.get(id=new_asset_id)
# Verify asset properties were copied correctly
assert new_asset.workspace == workspace
assert new_asset.project_id == project.id
assert new_asset.entity_type == file_asset.entity_type
assert new_asset.attributes == file_asset.attributes
assert new_asset.size == file_asset.size
assert new_asset.is_uploaded is True
@pytest.mark.django_db
@patch("plane.bgtasks.copy_s3_object.S3Storage")
def test_copy_assets_empty_asset_ids(
self, mock_s3_storage, workspace, project, issue
):
"""Test copying with empty asset_ids list"""
# Arrange
mock_storage_instance = MagicMock()
mock_s3_storage.return_value = mock_storage_instance
# Act
result = copy_assets(
entity=issue,
entity_identifier=issue.id,
project_id=project.id,
asset_ids=[],
user_id=issue.created_by_id,
)
# Assert
assert result == []
mock_storage_instance.copy_object.assert_not_called()
@pytest.mark.django_db
@patch("plane.bgtasks.copy_s3_object.S3Storage")
def test_copy_assets_nonexistent_asset(
self, mock_s3_storage, workspace, project, issue
):
"""Test copying with non-existent asset ID"""
# Arrange
mock_storage_instance = MagicMock()
mock_s3_storage.return_value = mock_storage_instance
non_existent_id = "00000000-0000-0000-0000-000000000000"
# Act
result = copy_assets(
entity=issue,
entity_identifier=issue.id,
project_id=project.id,
asset_ids=[non_existent_id],
user_id=issue.created_by_id,
)
# Assert
assert result == []
mock_storage_instance.copy_object.assert_not_called()
+3 -3
View File
@@ -29,7 +29,7 @@
"@tiptap/core": "2.10.4",
"@tiptap/html": "2.11.0",
"axios": "^1.8.3",
"compression": "^1.7.4",
"compression": "1.8.1",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.21.2",
@@ -37,7 +37,7 @@
"helmet": "^7.1.0",
"ioredis": "^5.4.1",
"lodash": "^4.17.21",
"morgan": "^1.10.0",
"morgan": "1.10.1",
"pino-http": "^10.3.0",
"pino-pretty": "^11.2.2",
"uuid": "^10.0.0",
@@ -48,7 +48,7 @@
"devDependencies": {
"@plane/eslint-config": "*",
"@plane/typescript-config": "*",
"@types/compression": "^1.7.5",
"@types/compression": "1.8.1",
"@types/cors": "^2.8.17",
"@types/dotenv": "^8.2.0",
"@types/express": "^4.17.21",
+34
View File
@@ -0,0 +1,34 @@
(plane_proxy) {
request_body {
max_size {$FILE_SIZE_LIMIT}
}
reverse_proxy /spaces/* space:3000
reverse_proxy /god-mode/* admin:3000
reverse_proxy /live/* live:3000
reverse_proxy /api/* api:8000
reverse_proxy /auth/* api:8000
reverse_proxy /{$BUCKET_NAME}/* plane-minio:9000
reverse_proxy /* web:3000
}
{
{$CERT_EMAIL}
acme_ca {$CERT_ACME_CA:https://acme-v02.api.letsencrypt.org/directory}
{$CERT_ACME_DNS}
servers {
max_header_size 25MB
client_ip_headers X-Forwarded-For X-Real-IP
trusted_proxies static {$TRUSTED_PROXIES:0.0.0.0/0}
}
}
{$SITE_ADDRESS} {
import plane_proxy
}
+14
View File
@@ -0,0 +1,14 @@
FROM caddy:2.10.0-builder-alpine AS caddy-builder
RUN xcaddy build \
--with github.com/caddy-dns/cloudflare@v0.2.1 \
--with github.com/caddy-dns/digitalocean@04bde2867106aa1b44c2f9da41a285fa02e629c5 \
--with github.com/mholt/caddy-l4@4d3c80e89c5f80438a3e048a410d5543ff5fb9f4
FROM caddy:2.10.0-builder-alpine
RUN apk add --no-cache nss-tools bash curl
COPY --from=caddy-builder /usr/bin/caddy /usr/bin/caddy
COPY Caddyfile.ce /etc/caddy/Caddyfile
@@ -29,7 +29,7 @@ export const ExtendedSidebarWrapper: FC<Props> = observer((props) => {
id={excludedElementId}
ref={extendedSidebarRef}
className={cn(
`absolute h-full z-[19] flex flex-col py-2 transform transition-all duration-300 ease-in-out bg-custom-background-100 border-r border-custom-sidebar-border-200 p-4 shadow-sm`,
`absolute h-full z-[19] flex flex-col py-2 transform transition-all duration-300 ease-in-out bg-custom-sidebar-background-100 border-r border-custom-sidebar-border-200 p-4 shadow-sm`,
{
"translate-x-0 opacity-100": isExtendedSidebarOpened,
[`-translate-x-[${EXTENDED_SIDEBAR_WIDTH}px] opacity-0 hidden`]: !isExtendedSidebarOpened,
@@ -11,6 +11,7 @@ export default function WorkspaceLayout({ children }: { children: React.ReactNod
<CommandPalette />
<WorkspaceAuthWrapper>
<div className="relative flex flex-col h-full w-full overflow-hidden rounded-lg border border-custom-border-200">
<div id="full-screen-portal" className="inset-0 absolute w-full" />
<div className="relative flex size-full overflow-hidden">
<ProjectAppSidebar />
<main className="relative flex h-full w-full flex-col overflow-hidden bg-custom-background-100">
@@ -6,7 +6,7 @@ import { useParams } from "next/navigation";
// icons
import { ChevronDown } from "lucide-react";
// plane constants
import { EIssueLayoutTypes, EIssueFilterType, ISSUE_LAYOUTS, ISSUE_DISPLAY_FILTERS_BY_PAGE } from "@plane/constants";
import { EIssueFilterType, ISSUE_LAYOUTS, ISSUE_DISPLAY_FILTERS_BY_PAGE } from "@plane/constants";
// plane i18n
import { useTranslation } from "@plane/i18n";
// types
@@ -16,6 +16,7 @@ import {
IIssueDisplayProperties,
IIssueFilterOptions,
TIssueLayouts,
EIssueLayoutTypes,
} from "@plane/types";
// ui
import { CustomMenu } from "@plane/ui";
@@ -8,7 +8,6 @@ import { PanelRight } from "lucide-react";
// plane imports
import {
EIssueFilterType,
EIssueLayoutTypes,
EUserPermissions,
EUserPermissionsLevel,
EProjectFeatureKey,
@@ -23,6 +22,7 @@ import {
IIssueDisplayFilterOptions,
IIssueDisplayProperties,
IIssueFilterOptions,
EIssueLayoutTypes,
} from "@plane/types";
import { Breadcrumbs, Button, ContrastIcon, BreadcrumbNavigationSearchDropdown, Header, Tooltip } from "@plane/ui";
import { cn, isIssueFilterActive } from "@plane/utils";
@@ -5,13 +5,14 @@ import { useParams } from "next/navigation";
// icons
import { Calendar, ChevronDown, Kanban, List } from "lucide-react";
// plane imports
import { EIssueLayoutTypes, EIssueFilterType, ISSUE_LAYOUTS, ISSUE_DISPLAY_FILTERS_BY_PAGE } from "@plane/constants";
import { EIssueFilterType, ISSUE_LAYOUTS, ISSUE_DISPLAY_FILTERS_BY_PAGE } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import {
EIssuesStoreType,
IIssueDisplayFilterOptions,
IIssueDisplayProperties,
IIssueFilterOptions,
EIssueLayoutTypes,
} from "@plane/types";
import { CustomMenu } from "@plane/ui";
import { isIssueFilterActive } from "@plane/utils";
@@ -4,7 +4,7 @@ import { FC, useCallback } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane constants
import { EIssueLayoutTypes, EIssueFilterType, ISSUE_DISPLAY_FILTERS_BY_PAGE } from "@plane/constants";
import { EIssueFilterType, ISSUE_DISPLAY_FILTERS_BY_PAGE } from "@plane/constants";
// i18n
import { useTranslation } from "@plane/i18n";
// types
@@ -13,6 +13,7 @@ import {
IIssueDisplayFilterOptions,
IIssueDisplayProperties,
IIssueFilterOptions,
EIssueLayoutTypes,
} from "@plane/types";
// ui
import { Breadcrumbs, LayersIcon, Tooltip } from "@plane/ui";
@@ -94,7 +95,7 @@ export const ProjectDraftIssueHeader: FC = observer(() => {
const issueCount = undefined;
return (
<div className="relative z-10 flex h-[3.75rem] w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 bg-custom-sidebar-background-100 p-4">
<div className="relative z-10 flex h-header w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 bg-custom-sidebar-background-100 p-4">
<div className="flex w-full flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap">
<div className="flex items-center gap-2.5">
<Breadcrumbs isLoading={loader === "init-loader"}>
@@ -6,13 +6,14 @@ import { useParams } from "next/navigation";
// icons
import { Calendar, ChevronDown, Kanban, List } from "lucide-react";
// plane imports
import { EIssueLayoutTypes, EIssueFilterType, ISSUE_LAYOUTS, ISSUE_DISPLAY_FILTERS_BY_PAGE } from "@plane/constants";
import { EIssueFilterType, ISSUE_LAYOUTS, ISSUE_DISPLAY_FILTERS_BY_PAGE } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import {
EIssuesStoreType,
IIssueDisplayFilterOptions,
IIssueDisplayProperties,
IIssueFilterOptions,
EIssueLayoutTypes,
} from "@plane/types";
import { CustomMenu } from "@plane/ui";
import { isIssueFilterActive } from "@plane/utils";
@@ -7,7 +7,6 @@ import { useParams } from "next/navigation";
import { PanelRight } from "lucide-react";
// plane imports
import {
EIssueLayoutTypes,
EIssueFilterType,
ISSUE_DISPLAY_FILTERS_BY_PAGE,
EUserPermissions,
@@ -21,6 +20,7 @@ import {
IIssueDisplayFilterOptions,
IIssueDisplayProperties,
IIssueFilterOptions,
EIssueLayoutTypes,
} from "@plane/types";
import { Breadcrumbs, Button, DiceIcon, Header, BreadcrumbNavigationSearchDropdown, Tooltip } from "@plane/ui";
import { cn, isIssueFilterActive } from "@plane/utils";
@@ -6,13 +6,14 @@ import { useParams } from "next/navigation";
// icons
import { Calendar, ChevronDown, Kanban, List } from "lucide-react";
// plane imports
import { EIssueLayoutTypes, EIssueFilterType, ISSUE_LAYOUTS, ISSUE_DISPLAY_FILTERS_BY_PAGE } from "@plane/constants";
import { EIssueFilterType, ISSUE_LAYOUTS, ISSUE_DISPLAY_FILTERS_BY_PAGE } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import {
EIssuesStoreType,
IIssueDisplayFilterOptions,
IIssueDisplayProperties,
IIssueFilterOptions,
EIssueLayoutTypes,
} from "@plane/types";
import { CustomMenu } from "@plane/ui";
import { isIssueFilterActive } from "@plane/utils";
@@ -4,11 +4,18 @@ import { useState } from "react";
import { observer } from "mobx-react";
import { useParams, useRouter, useSearchParams } from "next/navigation";
// constants
import { EPageAccess, EProjectFeatureKey, PROJECT_TRACKER_ELEMENTS } from "@plane/constants";
import {
EPageAccess,
EProjectFeatureKey,
PROJECT_PAGE_TRACKER_EVENTS,
PROJECT_TRACKER_ELEMENTS,
} from "@plane/constants";
// plane types
import { TPage } from "@plane/types";
// plane ui
import { Breadcrumbs, Button, Header, setToast, TOAST_TYPE } from "@plane/ui";
// helpers
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
// hooks
import { useProject } from "@/hooks/store";
// plane web
@@ -37,16 +44,29 @@ export const PagesListHeader = observer(() => {
await createPage(payload)
.then((res) => {
captureSuccess({
eventName: PROJECT_PAGE_TRACKER_EVENTS.create,
payload: {
id: res?.id,
state: "SUCCESS",
},
});
const pageId = `/${workspaceSlug}/projects/${currentProjectDetails?.id}/pages/${res?.id}`;
router.push(pageId);
})
.catch((err) =>
.catch((err) => {
captureError({
eventName: PROJECT_PAGE_TRACKER_EVENTS.create,
payload: {
state: "ERROR",
},
});
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: err?.data?.error || "Page could not be created. Please try again.",
})
)
});
})
.finally(() => setIsCreatingPage(false));
};
@@ -6,7 +6,6 @@ import { useParams } from "next/navigation";
import { Layers, Lock } from "lucide-react";
// plane constants
import {
EIssueLayoutTypes,
EIssueFilterType,
ISSUE_DISPLAY_FILTERS_BY_PAGE,
EUserPermissions,
@@ -22,6 +21,7 @@ import {
IIssueDisplayFilterOptions,
IIssueDisplayProperties,
IIssueFilterOptions,
EIssueLayoutTypes,
} from "@plane/types";
// ui
import { Breadcrumbs, Button, Tooltip, Header, BreadcrumbNavigationSearchDropdown } from "@plane/ui";
@@ -8,7 +8,6 @@ import { Layers } from "lucide-react";
import {
EIssueFilterType,
ISSUE_DISPLAY_FILTERS_BY_PAGE,
EIssueLayoutTypes,
GLOBAL_VIEW_TRACKER_ELEMENTS,
DEFAULT_GLOBAL_VIEWS_LIST,
} from "@plane/constants";
@@ -19,6 +18,7 @@ import {
IIssueDisplayProperties,
IIssueFilterOptions,
ICustomSearchSelectOption,
EIssueLayoutTypes,
} from "@plane/types";
import { Breadcrumbs, Button, Header, BreadcrumbNavigationSearchDropdown } from "@plane/ui";
import { isIssueFilterActive } from "@plane/utils";
@@ -4,7 +4,7 @@ import { useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import useSWR from "swr";
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { EUserPermissions, EUserPermissionsLevel, WORKSPACE_SETTINGS_TRACKER_EVENTS } from "@plane/constants";
import { IWebhook } from "@plane/types";
// ui
import { TOAST_TYPE, setToast } from "@plane/ui";
@@ -14,6 +14,7 @@ import { PageHead } from "@/components/core";
import { SettingsContentWrapper } from "@/components/settings";
import { DeleteWebhookModal, WebhookDeleteSection, WebhookForm } from "@/components/web-hooks";
// hooks
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
import { useUserPermissions, useWebhook, useWorkspace } from "@/hooks/store";
const WebhookDetailsPage = observer(() => {
@@ -55,6 +56,12 @@ const WebhookDetailsPage = observer(() => {
};
await updateWebhook(workspaceSlug.toString(), formData.id, payload)
.then(() => {
captureSuccess({
eventName: WORKSPACE_SETTINGS_TRACKER_EVENTS.webhook_updated,
payload: {
webhook: formData.id,
},
});
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
@@ -62,6 +69,13 @@ const WebhookDetailsPage = observer(() => {
});
})
.catch((error) => {
captureError({
eventName: WORKSPACE_SETTINGS_TRACKER_EVENTS.webhook_updated,
payload: {
webhook: formData.id,
},
error: error as Error,
});
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
@@ -5,7 +5,7 @@ import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import useSWR from "swr";
// plane imports
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { EUserPermissions, EUserPermissionsLevel, WORKSPACE_SETTINGS_TRACKER_ELEMENTS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
// components
import { NotAuthorizedView } from "@/components/auth-screens";
@@ -15,6 +15,7 @@ import { SettingsContentWrapper, SettingsHeading } from "@/components/settings";
import { WebhookSettingsLoader } from "@/components/ui";
import { WebhooksList, CreateWebhookModal } from "@/components/web-hooks";
// hooks
import { captureClick } from "@/helpers/event-tracker.helper";
import { useUserPermissions, useWebhook, useWorkspace } from "@/hooks/store";
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
@@ -71,7 +72,12 @@ const WebhooksListPage = observer(() => {
description={t("workspace_settings.settings.webhooks.description")}
button={{
label: t("workspace_settings.settings.webhooks.add_webhook"),
onClick: () => setShowCreateWebhookModal(true),
onClick: () => {
captureClick({
elementName: WORKSPACE_SETTINGS_TRACKER_ELEMENTS.HEADER_ADD_WEBHOOK_BUTTON,
});
setShowCreateWebhookModal(true);
},
}}
/>
{Object.keys(webhooks).length > 0 ? (
@@ -89,7 +95,12 @@ const WebhooksListPage = observer(() => {
size="md"
primaryButton={{
text: t("workspace_settings.settings.webhooks.add_webhook"),
onClick: () => setShowCreateWebhookModal(true),
onClick: () => {
captureClick({
elementName: WORKSPACE_SETTINGS_TRACKER_ELEMENTS.EMPTY_STATE_ADD_WEBHOOK_BUTTON,
});
setShowCreateWebhookModal(true);
},
}}
/>
</div>
@@ -4,6 +4,7 @@ import React, { useState } from "react";
import { observer } from "mobx-react";
import useSWR from "swr";
// plane imports
import { PROFILE_SETTINGS_TRACKER_ELEMENTS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
// component
import { APITokenService } from "@plane/services";
@@ -14,6 +15,7 @@ import { SettingsHeading } from "@/components/settings";
import { APITokenSettingsLoader } from "@/components/ui";
import { API_TOKENS_LIST } from "@/constants/fetch-keys";
// store hooks
import { captureClick } from "@/helpers/event-tracker.helper";
import { useWorkspace } from "@/hooks/store";
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
// services
@@ -53,7 +55,12 @@ const ApiTokensPage = observer(() => {
description={t("account_settings.api_tokens.description")}
button={{
label: t("workspace_settings.settings.api_tokens.add_token"),
onClick: () => setIsCreateTokenModalOpen(true),
onClick: () => {
captureClick({
elementName: PROFILE_SETTINGS_TRACKER_ELEMENTS.HEADER_ADD_PAT_BUTTON,
});
setIsCreateTokenModalOpen(true);
},
}}
/>
<div>
@@ -69,7 +76,12 @@ const ApiTokensPage = observer(() => {
description={t("account_settings.api_tokens.description")}
button={{
label: t("workspace_settings.settings.api_tokens.add_token"),
onClick: () => setIsCreateTokenModalOpen(true),
onClick: () => {
captureClick({
elementName: PROFILE_SETTINGS_TRACKER_ELEMENTS.HEADER_ADD_PAT_BUTTON,
});
setIsCreateTokenModalOpen(true);
},
}}
/>
<div className="h-full w-full flex items-center justify-center">
@@ -81,7 +93,12 @@ const ApiTokensPage = observer(() => {
size="md"
primaryButton={{
text: t("workspace_settings.settings.api_tokens.add_token"),
onClick: () => setIsCreateTokenModalOpen(true),
onClick: () => {
captureClick({
elementName: PROFILE_SETTINGS_TRACKER_ELEMENTS.EMPTY_STATE_ADD_PAT_BUTTON,
});
setIsCreateTokenModalOpen(true);
},
}}
/>
</div>
@@ -6,7 +6,6 @@ import { TIssueComment } from "@plane/types";
import { Avatar, Tooltip } from "@plane/ui";
import { calculateTimeAgo, cn, getFileURL, renderFormattedDate, renderFormattedTime } from "@plane/utils";
// hooks
//
import { useMember } from "@/hooks/store";
type TCommentBlock = {
@@ -18,13 +17,17 @@ type TCommentBlock = {
export const CommentBlock: FC<TCommentBlock> = observer((props) => {
const { comment, ends, quickActions, children } = props;
// refs
const commentBlockRef = useRef<HTMLDivElement>(null);
// store hooks
const { getUserDetails } = useMember();
const { t } = useTranslation();
// derived values
const userDetails = getUserDetails(comment?.actor);
// translation
const { t } = useTranslation();
if (!comment || !userDetails) return null;
if (!comment || !userDetails) return <></>;
return (
<div
className={`relative flex gap-3 ${ends === "top" ? `pb-2` : ends === "bottom" ? `pt-2` : `py-2`}`}
@@ -1,6 +1,7 @@
import { FC } from "react";
import { observer } from "mobx-react";
import { Pen, Trash } from "lucide-react";
import { PROJECT_SETTINGS_TRACKER_ELEMENTS } from "@plane/constants";
import { Tooltip } from "@plane/ui";
// components
import { ProIcon } from "@/components/common";
@@ -29,13 +30,17 @@ export const EstimateListItemButtons: FC<TEstimateListItem> = observer((props) =
}
position="top"
>
<button className="relative flex-shrink-0 w-6 h-6 flex justify-center items-center rounded cursor-pointer transition-colors overflow-hidden hover:bg-custom-background-80">
<button
className="relative flex-shrink-0 w-6 h-6 flex justify-center items-center rounded cursor-pointer transition-colors overflow-hidden hover:bg-custom-background-80"
data-ph-element={PROJECT_SETTINGS_TRACKER_ELEMENTS.ESTIMATES_LIST_ITEM}
>
<Pen size={12} />
</button>
</Tooltip>
<button
className="relative flex-shrink-0 w-6 h-6 flex justify-center items-center rounded cursor-pointer transition-colors overflow-hidden hover:bg-custom-background-80"
onClick={() => onDeleteClick && onDeleteClick(estimateId)}
data-ph-element={PROJECT_SETTINGS_TRACKER_ELEMENTS.ESTIMATES_LIST_ITEM}
>
<Trash size={12} />
</button>
@@ -12,6 +12,7 @@ export interface CopyMenuHelperProps {
activeLayout: string;
setCreateUpdateIssueModal: (open: boolean) => void;
setDuplicateWorkItemModal?: (open: boolean) => void;
workspaceSlug?: string;
}
export const createCopyMenuWithDuplication = (props: CopyMenuHelperProps): TContextMenuItem => {
@@ -2,11 +2,10 @@ import { FC, useEffect, useRef } from "react";
import { observer } from "mobx-react";
import { UseFormRegister, UseFormSetFocus } from "react-hook-form";
// plane constants
import { EIssueLayoutTypes } from "@plane/constants";
// plane helpers
import { useOutsideClickDetector } from "@plane/hooks";
// types
import { TIssue } from "@plane/types";
import { TIssue, EIssueLayoutTypes } from "@plane/types";
// components
import {
CalendarQuickAddIssueForm,
+40 -21
View File
@@ -88,31 +88,50 @@ export const CreateProjectForm: FC<TCreateProjectFormProps> = observer((props) =
handleNextStep(res.id);
})
.catch((err) => {
captureError({
eventName: PROJECT_TRACKER_EVENTS.create,
payload: {
identifier: formData.identifier,
},
});
if (err?.data.code === "PROJECT_NAME_ALREADY_EXIST") {
setToast({
type: TOAST_TYPE.ERROR,
title: t("toast.error"),
message: t("project_name_already_taken"),
try {
captureError({
eventName: PROJECT_TRACKER_EVENTS.create,
payload: {
identifier: formData.identifier,
},
});
} else if (err?.data.code === "PROJECT_IDENTIFIER_ALREADY_EXIST") {
setToast({
type: TOAST_TYPE.ERROR,
title: t("toast.error"),
message: t("project_identifier_already_taken"),
});
} else {
Object.keys(err?.data ?? {}).map((key) => {
// Handle the new error format where codes are nested in arrays under field names
const errorData = err?.data ?? {};
const nameError = errorData.name?.includes("PROJECT_NAME_ALREADY_EXIST");
const identifierError = errorData?.identifier?.includes("PROJECT_IDENTIFIER_ALREADY_EXIST");
if (nameError || identifierError) {
if (nameError) {
setToast({
type: TOAST_TYPE.ERROR,
title: t("toast.error"),
message: t("project_name_already_taken"),
});
}
if (identifierError) {
setToast({
type: TOAST_TYPE.ERROR,
title: t("toast.error"),
message: t("project_identifier_already_taken"),
});
}
} else {
setToast({
type: TOAST_TYPE.ERROR,
title: t("error"),
message: err.data[key],
title: t("toast.error"),
message: t("something_went_wrong"),
});
}
} catch (error) {
// Fallback error handling if the error processing fails
console.error("Error processing API error:", error);
setToast({
type: TOAST_TYPE.ERROR,
title: t("toast.error"),
message: t("something_went_wrong"),
});
}
});
+1 -1
View File
@@ -1,4 +1,4 @@
import { EIssueLayoutTypes } from "@plane/constants";
import { EIssueLayoutTypes } from "@plane/types";
import { TWorkspaceLayoutProps } from "@/components/views/helper";
export type TLayoutSelectionProps = {
@@ -5,6 +5,8 @@ import {
SUBSCRIPTION_REDIRECTION_URLS,
SUBSCRIPTION_WITH_BILLING_FREQUENCY,
TALK_TO_SALES_URL,
WORKSPACE_SETTINGS_TRACKER_ELEMENTS,
WORKSPACE_SETTINGS_TRACKER_EVENTS,
} from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { EProductSubscriptionEnum, TBillingFrequency } from "@plane/types";
@@ -16,6 +18,7 @@ import { DiscountInfo } from "@/components/license/modal/card/discount-info";
import { getUpgradeButtonStyle } from "@/components/workspace/billing/subscription";
import { TPlanDetail } from "@/constants/plans";
// local imports
import { captureSuccess } from "@/helpers/event-tracker.helper";
import { PlanFrequencyToggle } from "./frequency-toggle";
type TPlanDetailProps = {
@@ -49,6 +52,12 @@ export const PlanDetail: FC<TPlanDetailProps> = observer((props) => {
const frequency = billingFrequency ?? "year";
// Get the redirection URL based on the subscription type and billing frequency
const redirectUrl = SUBSCRIPTION_REDIRECTION_URLS[subscriptionType][frequency] ?? TALK_TO_SALES_URL;
captureSuccess({
eventName: WORKSPACE_SETTINGS_TRACKER_EVENTS.upgrade_plan_redirected,
payload: {
subscriptionType,
},
});
// Open the URL in a new tab
window.open(redirectUrl, "_blank");
};
@@ -101,7 +110,15 @@ export const PlanDetail: FC<TPlanDetailProps> = observer((props) => {
{/* Subscription button */}
<div className={cn("flex flex-col gap-1 py-3 items-start transition-all duration-300")}>
<button onClick={handleRedirection} className={cn(upgradeButtonStyle, COMMON_BUTTON_STYLE)}>
<button
onClick={handleRedirection}
className={cn(upgradeButtonStyle, COMMON_BUTTON_STYLE)}
data-ph-element={
isSubscriptionActive
? WORKSPACE_SETTINGS_TRACKER_ELEMENTS.BILLING_UPGRADE_BUTTON(subscriptionType)
: WORKSPACE_SETTINGS_TRACKER_ELEMENTS.BILLING_TALK_TO_SALES_BUTTON
}
>
{isSubscriptionActive ? `Upgrade to ${subscriptionName}` : t("common.upgrade_cta.talk_to_sales")}
</button>
</div>
@@ -43,7 +43,7 @@ export interface IIssueActivityStore extends IIssueActivityStoreActions {
// helper methods
getActivitiesByIssueId: (issueId: string) => string[] | undefined;
getActivityById: (activityId: string) => TIssueActivity | undefined;
getActivityCommentByIssueId: (issueId: string, sortOrder: E_SORT_ORDER) => TIssueActivityComment[] | undefined;
getActivityAndCommentsByIssueId: (issueId: string, sortOrder: E_SORT_ORDER) => TIssueActivityComment[] | undefined;
}
export class IssueActivityStore implements IIssueActivityStore {
@@ -84,7 +84,7 @@ export class IssueActivityStore implements IIssueActivityStore {
return this.activityMap[activityId] ?? undefined;
};
getActivityCommentByIssueId = computedFn((issueId: string, sortOrder: E_SORT_ORDER) => {
getActivityAndCommentsByIssueId = computedFn((issueId: string, sortOrder: E_SORT_ORDER) => {
if (!issueId) return undefined;
let activityComments: TIssueActivityComment[] = [];
@@ -92,10 +92,12 @@ export class IssueActivityStore implements IIssueActivityStore {
const currentStore =
this.serviceType === EIssueServiceType.EPICS ? this.store.issue.epicDetail : this.store.issue.issueDetail;
const activities = this.getActivitiesByIssueId(issueId) || [];
const comments = currentStore.comment.getCommentsByIssueId(issueId) || [];
const activities = this.getActivitiesByIssueId(issueId);
const comments = currentStore.comment.getCommentsByIssueId(issueId);
activities.forEach((activityId) => {
if (!activities || !comments) return undefined;
activities?.forEach((activityId) => {
const activity = this.getActivityById(activityId);
if (!activity) return;
activityComments.push({
@@ -105,7 +107,7 @@ export class IssueActivityStore implements IIssueActivityStore {
});
});
comments.forEach((commentId) => {
comments?.forEach((commentId) => {
const comment = currentStore.comment.getCommentById(commentId);
if (!comment) return;
activityComments.push({
@@ -3,10 +3,12 @@
import React, { useState } from "react";
import { Trash2 } from "lucide-react";
import { Dialog, Transition } from "@headlessui/react";
import { PROFILE_SETTINGS_TRACKER_EVENTS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
// ui
import { Button, TOAST_TYPE, setToast } from "@plane/ui";
// hooks
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
import { useUser } from "@/hooks/store";
import { useAppRouter } from "@/hooks/use-app-router";
@@ -35,6 +37,9 @@ export const DeactivateAccountModal: React.FC<Props> = (props) => {
await deactivateAccount()
.then(() => {
captureSuccess({
eventName: PROFILE_SETTINGS_TRACKER_EVENTS.deactivate_account,
});
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
@@ -44,13 +49,16 @@ export const DeactivateAccountModal: React.FC<Props> = (props) => {
router.push("/");
handleClose();
})
.catch((err: any) =>
.catch((err: any) => {
captureError({
eventName: PROFILE_SETTINGS_TRACKER_EVENTS.deactivate_account,
});
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: err?.error,
})
)
});
})
.finally(() => setIsDeactivating(false));
};
@@ -1,8 +1,9 @@
import React, { useEffect, useState } from "react";
import { observer } from "mobx-react";
import { Dialog, Transition } from "@headlessui/react";
// plane package imports
import { createPortal } from "react-dom";
import { ICycle, IModule, IProject } from "@plane/types";
import { cn } from "@plane/utils";
import { useAnalytics } from "@/hooks/store";
// plane web components
import { WorkItemsModalMainContent } from "./content";
@@ -23,6 +24,7 @@ export const WorkItemsModal: React.FC<Props> = observer((props) => {
const [fullScreen, setFullScreen] = useState(false);
const handleClose = () => {
setFullScreen(false);
onClose();
};
@@ -30,50 +32,40 @@ export const WorkItemsModal: React.FC<Props> = observer((props) => {
updateIsEpic(isEpic ?? false);
}, [isEpic, updateIsEpic]);
return (
<Transition.Root appear show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-20" onClose={handleClose}>
<Transition.Child
as={React.Fragment}
enter="transition-transform duration-300"
enterFrom="translate-x-full"
enterTo="translate-x-0"
leave="transition-transform duration-200"
leaveFrom="translate-x-0"
leaveTo="translate-x-full"
const portalContainer = document.getElementById("full-screen-portal") as HTMLElement;
if (!isOpen) return null;
const content = (
<div className={cn("inset-0 z-[25] h-full w-full overflow-y-auto", fullScreen ? "absolute" : "fixed")}>
<div
className={`right-0 top-0 z-20 h-full bg-custom-background-100 shadow-custom-shadow-md ${
fullScreen ? "w-full p-2 absolute" : "w-full sm:w-full md:w-1/2 fixed"
}`}
>
<div
className={`flex h-full flex-col overflow-hidden border-custom-border-200 bg-custom-background-100 text-left ${
fullScreen ? "rounded-lg border" : "border-l"
}`}
>
<div className="fixed inset-0 z-20 h-full w-full overflow-y-auto">
<Dialog.Panel>
<div
className={`fixed right-0 top-0 z-20 h-full bg-custom-background-100 shadow-custom-shadow-md ${
fullScreen ? "w-full p-2" : "w-full sm:w-full md:w-1/2"
}`}
>
<div
className={`flex h-full flex-col overflow-hidden border-custom-border-200 bg-custom-background-100 text-left ${
fullScreen ? "rounded-lg border" : "border-l"
}`}
>
<WorkItemsModalHeader
fullScreen={fullScreen}
handleClose={handleClose}
setFullScreen={setFullScreen}
title={projectDetails?.name ?? ""}
cycle={cycleDetails}
module={moduleDetails}
/>
<WorkItemsModalMainContent
fullScreen={fullScreen}
projectDetails={projectDetails}
cycleDetails={cycleDetails}
moduleDetails={moduleDetails}
/>
</div>
</div>
</Dialog.Panel>
</div>
</Transition.Child>
</Dialog>
</Transition.Root>
<WorkItemsModalHeader
fullScreen={fullScreen}
handleClose={handleClose}
setFullScreen={setFullScreen}
title={projectDetails?.name ?? ""}
cycle={cycleDetails}
module={moduleDetails}
/>
<WorkItemsModalMainContent
fullScreen={fullScreen}
projectDetails={projectDetails}
cycleDetails={cycleDetails}
moduleDetails={moduleDetails}
/>
</div>
</div>
</div>
);
return fullScreen && portalContainer ? createPortal(content, portalContainer) : content;
});
@@ -3,6 +3,7 @@
import { useState, FC } from "react";
import { mutate } from "swr";
// types
import { PROFILE_SETTINGS_TRACKER_EVENTS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { APITokenService } from "@plane/services";
import { IApiToken } from "@plane/types";
@@ -10,6 +11,7 @@ import { IApiToken } from "@plane/types";
import { AlertModalCore, TOAST_TYPE, setToast } from "@plane/ui";
// fetch-keys
import { API_TOKENS_LIST } from "@/constants/fetch-keys";
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
type Props = {
isOpen: boolean;
@@ -48,6 +50,12 @@ export const DeleteApiTokenModal: FC<Props> = (props) => {
(prevData) => (prevData ?? []).filter((token) => token.id !== tokenId),
false
);
captureSuccess({
eventName: PROFILE_SETTINGS_TRACKER_EVENTS.pat_deleted,
payload: {
token: tokenId,
},
});
handleClose();
})
@@ -58,6 +66,15 @@ export const DeleteApiTokenModal: FC<Props> = (props) => {
message: err?.message ?? t("workspace_settings.settings.api_tokens.delete.error.message"),
})
)
.catch((err) => {
captureError({
eventName: PROFILE_SETTINGS_TRACKER_EVENTS.pat_deleted,
payload: {
token: tokenId,
},
error: err as Error,
});
})
.finally(() => setDeleteLoading(false));
};
@@ -3,6 +3,7 @@
import React, { useState } from "react";
import { mutate } from "swr";
// types
import { PROFILE_SETTINGS_TRACKER_EVENTS } from "@plane/constants";
import { APITokenService } from "@plane/services";
import { IApiToken } from "@plane/types";
// ui
@@ -12,6 +13,7 @@ import { renderFormattedDate, csvDownload } from "@plane/utils";
import { CreateApiTokenForm, GeneratedTokenDetails } from "@/components/api-token";
// fetch-keys
import { API_TOKENS_LIST } from "@/constants/fetch-keys";
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
// helpers
// services
@@ -66,6 +68,12 @@ export const CreateApiTokenModal: React.FC<Props> = (props) => {
},
false
);
captureSuccess({
eventName: PROFILE_SETTINGS_TRACKER_EVENTS.pat_created,
payload: {
token: res.id,
},
});
})
.catch((err) => {
setToast({
@@ -74,6 +82,10 @@ export const CreateApiTokenModal: React.FC<Props> = (props) => {
message: err.message || err.detail,
});
captureError({
eventName: PROFILE_SETTINGS_TRACKER_EVENTS.pat_created,
});
throw err;
});
};
@@ -2,6 +2,7 @@
import { useState } from "react";
import { XCircle } from "lucide-react";
import { PROFILE_SETTINGS_TRACKER_ELEMENTS } from "@plane/constants";
import { IApiToken } from "@plane/types";
// components
import { Tooltip } from "@plane/ui";
@@ -31,6 +32,7 @@ export const ApiTokenListItem: React.FC<Props> = (props) => {
<button
onClick={() => setDeleteModalOpen(true)}
className="absolute right-4 hidden place-items-center group-hover:grid"
data-ph-element={PROFILE_SETTINGS_TRACKER_ELEMENTS.LIST_ITEM_DELETE_ICON}
>
<XCircle className="h-4 w-4 text-red-500" />
</button>
@@ -5,7 +5,13 @@ import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { ArchiveRestore } from "lucide-react";
// types
import { PROJECT_AUTOMATION_MONTHS, EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import {
PROJECT_AUTOMATION_MONTHS,
EUserPermissions,
EUserPermissionsLevel,
PROJECT_SETTINGS_TRACKER_ELEMENTS,
PROJECT_SETTINGS_TRACKER_EVENTS,
} from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { IProject } from "@plane/types";
// ui
@@ -14,6 +20,7 @@ import { CustomSelect, Loader, ToggleSwitch } from "@plane/ui";
import { SelectMonthModal } from "@/components/automation";
// constants
// hooks
import { captureElementAndEvent } from "@/helpers/event-tracker.helper";
import { useProject, useUserPermissions } from "@/hooks/store";
type Props = {
@@ -65,11 +72,22 @@ export const AutoArchiveAutomation: React.FC<Props> = observer((props) => {
</div>
<ToggleSwitch
value={currentProjectDetails?.archive_in !== 0}
onChange={() =>
currentProjectDetails?.archive_in === 0
? handleChange({ archive_in: 1 })
: handleChange({ archive_in: 0 })
}
onChange={async () => {
if (currentProjectDetails?.archive_in === 0) {
await handleChange({ archive_in: 1 });
} else {
await handleChange({ archive_in: 0 });
}
captureElementAndEvent({
element: {
elementName: PROJECT_SETTINGS_TRACKER_ELEMENTS.AUTOMATIONS_ARCHIVE_TOGGLE_BUTTON,
},
event: {
eventName: PROJECT_SETTINGS_TRACKER_EVENTS.auto_archive_workitems,
state: "SUCCESS",
},
});
}}
size="sm"
disabled={!isAdmin}
/>
@@ -6,7 +6,14 @@ import { useParams } from "next/navigation";
// icons
import { ArchiveX } from "lucide-react";
// types
import { PROJECT_AUTOMATION_MONTHS, EUserPermissions, EUserPermissionsLevel, EIconSize } from "@plane/constants";
import {
PROJECT_AUTOMATION_MONTHS,
EUserPermissions,
EUserPermissionsLevel,
EIconSize,
PROJECT_SETTINGS_TRACKER_ELEMENTS,
PROJECT_SETTINGS_TRACKER_EVENTS,
} from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { IProject } from "@plane/types";
// ui
@@ -15,6 +22,7 @@ import { CustomSelect, CustomSearchSelect, ToggleSwitch, StateGroupIcon, DoubleC
import { SelectMonthModal } from "@/components/automation";
// constants
// hooks
import { captureElementAndEvent } from "@/helpers/event-tracker.helper";
import { useProject, useProjectState, useUserPermissions } from "@/hooks/store";
type Props = {
@@ -91,11 +99,22 @@ export const AutoCloseAutomation: React.FC<Props> = observer((props) => {
</div>
<ToggleSwitch
value={currentProjectDetails?.close_in !== 0}
onChange={() =>
currentProjectDetails?.close_in === 0
? handleChange({ close_in: 1, default_state: defaultState })
: handleChange({ close_in: 0, default_state: null })
}
onChange={async () => {
if (currentProjectDetails?.close_in === 0) {
await handleChange({ close_in: 1, default_state: defaultState });
} else {
await handleChange({ close_in: 0, default_state: null });
}
captureElementAndEvent({
element: {
elementName: PROJECT_SETTINGS_TRACKER_ELEMENTS.AUTOMATIONS_CLOSE_TOGGLE_BUTTON,
},
event: {
eventName: PROJECT_SETTINGS_TRACKER_EVENTS.auto_close_workitems,
state: "SUCCESS",
},
});
}}
size="sm"
disabled={!isAdmin}
/>
@@ -25,7 +25,7 @@ export const ShortcutsModal: FC<Props> = (props) => {
return (
<Transition.Root show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-20" onClose={handleClose}>
<Dialog as="div" className="relative z-30" onClose={handleClose}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
@@ -38,7 +38,7 @@ export const ShortcutsModal: FC<Props> = (props) => {
<div className="fixed inset-0 bg-custom-backdrop transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-20 overflow-y-auto">
<div className="fixed inset-0 z-30 overflow-y-auto">
<div className="my-10 flex items-center justify-center p-4 text-center sm:p-0 md:my-20">
<Transition.Child
as={Fragment}
@@ -0,0 +1,85 @@
import { useEffect, useState } from "react";
import { observer } from "mobx-react";
import { usePathname } from "next/navigation";
import { Globe2, Lock } from "lucide-react";
// plane imports
import type { EditorReadOnlyRefApi } from "@plane/editor";
import { useHashScroll } from "@plane/hooks";
import { EIssueCommentAccessSpecifier, type TCommentsOperations, type TIssueComment } from "@plane/types";
import { cn } from "@plane/utils";
// components
import { LiteTextReadOnlyEditor } from "@/components/editor";
// local imports
import { CommentReactions } from "../comment-reaction";
type Props = {
activityOperations: TCommentsOperations;
comment: TIssueComment;
disabled: boolean;
projectId?: string;
readOnlyEditorRef: React.RefObject<EditorReadOnlyRefApi>;
showAccessSpecifier: boolean;
workspaceId: string;
workspaceSlug: string;
};
export const CommentCardDisplay: React.FC<Props> = observer((props) => {
const {
activityOperations,
comment,
disabled,
projectId,
readOnlyEditorRef,
showAccessSpecifier,
workspaceId,
workspaceSlug,
} = props;
// states
const [highlightClassName, setHighlightClassName] = useState("");
// navigation
const pathname = usePathname();
// derived values
const commentBlockId = `comment-${comment?.id}`;
// scroll to comment
const { isHashMatch } = useHashScroll({
elementId: commentBlockId,
pathname,
});
useEffect(() => {
if (!isHashMatch) return;
setHighlightClassName("border-custom-primary-100");
const timeout = setTimeout(() => {
setHighlightClassName("");
}, 8000);
return () => clearTimeout(timeout);
}, [isHashMatch]);
return (
<div id={commentBlockId} className="relative flex flex-col gap-2">
{showAccessSpecifier && (
<div className="absolute right-2.5 top-2.5 z-[1] text-custom-text-300">
{comment.access === EIssueCommentAccessSpecifier.INTERNAL ? (
<Lock className="size-3" />
) : (
<Globe2 className="size-3" />
)}
</div>
)}
<LiteTextReadOnlyEditor
ref={readOnlyEditorRef}
id={comment.id}
initialValue={comment.comment_html ?? ""}
workspaceId={workspaceId}
workspaceSlug={workspaceSlug}
containerClassName={cn("!py-1 transition-[border-color] duration-500", highlightClassName)}
projectId={projectId?.toString()}
displayConfig={{
fontSize: "small-font",
}}
/>
<CommentReactions comment={comment} disabled={disabled} activityOperations={activityOperations} />
</div>
);
});
@@ -0,0 +1,130 @@
import React, { useEffect, useRef } from "react";
import { observer } from "mobx-react";
import { useForm } from "react-hook-form";
import { Check, X } from "lucide-react";
// plane imports
import type { EditorReadOnlyRefApi, EditorRefApi } from "@plane/editor";
import type { TCommentsOperations, TIssueComment } from "@plane/types";
import { isCommentEmpty } from "@plane/utils";
// components
import { LiteTextEditor } from "@/components/editor";
type Props = {
activityOperations: TCommentsOperations;
comment: TIssueComment;
isEditing: boolean;
projectId?: string;
readOnlyEditorRef: EditorReadOnlyRefApi | null;
setIsEditing: (isEditing: boolean) => void;
workspaceId: string;
workspaceSlug: string;
};
export const CommentCardEditForm: React.FC<Props> = observer((props) => {
const {
activityOperations,
comment,
isEditing,
projectId,
readOnlyEditorRef,
setIsEditing,
workspaceId,
workspaceSlug,
} = props;
// refs
const editorRef = useRef<EditorRefApi>(null);
// form info
const {
formState: { isSubmitting },
handleSubmit,
setFocus,
watch,
setValue,
} = useForm<Partial<TIssueComment>>({
defaultValues: { comment_html: comment?.comment_html },
});
const commentHTML = watch("comment_html");
const isEmpty = isCommentEmpty(commentHTML ?? undefined);
const isEditorReadyToDiscard = editorRef.current?.isEditorReadyToDiscard();
const isSubmitButtonDisabled = isSubmitting || !isEditorReadyToDiscard;
const isDisabled = isSubmitting || isEmpty || isSubmitButtonDisabled;
const onEnter = async (formData: Partial<TIssueComment>) => {
if (isSubmitting || !comment) return;
setIsEditing(false);
await activityOperations.updateComment(comment.id, formData);
editorRef.current?.setEditorValue(formData?.comment_html ?? "<p></p>");
readOnlyEditorRef?.setEditorValue(formData?.comment_html ?? "<p></p>");
};
useEffect(() => {
if (isEditing) {
setFocus("comment_html");
}
}, [isEditing, setFocus]);
return (
<form className="flex flex-col gap-2">
<div
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey && !e.ctrlKey && !e.metaKey && !isEmpty) handleSubmit(onEnter)(e);
}}
>
<LiteTextEditor
workspaceId={workspaceId}
workspaceSlug={workspaceSlug}
ref={editorRef}
id={comment.id}
initialValue={commentHTML ?? ""}
value={null}
onChange={(_comment_json, comment_html) => setValue("comment_html", comment_html)}
onEnterKeyPress={(e) => {
if (!isEmpty && !isSubmitting) {
handleSubmit(onEnter)(e);
}
}}
showSubmitButton={false}
uploadFile={async (blockId, file) => {
const { asset_id } = await activityOperations.uploadCommentAsset(blockId, file, comment.id);
return asset_id;
}}
projectId={projectId?.toString() ?? ""}
parentClassName="p-2"
displayConfig={{
fontSize: "small-font",
}}
/>
</div>
<div className="flex gap-1 self-end">
{!isEmpty && (
<button
type="button"
onClick={handleSubmit(onEnter)}
disabled={isDisabled}
className={`group rounded border border-green-500 bg-green-500/20 p-2 shadow-md duration-300 ${
isEmpty ? "cursor-not-allowed bg-gray-200" : "hover:bg-green-500"
}`}
>
<Check
className={`h-3 w-3 text-green-500 duration-300 ${isEmpty ? "text-black" : "group-hover:text-white"}`}
/>
</button>
)}
<button
type="button"
className="group rounded border border-red-500 bg-red-500/20 p-2 shadow-md duration-300 hover:bg-red-500"
onClick={() => {
setIsEditing(false);
editorRef.current?.setEditorValue(comment.comment_html ?? "<p></p>");
}}
>
<X className="size-3 text-red-500 duration-300 group-hover:text-white" />
</button>
</div>
</form>
);
});
@@ -0,0 +1,85 @@
"use client";
import { FC, useRef, useState } from "react";
import { observer } from "mobx-react";
// plane imports
import type { EditorReadOnlyRefApi } from "@plane/editor";
import type { TIssueComment, TCommentsOperations } from "@plane/types";
// plane web imports
import { CommentBlock } from "@/plane-web/components/comments";
// local imports
import { CommentQuickActions } from "../quick-actions";
import { CommentCardDisplay } from "./display";
import { CommentCardEditForm } from "./edit-form";
type TCommentCard = {
workspaceSlug: string;
comment: TIssueComment | undefined;
activityOperations: TCommentsOperations;
ends: "top" | "bottom" | undefined;
showAccessSpecifier: boolean;
showCopyLinkOption: boolean;
disabled?: boolean;
projectId?: string;
};
export const CommentCard: FC<TCommentCard> = observer((props) => {
const {
workspaceSlug,
comment,
activityOperations,
ends,
showAccessSpecifier,
showCopyLinkOption,
disabled = false,
projectId,
} = props;
const readOnlyEditorRef = useRef<EditorReadOnlyRefApi>(null);
// states
const [isEditing, setIsEditing] = useState(false);
// derived values
const workspaceId = comment?.workspace;
if (!comment || !workspaceId) return null;
return (
<CommentBlock
comment={comment}
quickActions={
!disabled && (
<CommentQuickActions
activityOperations={activityOperations}
comment={comment}
setEditMode={() => setIsEditing(true)}
showAccessSpecifier={showAccessSpecifier}
showCopyLinkOption={showCopyLinkOption}
/>
)
}
ends={ends}
>
{isEditing ? (
<CommentCardEditForm
activityOperations={activityOperations}
comment={comment}
isEditing
readOnlyEditorRef={readOnlyEditorRef.current}
setIsEditing={setIsEditing}
workspaceId={workspaceId}
workspaceSlug={workspaceSlug}
/>
) : (
<CommentCardDisplay
activityOperations={activityOperations}
comment={comment}
disabled={disabled}
projectId={projectId}
readOnlyEditorRef={readOnlyEditorRef}
showAccessSpecifier={showAccessSpecifier}
workspaceId={workspaceId}
workspaceSlug={workspaceSlug}
/>
)}
</CommentBlock>
);
});
@@ -1,224 +0,0 @@
"use client";
import { FC, useEffect, useRef, useState } from "react";
import { observer } from "mobx-react";
import { useForm } from "react-hook-form";
import { Check, Globe2, Lock, Pencil, Trash2, X } from "lucide-react";
// PLane
import { EIssueCommentAccessSpecifier } from "@plane/constants";
import { EditorReadOnlyRefApi, EditorRefApi } from "@plane/editor";
import { useTranslation } from "@plane/i18n";
import { TIssueComment, TCommentsOperations } from "@plane/types";
import { CustomMenu } from "@plane/ui";
// components
import { isCommentEmpty } from "@plane/utils";
import { LiteTextEditor, LiteTextReadOnlyEditor } from "@/components/editor";
// helpers
// hooks
import { useUser } from "@/hooks/store";
//
import { CommentBlock } from "@/plane-web/components/comments";
import { CommentReactions } from "./comment-reaction";
type TCommentCard = {
workspaceSlug: string;
comment: TIssueComment | undefined;
activityOperations: TCommentsOperations;
ends: "top" | "bottom" | undefined;
showAccessSpecifier?: boolean;
disabled?: boolean;
projectId?: string;
};
export const CommentCard: FC<TCommentCard> = observer((props) => {
const {
workspaceSlug,
comment,
activityOperations,
ends,
showAccessSpecifier = false,
disabled = false,
projectId,
} = props;
const { t } = useTranslation();
// refs
const editorRef = useRef<EditorRefApi>(null);
const showEditorRef = useRef<EditorReadOnlyRefApi>(null);
// state
const [isEditing, setIsEditing] = useState(false);
// store hooks
const { data: currentUser } = useUser();
// form info
const {
formState: { isSubmitting },
handleSubmit,
setFocus,
watch,
setValue,
} = useForm<Partial<TIssueComment>>({
defaultValues: { comment_html: comment?.comment_html },
});
// derived values
const workspaceId = comment?.workspace;
const commentHTML = watch("comment_html");
const isEmpty = isCommentEmpty(commentHTML ?? undefined);
const isEditorReadyToDiscard = editorRef.current?.isEditorReadyToDiscard();
const isSubmitButtonDisabled = isSubmitting || !isEditorReadyToDiscard;
const isDisabled = isSubmitting || isEmpty || isSubmitButtonDisabled;
// helpers
const onEnter = async (formData: Partial<TIssueComment>) => {
if (isSubmitting || !comment) return;
setIsEditing(false);
await activityOperations.updateComment(comment.id, formData);
editorRef.current?.setEditorValue(formData?.comment_html ?? "<p></p>");
showEditorRef.current?.setEditorValue(formData?.comment_html ?? "<p></p>");
};
useEffect(() => {
if (isEditing) {
setFocus("comment_html");
}
}, [isEditing, setFocus]);
if (!comment || !currentUser || !workspaceId) return <></>;
return (
<CommentBlock
comment={comment}
quickActions={
<>
{!disabled && currentUser?.id === comment.actor && (
<CustomMenu ellipsis closeOnSelect>
<CustomMenu.MenuItem onClick={() => setIsEditing(true)} className="flex items-center gap-1">
<Pencil className="flex-shrink-0 size-3" />
{t("common.actions.edit")}
</CustomMenu.MenuItem>
{showAccessSpecifier && (
<>
{comment.access === "INTERNAL" ? (
<CustomMenu.MenuItem
onClick={() =>
activityOperations.updateComment(comment.id, { access: EIssueCommentAccessSpecifier.EXTERNAL })
}
className="flex items-center gap-1"
>
<Globe2 className="flex-shrink-0 size-3" />
{t("issue.comments.switch.public")}
</CustomMenu.MenuItem>
) : (
<CustomMenu.MenuItem
onClick={() =>
activityOperations.updateComment(comment.id, { access: EIssueCommentAccessSpecifier.INTERNAL })
}
className="flex items-center gap-1"
>
<Lock className="flex-shrink-0 size-3" />
{t("issue.comments.switch.private")}
</CustomMenu.MenuItem>
)}
</>
)}
<CustomMenu.MenuItem
onClick={() => activityOperations.removeComment(comment.id)}
className="flex items-center gap-1"
>
<Trash2 className="flex-shrink-0 size-3" />
{t("common.actions.delete")}
</CustomMenu.MenuItem>
</CustomMenu>
)}
</>
}
ends={ends}
>
<>
<form className={`flex-col gap-2 ${isEditing ? "flex" : "hidden"}`}>
<div
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey && !e.ctrlKey && !e.metaKey && !isEmpty) handleSubmit(onEnter)(e);
}}
>
<LiteTextEditor
workspaceId={workspaceId}
workspaceSlug={workspaceSlug}
ref={editorRef}
id={comment.id}
initialValue={commentHTML ?? ""}
value={null}
onChange={(comment_json, comment_html) => setValue("comment_html", comment_html)}
onEnterKeyPress={(e) => {
if (!isEmpty && !isSubmitting) {
handleSubmit(onEnter)(e);
}
}}
showSubmitButton={false}
uploadFile={async (blockId, file) => {
const { asset_id } = await activityOperations.uploadCommentAsset(blockId, file, comment.id);
return asset_id;
}}
projectId={projectId?.toString() ?? ""}
parentClassName="p-2"
displayConfig={{
fontSize: "small-font",
}}
/>
</div>
<div className="flex gap-1 self-end">
{!isEmpty && (
<button
type="button"
onClick={handleSubmit(onEnter)}
disabled={isDisabled}
className={`group rounded border border-green-500 bg-green-500/20 p-2 shadow-md duration-300 ${
isEmpty ? "cursor-not-allowed bg-gray-200" : "hover:bg-green-500"
}`}
>
<Check
className={`h-3 w-3 text-green-500 duration-300 ${isEmpty ? "text-black" : "group-hover:text-white"}`}
/>
</button>
)}
<button
type="button"
className="group rounded border border-red-500 bg-red-500/20 p-2 shadow-md duration-300 hover:bg-red-500"
onClick={() => {
setIsEditing(false);
editorRef.current?.setEditorValue(comment.comment_html ?? "<p></p>");
}}
>
<X className="size-3 text-red-500 duration-300 group-hover:text-white" />
</button>
</div>
</form>
<div className={`relative flex flex-col gap-2 ${isEditing ? "hidden" : ""}`}>
{showAccessSpecifier && (
<div className="absolute right-2.5 top-2.5 z-[1] text-custom-text-300">
{comment.access === EIssueCommentAccessSpecifier.INTERNAL ? (
<Lock className="h-3 w-3" />
) : (
<Globe2 className="h-3 w-3" />
)}
</div>
)}
<LiteTextReadOnlyEditor
ref={showEditorRef}
id={comment.id}
initialValue={comment.comment_html ?? ""}
workspaceId={workspaceId}
workspaceSlug={workspaceSlug}
containerClassName="!py-1"
projectId={(projectId as string) ?? ""}
displayConfig={{
fontSize: "small-font",
}}
/>
<CommentReactions comment={comment} disabled={disabled} activityOperations={activityOperations} />
</div>
</>
</CommentBlock>
);
});
+15 -3
View File
@@ -4,11 +4,10 @@ import React, { FC, useMemo } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane imports
import smoothScrollIntoView from "smooth-scroll-into-view-if-needed";
import { E_SORT_ORDER } from "@plane/constants";
import { TCommentsOperations, TIssueComment } from "@plane/types";
// local components
import { CommentCard } from "./comment-card";
import { CommentCard } from "./card/root";
import { CommentCreate } from "./comment-create";
type TCommentsWrapper = {
@@ -19,10 +18,21 @@ type TCommentsWrapper = {
comments: TIssueComment[] | string[];
sortOrder?: E_SORT_ORDER;
getCommentById?: (activityId: string) => TIssueComment | undefined;
showAccessSpecifier?: boolean;
showCopyLinkOption?: boolean;
};
export const CommentsWrapper: FC<TCommentsWrapper> = observer((props) => {
const { entityId, activityOperations, comments, getCommentById, isEditingAllowed = true, projectId } = props;
const {
entityId,
activityOperations,
comments,
getCommentById,
isEditingAllowed = true,
projectId,
showAccessSpecifier = false,
showCopyLinkOption = false,
} = props;
// router
const { workspaceSlug: routerWorkspaceSlug } = useParams();
const workspaceSlug = routerWorkspaceSlug?.toString();
@@ -61,6 +71,8 @@ export const CommentsWrapper: FC<TCommentsWrapper> = observer((props) => {
disabled={!isEditingAllowed}
ends={index === 0 ? "top" : index === comments.length - 1 ? "bottom" : undefined}
projectId={projectId}
showAccessSpecifier={showAccessSpecifier}
showCopyLinkOption={showCopyLinkOption}
/>
);
})}
@@ -0,0 +1,113 @@
"use client";
import { FC, useMemo } from "react";
import { observer } from "mobx-react";
import { Globe2, Link, Lock, Pencil, Trash2 } from "lucide-react";
// plane imports
import { EIssueCommentAccessSpecifier } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import type { TIssueComment, TCommentsOperations } from "@plane/types";
import { CustomMenu, TContextMenuItem } from "@plane/ui";
import { cn } from "@plane/utils";
// hooks
import { useUser } from "@/hooks/store";
type TCommentCard = {
activityOperations: TCommentsOperations;
comment: TIssueComment;
setEditMode: () => void;
showAccessSpecifier: boolean;
showCopyLinkOption: boolean;
};
export const CommentQuickActions: FC<TCommentCard> = observer((props) => {
const { activityOperations, comment, setEditMode, showAccessSpecifier, showCopyLinkOption } = props;
// store hooks
const { data: currentUser } = useUser();
// derived values
const isAuthor = currentUser?.id === comment.actor;
const canEdit = isAuthor;
const canDelete = isAuthor;
// translation
const { t } = useTranslation();
const MENU_ITEMS: TContextMenuItem[] = useMemo(
() => [
{
key: "edit",
action: setEditMode,
title: t("common.actions.edit"),
icon: Pencil,
shouldRender: canEdit,
},
{
key: "copy_link",
action: () => activityOperations.copyCommentLink(comment.id),
title: t("common.actions.copy_link"),
icon: Link,
shouldRender: showCopyLinkOption,
},
{
key: "access_specifier",
action: () =>
activityOperations.updateComment(comment.id, {
access:
comment.access === EIssueCommentAccessSpecifier.INTERNAL
? EIssueCommentAccessSpecifier.EXTERNAL
: EIssueCommentAccessSpecifier.INTERNAL,
}),
title:
comment.access === EIssueCommentAccessSpecifier.INTERNAL
? t("issue.comments.switch.public")
: t("issue.comments.switch.private"),
icon: comment.access === EIssueCommentAccessSpecifier.INTERNAL ? Globe2 : Lock,
shouldRender: showAccessSpecifier,
},
{
key: "delete",
action: () => activityOperations.removeComment(comment.id),
title: t("common.actions.delete"),
icon: Trash2,
shouldRender: canDelete,
},
],
[activityOperations, canDelete, canEdit, comment, setEditMode, showAccessSpecifier, showCopyLinkOption]
);
return (
<CustomMenu ellipsis closeOnSelect>
{MENU_ITEMS.map((item) => {
if (item.shouldRender === false) return null;
return (
<CustomMenu.MenuItem
key={item.key}
onClick={() => item.action()}
className={cn(
"flex items-center gap-2",
{
"text-custom-text-400": item.disabled,
},
item.className
)}
disabled={item.disabled}
>
{item.icon && <item.icon className={cn("shrink-0 size-3", item.iconClassName)} />}
<div>
<h5>{item.title}</h5>
{item.description && (
<p
className={cn("text-custom-text-300 whitespace-pre-line", {
"text-custom-text-400": item.disabled,
})}
>
{item.description}
</p>
)}
</div>
</CustomMenu.MenuItem>
);
})}
</CustomMenu>
);
});
+1 -1
View File
@@ -21,7 +21,7 @@ export const AppHeader = observer((props: AppHeaderProps) => {
return (
<div className="z-[18]">
<Row className="h-[3.75rem] flex gap-2 w-full items-center border-b border-custom-border-200 bg-custom-sidebar-background-100">
<Row className="h-header flex gap-2 w-full items-center border-b border-custom-border-200 bg-custom-sidebar-background-100">
{sidebarCollapsed && <AppSidebarToggleButton />}
<div className="w-full">{header}</div>
</Row>
@@ -50,7 +50,7 @@ export const DateFilterModal: React.FC<Props> = ({ title, handleClose, isOpen, o
return (
<Transition.Root show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-20" onClose={handleClose}>
<Dialog as="div" className="relative z-30" onClose={handleClose}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
@@ -128,7 +128,7 @@ export const ExistingIssuesListModal: React.FC<Props> = (props) => {
return (
<>
<Transition.Root show={isOpen} as={React.Fragment} afterLeave={() => setSearchTerm("")} appear>
<Dialog as="div" className="relative z-20" onClose={handleClose}>
<Dialog as="div" className="relative z-30" onClose={handleClose}>
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
@@ -141,7 +141,7 @@ export const ExistingIssuesListModal: React.FC<Props> = (props) => {
<div className="fixed inset-0 bg-custom-backdrop transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto p-4 sm:p-6 md:p-20">
<div className="fixed inset-0 z-30 overflow-y-auto p-4 sm:p-6 md:p-20">
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
@@ -4,11 +4,13 @@ import { useMemo } from "react";
import { observer } from "mobx-react";
import { Controller, useForm } from "react-hook-form";
// types
import { PROFILE_SETTINGS_TRACKER_ELEMENTS, PROFILE_SETTINGS_TRACKER_EVENTS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { IUserTheme } from "@plane/types";
// ui
import { Button, InputColorPicker, setPromiseToast } from "@plane/ui";
// hooks
import { captureElementAndEvent } from "@/helpers/event-tracker.helper";
import { useUserProfile } from "@/hooks/store";
type TCustomThemeSelector = {
@@ -81,6 +83,35 @@ export const CustomThemeSelector: React.FC<TCustomThemeSelector> = observer((pro
message: () => t("failed_to_update_the_theme"),
},
});
updateCurrentUserThemePromise
.then(() => {
captureElementAndEvent({
element: {
elementName: PROFILE_SETTINGS_TRACKER_ELEMENTS.THEME_DROPDOWN,
},
event: {
eventName: PROFILE_SETTINGS_TRACKER_EVENTS.theme_updated,
payload: {
theme: payload.theme,
},
state: "SUCCESS",
},
});
})
.catch(() => {
captureElementAndEvent({
element: {
elementName: PROFILE_SETTINGS_TRACKER_ELEMENTS.THEME_DROPDOWN,
},
event: {
eventName: PROFILE_SETTINGS_TRACKER_EVENTS.theme_updated,
payload: {
theme: payload.theme,
},
state: "ERROR",
},
});
});
return;
};
+1 -1
View File
@@ -170,7 +170,7 @@ export const DateDropdown: React.FC<Props> = observer((props) => {
<Combobox.Options data-prevent-outside-click static>
<div
className={cn(
"my-1 bg-custom-background-100 shadow-custom-shadow-rg border-[0.5px] border-custom-border-300 rounded-md overflow-hidden z-20",
"my-1 bg-custom-background-100 shadow-custom-shadow-rg border-[0.5px] border-custom-border-300 rounded-md overflow-hidden z-30",
optionsClassName
)}
ref={setPopperElement}
@@ -2,13 +2,15 @@ import { useCallback, useMemo } from "react";
import { observer } from "mobx-react";
import { Check } from "lucide-react";
// plane constants
import { EIssueLayoutTypes, ISSUE_LAYOUT_MAP } from "@plane/constants";
import { ISSUE_LAYOUT_MAP } from "@plane/constants";
// plane i18n
import { useTranslation } from "@plane/i18n";
// plane ui
import { Dropdown } from "@plane/ui";
// plane utils
import { cn } from "@plane/utils";
// types
import { EIssueLayoutTypes } from "@plane/types";
// components
import { IssueLayoutIcon } from "@/components/issues";
@@ -114,7 +114,7 @@ export const MemberOptions: React.FC<Props> = observer((props: Props) => {
<Combobox.Options data-prevent-outside-click static>
<div
className={cn(
"my-1 w-48 rounded border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none z-20",
"my-1 w-48 rounded border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none z-30",
optionsClassName
)}
ref={setPopperElement}
@@ -3,8 +3,10 @@
import { FC, useState } from "react";
import { observer } from "mobx-react";
// ui
import { PROJECT_SETTINGS_TRACKER_EVENTS } from "@plane/constants";
import { Button, EModalPosition, EModalWidth, ModalCore, TOAST_TYPE, setToast } from "@plane/ui";
// hooks
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
import { useEstimate, useProject, useProjectEstimates } from "@/hooks/store";
type TDeleteEstimateModal = {
@@ -35,6 +37,12 @@ export const DeleteEstimateModal: FC<TDeleteEstimateModal> = observer((props) =>
await updateProject(workspaceSlug, projectId, { estimate: null });
}
setButtonLoader(false);
captureSuccess({
eventName: PROJECT_SETTINGS_TRACKER_EVENTS.estimate_deleted,
payload: {
id: estimateId,
},
});
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Estimate deleted",
@@ -43,6 +51,12 @@ export const DeleteEstimateModal: FC<TDeleteEstimateModal> = observer((props) =>
handleClose();
} catch (error) {
setButtonLoader(false);
captureError({
eventName: PROJECT_SETTINGS_TRACKER_EVENTS.estimate_deleted,
payload: {
id: estimateId,
},
});
setToast({
type: TOAST_TYPE.ERROR,
title: "Estimate creation failed",
@@ -2,8 +2,10 @@
import { FC } from "react";
import { useTheme } from "next-themes";
import { PROJECT_SETTINGS_TRACKER_ELEMENTS, PROJECT_SETTINGS_TRACKER_EVENTS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
// public images
import { captureElementAndEvent } from "@/helpers/event-tracker.helper";
import { DetailedEmptyState } from "../empty-state";
type TEstimateEmptyScreen = {
@@ -26,7 +28,18 @@ export const EstimateEmptyScreen: FC<TEstimateEmptyScreen> = (props) => {
className="w-full !px-0 !py-0"
primaryButton={{
text: t("project_settings.empty_state.estimates.primary_button"),
onClick: onButtonClick,
onClick: () => {
onButtonClick();
captureElementAndEvent({
element: {
elementName: PROJECT_SETTINGS_TRACKER_ELEMENTS.ESTIMATES_EMPTY_STATE_CREATE_BUTTON,
},
event: {
eventName: PROJECT_SETTINGS_TRACKER_EVENTS.estimate_created,
state: "SUCCESS",
},
});
},
}}
/>
);
@@ -2,9 +2,11 @@
import { FC } from "react";
import { observer } from "mobx-react";
import { PROJECT_SETTINGS_TRACKER_ELEMENTS, PROJECT_SETTINGS_TRACKER_EVENTS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { TOAST_TYPE, ToggleSwitch, setToast } from "@plane/ui";
// hooks
import { captureElementAndEvent } from "@/helpers/event-tracker.helper";
import { useProject, useProjectEstimates } from "@/hooks/store";
// i18n
type TEstimateDisableSwitch = {
@@ -30,6 +32,15 @@ export const EstimateDisableSwitch: FC<TEstimateDisableSwitch> = observer((props
await updateProject(workspaceSlug, projectId, {
estimate: currentProjectActiveEstimate ? null : currentActiveEstimateId,
});
captureElementAndEvent({
element: {
elementName: PROJECT_SETTINGS_TRACKER_ELEMENTS.ESTIMATES_TOGGLE_BUTTON,
},
event: {
eventName: PROJECT_SETTINGS_TRACKER_EVENTS.estimates_toggle,
state: "SUCCESS",
},
});
setToast({
type: TOAST_TYPE.SUCCESS,
title: currentProjectActiveEstimate
@@ -40,6 +51,15 @@ export const EstimateDisableSwitch: FC<TEstimateDisableSwitch> = observer((props
: t("project_settings.estimates.toasts.enabled.success.message"),
});
} catch (err) {
captureElementAndEvent({
element: {
elementName: PROJECT_SETTINGS_TRACKER_ELEMENTS.ESTIMATES_TOGGLE_BUTTON,
},
event: {
eventName: PROJECT_SETTINGS_TRACKER_EVENTS.estimates_toggle,
state: "ERROR",
},
});
setToast({
type: TOAST_TYPE.ERROR,
title: t("project_settings.estimates.toasts.disabled.error.title"),
@@ -1,9 +1,16 @@
import { useState } from "react";
import { intersection } from "lodash";
import { Controller, useForm } from "react-hook-form";
import { EUserPermissions, EUserPermissionsLevel, EXPORTERS_LIST } from "@plane/constants";
import {
EUserPermissions,
EUserPermissionsLevel,
EXPORTERS_LIST,
WORKSPACE_SETTINGS_TRACKER_EVENTS,
WORKSPACE_SETTINGS_TRACKER_ELEMENTS,
} from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Button, CustomSearchSelect, CustomSelect, TOAST_TYPE, setToast } from "@plane/ui";
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
import { useProject, useUser, useUserPermissions } from "@/hooks/store";
import { ProjectExportService } from "@/services/project/project-export.service";
@@ -73,6 +80,12 @@ export const ExportForm = (props: Props) => {
.then(() => {
mutateServices();
setExportLoading(false);
captureSuccess({
eventName: WORKSPACE_SETTINGS_TRACKER_EVENTS.csv_exported,
payload: {
provider: formData.provider.provider,
},
});
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("workspace_settings.settings.exports.modal.toasts.success.title"),
@@ -88,8 +101,15 @@ export const ExportForm = (props: Props) => {
}),
});
})
.catch(() => {
.catch((error) => {
setExportLoading(false);
captureError({
eventName: WORKSPACE_SETTINGS_TRACKER_EVENTS.csv_exported,
payload: {
provider: formData.provider.provider,
},
error: error as Error,
});
setToast({
type: TOAST_TYPE.ERROR,
title: t("error"),
@@ -163,7 +183,12 @@ export const ExportForm = (props: Props) => {
</div>
</div>
<div className="flex items-center justify-between ">
<Button variant="primary" type="submit" loading={exportLoading}>
<Button
variant="primary"
type="submit"
loading={exportLoading}
data-ph-element={WORKSPACE_SETTINGS_TRACKER_ELEMENTS.EXPORT_BUTTON}
>
{exportLoading ? `${t("workspace_settings.settings.exports.exporting")}...` : t("export")}
</Button>
</div>
@@ -1,4 +1,5 @@
import { FC, useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { observer } from "mobx-react";
// plane imports
// components
@@ -176,10 +177,12 @@ export const ChartViewRoot: FC<ChartViewRootProps> = observer((props) => {
scrollContainer.scrollLeft = scrollWidth;
};
return (
const portalContainer = document.getElementById("full-screen-portal") as HTMLElement;
const content = (
<div
className={cn("relative flex flex-col h-full select-none rounded-sm bg-custom-background-100 shadow", {
"fixed inset-0 z-20 bg-custom-background-100": fullScreenMode,
"inset-0 z-[25] bg-custom-background-100": fullScreenMode,
"border-[0.5px] border-custom-border-200": border,
})}
>
@@ -217,4 +220,6 @@ export const ChartViewRoot: FC<ChartViewRootProps> = observer((props) => {
/>
</div>
);
return fullScreenMode && portalContainer ? createPortal(content, portalContainer) : content;
});
@@ -123,7 +123,7 @@ export const SelectDuplicateInboxIssueModal: React.FC<Props> = (props) => {
<Transition.Root show={isOpen} as={React.Fragment} afterLeave={() => setQuery("")} appear>
<div className="flex flex-wrap items-start">
<div className="space-y-1 sm:basis-1/2">
<Dialog as="div" className="relative z-20" onClose={handleClose}>
<Dialog as="div" className="relative z-30" onClose={handleClose}>
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
@@ -136,7 +136,7 @@ export const SelectDuplicateInboxIssueModal: React.FC<Props> = (props) => {
<div className="fixed inset-0 bg-custom-backdrop transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-20 overflow-y-auto p-4 sm:p-6 md:p-20">
<div className="fixed inset-0 z-30 overflow-y-auto p-4 sm:p-6 md:p-20">
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
@@ -64,7 +64,7 @@ export const ArchiveIssueModal: React.FC<Props> = (props) => {
return (
<Transition.Root show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-20" onClose={onClose}>
<Dialog as="div" className="relative z-30" onClose={onClose}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
@@ -77,7 +77,7 @@ export const ArchiveIssueModal: React.FC<Props> = (props) => {
<div className="fixed inset-0 bg-custom-backdrop transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="fixed inset-0 z-30 overflow-y-auto">
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<Transition.Child
as={Fragment}
@@ -32,7 +32,7 @@ export const ConfirmIssueDiscard: React.FC<Props> = (props) => {
return (
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-20" onClose={handleClose}>
<Dialog as="div" className="relative z-30" onClose={handleClose}>
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
@@ -45,7 +45,7 @@ export const ConfirmIssueDiscard: React.FC<Props> = (props) => {
<div className="fixed inset-0 bg-custom-backdrop transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="fixed inset-0 z-30 overflow-y-auto">
<div className="my-10 flex items-center justify-center p-4 text-center sm:p-0 md:my-32">
<Transition.Child
as={React.Fragment}
+2 -1
View File
@@ -3,7 +3,7 @@
import { useCallback, useState } from "react";
import { observer } from "mobx-react";
// plane constants
import { EIssueLayoutTypes, EIssueFilterType, ISSUE_STORE_TO_FILTERS_MAP } from "@plane/constants";
import { EIssueFilterType, ISSUE_STORE_TO_FILTERS_MAP } from "@plane/constants";
// i18n
import { useTranslation } from "@plane/i18n";
// types
@@ -12,6 +12,7 @@ import {
IIssueDisplayFilterOptions,
IIssueDisplayProperties,
IIssueFilterOptions,
EIssueLayoutTypes,
} from "@plane/types";
import { Button } from "@plane/ui";
// components
@@ -1,16 +1,18 @@
import { FC } from "react";
import { observer } from "mobx-react";
// constants
// plane imports
import { E_SORT_ORDER, TActivityFilters, filterActivityOnSelectedFilters } from "@plane/constants";
// hooks
import { TCommentsOperations } from "@plane/types";
import { CommentCard } from "@/components/comments/comment-card";
// components
import { CommentCard } from "@/components/comments/card/root";
// hooks
import { useIssueDetail } from "@/hooks/store";
// plane web components
import { IssueAdditionalPropertiesActivity } from "@/plane-web/components/issues";
import { IssueActivityWorklog } from "@/plane-web/components/issues/worklog/activity/root";
// components
// local imports
import { IssueActivityItem } from "./activity/activity-list";
import { IssueActivityLoader } from "./loader";
type TIssueActivityCommentRoot = {
workspaceSlug: string;
@@ -34,21 +36,23 @@ export const IssueActivityCommentRoot: FC<TIssueActivityCommentRoot> = observer(
disabled,
sortOrder,
} = props;
// hooks
// store hooks
const {
activity: { getActivityCommentByIssueId },
activity: { getActivityAndCommentsByIssueId },
comment: { getCommentById },
} = useIssueDetail();
// derived values
const activityAndComments = getActivityAndCommentsByIssueId(issueId, sortOrder);
const activityComments = getActivityCommentByIssueId(issueId, sortOrder);
if (!activityAndComments) return <IssueActivityLoader />;
if (!activityComments || (activityComments && activityComments.length <= 0)) return <></>;
if (activityAndComments.length <= 0) return null;
const filteredActivityComments = filterActivityOnSelectedFilters(activityComments, selectedFilters);
const filteredActivityAndComments = filterActivityOnSelectedFilters(activityAndComments, selectedFilters);
return (
<div>
{filteredActivityComments.map((activityComment, index) => {
{filteredActivityAndComments.map((activityComment, index) => {
const comment = getCommentById(activityComment.id);
return activityComment.activity_type === "COMMENT" ? (
<CommentCard
@@ -56,20 +60,21 @@ export const IssueActivityCommentRoot: FC<TIssueActivityCommentRoot> = observer(
workspaceSlug={workspaceSlug}
comment={comment}
activityOperations={activityOperations}
ends={index === 0 ? "top" : index === filteredActivityComments.length - 1 ? "bottom" : undefined}
showAccessSpecifier={showAccessSpecifier}
ends={index === 0 ? "top" : index === filteredActivityAndComments.length - 1 ? "bottom" : undefined}
showAccessSpecifier={!!showAccessSpecifier}
showCopyLinkOption
disabled={disabled}
projectId={projectId}
/>
) : activityComment.activity_type === "ACTIVITY" ? (
<IssueActivityItem
activityId={activityComment.id}
ends={index === 0 ? "top" : index === filteredActivityComments.length - 1 ? "bottom" : undefined}
ends={index === 0 ? "top" : index === filteredActivityAndComments.length - 1 ? "bottom" : undefined}
/>
) : activityComment.activity_type === "ISSUE_ADDITIONAL_PROPERTIES_ACTIVITY" ? (
<IssueAdditionalPropertiesActivity
activityId={activityComment.id}
ends={index === 0 ? "top" : index === filteredActivityComments.length - 1 ? "bottom" : undefined}
ends={index === 0 ? "top" : index === filteredActivityAndComments.length - 1 ? "bottom" : undefined}
/>
) : activityComment.activity_type === "WORKLOG" ? (
<IssueActivityWorklog
@@ -77,7 +82,7 @@ export const IssueActivityCommentRoot: FC<TIssueActivityCommentRoot> = observer(
projectId={projectId}
issueId={issueId}
activityComment={activityComment}
ends={index === 0 ? "top" : index === filteredActivityComments.length - 1 ? "bottom" : undefined}
ends={index === 0 ? "top" : index === filteredActivityAndComments.length - 1 ? "bottom" : undefined}
/>
) : (
<></>
@@ -1,9 +1,9 @@
import { useMemo } from "react";
import { useTranslation } from "@plane/i18n";
import { EFileAssetType, TCommentsOperations, TIssueComment } from "@plane/types";
import { EFileAssetType, type TCommentsOperations } from "@plane/types";
import { setToast, TOAST_TYPE } from "@plane/ui";
import { formatTextList } from "@plane/utils";
import { useEditorAsset, useIssueDetail, useMember, useUser } from "@/hooks/store";
import { copyUrlToClipboard, formatTextList, generateWorkItemLink } from "@plane/utils";
import { useEditorAsset, useIssueDetail, useMember, useProject, useUser } from "@/hooks/store";
export const useCommentOperations = (
workspaceSlug: string | undefined,
@@ -18,16 +18,49 @@ export const useCommentOperations = (
removeComment,
createCommentReaction,
removeCommentReaction,
issue: { getIssueById },
} = useIssueDetail();
const { getProjectById } = useProject();
const { getUserDetails } = useMember();
const { uploadEditorAsset } = useEditorAsset();
const { data: currentUser } = useUser();
// derived values
const issueDetails = issueId ? getIssueById(issueId) : undefined;
const projectDetails = projectId ? getProjectById(projectId) : undefined;
// translation
const { t } = useTranslation();
const operations = useMemo(() => {
const operations: TCommentsOperations = useMemo(() => {
// Define operations object with all methods
const ops = {
createComment: async (data: Partial<TIssueComment>) => {
const ops: TCommentsOperations = {
copyCommentLink: (id) => {
if (!workspaceSlug || !issueDetails) return;
try {
const workItemLink = generateWorkItemLink({
workspaceSlug,
projectId: issueDetails.project_id,
issueId,
projectIdentifier: projectDetails?.identifier,
sequenceId: issueDetails.sequence_id,
});
const commentLink = `${workItemLink}#comment-${id}`;
copyUrlToClipboard(commentLink).then(() => {
setToast({
title: t("common.success"),
type: TOAST_TYPE.SUCCESS,
message: t("issue.comments.copy_link.success"),
});
});
} catch (error) {
console.error("Error in copying comment link:", error);
setToast({
title: t("common.error.label"),
type: TOAST_TYPE.ERROR,
message: t("issue.comments.copy_link.error"),
});
}
},
createComment: async (data) => {
try {
if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing fields");
const comment = await createComment(workspaceSlug, projectId, issueId, data);
@@ -45,7 +78,7 @@ export const useCommentOperations = (
});
}
},
updateComment: async (commentId: string, data: Partial<TIssueComment>) => {
updateComment: async (commentId, data) => {
try {
if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing fields");
await updateComment(workspaceSlug, projectId, issueId, commentId, data);
@@ -62,7 +95,7 @@ export const useCommentOperations = (
});
}
},
removeComment: async (commentId: string) => {
removeComment: async (commentId) => {
try {
if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing fields");
await removeComment(workspaceSlug, projectId, issueId, commentId);
@@ -79,7 +112,7 @@ export const useCommentOperations = (
});
}
},
uploadCommentAsset: async (blockId: string, file: File, commentId?: string) => {
uploadCommentAsset: async (blockId, file, commentId) => {
try {
if (!workspaceSlug || !projectId) throw new Error("Missing fields");
const res = await uploadEditorAsset({
@@ -98,7 +131,7 @@ export const useCommentOperations = (
throw new Error(t("issue.comments.upload.error"));
}
},
addCommentReaction: async (commentId: string, reaction: string) => {
addCommentReaction: async (commentId, reaction) => {
try {
if (!workspaceSlug || !projectId || !commentId) throw new Error("Missing fields");
await createCommentReaction(workspaceSlug, projectId, commentId, reaction);
@@ -107,7 +140,7 @@ export const useCommentOperations = (
type: TOAST_TYPE.SUCCESS,
message: "Reaction created successfully",
});
} catch (error) {
} catch {
setToast({
title: "Error!",
type: TOAST_TYPE.ERROR,
@@ -115,7 +148,7 @@ export const useCommentOperations = (
});
}
},
deleteCommentReaction: async (commentId: string, reaction: string) => {
deleteCommentReaction: async (commentId, reaction) => {
try {
if (!workspaceSlug || !projectId || !commentId || !currentUser?.id) throw new Error("Missing fields");
removeCommentReaction(workspaceSlug, projectId, commentId, reaction, currentUser.id);
@@ -124,7 +157,7 @@ export const useCommentOperations = (
type: TOAST_TYPE.SUCCESS,
message: "Reaction removed successfully",
});
} catch (error) {
} catch {
setToast({
title: "Error!",
type: TOAST_TYPE.ERROR,
@@ -132,14 +165,14 @@ export const useCommentOperations = (
});
}
},
react: async (commentId: string, reactionEmoji: string, userReactions: string[]) => {
react: async (commentId, reactionEmoji, userReactions) => {
if (userReactions.includes(reactionEmoji)) await ops.deleteCommentReaction(commentId, reactionEmoji);
else await ops.addCommentReaction(commentId, reactionEmoji);
},
reactionIds: (commentId: string) => getCommentReactionsByCommentId(commentId),
userReactions: (commentId: string) =>
reactionIds: (commentId) => getCommentReactionsByCommentId(commentId),
userReactions: (commentId) =>
currentUser ? commentReactionsByUser(commentId, currentUser?.id).map((r) => r.reaction) : [],
getReactionUsers: (reaction: string, reactionIds: Record<string, string[]>): string => {
getReactionUsers: (reaction, reactionIds) => {
const reactionUsers = (reactionIds?.[reaction] || [])
.map((reactionId) => {
const reactionDetails = getCommentReactionById(reactionId);
@@ -0,0 +1,31 @@
// plane imports
import { Loader } from "@plane/ui";
export const IssueActivityLoader = () => (
<Loader className="space-y-8">
<div className="flex items-start gap-3">
<Loader.Item className="shrink-0" height="28px" width="28px" />
<div className="space-y-2 w-full">
<Loader.Item height="8px" width="60%" />
<Loader.Item height="8px" width="40%" />
<Loader.Item height="10px" width="100%" />
</div>
</div>
<div className="flex items-start gap-3">
<Loader.Item className="shrink-0" height="28px" width="28px" />
<div className="space-y-2 w-full">
<Loader.Item height="8px" width="40%" />
<Loader.Item height="8px" width="60%" />
<Loader.Item height="10px" width="80%" />
</div>
</div>
<div className="flex items-start gap-3">
<Loader.Item className="shrink-0" height="28px" width="28px" />
<div className="space-y-2 w-full">
<Loader.Item height="8px" width="60%" />
<Loader.Item height="8px" width="40%" />
<Loader.Item height="10px" width="100%" />
</div>
</div>
</Loader>
);
@@ -1,26 +1,22 @@
"use client";
import React, { FC, useState } from "react";
import React, { FC, useRef } from "react";
import { observer } from "mobx-react";
import { ArchiveIcon, ArchiveRestoreIcon, LinkIcon, Trash2 } from "lucide-react";
import {
ARCHIVABLE_STATE_GROUPS,
EUserPermissions,
EUserPermissionsLevel,
WORK_ITEM_TRACKER_EVENTS,
} from "@plane/constants";
import { LinkIcon } from "lucide-react";
import { WORK_ITEM_TRACKER_EVENTS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { EIssuesStoreType } from "@plane/types";
import { TOAST_TYPE, Tooltip, setToast } from "@plane/ui";
import { cn, generateWorkItemLink, copyTextToClipboard } from "@plane/utils";
import { generateWorkItemLink, copyTextToClipboard } from "@plane/utils";
// components
import { ArchiveIssueModal, DeleteIssueModal, IssueSubscription } from "@/components/issues";
import { IssueSubscription } from "@/components/issues";
// helpers
// hooks
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
import { useIssueDetail, useIssues, useProject, useProjectState, useUser, useUserPermissions } from "@/hooks/store";
import { useIssueDetail, useIssues, useProject, useUser } from "@/hooks/store";
import { useAppRouter } from "@/hooks/use-app-router";
import { usePlatformOS } from "@/hooks/use-platform-os";
import { WorkItemDetailQuickActions } from "../issue-layouts/quick-action-dropdowns";
type Props = {
workspaceSlug: string;
@@ -31,19 +27,16 @@ type Props = {
export const IssueDetailQuickActions: FC<Props> = observer((props) => {
const { workspaceSlug, projectId, issueId } = props;
const { t } = useTranslation();
// states
const [deleteIssueModal, setDeleteIssueModal] = useState(false);
const [archiveIssueModal, setArchiveIssueModal] = useState(false);
const [isRestoring, setIsRestoring] = useState(false);
// ref
const parentRef = useRef<HTMLDivElement>(null);
// router
const router = useAppRouter();
// hooks
const { data: currentUser } = useUser();
const { allowPermissions } = useUserPermissions();
const { isMobile } = usePlatformOS();
const { getStateById } = useProjectState();
const { getProjectIdentifierById } = useProject();
const {
issue: { getIssueById },
@@ -61,7 +54,6 @@ export const IssueDetailQuickActions: FC<Props> = observer((props) => {
const issue = getIssueById(issueId);
if (!issue) return <></>;
const stateDetails = getStateById(issue.state_id);
const projectIdentifier = getProjectIdentifierById(projectId);
const workItemLink = generateWorkItemLink({
@@ -133,8 +125,6 @@ export const IssueDetailQuickActions: FC<Props> = observer((props) => {
const handleRestore = async () => {
if (!workspaceSlug || !projectId || !issueId) return;
setIsRestoring(true);
await restoreIssue(workspaceSlug.toString(), projectId.toString(), issueId.toString())
.then(() => {
setToast({
@@ -150,40 +140,11 @@ export const IssueDetailQuickActions: FC<Props> = observer((props) => {
title: t("toast.error"),
message: t("issue.restore.failed.message"),
});
})
.finally(() => setIsRestoring(false));
});
};
// auth
const isEditable = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.PROJECT,
workspaceSlug,
projectId
);
const canRestoreIssue = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.PROJECT,
workspaceSlug,
projectId
);
const isArchivingAllowed = !issue?.archived_at && isEditable;
const isInArchivableGroup = !!stateDetails && ARCHIVABLE_STATE_GROUPS.includes(stateDetails?.group);
return (
<>
<DeleteIssueModal
handleClose={() => setDeleteIssueModal(false)}
isOpen={deleteIssueModal}
data={issue}
onSubmit={handleDeleteIssue}
/>
<ArchiveIssueModal
isOpen={archiveIssueModal}
handleClose={() => setArchiveIssueModal(false)}
data={issue}
onSubmit={handleArchiveIssue}
/>
<div className="flex items-center justify-end flex-shrink-0">
<div className="flex flex-wrap items-center gap-4">
{currentUser && !issue?.archived_at && (
@@ -199,64 +160,13 @@ export const IssueDetailQuickActions: FC<Props> = observer((props) => {
<LinkIcon className="h-4 w-4" />
</button>
</Tooltip>
{issue?.archived_at && canRestoreIssue ? (
<>
<Tooltip isMobile={isMobile} tooltipContent="Restore">
<button
type="button"
className={cn(
"grid h-5 w-5 place-items-center rounded focus:outline-none focus:ring-2 focus:ring-custom-primary",
{
"hover:text-custom-text-200": isInArchivableGroup,
"cursor-not-allowed text-custom-text-400": !isInArchivableGroup,
}
)}
onClick={handleRestore}
disabled={isRestoring}
>
<ArchiveRestoreIcon className="h-4 w-4" />
</button>
</Tooltip>
</>
) : (
<>
{isArchivingAllowed && (
<Tooltip
isMobile={isMobile}
tooltipContent={isInArchivableGroup ? t("common.actions.archive") : t("issue.archive.description")}
>
<button
type="button"
className={cn(
"grid h-5 w-5 place-items-center rounded focus:outline-none focus:ring-2 focus:ring-custom-primary",
{
"hover:text-custom-text-200": isInArchivableGroup,
"cursor-not-allowed text-custom-text-400": !isInArchivableGroup,
}
)}
onClick={() => {
if (!isInArchivableGroup) return;
setArchiveIssueModal(true);
}}
>
<ArchiveIcon className="h-4 w-4" />
</button>
</Tooltip>
)}
</>
)}
{isEditable && (
<Tooltip tooltipContent={t("common.actions.delete")} isMobile={isMobile}>
<button
type="button"
className="grid h-5 w-5 place-items-center rounded hover:text-custom-text-200 focus:outline-none focus:ring-2 focus:ring-custom-primary"
onClick={() => setDeleteIssueModal(true)}
>
<Trash2 className="h-4 w-4" />
</button>
</Tooltip>
)}
<WorkItemDetailQuickActions
parentRef={parentRef}
issue={issue}
handleDelete={handleDeleteIssue}
handleArchive={handleArchiveIssue}
handleRestore={handleRestore}
/>
</div>
</div>
</div>

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