fix: Implementing mobx, refactoring service layer and rewriting components (#2441)

* chore: kanban refactoring

* chore: Implemented new kanaban board UX and implemented draggable using react beautiful dnd

* chore: updated yarn lock

* chore: updated the store for issues and issue filters

* chore: resolved build error

* chore: created filters and updated the issue filters, display_filter and display_properties in mobx and components

* chore: implemented filters for issues

* chore: UI theming updates

* chore: handled single and multi select in filter cards

* chore: implemented filters and views in kanaban

* chore: updating filters, display_filter and display properties

* chore: filter, layout, display filters, extra filters and display properties render validation

* chore: clean up and resolved import warnings

* chore: type check

* chore: renamed gantt key to gantt_chart

* chore: filter render UI and Functionality implementation

* chore: filter empty state handling in issue filter selection

* Implementing list view

* chore: kanban drag drop logic

* filtering

* chore: store setup

* chore: handled build issues

* chore: store setup

* user filter

* chore: store setup

* chore: store fixes and static data setup

* chore: store setup for build fixes

* fix: merge conflicts (#2231)

* chore: dynamic position dropdown (#2138)

* chore: dynamic position state dropdown for issue view

* style: state select dropdown styling

* fix: state icon attribute names

* chore: state select dynamic dropdown

* chore: member select dynamic dropdown

* chore: label select dynamic dropdown

* chore: priority select dynamic dropdown

* chore: label select dropdown improvement

* refactor: state dropdown location

* chore: dropdown improvement and code refactor

* chore: dynamic dropdown hook type added

---------

Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>

* fix: fields not getting selected in the create issue form (#2212)

* fix: hydration error and draft issue workflow

* fix: build error

* fix: properties getting de-selected after create, module & cycle not getting auto-select on the form

* fix: display layout, props being updated directly

* chore: sub issues count in individual issue (#2221)

* fix: service imports

* chore: rename csv service file

---------

Co-authored-by: Anmol Singh Bhatia <121005188+anmolsinghbhatia@users.noreply.github.com>
Co-authored-by: Dakshesh Jain <65905942+dakshesh14@users.noreply.github.com>
Co-authored-by: Bavisetti Narayan <72156168+NarayanBavisetti@users.noreply.github.com>

* chore: store fixes

* chore: update issue detail store to handle peek overview (#2237)

* chore: dynamic position dropdown (#2138)

* chore: dynamic position state dropdown for issue view

* style: state select dropdown styling

* fix: state icon attribute names

* chore: state select dynamic dropdown

* chore: member select dynamic dropdown

* chore: label select dynamic dropdown

* chore: priority select dynamic dropdown

* chore: label select dropdown improvement

* refactor: state dropdown location

* chore: dropdown improvement and code refactor

* chore: dynamic dropdown hook type added

---------

Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>

* fix: fields not getting selected in the create issue form (#2212)

* fix: hydration error and draft issue workflow

* fix: build error

* fix: properties getting de-selected after create, module & cycle not getting auto-select on the form

* fix: display layout, props being updated directly

* chore: sub issues count in individual issue (#2221)

* Implemented nested issues in the sub issues section in issue detail page (#2233)

* feat: subissues infinte level

* feat: updated UI for sub issues

* feat: subissues new ui and nested sub issues in issue detail

* chore: removed repeated code

* refactor: product updates modal layout (#2225)

* fix: handle no issues in custom analytics (#2226)

* fix: activity label color (#2227)

* fix: profile issues layout switch (#2228)

* chore: update service imports

* chore: update issue detail store to handle peek overview

---------

Co-authored-by: Anmol Singh Bhatia <121005188+anmolsinghbhatia@users.noreply.github.com>
Co-authored-by: Dakshesh Jain <65905942+dakshesh14@users.noreply.github.com>
Co-authored-by: Bavisetti Narayan <72156168+NarayanBavisetti@users.noreply.github.com>
Co-authored-by: guru_sainath <gurusainath007@gmail.com>

* chore: minor fixes

* workspace project fixes

* feat: project issues topbar (#2256)

* chore: project issues topbar

* style: theming and minor UI fixes

* refactor: file structure

* chore: layout wise authorization added

* style: filter dropdowns

* chore: add fetch keys

* chore: minor fixes

* chore: filters dropdown (#2260)

* chore: project issues topbar

* style: theming and minor UI fixes

* refactor: file structure

* chore: layout wise authorization added

* style: filter dropdowns

* chore: add fetch keys

* feat: search option for filters

* fix: sticky headers

* chore: sub_group_by section added

* fix: leave project fixes

* refactor: project card component refactor

* Implemented swimlanes and kanban view  (#2262)

* chore: issue store for kanban and calendar

* chore: updated ui for kanba and swimlanes

* chore: yarn.lock updated

* fix: computed filters logic

* chore: added sub_group_by in params and handled sub-group-by render error in display filter's

* fix: ui package setup and project update form refactor

* fix: ui package setup

* fix: minor ui fixes

* dev: calendar view layout revamp (#2293)

* dev: calendar view init

* chore: new render logic

* chore: implement calendar view

* chore: calendar view

* refactor: calendar payload

* chore: remove active month logic from backend

* chore: setup new store for calendar

* refactor: issues fetching structure

* chore: months dropdown

* chore: modify request query params for calendar layout

* refactor: remove console logs and add comments

* chore: removed demo m-store routes

* cycles changes

* chore: issues grouped kanban and swimlanes UI and functionality (#2294)

* chore: updated the all the group_by and sub_group_by UI and functionality render in kanban

* chore: kanban sorting in mobx and ui updates

* chore: ui changes and drag and drop functionality changes in kanban

* chore: issues count render in kanban default and swimlanes

* chore: Added icons to the group_by and sub_group_by in kanban and swimlanes

* refactor: filter components, constants and helper functions (#2297)

* refactor: filters and display filters to accept handlers as props

* refactor: filters and display filters folder structure

* refactor: change issue layout options constant structure

* chore: display filters validations

* chore: view less filters functionality

* fix: display filters validation

* refactor: wrap functions around useCallback

* chore: start and target date filter options added

* refactor: query params generator function

* fix: query params generator function

* dev: gantt chart implementation using MobX (#2302)

* dev: fetch project gantt issues using mobx

* chore: handle group by options in the kanban layout

* dev: spreadsheet layout implementation using MobX (#2306)

* dev: implement spreadsheet view using mobx

* refactor: remove console logs and props

* chore: refactoring cycles list

* feat: adding additional ui components

* dev: applied filters list implementation using MobX (#2325)

* dev: applied filters list UI

* fix: filter item height

* chore: remove unnecessary classes

* fix: params generator

* fix: cycles views list and board

* fix: cycles list rendering fixes

* fix: layout fixes

* refactor: filter components (#2359)

* fix: calendar layout dividers

* refactor: filter selection components

* fix: dropdown closing after selection

* refactor: filters components

* chore: issue properties for list and kanban layouts and implemented estimates in project store (#2363)

* chore: issue properties for state, priorit, labels and members

* feat: implemented assignee, labels properties

* fix: implemented estimates in project store and issue properties

* chore: staer_date and due_date and validation properties in kanban

* chore: filters import conflict

* dev: setup module and module filter store (#2364)

* dev: implement module issues using mobx store

* dev: module filter store setup

* chore: module store crud operations

* chore: issue list layout (#2367)

* chore: merge develop (#2388)

* fix: build erros

* chore: cycles, modules store integration, list and kanban layouts and updated kanban logic (#2399)

* chore: cycle, cycle-issue, cycle-filters, cycle-kanban, cycle layout setup

* chore: cycles kanban and list view store

* chore: cycles, modules kanban and list, kanban view store

* refactor: change naming convention (#2383)

* fix:auth layer revamp

* chore: Implemented list and kanban views in project modules (#2402)

* chore: updated kanban logic in project cycles and modules

* chore: updated list and kanban in module

* dev: implement global views using MobX (#2404)

* fix: selfhosted fixes (#2154)

* fix: selfhosted fixes

* fix: updated env example

* chore: dynamic position dropdown (#2138)

* chore: dynamic position state dropdown for issue view

* style: state select dropdown styling

* fix: state icon attribute names

* chore: state select dynamic dropdown

* chore: member select dynamic dropdown

* chore: label select dynamic dropdown

* chore: priority select dynamic dropdown

* chore: label select dropdown improvement

* refactor: state dropdown location

* chore: dropdown improvement and code refactor

* chore: dynamic dropdown hook type added

---------

Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>

* fix: fields not getting selected in the create issue form (#2212)

* fix: hydration error and draft issue workflow

* fix: build error

* fix: properties getting de-selected after create, module & cycle not getting auto-select on the form

* fix: display layout, props being updated directly

* chore: sub issues count in individual issue (#2221)

* Implemented nested issues in the sub issues section in issue detail page (#2233)

* feat: subissues infinte level

* feat: updated UI for sub issues

* feat: subissues new ui and nested sub issues in issue detail

* chore: removed repeated code

* refactor: product updates modal layout (#2225)

* fix: handle no issues in custom analytics (#2226)

* fix: activity label color (#2227)

* fix: profile issues layout switch (#2228)

* fix: issues resolved in sub issues (#2238)

* fix: aws region name (#2234)

* chore: updated docker naming conventions (#2239)

* naming convention changes

* dev: update docker-compose-hub in consistent with docker-compose

* dev: updated docker container name

---------

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

* chore: added state and priority order in workspace user profile (#2241)

* fix: changed priority from None to none (#2229)

* fix: cycle and module stats when issues are archived (#2185)

* fix: cycle and module stats when issues are archived

* fix: added draft filter

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>

* feat: quick add  (#2240)

* feat: quick add

* style: made text color muted

* chore: added epoch in draft (#2244)

* chore: added epoch in draft

* chore: removed extra spaces

* fix: resolved pending issue graph in analytics, user wishes in dashboard, and typo in projects list (#2247)

* style: settings page improvement (#2211)

* style: settings page improvement

* style: toggle switch styling

---------

Co-authored-by: Anmol Singh Bhatia <asb@Anmols-MacBook-Pro.local>

* chore: changed priority props in workspace and project (#2253)

* fix: bug fix related to fetching dropdown options for the profile issue (#2246)

* fix: sub issue state and member select build error (#2254)

* rename view to layout (#2255)

Co-authored-by: Your Name <you@example.com>

* fix: bug fixes and ui improvement (#2250)

* dev: remove auto filter endpoint

* feat: quick-add placement in spreadsheet and gantt  (#2259)

* feat: sticking quick-add at the bottom of the screen

fix: opening create issue modal instead of quick-add in draft-issues, my-issue and profile page

* fix: build error due to dynamic import

* fix: draft issue delete not working (#2249)

* fix: draft issue not deleting, project can't be changed in draft issue modal

* fix: removed mutation for view where draft issues are not shown

* fix: inline create issue for draft issue

* fix: clearing data from localstorage on discard click

* feat: Add peek overview in sub issues and updated UI for empty states. (#2263)

* chore: add tooltip to show full time on activity logs (#2235)

* fix: issue automation iterable error (#2208)

* fix: n+1 queries for cycle list and project member endpoints (#2257)

* [fix] nginx continuously rewriting and reloading on index page of spaces app  (#2236)

* chore: shifted index page to /home route

* chore: added rewrite logic, to rewrite index to /home

* chore: routed home to login route as login page

* chore: updated nginx config to route to login

* chore: updated path for home

* dev: migration for 0.13 (#2266)

* dev: updated migrations

* dev: migration for 0.13

* dev: re-split migrations into two different files (#2268)

* dev: split issue activity migration separate files

* dev: resplit migrations into two different files

* dev: changed the batch size

* chore: udpate date filters to support dynamic options

* fix: bugs in quick-add and draft issues (#2269)

* fix: 'Last Drafted Issue' making sidebar look weird on collapsed

* feat: scroll to the bottom when issue is created

* fix: 'Add Issue' button overlapping issue card in spreadsheet view

* fix: wrong placement of quick-add in calender layout

* fix: spacing for issue card in spreadsheet view

* chore: add instructions to contributing guide (#2270)

* chore: add instructions to contributing guide

* dev: update contributing.md to use the new configuration

---------

Co-authored-by: Nikhil <118773738+pablohashescobar@users.noreply.github.com>

* fix: user dashboard greeting timezone (#2267)

* chore: user greeting timezone

* fix: group by labels not working on workspace level

* feat: workspace global view, style: spreadsheet view revamp (#2273)

* chore: workspace view types, services and hooks added

* style: spreadsheet view revamp and code refactor

* feat: workspace view

* fix: build fix

* chore: sidebar workspace issues redirection updated

* style: gantt layout quick-add padding (#2272)

* fix: 'Last Drafted Issue' making sidebar look weird on collapsed

* feat: scroll to the bottom when issue is created

* fix: 'Add Issue' button overlapping issue card in spreadsheet view

* fix: wrong placement of quick-add in calender layout

* fix: spacing for issue card in spreadsheet view

* style: gantt layout quick-add padding

style: removed 'State group' from draft issue

* style: decrese shadow, quick-add position on calender layout, and 'add issue' sticky

* style: button color

* fix: block click happening while moving (#2275)

* dev: refactor date filters to a single function

* chore: handle calendar date range in frontend (#2277)

* chore: gantt chart empty state (#2279)

* chore: gantt empty state

* chore: Add heading to the gantt sidebar

* style: calender quick-add same width as single date (#2280)

* style: calender quick-add same width as single date

* style: margin bottom in quick-add in spreadsheet view

* fix: quick add opening in list-layout

* style: reduced margin left

* chore: updated created at in draft issue (#2278)

* chore: make target dates inclusive when filtering (#2276)

* chore: sort order and issue props for global views (#2283)

* chore: removed project filter (#2284)

* fix: inbox issue deletes (#2290)

* chore: views (#2288)

* chore: global views order by

* chore: update permissions for global views

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>

* chore: fetch issues from previous and next month in the calendar view (#2282)

* fix: issue activity estimate value bug fix (#2281)

* fix: issue activity estimate value bug fix

* fix: activity typo fix

* fix: ui and bugs (#2289)

* fix: 24 character limit on first & last name in onboarding page

* fix: no option: 'Add Issue' in archive issue page

* fix: in archive issue directly sending to issue detail page

* fix: issue type showing in archive issue

* fix: custom menu overflowing

* fix: changing subscriber in filters has no effect

* style: border in quick-add

* fix: on onboarding member role overflowing

* fix: inconsistent icons in issue detail

* style: spacing, borders and shadows in quick-add

* fix: custom menu truncate

* fix: notifications for created by me and assigned to me (#2292)

* chore: workspace view display filters and properties , code refactor (#2295)

* chore: spreadsheet view context

* chore: spreadsheet context provider

* chore: spreadsheet view context

* chore: display filters and properties added in workspace view and code refactor

* fix: build error fix

* chore: set sub-issue display option to false for global views

---------

Co-authored-by: gurusainath <gurusainath007@gmail.com>

* chore: label create error (#2299)

* chore: global issues ui improvement and bug fixes (#2300)

* chore: workspace view mutation fix ,bug fixes and code refactor (#2301)

* chore: workspace view mutation fix ,bug fixes and code refactor

* chore: update workspace view toast alert added

* chore: workspace view order by removed (#2303)

* dev: updated migrations for 0.13-dev (#2305)

* chore: epoch migration batch size changed

* chore: reoredered the migration files

* dev: updated migrations for 0.13-dev

* chore: added epoch field

* dev: merged the migration files

* fix: workspace view filters count fix (#2307)

* fix: unsplash api fix (#2310)

* fix: workspace view redirection fix, style: spreadsheet view shadow scroll fix (#2314)

* fix: workspace view redirection fix

* style: spreadsheet view scroll shadow fix

* fix: update build workflow for the deploy app (#2315)

* fix: workspace view add issue mutation fix (#2317)

* dev: create action to sync PR changes to the repo (#2333)

* fix: ui package readme added (#2334)

* fix: variable name for token (#2336)

* dev: update add permissions to the action (#2337)

* dev: rename token variables (#2338)

* fix: updated readme fixes (#2339)

* dev: update sync workflow to run only when the source repo is configured (#2346)

* dev: update sync workflow to run only when the source repo is configured

* fix: naming convention changes

---------

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

* fix: issue relation mutation and draft issue (#2340)

* fix: issue relation mutation and draft issue

* fix: 'New Issue' in gantt view

fix: emoji select going under

* fix: profile page typo

* fix: sync workflow fixes (#2365)

* fix: sync job pr description escaped values fix (#2366)

* Update index.tsx (#2343)

Fixes #2342

* dev: update apiserver configuration files (#2348)

* dev: update apiserver configuration files

* dev: add email and minio redirection urls

* fix: themening  validation in store init. (#2350)

* chore: member can change role (#2371)

* chore: removed the issue draft log from my profile (#2368)

* adding sync info in pr title (#2373)

* chore: layout access validation and switch in plane deploy issues route (#2351)

* chore: handled route validation and layout access validation in plane deploy issues

* chore: impoved validation condition

* show current version in the help section dropdown (#2353)

* fix: table menu positioning (#2354)

* fix: handle cross project issues in the sub-issues. (#2357)

* fix: login process validation based on api config (#2361)

* dev: configuration endpoint for frontend client (#2355)

* dev: configuration endpoint for frontend clients

* dev: configuration enable magic and email/password signup

* dev: update unsplash keys

* dev: add unsplash API and add  env for magic login

* fix: 404 when redirecting user clicks on Sign In button (#2349)

* fix: 404 when redirecting user to login page

* fix: next_path redirection not working

* fix: authentication workflow update in plane deploy

---------

Co-authored-by: gurusainath <gurusainath007@gmail.com>

* fix: project setting member role validation (#2369)

* fix: project setting member role validation

* chore: opacity removed from member setting page

* chore: member setting page validation

* chore: project covers endpoint (#2370)

* chore: project covers endpoint

* dev: remove print logs

* dev: formatting

---------

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

* feat: default project cover images tab on the change cover popover (#2375)

* feat: default project cover images tab

* chore: remove unnecessary env vars from turbo.json

* chore: remove unnecessary OAuth envs (#2378)

* chore: remove unnecessary oauth envs

* merge conflicts resolved

* fix: adding new service

---------

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

* fix: added user store variables in mobx store observable (#2380)

* fix: state group icons (#2381)

* fix: removed default theme setting in the index page (#2382)

* fix: removed default theme setting in the index page

* fix: empty space

* dev: global views and workspace filters store implemented

* sync CE Master to EE Develop

* refactor: create update view modal

* chore: static issue global views

* refactor: remove old code

* refactor: filters select dropdown

* chore: fix calendar layout

* chore: mobx store for new applied filters

* chore: dded search functionality

---------

Co-authored-by: Vamsi Kurama <vamsi.kurama@gmail.com>
Co-authored-by: Nikhil <118773738+pablohashescobar@users.noreply.github.com>
Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
Co-authored-by: Anmol Singh Bhatia <121005188+anmolsinghbhatia@users.noreply.github.com>
Co-authored-by: Dakshesh Jain <65905942+dakshesh14@users.noreply.github.com>
Co-authored-by: Bavisetti Narayan <72156168+NarayanBavisetti@users.noreply.github.com>
Co-authored-by: guru_sainath <gurusainath007@gmail.com>
Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
Co-authored-by: Anmol Singh Bhatia <asb@Anmols-MacBook-Pro.local>
Co-authored-by: Rhea Jain <65884341+rhea0110@users.noreply.github.com>
Co-authored-by: Your Name <you@example.com>
Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
Co-authored-by: Henit Chobisa <chobisa.henit@gmail.com>
Co-authored-by: Thomas <git@thomasync.dev>
Co-authored-by: Luis Cruz <55716036+luis-cruzt@users.noreply.github.com>
Co-authored-by: Manish Gupta <manish@mgupta.me>

* fix: Auth fixes and Layout fixes (#2408)

* fix: auth fixes and layout improvements

* fix: layout fixes

* fix: analytics page fixes

* dev: implemented project views using MobX (#2410)

* dev: implemented project views list using mobx

* style: views list UI

* dev: implemented view issues page using mobx

* refactor: project view issues fetching

* chore:  plane ui library component and code refactor (#2406)

* chore: swap input component with plane/ui package

* chore: swap textarea component with plane/ui package

* chore: swap button component with plane/ui package

* chore: button component revamp

* fix: button type fix

* chore: secondary button revamp

* chore: button props updated

* chore: swap loader component with plane/ui package

* fix: build error fix

* chore: button component refactor

* chore: code refactor

* chore: swap toggle switch component with plane/ui package

* chore: swap spinner component with plane/ui package

* chore: swap progress bar componenet with plan/ui package

* chore: code refactor

* fix: gitignore fixes

* fix: project card fixes

* chore: ui component revamp (#2415)

* chore: swap tooltip component with plane ui package

* chore: swap linear progress component with plane ui package

* fix: login button fix

* chore: implement new worksapace wrapper for global views (#2412)

* chore: implement new worksapace wrapper for global views pages

* fix: merge conflicts

* fix: merge conflicts

* dev: add remaining layouts to cycle (#2413)

* fix: workspace auth wrapper changes

* chore: project card revamp and refactor (#2416)

* removing dist from ui

* refactor: analytics (#2419)

* refactor: helper functions

* chore: updated all the page headers

* refactor: custom analytics

* refactor: project analytics modal

* refactor: folder structure, remove junk code (#2423)

* refactor: folder structure

* chore: ad order by target date option

* refactor: remove old layout components

* refactor: inbox folder structure

* fix: services fixes

* fix: store imports changes

* fix: services export fixes

* fix: services implementation fixes

* fix: build issue fixes

* fix: react library fixes

* refactor: MobX store folder structure (#2435)

* refactor: store folder structure

* chore: update import statements

* fix: service import errors (#2436)

* fix: service imports

* chore: update service imports in store

* chore: fix remianing service imports

* build fixes

* editor ts config fixes

* fix: turbo and build fixes

* fix: Auth screen loading implementation

* fix: build issues

* fix: turbo settings for ui package

---------

Co-authored-by: gurusainath <gurusainath007@gmail.com>
Co-authored-by: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com>
Co-authored-by: Anmol Singh Bhatia <121005188+anmolsinghbhatia@users.noreply.github.com>
Co-authored-by: Dakshesh Jain <65905942+dakshesh14@users.noreply.github.com>
Co-authored-by: Bavisetti Narayan <72156168+NarayanBavisetti@users.noreply.github.com>
Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>
Co-authored-by: Vamsi Kurama <vamsi.kurama@gmail.com>
Co-authored-by: Nikhil <118773738+pablohashescobar@users.noreply.github.com>
Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
Co-authored-by: Anmol Singh Bhatia <asb@Anmols-MacBook-Pro.local>
Co-authored-by: Rhea Jain <65884341+rhea0110@users.noreply.github.com>
Co-authored-by: Your Name <you@example.com>
Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
Co-authored-by: Henit Chobisa <chobisa.henit@gmail.com>
Co-authored-by: Thomas <git@thomasync.dev>
Co-authored-by: Luis Cruz <55716036+luis-cruzt@users.noreply.github.com>
Co-authored-by: Manish Gupta <manish@mgupta.me>
This commit is contained in:
sriram veeraghanta 2023-10-15 23:50:12 +05:30 committed by GitHub
parent c6e021d41f
commit d80a593520
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
758 changed files with 30452 additions and 23632 deletions

View File

@ -2,7 +2,7 @@ name: Create PR in Plane EE Repository to sync the changes
on:
pull_request:
branches:
branches:
- master
types:
- closed

View File

@ -39,29 +39,33 @@ Meet [Plane](https://plane.so). An open-source software development tool to mana
The easiest way to get started with Plane is by creating a [Plane Cloud](https://app.plane.so) account. Plane Cloud offers a hosted solution for Plane. If you prefer to self-host Plane, please refer to our [deployment documentation](https://docs.plane.so/self-hosting).
## ⚡️ Contributors Quick Start
## ⚡️ Contributors Quick Start
### Prerequisite
Development system must have docker engine installed and running.
### Steps
### Steps
Setting up local environment is extremely easy and straight forward. Follow the below step and you will be ready to contribute
1. Clone the code locally using `git clone https://github.com/makeplane/plane.git`
1. Switch to the code folder `cd plane`
1. Create your feature or fix branch you plan to work on using `git checkout -b <feature-branch-name>`
1. Open terminal and run `./setup.sh`
1. Open the code on VSCode or similar equivalent IDE
1. Open the code on VSCode or similar equivalent IDE
1. Review the `.env` files available in various folders. Visit [Environment Setup](./ENV_SETUP.md) to know about various environment variables used in system
1. Run the docker command to initiate various services `docker compose -f docker-compose-local.yml up -d`
You are ready to make changes to the code. Do not forget to refresh the browser (in case id does not auto-reload)
```bash
./setup.sh
```
Thats it!
You are ready to make changes to the code. Do not forget to refresh the browser (in case id does not auto-reload)
Thats it!
## 🍙 Self Hosting
## 🍙 Self Hosting
For self hosting environment setup, visit the [Self Hosting](https://docs.plane.so/self-hosting) documentation page

View File

@ -187,7 +187,6 @@ def filter_target_date(params, filter, method):
if method == "GET":
target_dates = params.get("target_date").split(",")
if len(target_dates) and "" not in target_dates:
date_filter(filter=filter, date_term="target_date", queries=target_dates)
else:
if params.get("target_date", None) and len(params.get("target_date")):

View File

@ -27,7 +27,7 @@
"prettier": "latest",
"prettier-plugin-tailwindcss": "^0.5.4",
"tailwindcss": "^3.3.3",
"turbo": "latest"
"turbo": "^1.10.14"
},
"resolutions": {
"@types/react": "18.2.0"

View File

@ -1,12 +1,5 @@
{
"extends": "tsconfig/react.json",
"include": [
"src/**/*",
"index.d.ts"
],
"exclude": [
"dist",
"build",
"node_modules"
]
"extends": "tsconfig/react-library.json",
"include": ["src/**/*", "index.d.ts"],
"exclude": ["dist", "build", "node_modules"]
}

View File

@ -1,12 +1,5 @@
{
"extends": "tsconfig/react.json",
"include": [
"src/**/*",
"index.d.ts"
],
"exclude": [
"dist",
"build",
"node_modules"
]
"extends": "tsconfig/react-library.json",
"include": ["src/**/*", "index.d.ts"],
"exclude": ["dist", "build", "node_modules"]
}

View File

@ -1,12 +1,5 @@
{
"extends": "tsconfig/react.json",
"include": [
"src/**/*",
"index.d.ts"
],
"exclude": [
"dist",
"build",
"node_modules"
]
"extends": "tsconfig/react-library.json",
"include": ["src/**/*", "index.d.ts"],
"exclude": ["dist", "build", "node_modules"]
}

View File

@ -11,8 +11,9 @@ module.exports = {
"./layouts/**/*.tsx",
"./pages/**/*.tsx",
"./ui/**/*.tsx",
"../packages/editor/**/*.{js,ts,jsx,tsx}"
]
"../packages/ui/**/*.{js,ts,jsx,tsx}",
"../packages/editor/**/*.{js,ts,jsx,tsx}",
],
},
theme: {
extend: {

View File

@ -6,6 +6,7 @@
"jsx": "react-jsx",
"lib": ["ES2015", "DOM"],
"module": "ESNext",
"target": "es6"
"target": "es6",
"sourceMap": true
}
}

View File

@ -1,3 +0,0 @@
export const Button = () => {
return <button>button</button>;
};

View File

@ -1,17 +0,0 @@
// import * as React from "react";
// components
// export * from "./breadcrumbs";
// export * from "./button";
// export * from "./custom-listbox";
// export * from "./custom-menu";
// export * from "./custom-select";
// export * from "./empty-space";
// export * from "./header-button";
// export * from "./input";
// export * from "./loader";
// export * from "./outline-button";
// export * from "./select";
// export * from "./spinner";
// export * from "./text-area";
// export * from "./tooltip";
export * from "./button";

View File

@ -1,23 +1,40 @@
{
"name": "ui",
"version": "0.13.2",
"main": "./index.tsx",
"types": "./index.tsx",
"name": "@plane/ui",
"version": "0.0.1",
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"sideEffects": false,
"license": "MIT",
"files": [
"dist/**"
],
"scripts": {
"lint": "eslint *.ts*"
"build": "tsup src/index.tsx --format esm,cjs --dts --external react",
"dev": "tsup src/index.tsx --format esm,cjs --watch --dts --external react",
"lint": "eslint src/",
"clean": "rm -rf .turbo && rm -rf node_modules && rm -rf dist"
},
"devDependencies": {
"@types/react": "^18.0.17",
"@types/react-dom": "^18.0.6",
"@typescript-eslint/eslint-plugin": "^5.51.0",
"@types/node": "^20.5.2",
"@types/react": "18.2.0",
"@types/react-dom": "18.2.0",
"classnames": "^2.3.2",
"eslint": "^7.32.0",
"eslint-config-custom": "*",
"next": "12.3.2",
"react": "^18.2.0",
"tsconfig": "*",
"tailwind-config-custom": "*",
"tsup": "^5.10.1",
"typescript": "4.7.4"
},
"publishConfig": {
"access": "public"
},
"dependencies": {
"@blueprintjs/core": "^4.16.3",
"@blueprintjs/popover2": "^1.13.3",
"@headlessui/react": "^1.7.17",
"clsx": "^2.0.0",
"next-themes": "^0.2.1"
}
}

View File

@ -0,0 +1,66 @@
import * as React from "react";
import {
getIconStyling,
getButtonStyling,
TButtonVariant,
TButtonSizes,
} from "./helper";
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: TButtonVariant;
size?: TButtonSizes;
className?: string;
loading?: boolean;
disabled?: boolean;
appendIcon?: any;
prependIcon?: any;
children: React.ReactNode;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
(props, ref) => {
const {
variant = "primary",
size = "md",
className = "",
type = "button",
loading = false,
disabled = false,
prependIcon = null,
appendIcon = null,
children,
...rest
} = props;
const buttonStyle = getButtonStyling(variant, size, disabled || loading);
const buttonIconStyle = getIconStyling(size);
return (
<button
ref={ref}
type={type}
className={`${buttonStyle} ${className}`}
disabled={disabled || loading}
{...rest}
>
{prependIcon && (
<div className={buttonIconStyle}>
{React.cloneElement(prependIcon, { "stroke-width": 2 })}
</div>
)}
{children}
{appendIcon && (
<div className={buttonIconStyle}>
{React.cloneElement(appendIcon, { "stroke-width": 2 })}
</div>
)}
</button>
);
}
);
Button.displayName = "plane-ui-button";
export { Button };

View File

@ -0,0 +1,123 @@
export type TButtonVariant =
| "primary"
| "accent-primary"
| "outline-primary"
| "neutral-primary"
| "link-primary"
| "danger"
| "accent-danger"
| "outline-danger"
| "link-danger"
| "tertiary-danger";
export type TButtonSizes = "sm" | "md" | "lg" | "xl";
export interface IButtonStyling {
[key: string]: {
default: string;
hover: string;
pressed: string;
disabled: string;
};
}
enum buttonSizeStyling {
sm = `px-3 py-1.5 font-medium text-xs rounded flex items-center gap-1.5 whitespace-nowrap transition-all justify-center inline`,
md = `px-4 py-1.5 font-medium text-sm rounded flex items-center gap-1.5 whitespace-nowrap transition-all justify-center inline`,
lg = `px-5 py-2 font-medium text-sm rounded flex items-center gap-1.5 whitespace-nowrap transition-all justify-center inline`,
xl = `px-5 py-3.5 font-medium text-sm rounded flex items-center gap-1.5 whitespace-nowrap transition-all justify-center inline`,
}
enum buttonIconStyling {
sm = "h-3 w-3 flex justify-center items-center overflow-hidden my-0.5 flex-shrink-0",
md = "h-3.5 w-3.5 flex justify-center items-center overflow-hidden my-0.5 flex-shrink-0",
lg = "h-4 w-4 flex justify-center items-center overflow-hidden my-0.5 flex-shrink-0",
xl = "h-4 w-4 flex justify-center items-center overflow-hidden my-0.5 flex-shrink-0",
}
export const buttonStyling: IButtonStyling = {
primary: {
default: `text-white bg-custom-primary-100`,
hover: `hover:bg-custom-primary-200`,
pressed: `focus:text-custom-brand-40 focus:bg-custom-primary-200`,
disabled: `cursor-not-allowed !bg-custom-primary-60 hover:bg-custom-primary-60`,
},
"accent-primary": {
default: `bg-custom-primary-10 text-custom-primary-100`,
hover: `hover:bg-custom-primary-20 hover:text-custom-primary-200`,
pressed: `focus:bg-custom-primary-20`,
disabled: `cursor-not-allowed !text-custom-primary-60`,
},
"outline-primary": {
default: `text-custom-primary-100 bg-custom-background-100 border border-custom-primary-100`,
hover: `hover:border-custom-primary-80 hover:bg-custom-primary-10`,
pressed: `focus:text-custom-primary-80 focus:bg-custom-primary-10 focus:border-custom-primary-80`,
disabled: `cursor-not-allowed !text-custom-primary-60 !border-custom-primary-60 `,
},
"neutral-primary": {
default: `text-custom-text-200 bg-custom-background-100 border border-custom-border-200`,
hover: `hover:bg-custom-background-90`,
pressed: `focus:text-custom-text-300 focus:bg-custom-background-90`,
disabled: `cursor-not-allowed !text-custom-text-400`,
},
"link-primary": {
default: `text-custom-primary-100 bg-custom-background-100`,
hover: `hover:text-custom-primary-200`,
pressed: `focus:text-custom-primary-80 `,
disabled: `cursor-not-allowed !text-custom-primary-60`,
},
danger: {
default: `text-white bg-red-500`,
hover: ` hover:bg-red-600`,
pressed: `focus:text-red-200 focus:bg-red-600`,
disabled: `cursor-not-allowed !bg-red-300`,
},
"accent-danger": {
default: `text-red-500 bg-red-50`,
hover: `hover:text-red-600 hover:bg-red-100`,
pressed: `focus:text-red-500 focus:bg-red-100`,
disabled: `cursor-not-allowed !text-red-300`,
},
"outline-danger": {
default: `text-red-500 bg-custom-background-100 border border-red-500`,
hover: `hover:text-red-400 hover:border-red-400`,
pressed: `focus:text-red-400 focus:border-red-400`,
disabled: `cursor-not-allowed !text-red-300 !border-red-300`,
},
"link-danger": {
default: `text-red-500 bg-custom-background-100`,
hover: `hover:text-red-400`,
pressed: `focus:text-red-400`,
disabled: `cursor-not-allowed !text-red-300`,
},
"tertiary-danger": {
default: `text-red-500 bg-custom-background-100 border border-red-200`,
hover: `hover:bg-red-50 hover:border-red-300`,
pressed: `focus:text-red-400`,
disabled: `cursor-not-allowed !text-red-300`,
},
};
export const getButtonStyling = (
variant: TButtonVariant,
size: TButtonSizes,
disabled: boolean = false
): string => {
let _variant: string = ``;
const currentVariant = buttonStyling[variant];
_variant = `${currentVariant.default} ${
disabled ? currentVariant.disabled : currentVariant.hover
} ${currentVariant.pressed}`;
let _size: string = ``;
if (size) _size = buttonSizeStyling[size];
return `${_variant} ${_size}`;
};
export const getIconStyling = (size: TButtonSizes): string => {
let icon: string = ``;
if (size) icon = buttonIconStyling[size];
return icon;
};

View File

@ -0,0 +1,2 @@
export * from "./button";
export * from "./toggle-switch";

View File

@ -0,0 +1,49 @@
import * as React from "react";
import { Switch } from "@headlessui/react";
interface IToggleSwitchProps {
value: boolean;
onChange: (value: boolean) => void;
label?: string;
size?: "sm" | "md" | "lg";
disabled?: boolean;
className?: string;
}
const ToggleSwitch: React.FC<IToggleSwitchProps> = (props) => {
const { value, onChange, label, size = "sm", disabled, className } = props;
return (
<Switch
checked={value}
disabled={disabled}
onChange={onChange}
className={`relative flex-shrink-0 inline-flex ${
size === "sm" ? "h-4 w-6" : size === "md" ? "h-5 w-8" : "h-6 w-10"
} flex-shrink-0 cursor-pointer rounded-full border border-custom-border-200 transition-colors duration-200 ease-in-out focus:outline-none ${
value ? "bg-custom-primary-100" : "bg-gray-700"
} ${className || ""} ${disabled ? "cursor-not-allowed" : ""}`}
>
<span className="sr-only">{label}</span>
<span
aria-hidden="true"
className={`self-center inline-block ${
size === "sm" ? "h-2 w-2" : size === "md" ? "h-3 w-3" : "h-4 w-4"
} transform rounded-full shadow ring-0 transition duration-200 ease-in-out ${
value
? (size === "sm"
? "translate-x-3"
: size === "md"
? "translate-x-4"
: "translate-x-5") + " bg-white"
: "translate-x-0.5 bg-custom-background-90"
} ${disabled ? "cursor-not-allowed" : ""}`}
/>
</Switch>
);
};
ToggleSwitch.displayName = "plane-ui-toggle-switch";
export { ToggleSwitch };

View File

@ -0,0 +1,2 @@
export * from "./input";
export * from "./textarea";

View File

@ -0,0 +1,49 @@
import * as React from "react";
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {
mode?: "primary" | "transparent" | "true-transparent";
inputSize?: "sm" | "md";
hasError?: boolean;
className?: string;
}
const Input = React.forwardRef<HTMLInputElement, InputProps>((props, ref) => {
const {
id,
type,
name,
mode = "primary",
inputSize = "sm",
hasError = false,
className = "",
...rest
} = props;
return (
<input
id={id}
ref={ref}
type={type}
name={name}
className={`block rounded-md bg-transparent text-sm focus:outline-none placeholder-custom-text-400 ${
mode === "primary"
? "rounded-md border border-custom-border-200"
: mode === "transparent"
? "rounded border-none bg-transparent ring-0 transition-all focus:ring-1 focus:ring-custom-primary"
: mode === "true-transparent"
? "rounded border-none bg-transparent ring-0"
: ""
} ${hasError ? "border-red-500" : ""} ${
hasError && mode === "primary" ? "bg-red-500/20" : ""
} ${
inputSize === "sm" ? "px-3 py-2" : inputSize === "md" ? "p-3" : ""
} ${className}`}
{...rest}
/>
);
});
Input.displayName = "form-input-field";
export { Input };

View File

@ -0,0 +1,69 @@
import * as React from "react";
export interface TextAreaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
mode?: "primary" | "transparent";
hasError?: boolean;
className?: string;
}
// Updates the height of a <textarea> when the value changes.
const useAutoSizeTextArea = (
textAreaRef: HTMLTextAreaElement | null,
value: any
) => {
React.useEffect(() => {
if (textAreaRef) {
// We need to reset the height momentarily to get the correct scrollHeight for the textarea
textAreaRef.style.height = "0px";
const scrollHeight = textAreaRef.scrollHeight;
// We then set the height directly, outside of the render loop
// Trying to set this with state or a ref will product an incorrect value.
textAreaRef.style.height = scrollHeight + "px";
}
}, [textAreaRef, value]);
};
const TextArea = React.forwardRef<HTMLTextAreaElement, TextAreaProps>(
(props, ref) => {
const {
id,
name,
value = "",
rows = 1,
cols = 1,
mode = "primary",
hasError = false,
className = "",
...rest
} = props;
const textAreaRef = React.useRef<any>(ref);
ref && useAutoSizeTextArea(textAreaRef?.current, value);
return (
<textarea
id={id}
name={name}
ref={textAreaRef}
value={value}
rows={rows}
cols={cols}
className={`no-scrollbar w-full bg-transparent placeholder-custom-text-400 px-3 py-2 outline-none ${
mode === "primary"
? "rounded-md border border-custom-border-200"
: mode === "transparent"
? "rounded border-none bg-transparent ring-0 transition-all focus:ring-1 focus:ring-theme"
: ""
} ${hasError ? "border-red-500" : ""} ${
hasError && mode === "primary" ? "bg-red-100" : ""
} ${className}`}
{...rest}
/>
);
}
);
export { TextArea };

View File

@ -0,0 +1,6 @@
export * from "./button";
export * from "./form-fields";
export * from "./progress";
export * from "./spinners";
export * from "./loader";
export * from "./tooltip";

View File

@ -0,0 +1,30 @@
import React from "react";
type Props = {
children: React.ReactNode;
className?: string;
};
const Loader = ({ children, className = "" }: Props) => (
<div className={`${className} animate-pulse`} role="status">
{children}
</div>
);
type ItemProps = {
height?: string;
width?: string;
};
const Item: React.FC<ItemProps> = ({ height = "auto", width = "auto" }) => (
<div
className="rounded-md bg-custom-background-80"
style={{ height: height, width: width }}
/>
);
Loader.Item = Item;
Loader.displayName = "plane-ui-loader";
export { Loader };

View File

@ -0,0 +1,3 @@
export * from "./radial-progress";
export * from "./progress-bar";
export * from "./linear-progress-indicator";

View File

@ -0,0 +1,44 @@
import React from "react";
import { Tooltip } from "../tooltip";
type Props = {
data: any;
noTooltip?: boolean;
};
export const LinearProgressIndicator: React.FC<Props> = ({
data,
noTooltip = false,
}) => {
const total = data.reduce((acc: any, cur: any) => acc + cur.value, 0);
let progress = 0;
const bars = data.map((item: any) => {
const width = `${(item.value / total) * 100}%`;
const style = {
width,
backgroundColor: item.color,
};
progress += item.value;
if (noTooltip) return <div style={style} />;
else
return (
<Tooltip
key={item.id}
tooltipContent={`${item.name} ${Math.round(item.value)}%`}
>
<div style={style} />
</Tooltip>
);
});
return (
<div className="flex h-1 w-full items-center justify-between gap-1">
{total === 0 ? (
<div className="flex h-full w-full gap-1 bg-neutral-500">{bars}</div>
) : (
<div className="flex h-full w-full gap-1">{bars}</div>
)}
</div>
);
};

View File

@ -0,0 +1,77 @@
import React from "react";
type Props = {
maxValue?: number;
value?: number;
radius?: number;
strokeWidth?: number;
activeStrokeColor?: string;
inactiveStrokeColor?: string;
};
export const ProgressBar: React.FC<Props> = ({
maxValue = 0,
value = 0,
radius = 8,
strokeWidth = 2,
activeStrokeColor = "#3e98c7",
inactiveStrokeColor = "#ddd",
}) => {
// PIE Calc Fn
const generatePie = (value: any) => {
const x = radius - Math.cos((2 * Math.PI) / (100 / value)) * radius;
const y = radius + Math.sin((2 * Math.PI) / (100 / value)) * radius;
const long = value <= 50 ? 0 : 1;
const d = `M${radius} ${radius} L${radius} ${0} A${radius} ${radius} 0 ${long} 1 ${y} ${x} Z`;
return d;
};
// ---- PIE Area Calc --------
const calculatePieValue = (numberOfBars: any) => {
const angle = 360 / numberOfBars;
const pieValue = Math.floor(angle / 4);
return pieValue < 1 ? 1 : Math.floor(angle / 4);
};
// ---- PIE Render Fn --------
const renderPie = (i: any) => {
const DIRECTION = -1;
// Rotation Calc
const primaryRotationAngle = (maxValue - 1) * (360 / maxValue);
const rotationAngle =
-1 * DIRECTION * primaryRotationAngle +
i * DIRECTION * primaryRotationAngle;
const rotationTransformation = `rotate(${rotationAngle}, ${radius}, ${radius})`;
const pieValue = calculatePieValue(maxValue);
const dValue = generatePie(pieValue);
const fillColor =
value > 0 && i <= value ? activeStrokeColor : inactiveStrokeColor;
return (
<path
style={{ opacity: i === 0 ? 0 : 1 }}
key={i}
d={dValue}
fill={fillColor}
transform={rotationTransformation}
/>
);
};
// combining the Pies
const renderOuterCircle = () =>
[...Array(maxValue + 1)].map((e, i) => renderPie(i));
return (
<svg width={radius * 2} height={radius * 2}>
{renderOuterCircle()}
<circle
r={radius - strokeWidth}
cx={radius}
cy={radius}
className="progress-bar"
/>
</svg>
);
};

View File

@ -0,0 +1,45 @@
import React, { useState, useEffect, FC } from "react";
interface IRadialProgressBar {
progress: number;
}
export const RadialProgressBar: FC<IRadialProgressBar> = (props) => {
const { progress } = props;
const [circumference, setCircumference] = useState(0);
useEffect(() => {
const radius = 40;
const circumference = 2 * Math.PI * radius;
setCircumference(circumference);
}, []);
const progressOffset = ((100 - progress) / 100) * circumference;
return (
<div className="relative h-4 w-4">
<svg className="absolute top-0 left-0" viewBox="0 0 100 100">
<circle
className={"stroke-current opacity-10"}
cx="50"
cy="50"
r="40"
strokeWidth="12"
fill="none"
strokeDasharray={`${circumference} ${circumference}`}
/>
<circle
className={`stroke-current`}
cx="50"
cy="50"
r="40"
strokeWidth="12"
fill="none"
strokeDasharray={`${circumference} ${circumference}`}
strokeDashoffset={progressOffset}
transform="rotate(-90 50 50)"
/>
</svg>
</div>
);
};

View File

@ -0,0 +1,23 @@
import * as React from "react";
export const Spinner: React.FC = () => (
<div role="status">
<svg
aria-hidden="true"
className="mr-2 h-8 w-8 animate-spin fill-blue-600 text-custom-text-200"
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
fill="currentColor"
/>
<path
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
fill="currentFill"
/>
</svg>
<span className="sr-only">Loading...</span>
</div>
);

View File

@ -0,0 +1 @@
export * from "./circular-spinner";

View File

@ -0,0 +1 @@
export * from "./tooltip";

View File

@ -0,0 +1,86 @@
import React from "react";
// next-themes
import { useTheme } from "next-themes";
import { Tooltip2 } from "@blueprintjs/popover2";
export type TPosition =
| "top"
| "right"
| "bottom"
| "left"
| "auto"
| "auto-end"
| "auto-start"
| "bottom-left"
| "bottom-right"
| "left-bottom"
| "left-top"
| "right-bottom"
| "right-top"
| "top-left"
| "top-right";
interface ITooltipProps {
tooltipHeading?: string;
tooltipContent: string | React.ReactNode;
position?: TPosition;
children: JSX.Element;
disabled?: boolean;
className?: string;
openDelay?: number;
closeDelay?: number;
}
export const Tooltip: React.FC<ITooltipProps> = ({
tooltipHeading,
tooltipContent,
position = "top",
children,
disabled = false,
className = "",
openDelay = 200,
closeDelay,
}) => {
const { theme } = useTheme();
return (
<Tooltip2
disabled={disabled}
hoverOpenDelay={openDelay}
hoverCloseDelay={closeDelay}
content={
<div
className={`relative z-50 max-w-xs gap-1 rounded-md p-2 text-xs shadow-md ${
theme === "custom"
? "bg-custom-background-100 text-custom-text-200"
: "bg-black text-gray-400"
} break-words overflow-hidden ${className}`}
>
{tooltipHeading && (
<h5
className={`font-medium ${
theme === "custom" ? "text-custom-text-100" : "text-white"
}`}
>
{tooltipHeading}
</h5>
)}
{tooltipContent}
</div>
}
position={position}
renderTarget={({
isOpen: isTooltipOpen,
ref: eleReference,
...tooltipProps
}) =>
React.cloneElement(children, {
ref: eleReference,
...tooltipProps,
...children.props,
})
}
/>
);
};

View File

@ -1,5 +1,8 @@
{
"extends": "tsconfig/react-library.json",
"compilerOptions": {
"jsx": "react"
},
"include": ["."],
"exclude": ["dist", "build", "node_modules"]
}

View File

@ -7,7 +7,8 @@ import userService from "services/user.service";
// hooks
// import useToast from "hooks/use-toast";
// ui
import { Input, PrimaryButton, SecondaryButton } from "components/ui";
import { Input } from "components/ui";
import { Button } from "@plane/ui";
// types
type Props = {
setIsResettingPassword: React.Dispatch<React.SetStateAction<boolean>>;
@ -77,12 +78,12 @@ export const EmailResetPasswordForm: React.FC<Props> = ({ setIsResettingPassword
{errors.email && <div className="text-sm text-red-500">{errors.email.message}</div>}
</div>
<div className="mt-5 flex flex-col-reverse sm:flex-row items-center gap-2">
<SecondaryButton className="w-full text-center h-[46px]" onClick={() => setIsResettingPassword(false)}>
<Button variant="neutral-primary" className="w-full" onClick={() => setIsResettingPassword(false)}>
Go Back
</SecondaryButton>
<PrimaryButton type="submit" className="w-full text-center h-[46px]" loading={isSubmitting}>
</Button>
<Button variant="primary" className="w-full" type="submit" loading={isSubmitting}>
{isSubmitting ? "Sending link..." : "Send reset link"}
</PrimaryButton>
</Button>
</div>
</form>
);

View File

@ -3,9 +3,7 @@
"globalEnv": [
"NEXT_PUBLIC_API_BASE_URL",
"NEXT_PUBLIC_DEPLOY_URL",
"API_BASE_URL",
"NEXT_PUBLIC_SENTRY_DSN",
"SENTRY_AUTH_TOKEN",
"NEXT_PUBLIC_SENTRY_ENVIRONMENT",
"NEXT_PUBLIC_GITHUB_APP_NAME",
"NEXT_PUBLIC_ENABLE_SENTRY",
@ -28,20 +26,16 @@
],
"pipeline": {
"build": {
"dependsOn": [
"^build"
],
"outputs": [
".next/**",
"dist/**"
]
"dependsOn": ["^build"],
"outputs": [".next/**", "dist/**"]
},
"web#develop": {
"cache": false,
"persistent": true,
"dependsOn": [
"@plane/lite-text-editor#build",
"@plane/rich-text-editor#build"
"@plane/rich-text-editor#build",
"@plane/ui#build"
]
},
"space#develop": {
@ -49,39 +43,36 @@
"persistent": true,
"dependsOn": [
"@plane/lite-text-editor#build",
"@plane/rich-text-editor#build"
"@plane/rich-text-editor#build",
"@plane/ui#build"
]
},
"web#build": {
"cache": true,
"dependsOn": [
"@plane/lite-text-editor#build",
"@plane/rich-text-editor#build"
"@plane/rich-text-editor#build",
"@plane/ui#build"
]
},
"space#build": {
"cache": true,
"dependsOn": [
"@plane/lite-text-editor#build",
"@plane/rich-text-editor#build"
"@plane/rich-text-editor#build",
"@plane/ui#build"
]
},
"@plane/lite-text-editor#build": {
"cache": true,
"dependsOn": [
"@plane/editor-core#build"
]
"dependsOn": ["@plane/editor-core#build"]
},
"@plane/rich-text-editor#build": {
"cache": true,
"dependsOn": [
"@plane/editor-core#build"
]
"dependsOn": ["@plane/editor-core#build"]
},
"test": {
"dependsOn": [
"^build"
],
"dependsOn": ["^build"],
"outputs": []
},
"lint": {

View File

@ -1,5 +1,5 @@
{
"printWidth": 100,
"printWidth": 120,
"tabWidth": 2,
"trailingComma": "es5"
}

View File

@ -1,13 +1,12 @@
import React, { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { Controller, useForm } from "react-hook-form";
// ui
import { CheckCircleIcon } from "@heroicons/react/20/solid";
import { Input, PrimaryButton, SecondaryButton } from "components/ui";
import { Button, Input } from "@plane/ui";
// services
import authenticationService from "services/authentication.service";
import { AuthService } from "services/auth.service";
// hooks
import useToast from "hooks/use-toast";
import useTimer from "hooks/use-timer";
// icons
// types
type EmailCodeFormValues = {
@ -16,6 +15,8 @@ type EmailCodeFormValues = {
token?: string;
};
const authService = new AuthService();
export const EmailCodeForm = ({ handleSignIn }: any) => {
const [codeSent, setCodeSent] = useState(false);
const [codeResent, setCodeResent] = useState(false);
@ -27,8 +28,8 @@ export const EmailCodeForm = ({ handleSignIn }: any) => {
const { timer: resendCodeTimer, setTimer: setResendCodeTimer } = useTimer();
const {
register,
handleSubmit,
control,
setError,
setValue,
getValues,
@ -44,12 +45,11 @@ export const EmailCodeForm = ({ handleSignIn }: any) => {
reValidateMode: "onChange",
});
const isResendDisabled =
resendCodeTimer > 0 || isCodeResending || isSubmitting || errorResendingCode;
const isResendDisabled = resendCodeTimer > 0 || isCodeResending || isSubmitting || errorResendingCode;
const onSubmit = async ({ email }: EmailCodeFormValues) => {
setErrorResendingCode(false);
await authenticationService
await authService
.emailCode({ email })
.then((res) => {
setValue("key", res.key);
@ -67,7 +67,7 @@ export const EmailCodeForm = ({ handleSignIn }: any) => {
const handleSignin = async (formData: EmailCodeFormValues) => {
setIsLoading(true);
await authenticationService
await authService
.magicSignIn(formData)
.then((response) => {
handleSignIn(response);
@ -109,6 +109,7 @@ export const EmailCodeForm = ({ handleSignIn }: any) => {
return () => {
window.removeEventListener("keydown", submitForm);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [handleSubmit, codeSent]);
return (
@ -122,44 +123,58 @@ export const EmailCodeForm = ({ handleSignIn }: any) => {
)}
<form className="space-y-4 mt-10 sm:w-[360px] mx-auto">
<div className="space-y-1">
<Input
id="email"
type="email"
<Controller
control={control}
name="email"
register={register}
validations={{
rules={{
required: "Email address is required",
validate: (value) =>
/^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test(
value
) || "Email address is not valid",
}}
error={errors.email}
placeholder="Enter your email address..."
className="border-custom-border-300 h-[46px]"
render={({ field: { value, onChange, ref } }) => (
<Input
id="email"
name="email"
type="email"
value={value}
onChange={onChange}
ref={ref}
hasError={Boolean(errors.email)}
placeholder="Enter your email address..."
className="border-custom-border-300 h-[46px] w-full"
/>
)}
/>
</div>
{codeSent && (
<>
<Input
id="token"
type="token"
<Controller
control={control}
name="token"
register={register}
validations={{
rules={{
required: "Code is required",
}}
error={errors.token}
placeholder="Enter code..."
className="border-custom-border-300 h-[46px]"
render={({ field: { value, onChange, ref } }) => (
<Input
id="token"
name="token"
type="token"
value={value ?? ""}
onChange={onChange}
ref={ref}
hasError={Boolean(errors.token)}
placeholder="Enter code..."
className="border-custom-border-300 h-[46px] w-full"
/>
)}
/>
<button
type="button"
className={`flex w-full justify-end text-xs outline-none ${
isResendDisabled
? "cursor-default text-custom-text-200"
: "cursor-pointer text-custom-primary-100"
isResendDisabled ? "cursor-default text-custom-text-200" : "cursor-pointer text-custom-primary-100"
} `}
onClick={() => {
setIsCodeResending(true);
@ -184,20 +199,22 @@ export const EmailCodeForm = ({ handleSignIn }: any) => {
</>
)}
{codeSent ? (
<PrimaryButton
<Button
variant="primary"
type="submit"
className="w-full text-center h-[46px]"
className="w-full"
size="md"
onClick={handleSubmit(handleSignin)}
disabled={!isValid && isDirty}
loading={isLoading}
>
{isLoading ? "Signing in..." : "Sign in"}
</PrimaryButton>
</Button>
) : (
<PrimaryButton
className="w-full text-center h-[46px]"
size="md"
<Button
variant="primary"
className="w-full"
size="xl"
onClick={() => {
handleSubmit(onSubmit)().then(() => {
setResendCodeTimer(30);
@ -207,7 +224,7 @@ export const EmailCodeForm = ({ handleSignIn }: any) => {
loading={isSubmitting}
>
{isSubmitting ? "Sending code..." : "Send sign in code"}
</PrimaryButton>
</Button>
)}
</form>
</>

View File

@ -0,0 +1,69 @@
import { FC } from "react";
import { useRouter } from "next/router";
import { useForm, Controller } from "react-hook-form";
// ui
import { Input, Button } from "@plane/ui";
export interface EmailForgotPasswordFormValues {
email: string;
}
export interface IEmailForgotPasswordForm {
onSubmit: (formValues: any) => Promise<void>;
}
export const EmailForgotPasswordForm: FC<IEmailForgotPasswordForm> = (props) => {
const { onSubmit } = props;
// router
const router = useRouter();
// form data
const {
control,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<EmailForgotPasswordFormValues>({
defaultValues: {
email: "",
},
mode: "onChange",
reValidateMode: "onChange",
});
return (
<form className="space-y-4 mt-10 w-full sm:w-[360px] mx-auto" onSubmit={handleSubmit(onSubmit)}>
<div className="space-y-1">
<Controller
control={control}
name="email"
rules={{
required: "Email address is required",
validate: (value) =>
/^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test(
value
) || "Email address is not valid",
}}
render={({ field: { value, onChange } }) => (
<Input
id="email"
type="email"
name="email"
value={value}
onChange={onChange}
hasError={Boolean(errors.email)}
placeholder="Enter registered email address.."
className="border-custom-border-300 h-[46px]"
/>
)}
/>
</div>
<div className="mt-5 flex flex-col-reverse sm:flex-row items-center gap-2">
<Button className="w-full text-center h-[46px]" onClick={() => router.push("/")}>
Go Back
</Button>
<Button type="submit" className="w-full text-center h-[46px]" loading={isSubmitting}>
{isSubmitting ? "Sending link..." : "Send reset link"}
</Button>
</div>
</form>
);
};

View File

@ -1,24 +1,26 @@
import React from "react";
import { useForm } from "react-hook-form";
import { useForm, Controller } from "react-hook-form";
import { useRouter } from "next/router";
// ui
import { Input, PrimaryButton } from "components/ui";
// types
type EmailPasswordFormValues = {
import { Input, Button } from "@plane/ui";
export interface EmailPasswordFormValues {
email: string;
password?: string;
medium?: string;
};
}
type Props = {
export interface IEmailPasswordForm {
onSubmit: (formData: EmailPasswordFormValues) => Promise<void>;
setIsResettingPassword: (value: boolean) => void;
};
}
export const EmailPasswordForm: React.FC<Props> = (props) => {
const { onSubmit, setIsResettingPassword } = props;
export const EmailPasswordForm: React.FC<IEmailPasswordForm> = (props) => {
const { onSubmit } = props;
// router
const router = useRouter();
// form info
const {
register,
control,
handleSubmit,
formState: { errors, isSubmitting, isValid, isDirty },
} = useForm<EmailPasswordFormValues>({
@ -33,60 +35,80 @@ export const EmailPasswordForm: React.FC<Props> = (props) => {
return (
<>
<form
className="space-y-4 mt-10 w-full sm:w-[360px] mx-auto"
onSubmit={handleSubmit(onSubmit)}
>
<form className="space-y-4 mt-10 w-full sm:w-[360px] mx-auto" onSubmit={handleSubmit(onSubmit)}>
<div className="space-y-1">
<Input
id="email"
type="email"
<Controller
control={control}
name="email"
register={register}
validations={{
rules={{
required: "Email address is required",
validate: (value) =>
/^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test(
value
) || "Email address is not valid",
}}
error={errors.email}
placeholder="Enter your email address..."
className="border-custom-border-300 h-[46px]"
render={({ field: { value, onChange } }) => (
<Input
id="email"
type="email"
name="email"
value={value}
onChange={onChange}
hasError={Boolean(errors.email)}
placeholder="Enter your email address..."
className="border-custom-border-300 h-[46px]"
/>
)}
/>
</div>
<div className="space-y-1">
<Input
id="password"
type="password"
<Controller
control={control}
name="password"
register={register}
validations={{
rules={{
required: "Password is required",
}}
error={errors.password}
placeholder="Enter your password..."
className="border-custom-border-300 h-[46px]"
render={({ field: { value, onChange } }) => (
<Input
id="password"
type="password"
name="password"
value={value}
onChange={onChange}
hasError={Boolean(errors.password)}
placeholder="Enter your password..."
className="border-custom-border-300 h-[46px]"
/>
)}
/>
</div>
<div className="text-right text-xs">
<button
type="button"
onClick={() => setIsResettingPassword(true)}
onClick={() => router.push("/accounts/forgot-password")}
className="text-custom-text-200 hover:text-custom-primary-100"
>
Forgot your password?
</button>
</div>
<div>
<PrimaryButton
<Button
type="submit"
className="w-full text-center h-[46px]"
disabled={!isValid && isDirty}
loading={isSubmitting}
>
{isSubmitting ? "Signing in..." : "Sign in"}
</PrimaryButton>
</Button>
</div>
<div className="text-xs">
<button
type="button"
onClick={() => router.push("/accounts/sign-up")}
className="text-custom-text-200 hover:text-custom-primary-100"
>
{"Don't have an account? Sign Up"}
</button>
</div>
</form>
</>

View File

@ -1,97 +0,0 @@
import React from "react";
// react hook form
import { useForm } from "react-hook-form";
// services
import userService from "services/user.service";
// hooks
import useToast from "hooks/use-toast";
// ui
import { Input, PrimaryButton, SecondaryButton } from "components/ui";
// types
type Props = {
setIsResettingPassword: React.Dispatch<React.SetStateAction<boolean>>;
};
export const EmailResetPasswordForm: React.FC<Props> = ({ setIsResettingPassword }) => {
const { setToastAlert } = useToast();
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm({
defaultValues: {
email: "",
},
mode: "onChange",
reValidateMode: "onChange",
});
const forgotPassword = async (formData: any) => {
const payload = {
email: formData.email,
};
await userService
.forgotPassword(payload)
.then(() =>
setToastAlert({
type: "success",
title: "Success!",
message: "Password reset link has been sent to your email address.",
})
)
.catch((err) => {
if (err.status === 400)
setToastAlert({
type: "error",
title: "Error!",
message: "Please check the Email ID entered.",
});
else
setToastAlert({
type: "error",
title: "Error!",
message: "Something went wrong. Please try again.",
});
});
};
return (
<form
className="space-y-4 mt-10 w-full sm:w-[360px] mx-auto"
onSubmit={handleSubmit(forgotPassword)}
>
<div className="space-y-1">
<Input
id="email"
type="email"
name="email"
register={register}
validations={{
required: "Email address is required",
validate: (value) =>
/^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test(
value
) || "Email address is not valid",
}}
error={errors.email}
placeholder="Enter registered email address.."
className="border-custom-border-300 h-[46px]"
/>
</div>
<div className="mt-5 flex flex-col-reverse sm:flex-row items-center gap-2">
<SecondaryButton
className="w-full text-center h-[46px]"
onClick={() => setIsResettingPassword(false)}
>
Go Back
</SecondaryButton>
<PrimaryButton type="submit" className="w-full text-center h-[46px]" loading={isSubmitting}>
{isSubmitting ? "Sending link..." : "Send reset link"}
</PrimaryButton>
</div>
</form>
);
};

View File

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

View File

@ -54,11 +54,7 @@ export const GoogleLoginButton: FC<IGoogleLoginButton> = (props) => {
return (
<>
<Script src="https://accounts.google.com/gsi/client" async defer onLoad={loadScript} />
<div
className="overflow-hidden rounded w-full"
id="googleSignInButton"
ref={googleSignInButton}
/>
<div className="overflow-hidden rounded w-full" id="googleSignInButton" ref={googleSignInButton} />
</>
);
};

View File

@ -1,6 +1,6 @@
export * from "./email-code-form";
export * from "./email-password-form";
export * from "./email-reset-password-form";
export * from "./email-forgot-password-form";
export * from "./github-login-button";
export * from "./google-login";
export * from "./email-signup-form";

View File

@ -1,161 +0,0 @@
import React from "react";
import { useRouter } from "next/router";
// react-hook-form
import { useForm } from "react-hook-form";
// headless ui
import { Dialog, Transition } from "@headlessui/react";
// services
import analyticsService from "services/analytics.service";
// hooks
import useToast from "hooks/use-toast";
// ui
import { Input, PrimaryButton, SecondaryButton, TextArea } from "components/ui";
// types
import { IAnalyticsParams, ISaveAnalyticsFormData } from "types";
// types
type Props = {
isOpen: boolean;
handleClose: () => void;
params?: IAnalyticsParams;
};
type FormValues = {
name: string;
description: string;
};
const defaultValues: FormValues = {
name: "",
description: "",
};
export const CreateUpdateAnalyticsModal: React.FC<Props> = ({ isOpen, handleClose, params }) => {
const router = useRouter();
const { workspaceSlug } = router.query;
const { setToastAlert } = useToast();
const {
register,
formState: { errors, isSubmitting },
handleSubmit,
reset,
} = useForm<FormValues>({
defaultValues,
});
const onClose = () => {
handleClose();
reset(defaultValues);
};
const onSubmit = async (formData: FormValues) => {
if (!workspaceSlug) return;
const payload: ISaveAnalyticsFormData = {
name: formData.name,
description: formData.description,
query_dict: {
x_axis: "priority",
y_axis: "issue_count",
...params,
project: params?.project ?? [],
},
};
await analyticsService
.saveAnalytics(workspaceSlug.toString(), payload)
.then(() => {
setToastAlert({
type: "success",
title: "Success!",
message: "Analytics saved successfully.",
});
onClose();
})
.catch(() =>
setToastAlert({
type: "error",
title: "Error!",
message: "Analytics could not be saved. Please try again.",
})
);
};
return (
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-30" onClose={onClose}>
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-custom-backdrop bg-opacity-50 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform rounded-lg border border-custom-border-200 bg-custom-background-100 px-4 pt-5 pb-4 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl sm:p-6">
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<Dialog.Title
as="h3"
className="text-lg font-medium leading-6 text-custom-text-100"
>
Save Analytics
</Dialog.Title>
<div className="mt-5">
<Input
type="text"
id="name"
name="name"
placeholder="Title"
autoComplete="off"
error={errors.name}
register={register}
width="full"
validations={{
required: "Title is required",
}}
/>
<TextArea
id="description"
name="description"
placeholder="Description"
className="mt-3 h-32 resize-none text-sm"
error={errors.description}
register={register}
/>
</div>
</div>
<div className="mt-5 flex justify-end gap-2">
<SecondaryButton onClick={onClose}>Cancel</SecondaryButton>
<PrimaryButton type="submit" loading={isSubmitting}>
{isSubmitting ? "Saving..." : "Save Analytics"}
</PrimaryButton>
</div>
</form>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
};

View File

@ -1,133 +1,76 @@
import { useRouter } from "next/router";
import { mutate } from "swr";
// react-hook-form
import { Control, UseFormSetValue } from "react-hook-form";
// hooks
import useProjects from "hooks/use-projects";
import useSWR from "swr";
import { useForm } from "react-hook-form";
import { observer } from "mobx-react-lite";
// services
import { AnalyticsService } from "services/analytics.service";
// components
import {
AnalyticsGraph,
AnalyticsSelectBar,
AnalyticsSidebar,
AnalyticsTable,
} from "components/analytics";
// ui
import { Loader, PrimaryButton } from "components/ui";
// helpers
import { convertResponseToBarGraphData } from "helpers/analytics.helper";
import { CustomAnalyticsSelectBar, CustomAnalyticsMainContent, CustomAnalyticsSidebar } from "components/analytics";
// types
import { IAnalyticsParams, IAnalyticsResponse, ICurrentUserResponse } from "types";
import { IAnalyticsParams } from "types";
// fetch-keys
import { ANALYTICS } from "constants/fetch-keys";
type Props = {
analytics: IAnalyticsResponse | undefined;
analyticsError: any;
params: IAnalyticsParams;
control: Control<IAnalyticsParams, any>;
setValue: UseFormSetValue<IAnalyticsParams>;
additionalParams?: Partial<IAnalyticsParams>;
fullScreen: boolean;
user: ICurrentUserResponse | undefined;
};
export const CustomAnalytics: React.FC<Props> = ({
analytics,
analyticsError,
params,
control,
setValue,
fullScreen,
user,
}) => {
const defaultValues: IAnalyticsParams = {
x_axis: "priority",
y_axis: "issue_count",
segment: null,
project: null,
};
const analyticsService = new AnalyticsService();
export const CustomAnalytics: React.FC<Props> = observer((props) => {
const { additionalParams, fullScreen } = props;
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { control, watch, setValue } = useForm({ defaultValues });
const params: IAnalyticsParams = {
x_axis: watch("x_axis"),
y_axis: watch("y_axis"),
segment: watch("segment"),
project: projectId ? [projectId.toString()] : watch("project"),
...additionalParams,
};
const { data: analytics, error: analyticsError } = useSWR(
workspaceSlug ? ANALYTICS(workspaceSlug.toString(), params) : null,
workspaceSlug ? () => analyticsService.getAnalytics(workspaceSlug.toString(), params) : null
);
const isProjectLevel = projectId ? true : false;
const yAxisKey = params.y_axis === "issue_count" ? "count" : "estimate";
const barGraphData = convertResponseToBarGraphData(analytics?.distribution, params);
const { projects } = useProjects();
return (
<div
className={`overflow-hidden flex flex-col-reverse ${
fullScreen ? "md:grid md:grid-cols-4 md:h-full" : ""
}`}
>
<div className={`overflow-hidden flex flex-col-reverse ${fullScreen ? "md:grid md:grid-cols-4 md:h-full" : ""}`}>
<div className="col-span-3 flex flex-col h-full overflow-hidden">
<AnalyticsSelectBar
<CustomAnalyticsSelectBar
control={control}
setValue={setValue}
projects={projects ?? []}
params={params}
fullScreen={fullScreen}
isProjectLevel={isProjectLevel}
/>
{!analyticsError ? (
analytics ? (
analytics.total > 0 ? (
<div className="h-full overflow-y-auto">
<AnalyticsGraph
analytics={analytics}
barGraphData={barGraphData}
params={params}
yAxisKey={yAxisKey}
fullScreen={fullScreen}
/>
<AnalyticsTable
analytics={analytics}
barGraphData={barGraphData}
params={params}
yAxisKey={yAxisKey}
/>
</div>
) : (
<div className="grid h-full place-items-center p-5">
<div className="space-y-4 text-custom-text-200">
<p className="text-sm">No matching issues found. Try changing the parameters.</p>
</div>
</div>
)
) : (
<Loader className="space-y-6 p-5">
<Loader.Item height="300px" />
<Loader className="space-y-4">
<Loader.Item height="30px" />
<Loader.Item height="30px" />
<Loader.Item height="30px" />
<Loader.Item height="30px" />
</Loader>
</Loader>
)
) : (
<div className="grid h-full place-items-center p-5">
<div className="space-y-4 text-custom-text-200">
<p className="text-sm">There was some error in fetching the data.</p>
<div className="flex items-center justify-center gap-2">
<PrimaryButton
onClick={() => {
if (!workspaceSlug) return;
mutate(ANALYTICS(workspaceSlug.toString(), params));
}}
>
Refresh
</PrimaryButton>
</div>
</div>
</div>
)}
<CustomAnalyticsMainContent
analytics={analytics}
error={analyticsError}
fullScreen={fullScreen}
params={params}
/>
</div>
<AnalyticsSidebar
<CustomAnalyticsSidebar
analytics={analytics}
params={params}
fullScreen={fullScreen}
isProjectLevel={isProjectLevel}
user={user}
/>
</div>
);
};
});

View File

@ -6,7 +6,7 @@ import { CustomTooltip } from "./custom-tooltip";
import { BarGraph } from "components/ui";
// helpers
import { findStringWithMostCharacters } from "helpers/array.helper";
import { generateBarColor } from "helpers/analytics.helper";
import { generateBarColor, generateDisplayName } from "helpers/analytics.helper";
// types
import { IAnalyticsParams, IAnalyticsResponse } from "types";
@ -21,21 +21,7 @@ type Props = {
fullScreen: boolean;
};
export const AnalyticsGraph: React.FC<Props> = ({
analytics,
barGraphData,
params,
yAxisKey,
fullScreen,
}) => {
const renderAssigneeName = (assigneeId: string): string => {
const assignee = analytics.extras.assignee_details.find((a) => a.assignees__id === assigneeId);
if (!assignee) return "?";
return assignee.assignees__display_name || "?";
};
export const AnalyticsGraph: React.FC<Props> = ({ analytics, barGraphData, params, yAxisKey, fullScreen }) => {
const generateYAxisTickValues = () => {
if (!analytics) return [];
@ -110,7 +96,7 @@ export const AnalyticsGraph: React.FC<Props> = ({
<text x={0} y={21} textAnchor="middle" fontSize={9} fill="#ffffff">
{params.x_axis === "assignees__id"
? datum.value && datum.value !== "None"
? renderAssigneeName(datum.value)[0].toUpperCase()
? generateDisplayName(datum.value, analytics, params, "x_axis")[0].toUpperCase()
: "?"
: datum.value && datum.value !== "None"
? `${datum.value}`.toUpperCase()[0]
@ -119,7 +105,13 @@ export const AnalyticsGraph: React.FC<Props> = ({
</g>
);
}
: undefined,
: (datum) => (
<g transform={`translate(${datum.x},${datum.y})`}>
<text x={0} y={21} textAnchor="middle" fontSize={10}>
{generateDisplayName(datum.value, analytics, params, "x_axis")}
</text>
</g>
),
}}
theme={{
axis: {},

View File

@ -1,6 +1,7 @@
export * from "./graph";
export * from "./create-update-analytics-modal";
export * from "./select";
export * from "./custom-analytics";
export * from "./main-content";
export * from "./select-bar";
export * from "./sidebar";
export * from "./table";

View File

@ -0,0 +1,85 @@
import { useRouter } from "next/router";
import { mutate } from "swr";
// components
import { AnalyticsGraph, AnalyticsTable } from "components/analytics";
// ui
import { Button, Loader } from "@plane/ui";
// helpers
import { convertResponseToBarGraphData } from "helpers/analytics.helper";
// types
import { IAnalyticsParams, IAnalyticsResponse } from "types";
// fetch-keys
import { ANALYTICS } from "constants/fetch-keys";
type Props = {
analytics: IAnalyticsResponse | undefined;
error: any;
fullScreen: boolean;
params: IAnalyticsParams;
};
export const CustomAnalyticsMainContent: React.FC<Props> = (props) => {
const { analytics, error, fullScreen, params } = props;
const router = useRouter();
const { workspaceSlug } = router.query;
const yAxisKey = params.y_axis === "issue_count" ? "count" : "estimate";
const barGraphData = convertResponseToBarGraphData(analytics?.distribution, params);
return (
<>
{!error ? (
analytics ? (
analytics.total > 0 ? (
<div className="h-full overflow-y-auto">
<AnalyticsGraph
analytics={analytics}
barGraphData={barGraphData}
params={params}
yAxisKey={yAxisKey}
fullScreen={fullScreen}
/>
<AnalyticsTable analytics={analytics} barGraphData={barGraphData} params={params} yAxisKey={yAxisKey} />
</div>
) : (
<div className="grid h-full place-items-center p-5">
<div className="space-y-4 text-custom-text-200">
<p className="text-sm">No matching issues found. Try changing the parameters.</p>
</div>
</div>
)
) : (
<Loader className="space-y-6 p-5">
<Loader.Item height="300px" />
<Loader className="space-y-4">
<Loader.Item height="30px" />
<Loader.Item height="30px" />
<Loader.Item height="30px" />
<Loader.Item height="30px" />
</Loader>
</Loader>
)
) : (
<div className="grid h-full place-items-center p-5">
<div className="space-y-4 text-custom-text-200">
<p className="text-sm">There was some error in fetching the data.</p>
<div className="flex items-center justify-center gap-2">
<Button
variant="primary"
onClick={() => {
if (!workspaceSlug) return;
mutate(ANALYTICS(workspaceSlug.toString(), params));
}}
>
Refresh
</Button>
</div>
</div>
</div>
)}
</>
);
};

View File

@ -1,80 +1,85 @@
// react-hook-form
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
import { Control, Controller, UseFormSetValue } from "react-hook-form";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// components
import { SelectProject, SelectSegment, SelectXAxis, SelectYAxis } from "components/analytics";
// types
import { IAnalyticsParams, IProject } from "types";
import { IAnalyticsParams } from "types";
type Props = {
control: Control<IAnalyticsParams, any>;
setValue: UseFormSetValue<IAnalyticsParams>;
projects: IProject[];
params: IAnalyticsParams;
fullScreen: boolean;
isProjectLevel: boolean;
};
export const AnalyticsSelectBar: React.FC<Props> = ({
control,
setValue,
projects,
params,
fullScreen,
isProjectLevel,
}) => (
<div
className={`grid items-center gap-4 px-5 py-2.5 ${
isProjectLevel ? "grid-cols-3" : "grid-cols-2"
} ${fullScreen ? "lg:grid-cols-4 md:py-5" : ""}`}
>
{!isProjectLevel && (
export const CustomAnalyticsSelectBar: React.FC<Props> = observer((props) => {
const { control, setValue, params, fullScreen, isProjectLevel } = props;
const router = useRouter();
const { workspaceSlug } = router.query;
const { project: projectStore } = useMobxStore();
const projectsList = workspaceSlug ? projectStore.projects[workspaceSlug.toString()] : null;
return (
<div
className={`grid items-center gap-4 px-5 py-2.5 ${isProjectLevel ? "grid-cols-3" : "grid-cols-2"} ${
fullScreen ? "lg:grid-cols-4 md:py-5" : ""
}`}
>
{!isProjectLevel && (
<div>
<h6 className="text-xs text-custom-text-200">Project</h6>
<Controller
name="project"
control={control}
render={({ field: { value, onChange } }) => (
<SelectProject value={value ?? undefined} onChange={onChange} projects={projectsList ?? undefined} />
)}
/>
</div>
)}
<div>
<h6 className="text-xs text-custom-text-200">Project</h6>
<h6 className="text-xs text-custom-text-200">Measure (y-axis)</h6>
<Controller
name="project"
name="y_axis"
control={control}
render={({ field: { value, onChange } }) => <SelectYAxis value={value} onChange={onChange} />}
/>
</div>
<div>
<h6 className="text-xs text-custom-text-200">Dimension (x-axis)</h6>
<Controller
name="x_axis"
control={control}
render={({ field: { value, onChange } }) => (
<SelectProject value={value} onChange={onChange} projects={projects} />
<SelectXAxis
value={value}
onChange={(val: string) => {
if (params.segment === val) setValue("segment", null);
onChange(val);
}}
/>
)}
/>
</div>
<div>
<h6 className="text-xs text-custom-text-200">Group</h6>
<Controller
name="segment"
control={control}
render={({ field: { value, onChange } }) => (
<SelectSegment value={value} onChange={onChange} params={params} />
)}
/>
</div>
)}
<div>
<h6 className="text-xs text-custom-text-200">Measure (y-axis)</h6>
<Controller
name="y_axis"
control={control}
render={({ field: { value, onChange } }) => (
<SelectYAxis value={value} onChange={onChange} />
)}
/>
</div>
<div>
<h6 className="text-xs text-custom-text-200">Dimension (x-axis)</h6>
<Controller
name="x_axis"
control={control}
render={({ field: { value, onChange } }) => (
<SelectXAxis
value={value}
onChange={(val: string) => {
if (params.segment === val) setValue("segment", null);
onChange(val);
}}
/>
)}
/>
</div>
<div>
<h6 className="text-xs text-custom-text-200">Group</h6>
<Controller
name="segment"
control={control}
render={({ field: { value, onChange } }) => (
<SelectSegment value={value} onChange={onChange} params={params} />
)}
/>
</div>
</div>
);
);
});

View File

@ -4,9 +4,9 @@ import { CustomSearchSelect } from "components/ui";
import { IProject } from "types";
type Props = {
value: string[] | null | undefined;
value: string[] | undefined;
onChange: (val: string[] | null) => void;
projects: IProject[];
projects: IProject[] | undefined;
};
export const SelectProject: React.FC<Props> = ({ value, onChange, projects }) => {
@ -29,7 +29,7 @@ export const SelectProject: React.FC<Props> = ({ value, onChange, projects }) =>
label={
value && value.length > 0
? projects
.filter((p) => value.includes(p.id))
?.filter((p) => value.includes(p.id))
.map((p) => p.identifier)
.join(", ")
: "All projects"

View File

@ -34,8 +34,8 @@ export const SelectSegment: React.FC<Props> = ({ value, onChange, params }) => {
<CustomSelect.Option value={null}>No value</CustomSelect.Option>
{ANALYTICS_X_AXIS_VALUES.map((item) => {
if (params.x_axis === item.value) return null;
if (cycleId && item.value === "issue_cycle__cycle__name") return null;
if (moduleId && item.value === "issue_module__module__name") return null;
if (cycleId && item.value === "issue_cycle__cycle_id") return null;
if (moduleId && item.value === "issue_module__module_id") return null;
return (
<CustomSelect.Option key={item.value} value={item.value}>

View File

@ -25,8 +25,8 @@ export const SelectXAxis: React.FC<Props> = ({ value, onChange }) => {
maxHeight="lg"
>
{ANALYTICS_X_AXIS_VALUES.map((item) => {
if (cycleId && item.value === "issue_cycle__cycle__name") return null;
if (moduleId && item.value === "issue_module__module__name") return null;
if (cycleId && item.value === "issue_cycle__cycle_id") return null;
if (moduleId && item.value === "issue_module__module_id") return null;
return (
<CustomSelect.Option key={item.value} value={item.value}>

View File

@ -1,381 +0,0 @@
import { useRouter } from "next/router";
import useSWR, { mutate } from "swr";
// services
import analyticsService from "services/analytics.service";
import projectService from "services/project.service";
import cyclesService from "services/cycles.service";
import modulesService from "services/modules.service";
import trackEventServices from "services/track-event.service";
// hooks
import useProjects from "hooks/use-projects";
import useToast from "hooks/use-toast";
// ui
import { PrimaryButton, SecondaryButton } from "components/ui";
// icons
import {
ArrowDownTrayIcon,
ArrowPathIcon,
CalendarDaysIcon,
UserGroupIcon,
} from "@heroicons/react/24/outline";
import { ContrastIcon, LayerDiagonalIcon } from "components/icons";
// helpers
import { renderShortDate } from "helpers/date-time.helper";
import { renderEmoji } from "helpers/emoji.helper";
import { truncateText } from "helpers/string.helper";
// types
import {
IAnalyticsParams,
IAnalyticsResponse,
ICurrentUserResponse,
IExportAnalyticsFormData,
IWorkspace,
} from "types";
// fetch-keys
import { ANALYTICS, CYCLE_DETAILS, MODULE_DETAILS, PROJECT_DETAILS } from "constants/fetch-keys";
// constants
import { NETWORK_CHOICES } from "constants/project";
type Props = {
analytics: IAnalyticsResponse | undefined;
params: IAnalyticsParams;
fullScreen: boolean;
isProjectLevel: boolean;
user: ICurrentUserResponse | undefined;
};
export const AnalyticsSidebar: React.FC<Props> = ({
analytics,
params,
fullScreen,
isProjectLevel = false,
user,
}) => {
const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
const { projects } = useProjects();
const { setToastAlert } = useToast();
const { data: projectDetails } = useSWR(
workspaceSlug && projectId && !(cycleId || moduleId)
? PROJECT_DETAILS(projectId.toString())
: null,
workspaceSlug && projectId && !(cycleId || moduleId)
? () => projectService.getProject(workspaceSlug.toString(), projectId.toString())
: null
);
const { data: cycleDetails } = useSWR(
workspaceSlug && projectId && cycleId ? CYCLE_DETAILS(cycleId.toString()) : null,
workspaceSlug && projectId && cycleId
? () =>
cyclesService.getCycleDetails(
workspaceSlug.toString(),
projectId.toString(),
cycleId.toString()
)
: null
);
const { data: moduleDetails } = useSWR(
workspaceSlug && projectId && moduleId ? MODULE_DETAILS(moduleId.toString()) : null,
workspaceSlug && projectId && moduleId
? () =>
modulesService.getModuleDetails(
workspaceSlug.toString(),
projectId.toString(),
moduleId.toString()
)
: null
);
const trackExportAnalytics = () => {
const eventPayload: any = {
workspaceSlug: workspaceSlug?.toString(),
params: {
x_axis: params.x_axis,
y_axis: params.y_axis,
group: params.segment,
project: params.project,
},
};
if (projectDetails) {
const workspaceDetails = projectDetails.workspace as IWorkspace;
eventPayload.workspaceId = workspaceDetails.id;
eventPayload.workspaceName = workspaceDetails.name;
eventPayload.projectId = projectDetails.id;
eventPayload.projectIdentifier = projectDetails.identifier;
eventPayload.projectName = projectDetails.name;
}
if (cycleDetails || moduleDetails) {
const details = cycleDetails || moduleDetails;
eventPayload.workspaceId = details?.workspace_detail?.id;
eventPayload.workspaceName = details?.workspace_detail?.name;
eventPayload.projectId = details?.project_detail.id;
eventPayload.projectIdentifier = details?.project_detail.identifier;
eventPayload.projectName = details?.project_detail.name;
}
if (cycleDetails) {
eventPayload.cycleId = cycleDetails.id;
eventPayload.cycleName = cycleDetails.name;
}
if (moduleDetails) {
eventPayload.moduleId = moduleDetails.id;
eventPayload.moduleName = moduleDetails.name;
}
trackEventServices.trackAnalyticsEvent(
eventPayload,
cycleId
? "CYCLE_ANALYTICS_EXPORT"
: moduleId
? "MODULE_ANALYTICS_EXPORT"
: projectId
? "PROJECT_ANALYTICS_EXPORT"
: "WORKSPACE_ANALYTICS_EXPORT",
user
);
};
const exportAnalytics = () => {
if (!workspaceSlug) return;
const data: IExportAnalyticsFormData = {
x_axis: params.x_axis,
y_axis: params.y_axis,
};
if (params.segment) data.segment = params.segment;
if (params.project) data.project = params.project;
analyticsService
.exportAnalytics(workspaceSlug.toString(), data)
.then((res) => {
setToastAlert({
type: "success",
title: "Success!",
message: res.message,
});
trackExportAnalytics();
})
.catch(() =>
setToastAlert({
type: "error",
title: "Error!",
message: "There was some error in exporting the analytics. Please try again.",
})
);
};
const selectedProjects =
params.project && params.project.length > 0 ? params.project : projects?.map((p) => p.id);
return (
<div
className={`px-5 py-2.5 flex items-center justify-between space-y-2 ${
fullScreen
? "border-l border-custom-border-200 md:h-full md:border-l md:border-custom-border-200 md:space-y-4 overflow-hidden md:flex-col md:items-start md:py-5"
: ""
}`}
>
<div className="flex items-center gap-2 flex-wrap">
<div className="flex items-center gap-1 bg-custom-background-80 rounded-md px-3 py-1 text-custom-text-200 text-xs">
<LayerDiagonalIcon height={14} width={14} />
{analytics ? analytics.total : "..."} Issues
</div>
{isProjectLevel && (
<div className="flex items-center gap-1 bg-custom-background-80 rounded-md px-3 py-1 text-custom-text-200 text-xs">
<CalendarDaysIcon className="h-3.5 w-3.5" />
{renderShortDate(
(cycleId
? cycleDetails?.created_at
: moduleId
? moduleDetails?.created_at
: projectDetails?.created_at) ?? ""
)}
</div>
)}
</div>
<div className="h-full w-full overflow-hidden">
{fullScreen ? (
<>
{!isProjectLevel && selectedProjects && selectedProjects.length > 0 && (
<div className="hidden h-full overflow-hidden md:flex md:flex-col">
<h4 className="font-medium">Selected Projects</h4>
<div className="space-y-6 mt-4 h-full overflow-y-auto">
{selectedProjects.map((projectId) => {
const project = projects?.find((p) => p.id === projectId);
if (project)
return (
<div key={project.id} className="w-full">
<div className="text-sm flex items-center gap-1">
{project.emoji ? (
<span className="grid h-6 w-6 flex-shrink-0 place-items-center">
{renderEmoji(project.emoji)}
</span>
) : project.icon_prop ? (
<div className="h-6 w-6 grid place-items-center flex-shrink-0">
{renderEmoji(project.icon_prop)}
</div>
) : (
<span className="grid h-6 w-6 mr-1 flex-shrink-0 place-items-center rounded bg-gray-700 uppercase text-white">
{project?.name.charAt(0)}
</span>
)}
<h5 className="flex items-center gap-1">
<p className="break-words">{truncateText(project.name, 20)}</p>
<span className="text-custom-text-200 text-xs ml-1">
({project.identifier})
</span>
</h5>
</div>
<div className="mt-4 space-y-3 pl-2 w-full">
<div className="flex items-center justify-between gap-2 text-xs">
<div className="flex items-center gap-2">
<UserGroupIcon className="h-4 w-4 text-custom-text-200" />
<h6>Total members</h6>
</div>
<span className="text-custom-text-200">{project.total_members}</span>
</div>
<div className="flex items-center justify-between gap-2 text-xs">
<div className="flex items-center gap-2">
<ContrastIcon height={16} width={16} />
<h6>Total cycles</h6>
</div>
<span className="text-custom-text-200">{project.total_cycles}</span>
</div>
<div className="flex items-center justify-between gap-2 text-xs">
<div className="flex items-center gap-2">
<UserGroupIcon className="h-4 w-4 text-custom-text-200" />
<h6>Total modules</h6>
</div>
<span className="text-custom-text-200">{project.total_modules}</span>
</div>
</div>
</div>
);
})}
</div>
</div>
)}
{projectId ? (
cycleId && cycleDetails ? (
<div className="hidden md:block h-full overflow-y-auto">
<h4 className="font-medium break-words">Analytics for {cycleDetails.name}</h4>
<div className="space-y-4 mt-4">
<div className="flex items-center gap-2 text-xs">
<h6 className="text-custom-text-200">Lead</h6>
<span>{cycleDetails.owned_by?.display_name}</span>
</div>
<div className="flex items-center gap-2 text-xs">
<h6 className="text-custom-text-200">Start Date</h6>
<span>
{cycleDetails.start_date && cycleDetails.start_date !== ""
? renderShortDate(cycleDetails.start_date)
: "No start date"}
</span>
</div>
<div className="flex items-center gap-2 text-xs">
<h6 className="text-custom-text-200">Target Date</h6>
<span>
{cycleDetails.end_date && cycleDetails.end_date !== ""
? renderShortDate(cycleDetails.end_date)
: "No end date"}
</span>
</div>
</div>
</div>
) : moduleId && moduleDetails ? (
<div className="hidden md:block h-full overflow-y-auto">
<h4 className="font-medium break-words">Analytics for {moduleDetails.name}</h4>
<div className="space-y-4 mt-4">
<div className="flex items-center gap-2 text-xs">
<h6 className="text-custom-text-200">Lead</h6>
<span>{moduleDetails.lead_detail?.display_name}</span>
</div>
<div className="flex items-center gap-2 text-xs">
<h6 className="text-custom-text-200">Start Date</h6>
<span>
{moduleDetails.start_date && moduleDetails.start_date !== ""
? renderShortDate(moduleDetails.start_date)
: "No start date"}
</span>
</div>
<div className="flex items-center gap-2 text-xs">
<h6 className="text-custom-text-200">Target Date</h6>
<span>
{moduleDetails.target_date && moduleDetails.target_date !== ""
? renderShortDate(moduleDetails.target_date)
: "No end date"}
</span>
</div>
</div>
</div>
) : (
<div className="hidden md:flex md:flex-col h-full overflow-y-auto">
<div className="flex items-center gap-1">
{projectDetails?.emoji ? (
<div className="grid h-6 w-6 flex-shrink-0 place-items-center">
{renderEmoji(projectDetails.emoji)}
</div>
) : projectDetails?.icon_prop ? (
<div className="h-6 w-6 grid place-items-center flex-shrink-0">
{renderEmoji(projectDetails.icon_prop)}
</div>
) : (
<span className="grid h-6 w-6 mr-1 flex-shrink-0 place-items-center rounded bg-gray-700 uppercase text-white">
{projectDetails?.name.charAt(0)}
</span>
)}
<h4 className="font-medium break-words">{projectDetails?.name}</h4>
</div>
<div className="space-y-4 mt-4">
<div className="flex items-center gap-2 text-xs">
<h6 className="text-custom-text-200">Network</h6>
<span>
{NETWORK_CHOICES.find((n) => n.key === projectDetails?.network)?.label ??
""}
</span>
</div>
</div>
</div>
)
) : null}
</>
) : null}
</div>
<div className="flex items-center gap-2 flex-wrap justify-self-end">
<SecondaryButton
onClick={() => {
if (!workspaceSlug) return;
mutate(ANALYTICS(workspaceSlug.toString(), params));
}}
>
<div className="flex items-center gap-2 -my-1">
<ArrowPathIcon className="h-3.5 w-3.5" />
Refresh
</div>
</SecondaryButton>
<PrimaryButton onClick={exportAnalytics}>
<div className="flex items-center gap-2 -my-1">
<ArrowDownTrayIcon className="h-3.5 w-3.5" />
Export as CSV
</div>
</PrimaryButton>
</div>
</div>
);
};

View File

@ -0,0 +1,3 @@
export * from "./projects-list";
export * from "./sidebar-header";
export * from "./sidebar";

View File

@ -0,0 +1,65 @@
// icons
import { Contrast, LayoutGrid, Users } from "lucide-react";
// helpers
import { renderEmoji } from "helpers/emoji.helper";
import { truncateText } from "helpers/string.helper";
// types
import { IProject } from "types";
type Props = {
projects: IProject[];
};
export const CustomAnalyticsSidebarProjectsList: React.FC<Props> = (props) => {
const { projects } = props;
return (
<div className="hidden h-full overflow-hidden md:flex md:flex-col">
<h4 className="font-medium">Selected Projects</h4>
<div className="space-y-6 mt-4 h-full overflow-y-auto">
{projects.map((project) => (
<div key={project.id} className="w-full">
<div className="text-sm flex items-center gap-1">
{project.emoji ? (
<span className="grid h-6 w-6 flex-shrink-0 place-items-center">{renderEmoji(project.emoji)}</span>
) : project.icon_prop ? (
<div className="h-6 w-6 grid place-items-center flex-shrink-0">{renderEmoji(project.icon_prop)}</div>
) : (
<span className="grid h-6 w-6 mr-1 flex-shrink-0 place-items-center rounded bg-gray-700 uppercase text-white">
{project?.name.charAt(0)}
</span>
)}
<h5 className="flex items-center gap-1">
<p className="break-words">{truncateText(project.name, 20)}</p>
<span className="text-custom-text-200 text-xs ml-1">({project.identifier})</span>
</h5>
</div>
<div className="mt-4 space-y-3 pl-2 w-full">
<div className="flex items-center justify-between gap-2 text-xs">
<div className="flex items-center gap-2">
<Users className="text-custom-text-200" size={14} strokeWidth={2} />
<h6>Total members</h6>
</div>
<span className="text-custom-text-200">{project.total_members}</span>
</div>
<div className="flex items-center justify-between gap-2 text-xs">
<div className="flex items-center gap-2">
<Contrast className="text-custom-text-200" size={14} strokeWidth={2} />
<h6>Total cycles</h6>
</div>
<span className="text-custom-text-200">{project.total_cycles}</span>
</div>
<div className="flex items-center justify-between gap-2 text-xs">
<div className="flex items-center gap-2">
<LayoutGrid className="text-custom-text-200" size={14} strokeWidth={2} />
<h6>Total modules</h6>
</div>
<span className="text-custom-text-200">{project.total_modules}</span>
</div>
</div>
</div>
))}
</div>
</div>
);
};

View File

@ -0,0 +1,107 @@
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// helpers
import { renderEmoji } from "helpers/emoji.helper";
import { renderShortDate } from "helpers/date-time.helper";
// constants
import { NETWORK_CHOICES } from "constants/project";
export const CustomAnalyticsSidebarHeader = observer(() => {
const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
const { cycle: cycleStore, module: moduleStore, project: projectStore } = useMobxStore();
const cycleDetails = cycleId ? cycleStore.getCycleById(cycleId.toString()) : undefined;
const moduleDetails = moduleId ? moduleStore.getModuleById(moduleId.toString()) : undefined;
const projectDetails =
workspaceSlug && projectId
? projectStore.getProjectById(workspaceSlug.toString(), projectId.toString())
: undefined;
return (
<>
{projectId ? (
cycleDetails ? (
<div className="hidden md:block h-full overflow-y-auto">
<h4 className="font-medium break-words">Analytics for {cycleDetails.name}</h4>
<div className="space-y-4 mt-4">
<div className="flex items-center gap-2 text-xs">
<h6 className="text-custom-text-200">Lead</h6>
<span>{cycleDetails.owned_by?.display_name}</span>
</div>
<div className="flex items-center gap-2 text-xs">
<h6 className="text-custom-text-200">Start Date</h6>
<span>
{cycleDetails.start_date && cycleDetails.start_date !== ""
? renderShortDate(cycleDetails.start_date)
: "No start date"}
</span>
</div>
<div className="flex items-center gap-2 text-xs">
<h6 className="text-custom-text-200">Target Date</h6>
<span>
{cycleDetails.end_date && cycleDetails.end_date !== ""
? renderShortDate(cycleDetails.end_date)
: "No end date"}
</span>
</div>
</div>
</div>
) : moduleDetails ? (
<div className="hidden md:block h-full overflow-y-auto">
<h4 className="font-medium break-words">Analytics for {moduleDetails.name}</h4>
<div className="space-y-4 mt-4">
<div className="flex items-center gap-2 text-xs">
<h6 className="text-custom-text-200">Lead</h6>
<span>{moduleDetails.lead_detail?.display_name}</span>
</div>
<div className="flex items-center gap-2 text-xs">
<h6 className="text-custom-text-200">Start Date</h6>
<span>
{moduleDetails.start_date && moduleDetails.start_date !== ""
? renderShortDate(moduleDetails.start_date)
: "No start date"}
</span>
</div>
<div className="flex items-center gap-2 text-xs">
<h6 className="text-custom-text-200">Target Date</h6>
<span>
{moduleDetails.target_date && moduleDetails.target_date !== ""
? renderShortDate(moduleDetails.target_date)
: "No end date"}
</span>
</div>
</div>
</div>
) : (
<div className="hidden md:flex md:flex-col h-full overflow-y-auto">
<div className="flex items-center gap-1">
{projectDetails?.emoji ? (
<div className="grid h-6 w-6 flex-shrink-0 place-items-center">{renderEmoji(projectDetails.emoji)}</div>
) : projectDetails?.icon_prop ? (
<div className="h-6 w-6 grid place-items-center flex-shrink-0">
{renderEmoji(projectDetails.icon_prop)}
</div>
) : (
<span className="grid h-6 w-6 mr-1 flex-shrink-0 place-items-center rounded bg-gray-700 uppercase text-white">
{projectDetails?.name.charAt(0)}
</span>
)}
<h4 className="font-medium break-words">{projectDetails?.name}</h4>
</div>
<div className="space-y-4 mt-4">
<div className="flex items-center gap-2 text-xs">
<h6 className="text-custom-text-200">Network</h6>
<span>{NETWORK_CHOICES.find((n) => n.key === projectDetails?.network)?.label ?? ""}</span>
</div>
</div>
</div>
)
) : null}
</>
);
});

View File

@ -0,0 +1,215 @@
import { useEffect } from "react";
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
import { mutate } from "swr";
// services
import { AnalyticsService } from "services/analytics.service";
import { TrackEventService } from "services/track_event.service";
// hooks
import useToast from "hooks/use-toast";
import { useMobxStore } from "lib/mobx/store-provider";
// components
import { CustomAnalyticsSidebarHeader, CustomAnalyticsSidebarProjectsList } from "components/analytics";
// ui
import { Button } from "@plane/ui";
// icons
import { ArrowDownTrayIcon, ArrowPathIcon, CalendarDaysIcon } from "@heroicons/react/24/outline";
import { LayerDiagonalIcon } from "components/icons";
// helpers
import { renderShortDate } from "helpers/date-time.helper";
// types
import { IAnalyticsParams, IAnalyticsResponse, IExportAnalyticsFormData, IWorkspace } from "types";
// fetch-keys
import { ANALYTICS } from "constants/fetch-keys";
type Props = {
analytics: IAnalyticsResponse | undefined;
params: IAnalyticsParams;
fullScreen: boolean;
isProjectLevel: boolean;
};
const analyticsService = new AnalyticsService();
const trackEventService = new TrackEventService();
export const CustomAnalyticsSidebar: React.FC<Props> = observer(
({ analytics, params, fullScreen, isProjectLevel = false }) => {
const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
const { setToastAlert } = useToast();
const { user: userStore, project: projectStore, cycle: cycleStore, module: moduleStore } = useMobxStore();
const user = userStore.currentUser;
const projects = workspaceSlug ? projectStore.projects[workspaceSlug.toString()] : undefined;
const projectDetails =
workspaceSlug && projectId
? projectStore.getProjectById(workspaceSlug.toString(), projectId.toString()) ?? undefined
: undefined;
const trackExportAnalytics = () => {
if (!user) return;
const eventPayload: any = {
workspaceSlug: workspaceSlug?.toString(),
params: {
x_axis: params.x_axis,
y_axis: params.y_axis,
group: params.segment,
project: params.project,
},
};
if (projectDetails) {
const workspaceDetails = projectDetails.workspace as IWorkspace;
eventPayload.workspaceId = workspaceDetails.id;
eventPayload.workspaceName = workspaceDetails.name;
eventPayload.projectId = projectDetails.id;
eventPayload.projectIdentifier = projectDetails.identifier;
eventPayload.projectName = projectDetails.name;
}
if (cycleDetails || moduleDetails) {
const details = cycleDetails || moduleDetails;
eventPayload.workspaceId = details?.workspace_detail?.id;
eventPayload.workspaceName = details?.workspace_detail?.name;
eventPayload.projectId = details?.project_detail.id;
eventPayload.projectIdentifier = details?.project_detail.identifier;
eventPayload.projectName = details?.project_detail.name;
}
if (cycleDetails) {
eventPayload.cycleId = cycleDetails.id;
eventPayload.cycleName = cycleDetails.name;
}
if (moduleDetails) {
eventPayload.moduleId = moduleDetails.id;
eventPayload.moduleName = moduleDetails.name;
}
trackEventService.trackAnalyticsEvent(
eventPayload,
cycleId
? "CYCLE_ANALYTICS_EXPORT"
: moduleId
? "MODULE_ANALYTICS_EXPORT"
: projectId
? "PROJECT_ANALYTICS_EXPORT"
: "WORKSPACE_ANALYTICS_EXPORT",
user
);
};
const exportAnalytics = () => {
if (!workspaceSlug) return;
const data: IExportAnalyticsFormData = {
x_axis: params.x_axis,
y_axis: params.y_axis,
};
if (params.segment) data.segment = params.segment;
if (params.project) data.project = params.project;
analyticsService
.exportAnalytics(workspaceSlug.toString(), data)
.then((res) => {
setToastAlert({
type: "success",
title: "Success!",
message: res.message,
});
trackExportAnalytics();
})
.catch(() =>
setToastAlert({
type: "error",
title: "Error!",
message: "There was some error in exporting the analytics. Please try again.",
})
);
};
const cycleDetails = cycleId ? cycleStore.getCycleById(cycleId.toString()) : undefined;
const moduleDetails = moduleId ? moduleStore.getModuleById(moduleId.toString()) : undefined;
// fetch cycle details
useEffect(() => {
if (!workspaceSlug || !projectId || !cycleId || cycleDetails) return;
cycleStore.fetchCycleWithId(workspaceSlug.toString(), projectId.toString(), cycleId.toString());
}, [cycleId, cycleDetails, cycleStore, projectId, workspaceSlug]);
// fetch module details
useEffect(() => {
if (!workspaceSlug || !projectId || !moduleId || moduleDetails) return;
moduleStore.fetchModuleDetails(workspaceSlug.toString(), projectId.toString(), moduleId.toString());
}, [moduleId, moduleDetails, moduleStore, projectId, workspaceSlug]);
const selectedProjects = params.project && params.project.length > 0 ? params.project : projects?.map((p) => p.id);
return (
<div
className={`px-5 py-2.5 flex items-center justify-between space-y-2 ${
fullScreen
? "border-l border-custom-border-200 md:h-full md:border-l md:border-custom-border-200 md:space-y-4 overflow-hidden md:flex-col md:items-start md:py-5"
: ""
}`}
>
<div className="flex items-center gap-2 flex-wrap">
<div className="flex items-center gap-1 bg-custom-background-80 rounded-md px-3 py-1 text-custom-text-200 text-xs">
<LayerDiagonalIcon height={14} width={14} />
{analytics ? analytics.total : "..."} Issues
</div>
{isProjectLevel && (
<div className="flex items-center gap-1 bg-custom-background-80 rounded-md px-3 py-1 text-custom-text-200 text-xs">
<CalendarDaysIcon className="h-3.5 w-3.5" />
{renderShortDate(
(cycleId
? cycleDetails?.created_at
: moduleId
? moduleDetails?.created_at
: projectDetails?.created_at) ?? ""
)}
</div>
)}
</div>
<div className="h-full w-full overflow-hidden">
{fullScreen ? (
<>
{!isProjectLevel && selectedProjects && selectedProjects.length > 0 && (
<CustomAnalyticsSidebarProjectsList
projects={projects?.filter((p) => selectedProjects.includes(p.id)) ?? []}
/>
)}
<CustomAnalyticsSidebarHeader />
</>
) : null}
</div>
<div className="flex items-center gap-2 flex-wrap justify-self-end">
<Button
variant="neutral-primary"
prependIcon={<ArrowPathIcon className="h-3.5 w-3.5" />}
onClick={() => {
if (!workspaceSlug) return;
mutate(ANALYTICS(workspaceSlug.toString(), params));
}}
>
Refresh
</Button>
<Button variant="primary" prependIcon={<ArrowDownTrayIcon />} onClick={exportAnalytics}>
Export as CSV
</Button>
</div>
</div>
);
}
);

View File

@ -1,15 +1,13 @@
// nivo
import { BarDatum } from "@nivo/bar";
// icons
import { PriorityIcon } from "components/icons";
// helpers
import { addSpaceIfCamelCase } from "helpers/string.helper";
// helpers
import { generateBarColor, renderMonthAndYear } from "helpers/analytics.helper";
import { generateBarColor, generateDisplayName } from "helpers/analytics.helper";
// types
import { IAnalyticsParams, IAnalyticsResponse, TIssuePriorities } from "types";
// constants
import { ANALYTICS_X_AXIS_VALUES, ANALYTICS_Y_AXIS_VALUES, DATE_KEYS } from "constants/analytics";
import { ANALYTICS_X_AXIS_VALUES, ANALYTICS_Y_AXIS_VALUES } from "constants/analytics";
type Props = {
analytics: IAnalyticsResponse;
@ -21,112 +19,81 @@ type Props = {
yAxisKey: "count" | "estimate";
};
export const AnalyticsTable: React.FC<Props> = ({ analytics, barGraphData, params, yAxisKey }) => {
const renderAssigneeName = (assigneeId: string): string => {
const assignee = analytics.extras.assignee_details.find((a) => a.assignees__id === assigneeId);
if (!assignee) return "No assignee";
return assignee.assignees__display_name || "No assignee";
};
return (
<div className="flow-root">
<div className="overflow-x-auto">
<div className="inline-block min-w-full align-middle">
<table className="min-w-full divide-y divide-custom-border-200 whitespace-nowrap border-y border-custom-border-200">
<thead className="bg-custom-background-80">
<tr className="divide-x divide-custom-border-200 text-sm text-custom-text-100">
<th scope="col" className="py-3 px-2.5 text-left font-medium">
{ANALYTICS_X_AXIS_VALUES.find((v) => v.value === params.x_axis)?.label}
</th>
{params.segment ? (
barGraphData.xAxisKeys.map((key) => (
<th
key={`segment-${key}`}
scope="col"
className={`px-2.5 py-3 text-left font-medium ${
params.segment === "priority" || params.segment === "state__group"
? "capitalize"
: ""
}`}
>
<div className="flex items-center gap-2">
{params.segment === "priority" ? (
<PriorityIcon priority={key as TIssuePriorities} />
) : (
<span
className="h-3 w-3 flex-shrink-0 rounded"
style={{
backgroundColor: generateBarColor(key, analytics, params, "segment"),
}}
/>
)}
{params.segment === "assignees__id"
? renderAssigneeName(key)
: DATE_KEYS.includes(params.segment ?? "")
? renderMonthAndYear(key)
: key}
</div>
</th>
))
) : (
<th scope="col" className="py-3 px-2.5 text-left font-medium sm:pr-0">
{ANALYTICS_Y_AXIS_VALUES.find((v) => v.value === params.y_axis)?.label}
</th>
)}
</tr>
</thead>
<tbody className="divide-y divide-custom-border-200">
{barGraphData.data.map((item, index) => (
<tr
key={`table-row-${index}`}
className="divide-x divide-custom-border-200 text-xs text-custom-text-200"
>
<td
className={`flex items-center gap-2 whitespace-nowrap py-2 px-2.5 font-medium ${
params.x_axis === "priority" || params.x_axis === "state__group"
? "capitalize"
: ""
export const AnalyticsTable: React.FC<Props> = ({ analytics, barGraphData, params, yAxisKey }) => (
<div className="flow-root">
<div className="overflow-x-auto">
<div className="inline-block min-w-full align-middle">
<table className="min-w-full divide-y divide-custom-border-200 whitespace-nowrap border-y border-custom-border-200">
<thead className="bg-custom-background-80">
<tr className="divide-x divide-custom-border-200 text-sm text-custom-text-100">
<th scope="col" className="py-3 px-2.5 text-left font-medium">
{ANALYTICS_X_AXIS_VALUES.find((v) => v.value === params.x_axis)?.label}
</th>
{params.segment ? (
barGraphData.xAxisKeys.map((key) => (
<th
key={`segment-${key}`}
scope="col"
className={`px-2.5 py-3 text-left font-medium ${
params.segment === "priority" || params.segment === "state__group" ? "capitalize" : ""
}`}
>
{params.x_axis === "priority" ? (
<PriorityIcon priority={item.name as TIssuePriorities} />
) : (
<span
className="h-3 w-3 rounded"
style={{
backgroundColor: generateBarColor(
`${item.name}`,
analytics,
params,
"x_axis"
),
}}
/>
)}
{params.x_axis === "assignees__id"
? renderAssigneeName(`${item.name}`)
: addSpaceIfCamelCase(`${item.name}`)}
</td>
{params.segment ? (
barGraphData.xAxisKeys.map((key, index) => (
<td
key={`segment-value-${index}`}
className="whitespace-nowrap py-2 px-2.5 sm:pr-0"
>
{item[key] ?? 0}
</td>
))
<div className="flex items-center gap-2">
{params.segment === "priority" ? (
<PriorityIcon priority={key as TIssuePriorities} />
) : (
<span
className="h-3 w-3 flex-shrink-0 rounded"
style={{
backgroundColor: generateBarColor(key, analytics, params, "segment"),
}}
/>
)}
{generateDisplayName(key, analytics, params, "segment")}
</div>
</th>
))
) : (
<th scope="col" className="py-3 px-2.5 text-left font-medium sm:pr-0">
{ANALYTICS_Y_AXIS_VALUES.find((v) => v.value === params.y_axis)?.label}
</th>
)}
</tr>
</thead>
<tbody className="divide-y divide-custom-border-200">
{barGraphData.data.map((item, index) => (
<tr key={`table-row-${index}`} className="divide-x divide-custom-border-200 text-xs text-custom-text-200">
<td
className={`flex items-center gap-2 whitespace-nowrap py-2 px-2.5 font-medium ${
params.x_axis === "priority" || params.x_axis === "state__group" ? "capitalize" : ""
}`}
>
{params.x_axis === "priority" ? (
<PriorityIcon priority={item.name as TIssuePriorities} />
) : (
<td className="whitespace-nowrap py-2 px-2.5 sm:pr-0">{item[yAxisKey]}</td>
<span
className="h-3 w-3 rounded"
style={{
backgroundColor: generateBarColor(`${item.name}`, analytics, params, "x_axis"),
}}
/>
)}
</tr>
))}
</tbody>
</table>
</div>
{generateDisplayName(`${item.name}`, analytics, params, "x_axis")}
</td>
{params.segment ? (
barGraphData.xAxisKeys.map((key, index) => (
<td key={`segment-value-${index}`} className="whitespace-nowrap py-2 px-2.5 sm:pr-0">
{item[key] ?? 0}
</td>
))
) : (
<td className="whitespace-nowrap py-2 px-2.5 sm:pr-0">{item[yAxisKey]}</td>
)}
</tr>
))}
</tbody>
</table>
</div>
</div>
);
};
</div>
);

View File

@ -1,4 +1,3 @@
export * from "./custom-analytics";
export * from "./scope-and-demand";
export * from "./select";
export * from "./project-modal";

View File

@ -1,225 +0,0 @@
import React, { Fragment, useState } from "react";
import { useRouter } from "next/router";
import useSWR from "swr";
// react-hook-form
import { useForm } from "react-hook-form";
// headless ui
import { Tab } from "@headlessui/react";
// services
import analyticsService from "services/analytics.service";
import projectService from "services/project.service";
import cyclesService from "services/cycles.service";
import modulesService from "services/modules.service";
import trackEventServices from "services/track-event.service";
// components
import { CustomAnalytics, ScopeAndDemand } from "components/analytics";
// icons
import {
ArrowsPointingInIcon,
ArrowsPointingOutIcon,
XMarkIcon,
} from "@heroicons/react/24/outline";
// types
import { IAnalyticsParams, IWorkspace } from "types";
// fetch-keys
import { ANALYTICS, CYCLE_DETAILS, MODULE_DETAILS, PROJECT_DETAILS } from "constants/fetch-keys";
import useUserAuth from "hooks/use-user-auth";
type Props = {
isOpen: boolean;
onClose: () => void;
};
const defaultValues: IAnalyticsParams = {
x_axis: "priority",
y_axis: "issue_count",
segment: null,
project: null,
};
const tabsList = ["Scope and Demand", "Custom Analytics"];
export const AnalyticsProjectModal: React.FC<Props> = ({ isOpen, onClose }) => {
const [fullScreen, setFullScreen] = useState(false);
const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
const { user } = useUserAuth();
const { control, watch, setValue } = useForm<IAnalyticsParams>({ defaultValues });
const params: IAnalyticsParams = {
x_axis: watch("x_axis"),
y_axis: watch("y_axis"),
segment: watch("segment"),
project: projectId ? [projectId.toString()] : watch("project"),
cycle: cycleId ? cycleId.toString() : null,
module: moduleId ? moduleId.toString() : null,
};
const { data: analytics, error: analyticsError } = useSWR(
workspaceSlug ? ANALYTICS(workspaceSlug.toString(), params) : null,
workspaceSlug ? () => analyticsService.getAnalytics(workspaceSlug.toString(), params) : null
);
const { data: projectDetails } = useSWR(
workspaceSlug && projectId && !(cycleId || moduleId)
? PROJECT_DETAILS(projectId.toString())
: null,
workspaceSlug && projectId && !(cycleId || moduleId)
? () => projectService.getProject(workspaceSlug.toString(), projectId.toString())
: null
);
const { data: cycleDetails } = useSWR(
workspaceSlug && projectId && cycleId ? CYCLE_DETAILS(cycleId.toString()) : null,
workspaceSlug && projectId && cycleId
? () =>
cyclesService.getCycleDetails(
workspaceSlug.toString(),
projectId.toString(),
cycleId.toString()
)
: null
);
const { data: moduleDetails } = useSWR(
workspaceSlug && projectId && moduleId ? MODULE_DETAILS(moduleId.toString()) : null,
workspaceSlug && projectId && moduleId
? () =>
modulesService.getModuleDetails(
workspaceSlug.toString(),
projectId.toString(),
moduleId.toString()
)
: null
);
const trackAnalyticsEvent = (tab: string) => {
const eventPayload: any = {
workspaceSlug: workspaceSlug?.toString(),
};
if (projectDetails) {
const workspaceDetails = projectDetails.workspace as IWorkspace;
eventPayload.workspaceId = workspaceDetails.id;
eventPayload.workspaceName = workspaceDetails.name;
eventPayload.projectId = projectDetails.id;
eventPayload.projectIdentifier = projectDetails.identifier;
eventPayload.projectName = projectDetails.name;
}
if (cycleDetails || moduleDetails) {
const details = cycleDetails || moduleDetails;
eventPayload.workspaceId = details?.workspace_detail?.id;
eventPayload.workspaceName = details?.workspace_detail?.name;
eventPayload.projectId = details?.project_detail.id;
eventPayload.projectIdentifier = details?.project_detail.identifier;
eventPayload.projectName = details?.project_detail.name;
}
if (cycleDetails) {
eventPayload.cycleId = cycleDetails.id;
eventPayload.cycleName = cycleDetails.name;
}
if (moduleDetails) {
eventPayload.moduleId = moduleDetails.id;
eventPayload.moduleName = moduleDetails.name;
}
const eventType =
tab === "Scope and Demand" ? "SCOPE_AND_DEMAND_ANALYTICS" : "CUSTOM_ANALYTICS";
trackEventServices.trackAnalyticsEvent(
eventPayload,
cycleId ? `CYCLE_${eventType}` : moduleId ? `MODULE_${eventType}` : `PROJECT_${eventType}`,
user
);
};
const handleClose = () => {
onClose();
};
return (
<div
className={`absolute top-0 z-30 h-full bg-custom-background-90 ${
fullScreen ? "p-2 w-full" : "w-1/2"
} ${isOpen ? "right-0" : "-right-full"} duration-300 transition-all`}
>
<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="flex items-center justify-between gap-4 bg-custom-background-100 px-5 py-4 text-sm">
<h3 className="break-words">
Analytics for{" "}
{cycleId ? cycleDetails?.name : moduleId ? moduleDetails?.name : projectDetails?.name}
</h3>
<div className="flex items-center gap-2">
<button
type="button"
className="grid place-items-center p-1 text-custom-text-200 hover:text-custom-text-100"
onClick={() => setFullScreen((prevData) => !prevData)}
>
{fullScreen ? (
<ArrowsPointingInIcon className="h-4 w-4" />
) : (
<ArrowsPointingOutIcon className="h-3 w-3" />
)}
</button>
<button
type="button"
className="grid place-items-center p-1 text-custom-text-200 hover:text-custom-text-100"
onClick={handleClose}
>
<XMarkIcon className="h-4 w-4" />
</button>
</div>
</div>
<Tab.Group as={Fragment}>
<Tab.List as="div" className="space-x-2 border-b border-custom-border-200 p-5 pt-0">
{tabsList.map((tab) => (
<Tab
key={tab}
className={({ selected }) =>
`rounded-3xl border border-custom-border-200 px-4 py-2 text-xs hover:bg-custom-background-80 ${
selected ? "bg-custom-background-80" : ""
}`
}
onClick={() => trackAnalyticsEvent(tab)}
>
{tab}
</Tab>
))}
</Tab.List>
{/* <h4 className="p-5 pb-0">Analytics for</h4> */}
<Tab.Panels as={Fragment}>
<Tab.Panel as={Fragment}>
<ScopeAndDemand fullScreen={fullScreen} />
</Tab.Panel>
<Tab.Panel as={Fragment}>
<CustomAnalytics
analytics={analytics}
analyticsError={analyticsError}
params={params}
control={control}
setValue={setValue}
fullScreen={fullScreen}
user={user}
/>
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
</div>
</div>
);
};

View File

@ -0,0 +1,37 @@
import { observer } from "mobx-react-lite";
// icons
import { Expand, Shrink, X } from "lucide-react";
type Props = {
fullScreen: boolean;
handleClose: () => void;
setFullScreen: React.Dispatch<React.SetStateAction<boolean>>;
title: string;
};
export const ProjectAnalyticsModalHeader: React.FC<Props> = observer((props) => {
const { fullScreen, handleClose, setFullScreen, title } = props;
return (
<div className="flex items-center justify-between gap-4 bg-custom-background-100 px-5 py-4 text-sm">
<h3 className="break-words">Analytics for {title}</h3>
<div className="flex items-center gap-2">
<button
type="button"
className="grid place-items-center p-1 text-custom-text-200 hover:text-custom-text-100"
onClick={() => setFullScreen((prevData) => !prevData)}
>
{fullScreen ? <Shrink size={14} strokeWidth={2} /> : <Expand size={14} strokeWidth={2} />}
</button>
<button
type="button"
className="grid place-items-center p-1 text-custom-text-200 hover:text-custom-text-100"
onClick={handleClose}
>
<X size={14} strokeWidth={2} />
</button>
</div>
</div>
);
});

View File

@ -0,0 +1,3 @@
export * from "./header";
export * from "./main-content";
export * from "./modal";

View File

@ -0,0 +1,114 @@
import React from "react";
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
import { Tab } from "@headlessui/react";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// services
import { TrackEventService } from "services/track_event.service";
// components
import { CustomAnalytics, ScopeAndDemand } from "components/analytics";
// types
import { ICycle, IModule, IProject, IWorkspace } from "types";
// constants
import { ANALYTICS_TABS } from "constants/analytics";
type Props = {
fullScreen: boolean;
cycleDetails: ICycle | undefined;
moduleDetails: IModule | undefined;
projectDetails: IProject | undefined;
};
const trackEventService = new TrackEventService();
export const ProjectAnalyticsModalMainContent: React.FC<Props> = observer((props) => {
const { fullScreen, cycleDetails, moduleDetails, projectDetails } = props;
const router = useRouter();
const { workspaceSlug } = router.query;
const { user: userStore } = useMobxStore();
const user = userStore.currentUser;
const trackAnalyticsEvent = (tab: string) => {
if (!workspaceSlug || !user) return;
const eventPayload: any = {
workspaceSlug: workspaceSlug.toString(),
};
if (projectDetails) {
const workspaceDetails = projectDetails.workspace as IWorkspace;
eventPayload.workspaceId = workspaceDetails.id;
eventPayload.workspaceName = workspaceDetails.name;
eventPayload.projectId = projectDetails.id;
eventPayload.projectIdentifier = projectDetails.identifier;
eventPayload.projectName = projectDetails.name;
}
if (cycleDetails || moduleDetails) {
const details = cycleDetails || moduleDetails;
eventPayload.workspaceId = details?.workspace_detail?.id;
eventPayload.workspaceName = details?.workspace_detail?.name;
eventPayload.projectId = details?.project_detail.id;
eventPayload.projectIdentifier = details?.project_detail.identifier;
eventPayload.projectName = details?.project_detail.name;
}
if (cycleDetails) {
eventPayload.cycleId = cycleDetails.id;
eventPayload.cycleName = cycleDetails.name;
}
if (moduleDetails) {
eventPayload.moduleId = moduleDetails.id;
eventPayload.moduleName = moduleDetails.name;
}
const eventType = tab === "scope_and_demand" ? "SCOPE_AND_DEMAND_ANALYTICS" : "CUSTOM_ANALYTICS";
trackEventService.trackAnalyticsEvent(
eventPayload,
cycleDetails ? `CYCLE_${eventType}` : moduleDetails ? `MODULE_${eventType}` : `PROJECT_${eventType}`,
user
);
};
return (
<Tab.Group as={React.Fragment}>
<Tab.List as="div" className="space-x-2 border-b border-custom-border-200 p-5 pt-0">
{ANALYTICS_TABS.map((tab) => (
<Tab
key={tab.key}
className={({ selected }) =>
`rounded-3xl border border-custom-border-200 px-4 py-2 text-xs hover:bg-custom-background-80 ${
selected ? "bg-custom-background-80" : ""
}`
}
onClick={() => trackAnalyticsEvent(tab.key)}
>
{tab.title}
</Tab>
))}
</Tab.List>
<Tab.Panels as={React.Fragment}>
<Tab.Panel as={React.Fragment}>
<ScopeAndDemand fullScreen={fullScreen} />
</Tab.Panel>
<Tab.Panel as={React.Fragment}>
<CustomAnalytics
additionalParams={{
cycle: cycleDetails?.id,
module: moduleDetails?.id,
}}
fullScreen={fullScreen}
/>
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
);
});

View File

@ -0,0 +1,70 @@
import React, { useState } from "react";
import { observer } from "mobx-react-lite";
import { Dialog, Transition } from "@headlessui/react";
// components
import { ProjectAnalyticsModalHeader, ProjectAnalyticsModalMainContent } from "components/analytics";
// types
import { ICycle, IModule, IProject } from "types";
type Props = {
isOpen: boolean;
onClose: () => void;
cycleDetails?: ICycle | undefined;
moduleDetails?: IModule | undefined;
projectDetails?: IProject | undefined;
};
export const ProjectAnalyticsModal: React.FC<Props> = observer((props) => {
const { isOpen, onClose, cycleDetails, moduleDetails, projectDetails } = props;
const [fullScreen, setFullScreen] = useState(false);
const handleClose = () => {
onClose();
};
return (
<Transition.Root appear show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-20" onClose={handleClose}>
<div className="fixed inset-0 z-20 h-full w-full overflow-y-auto">
<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"
>
{/* TODO: fix full screen mode */}
<Dialog.Panel
className={`fixed z-20 bg-custom-background-100 top-0 right-0 h-full shadow-custom-shadow-md ${
fullScreen ? "w-full p-2" : "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"
}`}
>
<ProjectAnalyticsModalHeader
fullScreen={fullScreen}
handleClose={handleClose}
setFullScreen={setFullScreen}
title={cycleDetails?.name ?? moduleDetails?.name ?? projectDetails?.name ?? ""}
/>
<ProjectAnalyticsModalMainContent
fullScreen={fullScreen}
cycleDetails={cycleDetails}
moduleDetails={moduleDetails}
projectDetails={projectDetails}
/>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</Dialog>
</Transition.Root>
);
});

View File

@ -17,12 +17,7 @@ type Props = {
workspaceSlug: string;
};
export const AnalyticsLeaderboard: React.FC<Props> = ({
users,
title,
emptyStateMessage,
workspaceSlug,
}) => (
export const AnalyticsLeaderBoard: React.FC<Props> = ({ users, title, emptyStateMessage, workspaceSlug }) => (
<div className="p-3 border border-custom-border-200 rounded-[10px]">
<h6 className="text-base font-medium">{title}</h6>
{users.length > 0 ? (

View File

@ -3,16 +3,11 @@ import { useRouter } from "next/router";
import useSWR from "swr";
// services
import analyticsService from "services/analytics.service";
import { AnalyticsService } from "services/analytics.service";
// components
import {
AnalyticsDemand,
AnalyticsLeaderboard,
AnalyticsScope,
AnalyticsYearWiseIssues,
} from "components/analytics";
import { AnalyticsDemand, AnalyticsLeaderBoard, AnalyticsScope, AnalyticsYearWiseIssues } from "components/analytics";
// ui
import { Loader, PrimaryButton } from "components/ui";
import { Button, Loader } from "@plane/ui";
// fetch-keys
import { DEFAULT_ANALYTICS } from "constants/fetch-keys";
@ -20,7 +15,12 @@ type Props = {
fullScreen?: boolean;
};
export const ScopeAndDemand: React.FC<Props> = ({ fullScreen = true }) => {
// services
const analyticsService = new AnalyticsService();
export const ScopeAndDemand: React.FC<Props> = (props) => {
const { fullScreen = true } = props;
const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
@ -40,9 +40,7 @@ export const ScopeAndDemand: React.FC<Props> = ({ fullScreen = true }) => {
mutate: mutateDefaultAnalytics,
} = useSWR(
workspaceSlug ? DEFAULT_ANALYTICS(workspaceSlug.toString(), params) : null,
workspaceSlug
? () => analyticsService.getDefaultAnalytics(workspaceSlug.toString(), params)
: null
workspaceSlug ? () => analyticsService.getDefaultAnalytics(workspaceSlug.toString(), params) : null
);
return (
@ -53,7 +51,7 @@ export const ScopeAndDemand: React.FC<Props> = ({ fullScreen = true }) => {
<div className={`grid grid-cols-1 gap-5 ${fullScreen ? "md:grid-cols-2" : ""}`}>
<AnalyticsDemand defaultAnalytics={defaultAnalytics} />
<AnalyticsScope defaultAnalytics={defaultAnalytics} />
<AnalyticsLeaderboard
<AnalyticsLeaderBoard
users={defaultAnalytics.most_issue_created_user?.map((user) => ({
avatar: user?.created_by__avatar,
firstName: user?.created_by__first_name,
@ -63,10 +61,10 @@ export const ScopeAndDemand: React.FC<Props> = ({ fullScreen = true }) => {
id: user?.created_by__id,
}))}
title="Most issues created"
emptyStateMessage="Co-workers and the number issues created by them appears here."
emptyStateMessage="Co-workers and the number of issues created by them appears here."
workspaceSlug={workspaceSlug?.toString() ?? ""}
/>
<AnalyticsLeaderboard
<AnalyticsLeaderBoard
users={defaultAnalytics.most_issue_closed_user?.map((user) => ({
avatar: user?.assignees__avatar,
firstName: user?.assignees__first_name,
@ -76,7 +74,7 @@ export const ScopeAndDemand: React.FC<Props> = ({ fullScreen = true }) => {
id: user?.assignees__id,
}))}
title="Most issues closed"
emptyStateMessage="Co-workers and the number issues closed by them appears here."
emptyStateMessage="Co-workers and the number of issues closed by them appears here."
workspaceSlug={workspaceSlug?.toString() ?? ""}
/>
<div className={fullScreen ? "md:col-span-2" : ""}>
@ -97,7 +95,9 @@ export const ScopeAndDemand: React.FC<Props> = ({ fullScreen = true }) => {
<div className="space-y-4 text-custom-text-200">
<p className="text-sm">There was some error in fetching the data.</p>
<div className="flex items-center justify-center gap-2">
<PrimaryButton onClick={() => mutateDefaultAnalytics()}>Refresh</PrimaryButton>
<Button variant="primary" onClick={() => mutateDefaultAnalytics()}>
Refresh
</Button>
</div>
</div>
</div>

View File

@ -20,18 +20,15 @@ export const AnalyticsYearWiseIssues: React.FC<Props> = ({ defaultAnalytics }) =
{
id: "issues_closed",
color: "rgb(var(--color-primary-100))",
data: MONTHS_LIST.map((month) => ({
x: month.label.substring(0, 3),
data: Object.entries(MONTHS_LIST).map(([index, month]) => ({
x: month.shortTitle,
y:
defaultAnalytics.issue_completed_month_wise.find(
(data) => data.month === month.value
)?.count || 0,
defaultAnalytics.issue_completed_month_wise.find((data) => data.month === parseInt(index, 10))?.count ||
0,
})),
},
]}
customYAxisTickValues={defaultAnalytics.issue_completed_month_wise.map(
(data) => data.count
)}
customYAxisTickValues={defaultAnalytics.issue_completed_month_wise.map((data) => data.count)}
height="300px"
colors={(datum) => datum.color}
curve="monotoneX"

View File

@ -1,14 +1,11 @@
import { useState } from "react";
import Image from "next/image";
import { useRouter } from "next/router";
import { mutate } from "swr";
// services
import projectService from "services/project.service";
import { ProjectService } from "services/project";
// ui
import { PrimaryButton } from "components/ui";
import { Button } from "@plane/ui";
// icons
import { AssignmentClipboardIcon } from "components/icons";
// images
@ -16,6 +13,8 @@ import JoinProjectImg from "public/auth/project-not-authorized.svg";
// fetch-keys
import { USER_PROJECT_VIEW } from "constants/fetch-keys";
const projectService = new ProjectService();
export const JoinProject: React.FC = () => {
const [isJoiningProject, setIsJoiningProject] = useState(false);
@ -27,9 +26,7 @@ export const JoinProject: React.FC = () => {
setIsJoiningProject(true);
projectService
.joinProject(workspaceSlug as string, {
project_ids: [projectId as string],
})
.joinProject(workspaceSlug as string, [projectId as string])
.then(async () => {
await mutate(USER_PROJECT_VIEW(projectId.toString()));
setIsJoiningProject(false);
@ -45,25 +42,22 @@ export const JoinProject: React.FC = () => {
<div className="h-44 w-72">
<Image src={JoinProjectImg} height="176" width="288" alt="JoinProject" />
</div>
<h1 className="text-xl font-medium text-custom-text-100">
You are not a member of this project
</h1>
<h1 className="text-xl font-medium text-custom-text-100">You are not a member of this project</h1>
<div className="w-full max-w-md text-base text-custom-text-200">
<p className="mx-auto w-full text-sm md:w-3/4">
You are not a member of this project, but you can join this project by clicking the button
below.
You are not a member of this project, but you can join this project by clicking the button below.
</p>
</div>
<div>
<PrimaryButton
className="flex items-center gap-1"
<Button
variant="primary"
prependIcon={<AssignmentClipboardIcon color="white" />}
loading={isJoiningProject}
onClick={handleJoin}
>
<AssignmentClipboardIcon height={16} width={16} color="white" />
{isJoiningProject ? "Joining..." : "Click to join"}
</PrimaryButton>
</Button>
</div>
</div>
);

View File

@ -3,7 +3,7 @@ import Link from "next/link";
// layouts
import DefaultLayout from "layouts/default-layout";
// ui
import { PrimaryButton, SecondaryButton } from "components/ui";
import { Button } from "@plane/ui";
export const NotAWorkspaceMember = () => (
<DefaultLayout>
@ -12,19 +12,19 @@ export const NotAWorkspaceMember = () => (
<div className="space-y-2">
<h3 className="text-lg font-semibold">Not Authorized!</h3>
<p className="mx-auto w-1/2 text-sm text-custom-text-200">
You{"'"}re not a member of this workspace. Please contact the workspace admin to get an
invitation or check your pending invitations.
You{"'"}re not a member of this workspace. Please contact the workspace admin to get an invitation or check
your pending invitations.
</p>
</div>
<div className="flex items-center justify-center gap-2">
<Link href="/invitations">
<a>
<SecondaryButton>Check pending invites</SecondaryButton>
<Button variant="neutral-primary">Check pending invites</Button>
</a>
</Link>
<Link href="/create-workspace">
<a>
<PrimaryButton>Create new workspace</PrimaryButton>
<Button variant="primary">Create new workspace</Button>
</a>
</Link>
</div>

View File

@ -1,7 +1,8 @@
import React, { useState } from "react";
// component
import { CustomSelect, ToggleSwitch } from "components/ui";
import { CustomSelect } from "components/ui";
import { ToggleSwitch } from "@plane/ui";
import { SelectMonthModal } from "components/automation";
// icon
import { ArchiveRestore } from "lucide-react";
@ -16,11 +17,7 @@ type Props = {
disabled?: boolean;
};
export const AutoArchiveAutomation: React.FC<Props> = ({
projectDetails,
handleChange,
disabled = false,
}) => {
export const AutoArchiveAutomation: React.FC<Props> = ({ projectDetails, handleChange, disabled = false }) => {
const [monthModal, setmonthModal] = useState(false);
const initialValues: Partial<IProject> = { archive_in: 1 };
@ -49,9 +46,7 @@ export const AutoArchiveAutomation: React.FC<Props> = ({
<ToggleSwitch
value={projectDetails?.archive_in !== 0}
onChange={() =>
projectDetails?.archive_in === 0
? handleChange({ archive_in: 1 })
: handleChange({ archive_in: 0 })
projectDetails?.archive_in === 0 ? handleChange({ archive_in: 1 }) : handleChange({ archive_in: 0 })
}
size="sm"
disabled={disabled}
@ -61,15 +56,11 @@ export const AutoArchiveAutomation: React.FC<Props> = ({
{projectDetails?.archive_in !== 0 && (
<div className="ml-12">
<div className="flex items-center justify-between rounded px-5 py-4 bg-custom-background-90 border border-custom-border-200 gap-2 w-full">
<div className="w-1/2 text-sm font-medium">
Auto-archive issues that are closed for
</div>
<div className="w-1/2 text-sm font-medium">Auto-archive issues that are closed for</div>
<div className="w-1/2">
<CustomSelect
value={projectDetails?.archive_in}
label={`${projectDetails?.archive_in} ${
projectDetails?.archive_in === 1 ? "Month" : "Months"
}`}
label={`${projectDetails?.archive_in} ${projectDetails?.archive_in === 1 ? "Month" : "Months"}`}
onChange={(val: number) => {
handleChange({ archive_in: val });
}}

View File

@ -1,18 +1,16 @@
import React, { useState } from "react";
import useSWR from "swr";
import { useRouter } from "next/router";
// component
import { CustomSearchSelect, CustomSelect, Icon, ToggleSwitch } from "components/ui";
import { CustomSearchSelect, CustomSelect } from "components/ui";
import { SelectMonthModal } from "components/automation";
import { ToggleSwitch } from "@plane/ui";
// icons
import { Squares2X2Icon } from "@heroicons/react/24/outline";
import { StateGroupIcon } from "components/icons";
import { ArchiveX } from "lucide-react";
// services
import stateService from "services/state.service";
import { ProjectStateService } from "services/project";
// constants
import { PROJECT_AUTOMATION_MONTHS } from "constants/project";
import { STATES_LIST } from "constants/fetch-keys";
@ -27,11 +25,9 @@ type Props = {
disabled?: boolean;
};
export const AutoCloseAutomation: React.FC<Props> = ({
projectDetails,
handleChange,
disabled = false,
}) => {
const projectStateService = new ProjectStateService();
export const AutoCloseAutomation: React.FC<Props> = ({ projectDetails, handleChange, disabled = false }) => {
const [monthModal, setmonthModal] = useState(false);
const router = useRouter();
@ -40,7 +36,7 @@ export const AutoCloseAutomation: React.FC<Props> = ({
const { data: stateGroups } = useSWR(
workspaceSlug && projectId ? STATES_LIST(projectId as string) : null,
workspaceSlug && projectId
? () => stateService.getStates(workspaceSlug as string, projectId as string)
? () => projectStateService.getStates(workspaceSlug as string, projectId as string)
: null
);
const states = getStatesList(stateGroups);
@ -62,9 +58,7 @@ export const AutoCloseAutomation: React.FC<Props> = ({
const defaultState = stateGroups && stateGroups.cancelled ? stateGroups.cancelled[0].id : null;
const selectedOption = states?.find(
(s) => s.id === projectDetails?.default_state ?? defaultState
);
const selectedOption = states?.find((s) => s.id === projectDetails?.default_state ?? defaultState);
const currentDefaultState = states?.find((s) => s.id === defaultState);
const initialValues: Partial<IProject> = {
@ -111,15 +105,11 @@ export const AutoCloseAutomation: React.FC<Props> = ({
<div className="ml-12">
<div className="flex flex-col rounded bg-custom-background-90 border border-custom-border-200 p-2">
<div className="flex items-center justify-between px-5 py-4 gap-2 w-full">
<div className="w-1/2 text-sm font-medium">
Auto-close issues that are inactive for
</div>
<div className="w-1/2 text-sm font-medium">Auto-close issues that are inactive for</div>
<div className="w-1/2">
<CustomSelect
value={projectDetails?.close_in}
label={`${projectDetails?.close_in} ${
projectDetails?.close_in === 1 ? "Month" : "Months"
}`}
label={`${projectDetails?.close_in} ${projectDetails?.close_in === 1 ? "Month" : "Months"}`}
onChange={(val: number) => {
handleChange({ close_in: val });
}}
@ -149,9 +139,7 @@ export const AutoCloseAutomation: React.FC<Props> = ({
<div className="w-1/2 text-sm font-medium">Auto-close Status</div>
<div className="w-1/2 ">
<CustomSearchSelect
value={
projectDetails?.default_state ? projectDetails?.default_state : defaultState
}
value={projectDetails?.default_state ? projectDetails?.default_state : defaultState}
label={
<div className="flex items-center gap-2">
{selectedOption ? (
@ -173,9 +161,7 @@ export const AutoCloseAutomation: React.FC<Props> = ({
)}
{selectedOption?.name
? selectedOption.name
: currentDefaultState?.name ?? (
<span className="text-custom-text-200">State</span>
)}
: currentDefaultState?.name ?? <span className="text-custom-text-200">State</span>}
</div>
}
onChange={(val: string) => {

View File

@ -1,13 +1,11 @@
import React from "react";
import { useRouter } from "next/router";
// react-hook-form
import { useForm } from "react-hook-form";
import { Controller, useForm } from "react-hook-form";
// headless ui
import { Dialog, Transition } from "@headlessui/react";
// ui
import { Input, PrimaryButton, SecondaryButton } from "components/ui";
import { Button, Input } from "@plane/ui";
// types
import type { IProject } from "types";
@ -20,20 +18,14 @@ type Props = {
handleChange: (formData: Partial<IProject>) => Promise<void>;
};
export const SelectMonthModal: React.FC<Props> = ({
type,
initialValues,
isOpen,
handleClose,
handleChange,
}) => {
export const SelectMonthModal: React.FC<Props> = ({ type, initialValues, isOpen, handleClose, handleChange }) => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const {
register,
formState: { errors, isSubmitting },
handleSubmit,
control,
reset,
} = useForm<IProject>({
defaultValues: initialValues,
@ -50,27 +42,6 @@ export const SelectMonthModal: React.FC<Props> = ({
onClose();
};
const inputSection = (name: string) => (
<div className="relative flex flex-col gap-1 justify-center w-full">
<Input
type="number"
id={name}
name={name}
placeholder="Enter Months"
autoComplete="off"
register={register}
width="full"
validations={{
required: "Select a month between 1 and 12.",
min: 1,
max: 12,
}}
className="border-custom-border-200"
/>
<span className="absolute text-sm text-custom-text-200 top-2.5 right-8">Months</span>
</div>
);
return (
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-30" onClose={onClose}>
@ -100,30 +71,72 @@ export const SelectMonthModal: React.FC<Props> = ({
<Dialog.Panel className="relative transform rounded-lg bg-custom-background-90 px-4 pt-5 pb-4 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl sm:p-6">
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<Dialog.Title
as="h3"
className="text-lg font-medium leading-6 text-custom-text-100"
>
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-custom-text-100">
Customise Time Range
</Dialog.Title>
<div className="mt-8 flex items-center gap-2">
<div className="flex w-full flex-col gap-1 justify-center">
{type === "auto-close" ? (
<>
{inputSection("close_in")}
<Controller
control={control}
name="close_in"
rules={{
required: "Select a month between 1 and 12.",
min: 1,
max: 12,
}}
render={({ field: { value, onChange, ref } }) => (
<div className="relative flex flex-col gap-1 justify-center w-full">
<Input
id="close_in"
name="close_in"
type="number"
value={value.toString()}
onChange={onChange}
ref={ref}
hasError={Boolean(errors.close_in)}
placeholder="Enter Months"
className="border-custom-border-200 w-full"
/>
<span className="absolute text-sm text-custom-text-200 top-2.5 right-8">Months</span>
</div>
)}
/>
{errors.close_in && (
<span className="text-sm px-1 text-red-500">
Select a month between 1 and 12.
</span>
<span className="text-sm px-1 text-red-500">Select a month between 1 and 12.</span>
)}
</>
) : (
<>
{inputSection("archive_in")}
<Controller
control={control}
name="archive_in"
rules={{
required: "Select a month between 1 and 12.",
min: 1,
max: 12,
}}
render={({ field: { value, onChange, ref } }) => (
<div className="relative flex flex-col gap-1 justify-center w-full">
<Input
id="archive_in"
name="archive_in"
type="number"
value={value.toString()}
onChange={onChange}
ref={ref}
hasError={Boolean(errors.archive_in)}
placeholder="Enter Months"
className="border-custom-border-200 w-full"
/>
<span className="absolute text-sm text-custom-text-200 top-2.5 right-8">Months</span>
</div>
)}
/>
{errors.archive_in && (
<span className="text-sm px-1 text-red-500">
Select a month between 1 and 12.
</span>
<span className="text-sm px-1 text-red-500">Select a month between 1 and 12.</span>
)}
</>
)}
@ -131,10 +144,12 @@ export const SelectMonthModal: React.FC<Props> = ({
</div>
</div>
<div className="mt-5 flex justify-end gap-2">
<SecondaryButton onClick={onClose}>Cancel</SecondaryButton>
<PrimaryButton type="submit" loading={isSubmitting}>
<Button variant="neutral-primary" onClick={onClose}>
Cancel
</Button>
<Button variant="primary" type="submit" loading={isSubmitting}>
{isSubmitting ? "Submitting..." : "Submit"}
</PrimaryButton>
</Button>
</div>
</form>
</Dialog.Panel>

View File

@ -5,7 +5,6 @@ import { Command } from "cmdk";
import { THEMES_OBJ } from "constants/themes";
import { useTheme } from "next-themes";
import { SettingIcon } from "components/icons";
import userService from "services/user.service";
import useUser from "hooks/use-user";
// helper
import { unsetCustomCssVariables } from "helpers/theme.helper";
@ -25,7 +24,7 @@ export const ChangeInterfaceTheme: React.FC<Props> = observer(({ setIsPaletteOpe
const { setTheme } = useTheme();
const { user, mutateUser } = useUser();
const { user } = useUser();
const updateUserTheme = (newTheme: string) => {
if (!user) return;

View File

@ -9,9 +9,9 @@ import { Command } from "cmdk";
// headless ui
import { Dialog, Transition } from "@headlessui/react";
// services
import workspaceService from "services/workspace.service";
import issuesService from "services/issues.service";
import inboxService from "services/inbox.service";
import { WorkspaceService } from "services/workspace.service";
import { IssueService } from "services/issue";
import { InboxService } from "services/inbox.service";
// hooks
import useProjectDetails from "hooks/use-project-details";
import useDebounce from "hooks/use-debounce";
@ -26,7 +26,8 @@ import {
commandGroups,
} from "components/command-palette";
// ui
import { Icon, Loader, ToggleSwitch, Tooltip } from "components/ui";
import { Icon } from "components/ui";
import { Loader, ToggleSwitch, Tooltip } from "@plane/ui";
// icons
import { DiscordIcon, GithubIcon, SettingIcon } from "components/icons";
import { InboxIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline";
@ -43,6 +44,11 @@ type Props = {
setIsPaletteOpen: React.Dispatch<React.SetStateAction<boolean>>;
};
// services
const workspaceService = new WorkspaceService();
const issueService = new IssueService();
const inboxService = new InboxService();
export const CommandK: React.FC<Props> = ({ deleteIssue, isPaletteOpen, setIsPaletteOpen }) => {
const [placeholder, setPlaceholder] = useState("Type a command or search...");
@ -79,16 +85,13 @@ export const CommandK: React.FC<Props> = ({ deleteIssue, isPaletteOpen, setIsPal
const { data: issueDetails } = useSWR(
workspaceSlug && projectId && issueId ? ISSUE_DETAILS(issueId as string) : null,
workspaceSlug && projectId && issueId
? () =>
issuesService.retrieve(workspaceSlug as string, projectId as string, issueId as string)
? () => issueService.retrieve(workspaceSlug as string, projectId as string, issueId as string)
: null
);
const { data: inboxList } = useSWR(
workspaceSlug && projectId ? INBOX_LIST(projectId as string) : null,
workspaceSlug && projectId
? () => inboxService.getInboxes(workspaceSlug as string, projectId as string)
: null
workspaceSlug && projectId ? () => inboxService.getInboxes(workspaceSlug as string, projectId as string) : null
);
const updateIssue = useCallback(
@ -110,7 +113,7 @@ export const CommandK: React.FC<Props> = ({ deleteIssue, isPaletteOpen, setIsPal
);
const payload = { ...formData };
await issuesService
await issueService
.patchIssue(workspaceSlug as string, projectId as string, issueId as string, payload, user)
.then(() => {
mutate(PROJECT_ISSUES_ACTIVITY(issueId as string));
@ -272,8 +275,7 @@ export const CommandK: React.FC<Props> = ({ deleteIssue, isPaletteOpen, setIsPal
>
{issueDetails && (
<div className="overflow-hidden truncate rounded-md bg-custom-background-80 p-2 text-xs font-medium text-custom-text-200">
{issueDetails.project_detail.identifier}-{issueDetails.sequence_id}{" "}
{issueDetails.name}
{issueDetails.project_detail.identifier}-{issueDetails.sequence_id} {issueDetails.name}
</div>
)}
{projectId && (
@ -324,12 +326,9 @@ export const CommandK: React.FC<Props> = ({ deleteIssue, isPaletteOpen, setIsPal
</h5>
)}
{!isLoading &&
resultsCount === 0 &&
searchTerm !== "" &&
debouncedSearchTerm !== "" && (
<div className="my-4 text-center text-custom-text-200">No results found.</div>
)}
{!isLoading && resultsCount === 0 && searchTerm !== "" && debouncedSearchTerm !== "" && (
<div className="my-4 text-center text-custom-text-200">No results found.</div>
)}
{(isLoading || isSearching) && (
<Command.Loading>
@ -362,9 +361,7 @@ export const CommandK: React.FC<Props> = ({ deleteIssue, isPaletteOpen, setIsPal
>
<div className="flex items-center gap-2 overflow-hidden text-custom-text-200">
<Icon iconName={currentSection.icon} />
<p className="block flex-1 truncate">
{currentSection.itemName(item)}
</p>
<p className="block flex-1 truncate">{currentSection.itemName(item)}</p>
</div>
</Command.Item>
))}
@ -577,9 +574,7 @@ export const CommandK: React.FC<Props> = ({ deleteIssue, isPaletteOpen, setIsPal
<Command.Item
onSelect={() => {
setIsPaletteOpen(false);
redirect(
`/${workspaceSlug}/projects/${projectId}/inbox/${inboxList?.[0]?.id}`
);
redirect(`/${workspaceSlug}/projects/${projectId}/inbox/${inboxList?.[0]?.id}`);
}}
className="focus:outline-none"
>
@ -672,10 +667,7 @@ export const CommandK: React.FC<Props> = ({ deleteIssue, isPaletteOpen, setIsPal
<Command.Item
onSelect={() => {
setIsPaletteOpen(false);
window.open(
"https://github.com/makeplane/plane/issues/new/choose",
"_blank"
);
window.open("https://github.com/makeplane/plane/issues/new/choose", "_blank");
}}
className="focus:outline-none"
>
@ -759,29 +751,15 @@ export const CommandK: React.FC<Props> = ({ deleteIssue, isPaletteOpen, setIsPal
</>
)}
{page === "change-issue-state" && issueDetails && (
<ChangeIssueState
issue={issueDetails}
setIsPaletteOpen={setIsPaletteOpen}
user={user}
/>
<ChangeIssueState issue={issueDetails} setIsPaletteOpen={setIsPaletteOpen} user={user} />
)}
{page === "change-issue-priority" && issueDetails && (
<ChangeIssuePriority
issue={issueDetails}
setIsPaletteOpen={setIsPaletteOpen}
user={user}
/>
<ChangeIssuePriority issue={issueDetails} setIsPaletteOpen={setIsPaletteOpen} user={user} />
)}
{page === "change-issue-assignee" && issueDetails && (
<ChangeIssueAssignee
issue={issueDetails}
setIsPaletteOpen={setIsPaletteOpen}
user={user}
/>
)}
{page === "change-interface-theme" && (
<ChangeInterfaceTheme setIsPaletteOpen={setIsPaletteOpen} />
<ChangeIssueAssignee issue={issueDetails} setIsPaletteOpen={setIsPaletteOpen} user={user} />
)}
{page === "change-interface-theme" && <ChangeInterfaceTheme setIsPaletteOpen={setIsPaletteOpen} />}
</Command.List>
</Command>
</Dialog.Panel>

View File

@ -1,7 +1,7 @@
import React, { useCallback, useEffect, useState } from "react";
import { useRouter } from "next/router";
import useSWR from "swr";
import { observer } from "mobx-react-lite";
// hooks
import useToast from "hooks/use-toast";
import useUser from "hooks/use-user";
@ -12,19 +12,19 @@ import { CreateUpdateCycleModal } from "components/cycles";
import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues";
import { CreateUpdateModuleModal } from "components/modules";
import { CreateProjectModal } from "components/project";
import { CreateUpdateViewModal } from "components/views";
import { CreateUpdateProjectViewModal } from "components/views";
import { CreateUpdatePageModal } from "components/pages";
// helpers
import { copyTextToClipboard } from "helpers/string.helper";
// services
import issuesService from "services/issues.service";
import inboxService from "services/inbox.service";
import { IssueService } from "services/issue";
// fetch keys
import { INBOX_LIST, ISSUE_DETAILS } from "constants/fetch-keys";
import { ISSUE_DETAILS } from "constants/fetch-keys";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
import { observable } from "mobx";
import { observer } from "mobx-react-lite";
// services
const issueService = new IssueService();
export const CommandPalette: React.FC = observer(() => {
const store: any = useMobxStore();
@ -50,8 +50,7 @@ export const CommandPalette: React.FC = observer(() => {
const { data: issueDetails } = useSWR(
workspaceSlug && projectId && issueId ? ISSUE_DETAILS(issueId as string) : null,
workspaceSlug && projectId && issueId
? () =>
issuesService.retrieve(workspaceSlug as string, projectId as string, issueId as string)
? () => issueService.retrieve(workspaceSlug as string, projectId as string, issueId as string)
: null
);
@ -76,7 +75,7 @@ export const CommandPalette: React.FC = observer(() => {
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
const { key, ctrlKey, metaKey, altKey, shiftKey } = e;
const { key, ctrlKey, metaKey, altKey } = e;
if (!key) return;
const keyPressed = key.toLowerCase();
@ -121,7 +120,7 @@ export const CommandPalette: React.FC = observer(() => {
}
}
},
[copyIssueUrlToClipboard]
[copyIssueUrlToClipboard, store.theme]
);
useEffect(() => {
@ -141,11 +140,7 @@ export const CommandPalette: React.FC = observer(() => {
<>
<ShortcutsModal isOpen={isShortcutsModalOpen} setIsOpen={setIsShortcutsModalOpen} />
{workspaceSlug && (
<CreateProjectModal
isOpen={isProjectModalOpen}
setIsOpen={setIsProjectModalOpen}
user={user}
/>
<CreateProjectModal isOpen={isProjectModalOpen} setIsOpen={setIsProjectModalOpen} user={user} />
)}
{projectId && (
<>
@ -159,10 +154,9 @@ export const CommandPalette: React.FC = observer(() => {
setIsOpen={setIsCreateModuleModalOpen}
user={user}
/>
<CreateUpdateViewModal
handleClose={() => setIsCreateViewModalOpen(false)}
<CreateUpdateProjectViewModal
isOpen={isCreateViewModalOpen}
user={user}
onClose={() => setIsCreateViewModalOpen(false)}
/>
<CreateUpdatePageModal
isOpen={isCreateUpdatePageModalOpen}
@ -184,11 +178,7 @@ export const CommandPalette: React.FC = observer(() => {
handleClose={() => setIsIssueModalOpen(false)}
fieldsToShow={inboxId ? ["name", "description", "priority"] : ["all"]}
prePopulateData={
cycleId
? { cycle: cycleId.toString() }
: moduleId
? { module: moduleId.toString() }
: undefined
cycleId ? { cycle: cycleId.toString() } : moduleId ? { module: moduleId.toString() } : undefined
}
/>
<BulkDeleteIssuesModal
@ -196,11 +186,7 @@ export const CommandPalette: React.FC = observer(() => {
setIsOpen={setIsBulkDeleteIssuesModalOpen}
user={user}
/>
<CommandK
deleteIssue={deleteIssue}
isPaletteOpen={isPaletteOpen}
setIsPaletteOpen={setIsPaletteOpen}
/>
<CommandK deleteIssue={deleteIssue} isPaletteOpen={isPaletteOpen} setIsPaletteOpen={setIsPaletteOpen} />
</>
);
});

View File

@ -1,13 +1,9 @@
import React, { Dispatch, SetStateAction, useCallback } from "react";
import { Dispatch, SetStateAction, useCallback, FC } from "react";
import { useRouter } from "next/router";
import { mutate } from "swr";
// cmdk
import { Command } from "cmdk";
// services
import issuesService from "services/issues.service";
import { IssueService } from "services/issue";
// hooks
import useProjectMembers from "hooks/use-project-members";
// constants
@ -17,22 +13,25 @@ import { Avatar } from "components/ui";
// icons
import { CheckIcon } from "components/icons";
// types
import { ICurrentUserResponse, IIssue } from "types";
import { IUser, IIssue } from "types";
type Props = {
setIsPaletteOpen: Dispatch<SetStateAction<boolean>>;
issue: IIssue;
user: ICurrentUserResponse | undefined;
user: IUser | undefined;
};
export const ChangeIssueAssignee: React.FC<Props> = ({ setIsPaletteOpen, issue, user }) => {
// services
const issueService = new IssueService();
export const ChangeIssueAssignee: FC<Props> = ({ setIsPaletteOpen, issue, user }) => {
const router = useRouter();
const { workspaceSlug, projectId, issueId } = router.query;
const { members } = useProjectMembers(workspaceSlug as string, projectId as string);
const options =
members?.map(({ member }) => ({
members?.map(({ member }: any) => ({
value: member.id,
query: member.display_name,
content: (
@ -67,7 +66,7 @@ export const ChangeIssueAssignee: React.FC<Props> = ({ setIsPaletteOpen, issue,
);
const payload = { ...formData };
await issuesService
await issueService
.patchIssue(workspaceSlug as string, projectId as string, issueId as string, payload, user)
.then(() => {
mutate(PROJECT_ISSUES_ACTIVITY(issueId as string));
@ -94,7 +93,7 @@ export const ChangeIssueAssignee: React.FC<Props> = ({ setIsPaletteOpen, issue,
return (
<>
{options.map((option) => (
{options.map((option: any) => (
<Command.Item
key={option.value}
onSelect={() => handleIssueAssignees(option.value)}

View File

@ -7,9 +7,9 @@ import { mutate } from "swr";
// cmdk
import { Command } from "cmdk";
// services
import issuesService from "services/issues.service";
import { IssueService } from "services/issue";
// types
import { ICurrentUserResponse, IIssue, TIssuePriorities } from "types";
import { IIssue, IUser, TIssuePriorities } from "types";
// constants
import { ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys";
import { PRIORITIES } from "constants/project";
@ -19,9 +19,12 @@ import { CheckIcon, PriorityIcon } from "components/icons";
type Props = {
setIsPaletteOpen: Dispatch<SetStateAction<boolean>>;
issue: IIssue;
user: ICurrentUserResponse;
user: IUser;
};
// services
const issueService = new IssueService();
export const ChangeIssuePriority: React.FC<Props> = ({ setIsPaletteOpen, issue, user }) => {
const router = useRouter();
const { workspaceSlug, projectId, issueId } = router.query;
@ -44,7 +47,7 @@ export const ChangeIssuePriority: React.FC<Props> = ({ setIsPaletteOpen, issue,
);
const payload = { ...formData };
await issuesService
await issueService
.patchIssue(workspaceSlug as string, projectId as string, issueId as string, payload, user)
.then(() => {
mutate(PROJECT_ISSUES_ACTIVITY(issueId as string));
@ -64,11 +67,7 @@ export const ChangeIssuePriority: React.FC<Props> = ({ setIsPaletteOpen, issue,
return (
<>
{PRIORITIES.map((priority) => (
<Command.Item
key={priority}
onSelect={() => handleIssueState(priority)}
className="focus:outline-none"
>
<Command.Item key={priority} onSelect={() => handleIssueState(priority)} className="focus:outline-none">
<div className="flex items-center space-x-3">
<PriorityIcon priority={priority} />
<span className="capitalize">{priority ?? "None"}</span>

View File

@ -7,34 +7,36 @@ import useSWR, { mutate } from "swr";
// cmdk
import { Command } from "cmdk";
// services
import issuesService from "services/issues.service";
import stateService from "services/state.service";
import { IssueService } from "services/issue";
import { ProjectStateService } from "services/project";
// ui
import { Spinner } from "components/ui";
import { Spinner } from "@plane/ui";
// icons
import { CheckIcon, StateGroupIcon } from "components/icons";
// helpers
import { getStatesList } from "helpers/state.helper";
// types
import { ICurrentUserResponse, IIssue } from "types";
import { IUser, IIssue } from "types";
// fetch keys
import { ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY, STATES_LIST } from "constants/fetch-keys";
type Props = {
setIsPaletteOpen: Dispatch<SetStateAction<boolean>>;
issue: IIssue;
user: ICurrentUserResponse | undefined;
user: IUser | undefined;
};
// services
const issueService = new IssueService();
const stateService = new ProjectStateService();
export const ChangeIssueState: React.FC<Props> = ({ setIsPaletteOpen, issue, user }) => {
const router = useRouter();
const { workspaceSlug, projectId, issueId } = router.query;
const { data: stateGroups, mutate: mutateIssueDetails } = useSWR(
workspaceSlug && projectId ? STATES_LIST(projectId as string) : null,
workspaceSlug && projectId
? () => stateService.getStates(workspaceSlug as string, projectId as string)
: null
workspaceSlug && projectId ? () => stateService.getStates(workspaceSlug as string, projectId as string) : null
);
const states = getStatesList(stateGroups);
@ -55,7 +57,7 @@ export const ChangeIssueState: React.FC<Props> = ({ setIsPaletteOpen, issue, use
);
const payload = { ...formData };
await issuesService
await issueService
.patchIssue(workspaceSlug as string, projectId as string, issueId as string, payload, user)
.then(() => {
mutateIssueDetails();
@ -78,18 +80,9 @@ export const ChangeIssueState: React.FC<Props> = ({ setIsPaletteOpen, issue, use
{states ? (
states.length > 0 ? (
states.map((state) => (
<Command.Item
key={state.id}
onSelect={() => handleIssueState(state.id)}
className="focus:outline-none"
>
<Command.Item key={state.id} onSelect={() => handleIssueState(state.id)} className="focus:outline-none">
<div className="flex items-center space-x-3">
<StateGroupIcon
stateGroup={state.group}
color={state.color}
height="16px"
width="16px"
/>
<StateGroupIcon stateGroup={state.group} color={state.color} height="16px" width="16px" />
<p>{state.name}</p>
</div>
<div>{state.id === issue.state && <CheckIcon className="h-3 w-3" />}</div>

View File

@ -6,7 +6,7 @@ import { XMarkIcon } from "@heroicons/react/20/solid";
import { MagnifyingGlassIcon } from "@heroicons/react/24/outline";
import { CommandIcon } from "components/icons";
// ui
import { Input } from "components/ui";
import { Input } from "@plane/ui";
type Props = {
isOpen: boolean;
@ -48,9 +48,7 @@ const allShortcuts = shortcuts.map((i) => i.shortcuts).flat(1);
export const ShortcutsModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
const [query, setQuery] = useState("");
const filteredShortcuts = allShortcuts.filter((shortcut) =>
shortcut.description.toLowerCase().includes(query.trim().toLowerCase()) || query === ""
? true
: false
shortcut.description.toLowerCase().includes(query.trim().toLowerCase()) || query === "" ? true : false
);
useEffect(() => {
@ -105,12 +103,13 @@ export const ShortcutsModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
<div className="flex w-full items-center justify-start gap-1 rounded border-[0.6px] border-custom-border-200 bg-custom-background-90 px-3 py-2">
<MagnifyingGlassIcon className="h-3.5 w-3.5 text-custom-text-200" />
<Input
className="w-full border-none bg-transparent py-1 px-2 text-xs text-custom-text-200 focus:outline-none"
id="search"
name="search"
type="text"
placeholder="Search for shortcuts"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search for shortcuts"
className="w-full border-none bg-transparent py-1 px-2 text-xs text-custom-text-200 focus:outline-none"
/>
</div>
</div>
@ -121,9 +120,7 @@ export const ShortcutsModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
<div key={shortcut.keys} className="flex w-full flex-col">
<div className="flex flex-col gap-y-3">
<div className="flex items-center justify-between">
<p className="text-sm text-custom-text-200">
{shortcut.description}
</p>
<p className="text-sm text-custom-text-200">{shortcut.description}</p>
<div className="flex items-center gap-x-2.5">
{shortcut.keys.split(",").map((key, index) => (
<span key={index} className="flex items-center gap-1">

View File

@ -0,0 +1,2 @@
export * from "./product-updates-modal";
export * from "./empty-state";

View File

@ -0,0 +1,115 @@
import React from "react";
import useSWR from "swr";
// headless ui
import { Dialog, Transition } from "@headlessui/react";
// services
import { WorkspaceService } from "services/workspace.service";
// components
import { MarkdownRenderer } from "components/ui";
import { Loader } from "@plane/ui";
// icons
import { XMarkIcon } from "@heroicons/react/20/solid";
// helpers
import { renderLongDateFormat } from "helpers/date-time.helper";
type Props = {
isOpen: boolean;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
};
// services
const workspaceService = new WorkspaceService();
export const ProductUpdatesModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
const { data: updates } = useSWR("PRODUCT_UPDATES", () => workspaceService.getProductUpdates());
return (
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-20" onClose={setIsOpen}>
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-custom-backdrop bg-opacity-50 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-20 h-full w-full">
<div className="grid place-items-center h-full w-full p-4">
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative overflow-hidden rounded-lg bg-custom-background-100 border border-custom-border-100 shadow-custom-shadow-rg] min-w-[100%] sm:min-w-[50%] sm:max-w-[50%]">
<div className="flex flex-col p-4 max-h-[90vh] w-full">
<Dialog.Title as="h3" className="flex items-center justify-between text-lg font-semibold">
<span>Product Updates</span>
<span>
<button type="button" onClick={() => setIsOpen(false)}>
<XMarkIcon
className="h-6 w-6 text-custom-text-200 hover:text-custom-text-100"
aria-hidden="true"
/>
</button>
</span>
</Dialog.Title>
{updates && updates.length > 0 ? (
<div className="h-full overflow-y-auto mt-4 space-y-4">
{updates.map((item, index) => (
<React.Fragment key={item.id}>
<div className="flex items-center gap-3 text-xs text-custom-text-200">
<span className="flex items-center rounded-full border border-custom-border-200 bg-custom-background-90 px-3 py-1.5 text-xs">
{item.tag_name}
</span>
<span>{renderLongDateFormat(item.published_at)}</span>
{index === 0 && (
<span className="flex items-center rounded-full border border-custom-border-200 bg-custom-primary px-3 py-1.5 text-xs text-white">
New
</span>
)}
</div>
<MarkdownRenderer markdown={item.body} />
</React.Fragment>
))}
</div>
) : (
<div className="grid place-items-center w-full mt-4">
<Loader className="space-y-6 w-full">
<div className="space-y-3">
<Loader.Item height="30px" />
<Loader.Item height="20px" width="80%" />
<Loader.Item height="20px" width="80%" />
</div>
<div className="space-y-3">
<Loader.Item height="30px" />
<Loader.Item height="20px" width="80%" />
<Loader.Item height="20px" width="80%" />
</div>
<div className="space-y-3">
<Loader.Item height="30px" />
<Loader.Item height="20px" width="80%" />
<Loader.Item height="20px" width="80%" />
</div>
</Loader>
</div>
)}
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
};

View File

@ -5,9 +5,10 @@ import useSWR from "swr";
// hook
import useEstimateOption from "hooks/use-estimate-option";
// services
import issuesService from "services/issues.service";
import { IssueLabelService } from "services/issue";
// icons
import { Icon, Tooltip } from "components/ui";
import { Tooltip } from "@plane/ui";
import { Icon } from "components/ui";
import {
TagIcon,
CopyPlus,
@ -23,12 +24,7 @@ import {
SignalMediumIcon,
MessageSquareIcon,
} from "lucide-react";
import {
BlockedIcon,
BlockerIcon,
RelatedIcon,
StackedLayersHorizontalIcon,
} from "components/icons";
import { BlockedIcon, BlockerIcon, RelatedIcon, StackedLayersHorizontalIcon } from "components/icons";
// helpers
import { renderShortDateWithYearFormat } from "helpers/date-time.helper";
import { capitalizeFirstLetter } from "helpers/string.helper";
@ -37,25 +33,22 @@ import { IIssueActivity } from "types";
// fetch-keys
import { WORKSPACE_LABELS } from "constants/fetch-keys";
// services
const issueLabelService = new IssueLabelService();
const IssueLink = ({ activity }: { activity: IIssueActivity }) => {
const router = useRouter();
const { workspaceSlug } = router.query;
return (
<Tooltip
tooltipContent={
activity.issue_detail ? activity.issue_detail.name : "This issue has been deleted"
}
>
<Tooltip tooltipContent={activity.issue_detail ? activity.issue_detail.name : "This issue has been deleted"}>
<a
href={`/${workspaceSlug}/projects/${activity.project}/issues/${activity.issue}`}
target="_blank"
rel="noopener noreferrer"
className="font-medium text-custom-text-100 inline-flex items-center gap-1 hover:underline"
>
{activity.issue_detail
? `${activity.project_detail.identifier}-${activity.issue_detail.sequence_id}`
: "Issue"}
{activity.issue_detail ? `${activity.project_detail.identifier}-${activity.issue_detail.sequence_id}` : "Issue"}
<RocketIcon size={12} color="#6b7280" />
</a>
</Tooltip>
@ -84,7 +77,7 @@ const LabelPill = ({ labelId }: { labelId: string }) => {
const { data: labels } = useSWR(
workspaceSlug ? WORKSPACE_LABELS(workspaceSlug.toString()) : null,
workspaceSlug ? () => issuesService.getWorkspaceLabels(workspaceSlug.toString()) : null
workspaceSlug ? () => issueLabelService.getWorkspaceIssueLabels(workspaceSlug.toString()) : null
);
return (
@ -103,20 +96,14 @@ const EstimatePoint = ({ point }: { point: string }) => {
return (
<span className="font-medium text-custom-text-100">
{isEstimateActive
? estimateValue
: `${currentPoint} ${currentPoint > 1 ? "points" : "point"}`}
{isEstimateActive ? estimateValue : `${currentPoint} ${currentPoint > 1 ? "points" : "point"}`}
</span>
);
};
const activityDetails: {
[key: string]: {
message: (
activity: IIssueActivity,
showIssue: boolean,
workspaceSlug: string
) => React.ReactNode;
message: (activity: IIssueActivity, showIssue: boolean, workspaceSlug: string) => React.ReactNode;
icon: React.ReactNode;
};
} = {
@ -209,8 +196,7 @@ const activityDetails: {
else
return (
<>
removed the blocking issue{" "}
<span className="font-medium text-custom-text-100">{activity.old_value}</span>.
removed the blocking issue <span className="font-medium text-custom-text-100">{activity.old_value}</span>.
</>
);
},
@ -266,8 +252,7 @@ const activityDetails: {
else
return (
<>
removed the relation from{" "}
<span className="font-medium text-custom-text-100">{activity.old_value}</span>.
removed the relation from <span className="font-medium text-custom-text-100">{activity.old_value}</span>.
</>
);
},
@ -554,8 +539,7 @@ const activityDetails: {
if (!activity.new_value)
return (
<>
removed the parent{" "}
<span className="font-medium text-custom-text-100">{activity.old_value}</span>
removed the parent <span className="font-medium text-custom-text-100">{activity.old_value}</span>
{showIssue && (
<>
{" "}
@ -568,8 +552,7 @@ const activityDetails: {
else
return (
<>
set the parent to{" "}
<span className="font-medium text-custom-text-100">{activity.new_value}</span>
set the parent to <span className="font-medium text-custom-text-100">{activity.new_value}</span>
{showIssue && (
<>
{" "}
@ -580,13 +563,7 @@ const activityDetails: {
</>
);
},
icon: (
<Icon
iconName="supervised_user_circle"
className="!text-xs !text-[#6b7280]"
aria-hidden="true"
/>
),
icon: <Icon iconName="supervised_user_circle" className="!text-xs !text-[#6b7280]" aria-hidden="true" />,
},
priority: {
message: (activity, showIssue) => (
@ -643,8 +620,7 @@ const activityDetails: {
state: {
message: (activity, showIssue) => (
<>
set the state to{" "}
<span className="font-medium text-custom-text-100">{activity.new_value}</span>
set the state to <span className="font-medium text-custom-text-100">{activity.new_value}</span>
{showIssue && (
<>
{" "}
@ -696,13 +672,7 @@ export const ActivityIcon = ({ activity }: { activity: IIssueActivity }) => (
<>{activityDetails[activity.field as keyof typeof activityDetails]?.icon}</>
);
export const ActivityMessage = ({
activity,
showIssue = false,
}: {
activity: IIssueActivity;
showIssue?: boolean;
}) => {
export const ActivityMessage = ({ activity, showIssue = false }: { activity: IIssueActivity; showIssue?: boolean }) => {
const router = useRouter();
const { workspaceSlug } = router.query;

View File

@ -1,28 +1,22 @@
import { Fragment } from "react";
// react-hook-form
import { Controller, useForm } from "react-hook-form";
// react-datepicker
import DatePicker from "react-datepicker";
// headless ui
import { Dialog, Transition } from "@headlessui/react";
// components
import { DateFilterSelect } from "./date-filter-select";
// ui
import { PrimaryButton, SecondaryButton } from "components/ui";
import { Button } from "@plane/ui";
// icons
import { XMarkIcon } from "@heroicons/react/20/solid";
// helpers
import { renderDateFormat, renderShortDateWithYearFormat } from "helpers/date-time.helper";
import { IIssueFilterOptions } from "types";
type Props = {
title: string;
field: keyof IIssueFilterOptions;
filters: IIssueFilterOptions;
handleClose: () => void;
isOpen: boolean;
onSelect: (option: any) => void;
onSelect: (val: string[]) => void;
};
type TFormValues = {
@ -37,14 +31,7 @@ const defaultValues: TFormValues = {
date2: new Date(new Date().getFullYear(), new Date().getMonth() + 1, new Date().getDate()),
};
export const DateFilterModal: React.FC<Props> = ({
title,
field,
filters,
handleClose,
isOpen,
onSelect,
}) => {
export const DateFilterModal: React.FC<Props> = ({ title, handleClose, isOpen, onSelect }) => {
const { handleSubmit, watch, control } = useForm<TFormValues>({
defaultValues,
});
@ -52,32 +39,13 @@ export const DateFilterModal: React.FC<Props> = ({
const handleFormSubmit = (formData: TFormValues) => {
const { filterType, date1, date2 } = formData;
if (filterType === "range") {
onSelect({
key: field,
value: [`${renderDateFormat(date1)};after`, `${renderDateFormat(date2)};before`],
});
} else {
const filteredArray = (filters?.[field] as string[])?.filter((item) => {
if (item?.includes(filterType)) return false;
if (filterType === "range") onSelect([`${renderDateFormat(date1)};after`, `${renderDateFormat(date2)};before`]);
else onSelect([`${renderDateFormat(date1)};${filterType}`]);
return true;
});
const filterOne = filteredArray && filteredArray?.length > 0 ? filteredArray[0] : null;
if (filterOne)
onSelect({ key: field, value: [filterOne, `${renderDateFormat(date1)};${filterType}`] });
else
onSelect({
key: field,
value: [`${renderDateFormat(date1)};${filterType}`],
});
}
handleClose();
};
const isInvalid =
watch("filterType") === "range" ? new Date(watch("date1")) > new Date(watch("date2")) : false;
const isInvalid = watch("filterType") === "range" ? new Date(watch("date1")) > new Date(watch("date2")) : false;
const nextDay = new Date(watch("date1"));
nextDay.setDate(nextDay.getDate() + 1);
@ -117,10 +85,7 @@ export const DateFilterModal: React.FC<Props> = ({
<DateFilterSelect title={title} value={value} onChange={onChange} />
)}
/>
<XMarkIcon
className="border-base h-4 w-4 cursor-pointer"
onClick={handleClose}
/>
<XMarkIcon className="border-base h-4 w-4 cursor-pointer" onClick={handleClose} />
</div>
<div className="flex w-full justify-between gap-4">
<Controller
@ -162,16 +127,12 @@ export const DateFilterModal: React.FC<Props> = ({
</h6>
)}
<div className="flex justify-end gap-4">
<SecondaryButton className="flex items-center gap-2" onClick={handleClose}>
<Button variant="neutral-primary" onClick={handleClose}>
Cancel
</SecondaryButton>
<PrimaryButton
type="submit"
className="flex items-center gap-2"
disabled={isInvalid}
>
</Button>
<Button variant="primary" type="submit" disabled={isInvalid}>
Apply
</PrimaryButton>
</Button>
</div>
</form>
</Dialog.Panel>

View File

@ -11,7 +11,8 @@ import useEstimateOption from "hooks/use-estimate-option";
// components
import { SelectFilters } from "components/views";
// ui
import { CustomMenu, ToggleSwitch, Tooltip } from "components/ui";
import { CustomMenu } from "components/ui";
import { ToggleSwitch, Tooltip } from "@plane/ui";
// icons
import { ChevronDownIcon } from "@heroicons/react/24/outline";
import {
@ -25,11 +26,11 @@ import {
import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper";
import { checkIfArraysHaveSameElements } from "helpers/array.helper";
// types
import { Properties, TIssueViewOptions } from "types";
import { Properties, TIssueLayouts } from "types";
// constants
import { GROUP_BY_OPTIONS, ORDER_BY_OPTIONS, FILTER_ISSUE_OPTIONS } from "constants/issue";
import { ISSUE_GROUP_BY_OPTIONS, ISSUE_ORDER_BY_OPTIONS, ISSUE_FILTER_OPTIONS } from "constants/issue";
const issueViewOptions: { type: TIssueViewOptions; Icon: any }[] = [
const issueViewOptions: { type: TIssueLayouts; Icon: any }[] = [
{
type: "list",
Icon: FormatListBulletedOutlined,
@ -52,7 +53,7 @@ const issueViewOptions: { type: TIssueViewOptions; Icon: any }[] = [
},
];
const issueViewForDraftIssues: { type: TIssueViewOptions; Icon: any }[] = [
const issueViewForDraftIssues: { type: TIssueLayouts; Icon: any }[] = [
{
type: "list",
Icon: FormatListBulletedOutlined,
@ -69,19 +70,10 @@ export const IssuesFilterView: React.FC = () => {
const isArchivedIssues = router.pathname.includes("archived-issues");
const isDraftIssues = router.pathname?.split("/")?.[4] === "draft-issues";
const {
displayFilters,
setDisplayFilters,
filters,
setFilters,
resetFilterToDefault,
setNewFilterDefaultView,
} = useIssuesView();
const { displayFilters, setDisplayFilters, filters, setFilters, resetFilterToDefault, setNewFilterDefaultView } =
useIssuesView();
const [properties, setProperties] = useIssuesProperties(
workspaceSlug as string,
projectId as string
);
const [properties, setProperties] = useIssuesProperties(workspaceSlug as string, projectId as string);
const { isEstimateActive } = useEstimateOption();
@ -92,11 +84,7 @@ export const IssuesFilterView: React.FC = () => {
{issueViewOptions.map((option) => (
<Tooltip
key={option.type}
tooltipContent={
<span className="capitalize">
{replaceUnderscoreIfSnakeCase(option.type)} Layout
</span>
}
tooltipContent={<span className="capitalize">{replaceUnderscoreIfSnakeCase(option.type)} Layout</span>}
position="bottom"
>
<button
@ -124,9 +112,7 @@ export const IssuesFilterView: React.FC = () => {
{issueViewForDraftIssues.map((option) => (
<Tooltip
key={option.type}
tooltipContent={
<span className="capitalize">{replaceUnderscoreIfSnakeCase(option.type)} View</span>
}
tooltipContent={<span className="capitalize">{replaceUnderscoreIfSnakeCase(option.type)} View</span>}
position="bottom"
>
<button
@ -166,9 +152,7 @@ export const IssuesFilterView: React.FC = () => {
if (valueExists)
setFilters(
{
[option.key]: ((filters[key] ?? []) as any[])?.filter(
(val) => val !== option.value
),
[option.key]: ((filters[key] ?? []) as any[])?.filter((val) => val !== option.value),
},
!Boolean(viewId)
);
@ -189,9 +173,7 @@ export const IssuesFilterView: React.FC = () => {
<>
<Popover.Button
className={`group flex items-center gap-2 rounded-md border border-custom-border-200 px-3 py-1.5 text-xs hover:bg-custom-sidebar-background-90 hover:text-custom-sidebar-text-100 focus:outline-none duration-300 ${
open
? "bg-custom-sidebar-background-90 text-custom-sidebar-text-100"
: "text-custom-sidebar-text-200"
open ? "bg-custom-sidebar-background-90 text-custom-sidebar-text-100" : "text-custom-sidebar-text-200"
}`}
>
Display
@ -218,27 +200,24 @@ export const IssuesFilterView: React.FC = () => {
<div className="w-28">
<CustomMenu
label={
GROUP_BY_OPTIONS.find(
(option) => option.key === displayFilters.group_by
)?.name ?? "Select"
ISSUE_GROUP_BY_OPTIONS.find((option) => option.key === displayFilters.group_by)
?.title ?? "Select"
}
className="!w-full"
buttonClassName="w-full"
>
{GROUP_BY_OPTIONS.map((option) => {
if (displayFilters.layout === "kanban" && option.key === null)
return null;
{ISSUE_GROUP_BY_OPTIONS.map((option) => {
if (displayFilters.layout === "kanban" && option.key === null) return null;
if (option.key === "project") return null;
if (isDraftIssues && option.key === "state_detail.group")
return null;
if (isDraftIssues && option.key === "state_detail.group") return null;
return (
<CustomMenu.MenuItem
key={option.key}
onClick={() => setDisplayFilters({ group_by: option.key })}
>
{option.name}
{option.title}
</CustomMenu.MenuItem>
);
})}
@ -246,51 +225,47 @@ export const IssuesFilterView: React.FC = () => {
</div>
</div>
)}
{displayFilters.layout !== "calendar" &&
displayFilters.layout !== "spreadsheet" && (
<div className="flex items-center justify-between">
<h4 className="text-custom-text-200">Order by</h4>
<div className="w-28">
<CustomMenu
label={
ORDER_BY_OPTIONS.find(
(option) => option.key === displayFilters.order_by
)?.name ?? "Select"
}
className="!w-full"
buttonClassName="w-full"
>
{ORDER_BY_OPTIONS.map((option) =>
displayFilters.group_by === "priority" &&
option.key === "priority" ? null : (
<CustomMenu.MenuItem
key={option.key}
onClick={() => {
setDisplayFilters({ order_by: option.key });
}}
>
{option.name}
</CustomMenu.MenuItem>
)
)}
</CustomMenu>
</div>
{displayFilters.layout !== "calendar" && displayFilters.layout !== "spreadsheet" && (
<div className="flex items-center justify-between">
<h4 className="text-custom-text-200">Order by</h4>
<div className="w-28">
<CustomMenu
label={
ISSUE_ORDER_BY_OPTIONS.find((option) => option.key === displayFilters.order_by)?.title ??
"Select"
}
className="!w-full"
buttonClassName="w-full"
>
{ISSUE_ORDER_BY_OPTIONS.map((option) =>
displayFilters.group_by === "priority" && option.key === "priority" ? null : (
<CustomMenu.MenuItem
key={option.key}
onClick={() => {
setDisplayFilters({ order_by: option.key });
}}
>
{option.title}
</CustomMenu.MenuItem>
)
)}
</CustomMenu>
</div>
)}
</div>
)}
{!isArchivedIssues && (
<div className="flex items-center justify-between">
<h4 className="text-custom-text-200">Issue type</h4>
<div className="w-28">
<CustomMenu
label={
FILTER_ISSUE_OPTIONS.find(
(option) => option.key === displayFilters.type
)?.name ?? "Select"
ISSUE_FILTER_OPTIONS.find((option) => option.key === displayFilters.type)?.title ??
"Select"
}
className="!w-full"
buttonClassName="w-full"
>
{FILTER_ISSUE_OPTIONS.map((option) => (
{ISSUE_FILTER_OPTIONS.map((option) => (
<CustomMenu.MenuItem
key={option.key}
onClick={() =>
@ -299,7 +274,7 @@ export const IssuesFilterView: React.FC = () => {
})
}
>
{option.name}
{option.title}
</CustomMenu.MenuItem>
))}
</CustomMenu>
@ -307,20 +282,17 @@ export const IssuesFilterView: React.FC = () => {
</div>
)}
{displayFilters.layout !== "calendar" &&
displayFilters.layout !== "spreadsheet" && (
<div className="flex items-center justify-between">
<h4 className="text-custom-text-200">Show sub-issues</h4>
<div className="w-28">
<ToggleSwitch
value={displayFilters.sub_issue ?? true}
onChange={() =>
setDisplayFilters({ sub_issue: !displayFilters.sub_issue })
}
/>
</div>
{displayFilters.layout !== "calendar" && displayFilters.layout !== "spreadsheet" && (
<div className="flex items-center justify-between">
<h4 className="text-custom-text-200">Show sub-issues</h4>
<div className="w-28">
<ToggleSwitch
value={displayFilters.sub_issue ?? true}
onChange={() => setDisplayFilters({ sub_issue: !displayFilters.sub_issue })}
/>
</div>
)}
</div>
)}
{displayFilters.layout !== "calendar" &&
displayFilters.layout !== "spreadsheet" &&
displayFilters.layout !== "gantt_chart" && (
@ -365,16 +337,11 @@ export const IssuesFilterView: React.FC = () => {
if (
displayFilters.layout === "spreadsheet" &&
(key === "attachment_count" ||
key === "link" ||
key === "sub_issue_count")
(key === "attachment_count" || key === "link" || key === "sub_issue_count")
)
return null;
if (
displayFilters.layout !== "spreadsheet" &&
(key === "created_on" || key === "updated_on")
)
if (displayFilters.layout !== "spreadsheet" && (key === "created_on" || key === "updated_on"))
return null;
return (

View File

@ -1,5 +1,4 @@
import React from "react";
import { FC } from "react";
// icons
import { XMarkIcon } from "@heroicons/react/24/outline";
import { PriorityIcon, StateGroupIcon } from "components/icons";
@ -10,13 +9,7 @@ import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper";
// helpers
import { renderShortDateWithYearFormat } from "helpers/date-time.helper";
// types
import {
IIssueLabels,
IProject,
IUserLite,
IWorkspaceIssueFilterOptions,
TStateGroups,
} from "types";
import { IIssueLabels, IProject, IUserLite, IWorkspaceIssueFilterOptions, TStateGroups } from "types";
// constants
import { STATE_GROUP_COLORS } from "constants/state";
@ -30,20 +23,12 @@ type Props = {
project?: IProject[] | undefined;
};
export const WorkspaceFiltersList: React.FC<Props> = ({
filters,
setFilters,
clearAllFilters,
labels,
members,
stateGroup,
project,
}) => {
export const WorkspaceFiltersList: FC<Props> = (props) => {
const { filters, setFilters, clearAllFilters, labels, members, project } = props;
if (!filters) return <></>;
const nullFilters = Object.keys(filters).filter(
(key) => filters[key as keyof IWorkspaceIssueFilterOptions] === null
);
const nullFilters = Object.keys(filters).filter((key) => filters[key as keyof IWorkspaceIssueFilterOptions] === null);
return (
<div className="flex flex-1 flex-wrap items-center gap-2 text-xs">
@ -189,9 +174,7 @@ export const WorkspaceFiltersList: React.FC<Props> = ({
className="cursor-pointer"
onClick={() =>
setFilters({
created_by: filters.created_by?.filter(
(p: any) => p !== memberId
),
created_by: filters.created_by?.filter((p: any) => p !== memberId),
})
}
>

View File

@ -4,14 +4,14 @@ import { useRouter } from "next/router";
import useSWR from "swr";
import { useDropzone } from "react-dropzone";
import { Tab, Transition, Popover } from "@headlessui/react";
import { Control, Controller } from "react-hook-form";
// services
import fileService from "services/file.service";
import { FileService } from "services/file.service";
// hooks
import useWorkspaceDetails from "hooks/use-workspace-details";
import useOutsideClickDetector from "hooks/use-outside-click-detector";
// components
import { Input, PrimaryButton, SecondaryButton, Loader } from "components/ui";
import { Button, Input, Loader } from "@plane/ui";
const tabOptions = [
{
@ -31,16 +31,15 @@ const tabOptions = [
type Props = {
label: string | React.ReactNode;
value: string | null;
control: Control<any>;
onChange: (data: string) => void;
disabled?: boolean;
};
export const ImagePickerPopover: React.FC<Props> = ({
label,
value,
onChange,
disabled = false,
}) => {
// services
const fileService = new FileService();
export const ImagePickerPopover: React.FC<Props> = ({ label, value, control, onChange, disabled = false }) => {
const ref = useRef<HTMLDivElement>(null);
const router = useRouter();
@ -64,14 +63,10 @@ export const ImagePickerPopover: React.FC<Props> = ({
}
);
const { data: projectCoverImages } = useSWR(
`PROJECT_COVER_IMAGES`,
() => fileService.getProjectCoverImages(),
{
revalidateOnFocus: false,
revalidateOnReconnect: false,
}
);
const { data: projectCoverImages } = useSWR(`PROJECT_COVER_IMAGES`, () => fileService.getProjectCoverImages(), {
revalidateOnFocus: false,
revalidateOnReconnect: false,
});
const imagePickerRef = useRef<HTMLDivElement>(null);
@ -154,8 +149,7 @@ export const ImagePickerPopover: React.FC<Props> = ({
<Tab.List as="span" className="inline-block rounded bg-custom-background-80 p-1">
{tabOptions.map((tab) => {
if (!unsplashImages && unsplashError && tab.key === "unsplash") return null;
if (projectCoverImages && projectCoverImages.length === 0 && tab.key === "images")
return null;
if (projectCoverImages && projectCoverImages.length === 0 && tab.key === "images") return null;
return (
<Tab
@ -175,17 +169,25 @@ export const ImagePickerPopover: React.FC<Props> = ({
{(unsplashImages || !unsplashError) && (
<Tab.Panel className="h-full w-full space-y-4 mt-4">
<div className="flex gap-x-2">
<Input
<Controller
control={control}
name="search"
className="text-sm"
id="search"
value={formData.search}
onChange={(e) => setFormData({ ...formData, search: e.target.value })}
placeholder="Search for images"
render={({ field: { value, ref } }) => (
<Input
id="search"
name="search"
type="text"
value={value}
onChange={(e) => setFormData({ ...formData, search: e.target.value })}
ref={ref}
placeholder="Search for images"
className="text-sm w-full"
/>
)}
/>
<PrimaryButton onClick={() => setSearchParams(formData.search)} size="sm">
<Button variant="primary" onClick={() => setSearchParams(formData.search)} size="sm">
Search
</PrimaryButton>
</Button>
</div>
{unsplashImages ? (
unsplashImages.length > 0 ? (
@ -208,9 +210,7 @@ export const ImagePickerPopover: React.FC<Props> = ({
))}
</div>
) : (
<p className="text-center text-custom-text-300 text-xs pt-7">
No images found.
</p>
<p className="text-center text-custom-text-300 text-xs pt-7">No images found.</p>
)
) : (
<Loader className="grid grid-cols-4 gap-4">
@ -249,9 +249,7 @@ export const ImagePickerPopover: React.FC<Props> = ({
))}
</div>
) : (
<p className="text-center text-custom-text-300 text-xs pt-7">
No images found.
</p>
<p className="text-center text-custom-text-300 text-xs pt-7">No images found.</p>
)
) : (
<Loader className="grid grid-cols-4 gap-4 pt-4">
@ -297,9 +295,7 @@ export const ImagePickerPopover: React.FC<Props> = ({
) : (
<div>
<span className="mt-2 block text-sm font-medium text-custom-text-200">
{isDragActive
? "Drop image here to upload"
: "Drag & drop image here"}
{isDragActive ? "Drop image here to upload" : "Drag & drop image here"}
</span>
</div>
)}
@ -320,23 +316,24 @@ export const ImagePickerPopover: React.FC<Props> = ({
</p>
<div className="flex items-center justify-end gap-2">
<SecondaryButton
className="w-full"
<Button
variant="neutral-primary"
onClick={() => {
setIsOpen(false);
setImage(null);
}}
>
Cancel
</SecondaryButton>
<PrimaryButton
</Button>
<Button
variant="primary"
className="w-full"
onClick={handleSubmit}
disabled={!image}
loading={isImageUploading}
>
{isImageUploading ? "Uploading..." : "Upload & Save"}
</PrimaryButton>
</Button>
</div>
</div>
</Tab.Panel>

View File

@ -1,26 +1,22 @@
import React, { useState } from "react";
import { useRouter } from "next/router";
import useSWR, { mutate } from "swr";
// react hook form
import { SubmitHandler, useForm } from "react-hook-form";
// headless ui
import { Combobox, Dialog, Transition } from "@headlessui/react";
// services
import issuesServices from "services/issues.service";
import { IssueService } from "services/issue";
// hooks
import useToast from "hooks/use-toast";
import useIssuesView from "hooks/use-issues-view";
import useCalendarIssuesView from "hooks/use-calendar-issues-view";
// ui
import { DangerButton, SecondaryButton } from "components/ui";
import { Button } from "@plane/ui";
// icons
import { MagnifyingGlassIcon } from "@heroicons/react/24/outline";
import { LayerDiagonalIcon } from "components/icons";
// types
import { ICurrentUserResponse, IIssue } from "types";
import { IUser, IIssue } from "types";
// fetch keys
import {
CYCLE_DETAILS,
@ -39,9 +35,11 @@ type FormInput = {
type Props = {
isOpen: boolean;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
user: ICurrentUserResponse | undefined;
user: IUser | undefined;
};
const issueService = new IssueService();
export const BulkDeleteIssuesModal: React.FC<Props> = ({ isOpen, setIsOpen, user }) => {
const [query, setQuery] = useState("");
@ -49,17 +47,13 @@ export const BulkDeleteIssuesModal: React.FC<Props> = ({ isOpen, setIsOpen, user
const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query;
const { data: issues } = useSWR(
workspaceSlug && projectId
? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string)
: null,
workspaceSlug && projectId
? () => issuesServices.getIssues(workspaceSlug as string, projectId as string)
: null
workspaceSlug && projectId ? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string) : null,
workspaceSlug && projectId ? () => issueService.getIssues(workspaceSlug as string, projectId as string) : null
);
const { setToastAlert } = useToast();
const { displayFilters, params } = useIssuesView();
const { params: calendarParams } = useCalendarIssuesView();
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { order_by, group_by, ...viewGanttParams } = params;
const {
@ -94,14 +88,6 @@ export const BulkDeleteIssuesModal: React.FC<Props> = ({ isOpen, setIsOpen, user
if (!Array.isArray(data.delete_issue_ids)) data.delete_issue_ids = [data.delete_issue_ids];
const calendarFetchKey = cycleId
? CYCLE_ISSUES_WITH_PARAMS(cycleId.toString(), calendarParams)
: moduleId
? MODULE_ISSUES_WITH_PARAMS(moduleId.toString(), calendarParams)
: viewId
? VIEW_ISSUES(viewId.toString(), calendarParams)
: PROJECT_ISSUES_LIST_WITH_PARAMS(projectId?.toString() ?? "", calendarParams);
const ganttFetchKey = cycleId
? CYCLE_ISSUES_WITH_PARAMS(cycleId.toString())
: moduleId
@ -110,7 +96,7 @@ export const BulkDeleteIssuesModal: React.FC<Props> = ({ isOpen, setIsOpen, user
? VIEW_ISSUES(viewId.toString(), viewGanttParams)
: PROJECT_ISSUES_LIST_WITH_PARAMS(projectId?.toString() ?? "");
await issuesServices
await issueService
.bulkDeleteIssues(
workspaceSlug as string,
projectId as string,
@ -126,8 +112,7 @@ export const BulkDeleteIssuesModal: React.FC<Props> = ({ isOpen, setIsOpen, user
message: "Issues deleted successfully!",
});
if (displayFilters.layout === "calendar") mutate(calendarFetchKey);
else if (displayFilters.layout === "gantt_chart") mutate(ganttFetchKey);
if (displayFilters.layout === "gantt_chart") mutate(ganttFetchKey);
else {
if (cycleId) {
mutate(CYCLE_ISSUES_WITH_PARAMS(cycleId.toString(), params));
@ -155,9 +140,7 @@ export const BulkDeleteIssuesModal: React.FC<Props> = ({ isOpen, setIsOpen, user
: issues?.filter(
(issue) =>
issue.name.toLowerCase().includes(query.toLowerCase()) ||
`${issue.project_detail.identifier}-${issue.sequence_id}`
.toLowerCase()
.includes(query.toLowerCase())
`${issue.project_detail.identifier}-${issue.sequence_id}`.toLowerCase().includes(query.toLowerCase())
) ?? [];
return (
@ -216,7 +199,7 @@ export const BulkDeleteIssuesModal: React.FC<Props> = ({ isOpen, setIsOpen, user
key={issue.id}
as="div"
value={issue.id}
className={({ active, selected }) =>
className={({ active }) =>
`flex cursor-pointer select-none items-center justify-between rounded-md px-3 py-2 ${
active ? "bg-custom-background-80 text-custom-text-100" : ""
}`
@ -257,10 +240,12 @@ export const BulkDeleteIssuesModal: React.FC<Props> = ({ isOpen, setIsOpen, user
{filteredIssues.length > 0 && (
<div className="flex items-center justify-end gap-2 p-3">
<SecondaryButton onClick={handleClose}>Cancel</SecondaryButton>
<DangerButton onClick={handleSubmit(handleDelete)} loading={isSubmitting}>
<Button variant="neutral-primary" onClick={handleClose}>
Cancel
</Button>
<Button variant="danger" onClick={handleSubmit(handleDelete)} loading={isSubmitting}>
{isSubmitting ? "Deleting..." : "Delete selected issues"}
</DangerButton>
</Button>
</div>
)}
</form>

View File

@ -7,13 +7,13 @@ import { mutate } from "swr";
// headless ui
import { Combobox, Dialog, Transition } from "@headlessui/react";
// services
import projectService from "services/project.service";
import { ProjectService } from "services/project";
// hooks
import useToast from "hooks/use-toast";
import useIssuesView from "hooks/use-issues-view";
import useDebounce from "hooks/use-debounce";
// ui
import { Loader, PrimaryButton, SecondaryButton, ToggleSwitch, Tooltip } from "components/ui";
import { Button, Loader, ToggleSwitch, Tooltip } from "@plane/ui";
// icons
import { LaunchOutlined } from "@mui/icons-material";
import { MagnifyingGlassIcon, XMarkIcon } from "@heroicons/react/24/outline";
@ -36,6 +36,8 @@ type Props = {
workspaceLevelToggle?: boolean;
};
const projectService = new ProjectService();
export const ExistingIssuesListModal: React.FC<Props> = ({
isOpen,
handleClose: onClose,
@ -117,12 +119,7 @@ export const ExistingIssuesListModal: React.FC<Props> = ({
return (
<>
<Transition.Root
show={isOpen}
as={React.Fragment}
afterLeave={() => setSearchTerm("")}
appear
>
<Transition.Root show={isOpen} as={React.Fragment} afterLeave={() => setSearchTerm("")} appear>
<Dialog as="div" className="relative z-20" onClose={handleClose}>
<Transition.Child
as={React.Fragment}
@ -180,11 +177,7 @@ export const ExistingIssuesListModal: React.FC<Props> = ({
<button
type="button"
className="group p-1"
onClick={() =>
setSelectedIssues((prevData) =>
prevData.filter((i) => i.id !== issue.id)
)
}
onClick={() => setSelectedIssues((prevData) => prevData.filter((i) => i.id !== issue.id))}
>
<XMarkIcon className="h-3 w-3 text-custom-text-200 group-hover:text-custom-text-100" />
</button>
@ -232,21 +225,15 @@ export const ExistingIssuesListModal: React.FC<Props> = ({
</h5>
)}
{!isSearching &&
issues.length === 0 &&
searchTerm !== "" &&
debouncedSearchTerm !== "" && (
<div className="flex flex-col items-center justify-center gap-4 px-3 py-8 text-center">
<LayerDiagonalIcon height="52" width="52" />
<h3 className="text-custom-text-200">
No issues found. Create a new issue with{" "}
<pre className="inline rounded bg-custom-background-80 px-2 py-1 text-sm">
C
</pre>
.
</h3>
</div>
)}
{!isSearching && issues.length === 0 && searchTerm !== "" && debouncedSearchTerm !== "" && (
<div className="flex flex-col items-center justify-center gap-4 px-3 py-8 text-center">
<LayerDiagonalIcon height="52" width="52" />
<h3 className="text-custom-text-200">
No issues found. Create a new issue with{" "}
<pre className="inline rounded bg-custom-background-80 px-2 py-1 text-sm">C</pre>.
</h3>
</div>
)}
{isSearching ? (
<Loader className="space-y-3 p-3">
@ -256,9 +243,7 @@ export const ExistingIssuesListModal: React.FC<Props> = ({
<Loader.Item height="40px" />
</Loader>
) : (
<ul
className={`text-sm text-custom-text-100 ${issues.length > 0 ? "p-2" : ""}`}
>
<ul className={`text-sm text-custom-text-100 ${issues.length > 0 ? "p-2" : ""}`}>
{issues.map((issue) => {
const selected = selectedIssues.some((i) => i.id === issue.id);
@ -309,10 +294,12 @@ export const ExistingIssuesListModal: React.FC<Props> = ({
</Combobox>
{selectedIssues.length > 0 && (
<div className="flex items-center justify-end gap-2 p-3">
<SecondaryButton onClick={handleClose}>Cancel</SecondaryButton>
<PrimaryButton onClick={onSubmit} loading={isSubmitting}>
<Button variant="neutral-primary" onClick={handleClose}>
Cancel
</Button>
<Button variant="primary" onClick={onSubmit} loading={isSubmitting}>
{isSubmitting ? "Adding..." : "Add selected issues"}
</PrimaryButton>
</Button>
</div>
)}
</Dialog.Panel>

View File

@ -1,15 +1,15 @@
import React, { useEffect, useState, useRef } from "react";
import { useRouter } from "next/router";
// react-hook-form
import { useForm } from "react-hook-form";
import { Controller, useForm } from "react-hook-form";
// services
import aiService from "services/ai.service";
import trackEventServices from "services/track-event.service";
import { AIService } from "services/ai.service";
import { TrackEventService } from "services/track_event.service";
// hooks
import useToast from "hooks/use-toast";
import useUserAuth from "hooks/use-user-auth";
// ui
import { Input, PrimaryButton, SecondaryButton } from "components/ui";
import { Button, Input } from "@plane/ui";
// components
import { RichReadOnlyEditor, RichReadOnlyEditorWithRef } from "@plane/rich-text-editor";
// types
@ -32,6 +32,10 @@ type FormData = {
task: string;
};
// services
const aiService = new AIService();
const trackEventService = new TrackEventService();
export const GptAssistantModal: React.FC<Props> = ({
isOpen,
handleClose,
@ -57,7 +61,7 @@ export const GptAssistantModal: React.FC<Props> = ({
const {
handleSubmit,
register,
control,
reset,
setFocus,
formState: { isSubmitting },
@ -111,9 +115,7 @@ export const GptAssistantModal: React.FC<Props> = ({
setToastAlert({
type: "error",
title: "Error!",
message:
error ||
"You have reached the maximum number of requests of 50 requests per month per user.",
message: error || "You have reached the maximum number of requests of 50 requests per month per user.",
});
else
setToastAlert({
@ -134,8 +136,9 @@ export const GptAssistantModal: React.FC<Props> = ({
return (
<div
className={`absolute ${inset} z-20 w-full space-y-4 rounded-[10px] border border-custom-border-200 bg-custom-background-100 p-4 shadow ${isOpen ? "block" : "hidden"
}`}
className={`absolute ${inset} z-20 w-full space-y-4 rounded-[10px] border border-custom-border-200 bg-custom-background-100 p-4 shadow ${
isOpen ? "block" : "hidden"
}`}
>
{((content && content !== "") || (htmlContent && htmlContent !== "<p></p>")) && (
<div className="text-sm">
@ -162,56 +165,50 @@ export const GptAssistantModal: React.FC<Props> = ({
)}
{invalidResponse && (
<div className="text-sm text-red-500">
No response could be generated. This may be due to insufficient content or task
information. Please try again.
No response could be generated. This may be due to insufficient content or task information. Please try again.
</div>
)}
<Input
type="text"
<Controller
control={control}
name="task"
register={register}
placeholder={`${content && content !== ""
? "Tell AI what action to perform on this content..."
: "Ask AI anything..."
}`}
autoComplete="off"
render={({ field: { value, onChange, ref } }) => (
<Input
id="task"
name="task"
type="text"
value={value}
onChange={onChange}
ref={ref}
placeholder={`${
content && content !== "" ? "Tell AI what action to perform on this content..." : "Ask AI anything..."
}`}
className="w-full"
/>
)}
/>
<div className={`flex gap-2 ${response === "" ? "justify-end" : "justify-between"}`}>
{response !== "" && (
<PrimaryButton
<Button
variant="primary"
onClick={() => {
onResponse(response);
onClose();
if (block)
trackEventServices.trackUseGPTResponseEvent(
block,
"USE_GPT_RESPONSE_IN_PAGE_BLOCK",
user
);
else if (issue)
trackEventServices.trackUseGPTResponseEvent(
issue,
"USE_GPT_RESPONSE_IN_ISSUE",
user
);
if (block && user)
trackEventService.trackUseGPTResponseEvent(block, "USE_GPT_RESPONSE_IN_PAGE_BLOCK", user);
else if (issue && user)
trackEventService.trackUseGPTResponseEvent(issue, "USE_GPT_RESPONSE_IN_ISSUE", user);
}}
>
Use this response
</PrimaryButton>
</Button>
)}
<div className="flex items-center gap-2">
<SecondaryButton onClick={onClose}>Close</SecondaryButton>
<PrimaryButton
type="button"
onClick={handleSubmit(handleResponse)}
loading={isSubmitting}
>
{isSubmitting
? "Generating response..."
: response === ""
? "Generate response"
: "Generate again"}
</PrimaryButton>
<Button variant="neutral-primary" onClick={onClose}>
Close
</Button>
<Button variant="primary" onClick={handleSubmit(handleResponse)} loading={isSubmitting}>
{isSubmitting ? "Generating response..." : response === "" ? "Generate response" : "Generate again"}
</Button>
</div>
</div>
</div>

View File

@ -7,11 +7,11 @@ import { useDropzone } from "react-dropzone";
// headless ui
import { Transition, Dialog } from "@headlessui/react";
// services
import fileService from "services/file.service";
import { FileService } from "services/file.service";
// hooks
import useWorkspaceDetails from "hooks/use-workspace-details";
// ui
import { DangerButton, PrimaryButton, SecondaryButton } from "components/ui";
import { Button } from "@plane/ui";
// icons
import { UserCircleIcon } from "components/icons";
@ -25,6 +25,9 @@ type Props = {
userImage?: boolean;
};
// services
const fileService = new FileService();
export const ImageUploadModal: React.FC<Props> = ({
value,
onSuccess,
@ -127,10 +130,7 @@ export const ImageUploadModal: React.FC<Props> = ({
>
<Dialog.Panel className="relative transform overflow-hidden rounded-lg border border-custom-border-200 bg-custom-background-100 px-5 py-8 text-left shadow-xl transition-all sm:w-full sm:max-w-xl sm:p-6">
<div className="space-y-5">
<Dialog.Title
as="h3"
className="text-lg font-medium leading-6 text-custom-text-100"
>
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-custom-text-100">
Upload Image
</Dialog.Title>
<div className="space-y-3">
@ -161,9 +161,7 @@ export const ImageUploadModal: React.FC<Props> = ({
<div>
<UserCircleIcon className="mx-auto h-16 w-16 text-custom-text-200" />
<span className="mt-2 block text-sm font-medium text-custom-text-200">
{isDragActive
? "Drop image here to upload"
: "Drag & drop image here"}
{isDragActive ? "Drop image here to upload" : "Drag & drop image here"}
</span>
</div>
)}
@ -185,19 +183,17 @@ export const ImageUploadModal: React.FC<Props> = ({
</p>
<div className="flex items-center justify-between">
<div className="flex items-center">
<DangerButton onClick={handleDelete} outline disabled={!value}>
<Button variant="danger" onClick={handleDelete} disabled={!value}>
{isRemoving ? "Removing..." : "Remove"}
</DangerButton>
</Button>
</div>
<div className="flex items-center gap-2">
<SecondaryButton onClick={handleClose}>Cancel</SecondaryButton>
<PrimaryButton
onClick={handleSubmit}
disabled={!image}
loading={isImageUploading}
>
<Button variant="neutral-primary" onClick={handleClose}>
Cancel
</Button>
<Button variant="primary" onClick={handleSubmit} disabled={!image} loading={isImageUploading}>
{isImageUploading ? "Uploading..." : "Upload & Save"}
</PrimaryButton>
</Button>
</div>
</div>
</Dialog.Panel>

View File

@ -1,11 +1,11 @@
import React, { useEffect } from "react";
import { FC, useEffect, Fragment } from "react";
// react-hook-form
import { useForm } from "react-hook-form";
import { Controller, useForm } from "react-hook-form";
// headless ui
import { Dialog, Transition } from "@headlessui/react";
// ui
import { Input, PrimaryButton, SecondaryButton } from "components/ui";
import { Button, Input } from "@plane/ui";
// types
import type { IIssueLink, linkDetails, ModuleLink } from "types";
@ -23,18 +23,13 @@ const defaultValues: IIssueLink | ModuleLink = {
url: "",
};
export const LinkModal: React.FC<Props> = ({
isOpen,
handleClose,
createIssueLink,
updateIssueLink,
status,
data,
}) => {
export const LinkModal: FC<Props> = (props) => {
const { isOpen, handleClose, createIssueLink, updateIssueLink, status, data } = props;
// form info
const {
register,
formState: { errors, isSubmitting },
handleSubmit,
control,
reset,
} = useForm<ModuleLink>({
defaultValues,
@ -70,10 +65,10 @@ export const LinkModal: React.FC<Props> = ({
}, [data, reset]);
return (
<Transition.Root show={isOpen} as={React.Fragment}>
<Transition.Root show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-20" onClose={onClose}>
<Transition.Child
as={React.Fragment}
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
@ -87,7 +82,7 @@ export const LinkModal: React.FC<Props> = ({
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
<Transition.Child
as={React.Fragment}
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
@ -99,46 +94,65 @@ export const LinkModal: React.FC<Props> = ({
<form onSubmit={handleSubmit(handleCreateUpdatePage)}>
<div>
<div className="space-y-5">
<Dialog.Title
as="h3"
className="text-lg font-medium leading-6 text-custom-text-100"
>
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-custom-text-100">
{status ? "Update Link" : "Add Link"}
</Dialog.Title>
<div className="mt-2 space-y-3">
<div>
<Input
id="url"
label="URL"
<label htmlFor="url" className="text-custom-text-200 mb-2">
URL
</label>
<Controller
control={control}
name="url"
type="url"
placeholder="https://..."
autoComplete="off"
error={errors.url}
register={register}
validations={{
rules={{
required: "URL is required",
}}
render={({ field: { value, onChange, ref } }) => (
<Input
id="url"
name="url"
type="url"
value={value}
onChange={onChange}
ref={ref}
hasError={Boolean(errors.url)}
placeholder="https://..."
className="w-full"
/>
)}
/>
</div>
<div>
<Input
id="title"
label="Title (optional)"
<label htmlFor="title" className="text-custom-text-200 mb-2">
{`Title (optional)`}
</label>
<Controller
control={control}
name="title"
type="text"
placeholder="Enter title"
autoComplete="off"
error={errors.title}
register={register}
render={({ field: { value, onChange, ref } }) => (
<Input
id="title"
name="title"
type="text"
value={value}
onChange={onChange}
ref={ref}
hasError={Boolean(errors.title)}
placeholder="Enter title"
className="w-full"
/>
)}
/>
</div>
</div>
</div>
</div>
<div className="mt-5 flex justify-end gap-2">
<SecondaryButton onClick={onClose}>Cancel</SecondaryButton>
<PrimaryButton type="submit" loading={isSubmitting}>
<Button variant="neutral-primary" onClick={onClose}>
Cancel
</Button>
<Button variant="primary" type="submit" loading={isSubmitting}>
{status
? isSubmitting
? "Updating Link..."
@ -146,7 +160,7 @@ export const LinkModal: React.FC<Props> = ({
: isSubmitting
? "Adding Link..."
: "Add Link"}
</PrimaryButton>
</Button>
</div>
</form>
</Dialog.Panel>

View File

@ -1,24 +1,11 @@
import { Fragment } from "react";
// headless ui
import { Popover, Transition } from "@headlessui/react";
// helper
import { renderEmoji } from "helpers/emoji.helper";
// icons
import { Icon } from "components/ui";
const reactionEmojis = [
"128077",
"128078",
"128516",
"128165",
"128533",
"129505",
"9992",
"128064",
];
const reactionEmojis = ["128077", "128078", "128516", "128165", "128533", "129505", "9992", "128064"];
interface Props {
size?: "sm" | "md" | "lg";
@ -28,7 +15,7 @@ interface Props {
}
export const ReactionSelector: React.FC<Props> = (props) => {
const { value, onSelect, position, size } = props;
const { onSelect, position, size } = props;
return (
<Popover className="relative">

View File

@ -1,6 +1,6 @@
import React from "react";
import { ProgressBar } from "components/ui";
import { ProgressBar } from "@plane/ui";
type TSingleProgressStatsProps = {
title: any;
@ -30,10 +30,7 @@ export const SingleProgressStats: React.FC<TSingleProgressStatsProps> = ({
<ProgressBar value={completed} maxValue={total} />
</span>
<span className="w-8 text-right">
{isNaN(Math.floor((completed / total) * 100))
? "0"
: Math.floor((completed / total) * 100)}
%
{isNaN(Math.floor((completed / total) * 100)) ? "0" : Math.floor((completed / total) * 100)}%
</span>
</div>
<span>of {total}</span>

View File

@ -1,7 +1,8 @@
import React from "react";
import { FC, Fragment } from "react";
// react-form
import {
Control,
Controller,
FieldError,
FieldErrorsImpl,
Merge,
@ -13,7 +14,7 @@ import {
import { ColorResult, SketchPicker } from "react-color";
// component
import { Popover, Transition } from "@headlessui/react";
import { Input } from "components/ui";
import { Input } from "@plane/ui";
// icons
import { ColorPickerIcon } from "components/icons";
// types
@ -24,18 +25,14 @@ type Props = {
position?: "left" | "right";
watch: UseFormWatch<any>;
setValue: UseFormSetValue<any>;
control: Control<ICustomTheme, any>;
error: FieldError | Merge<FieldError, FieldErrorsImpl<any>> | undefined;
register: UseFormRegister<any>;
};
export const ColorPickerInput: React.FC<Props> = ({
name,
position = "left",
watch,
setValue,
error,
register,
}) => {
export const ColorPickerInput: FC<Props> = (props) => {
const { name, position = "left", watch, setValue, error, control } = props;
const handleColorChange = (newColor: ColorResult) => {
const { hex } = newColor;
setValue(name, hex);
@ -60,22 +57,28 @@ export const ColorPickerInput: React.FC<Props> = ({
return (
<div className="relative">
<Input
id={name}
<Controller
control={control}
name={name}
type="name"
placeholder="#FFFFFF"
autoComplete="off"
error={error}
value={watch(name)}
register={register}
validations={{
rules={{
required: `${getColorText(name)} color is required`,
pattern: {
value: /^#(?:[0-9a-fA-F]{3}){1,2}$/g,
message: `${getColorText(name)} color should be hex format`,
},
}}
render={({ field: { onChange, ref } }) => (
<Input
id={name}
name={name}
type="text"
value={watch("name")}
onChange={onChange}
ref={ref}
hasError={Boolean(error)}
placeholder="#FFFFFF"
/>
)}
/>
<div className="absolute right-4 top-2.5">
<Popover className="relative grid place-items-center">
@ -95,16 +98,12 @@ export const ColorPickerInput: React.FC<Props> = ({
}}
/>
) : (
<ColorPickerIcon
height={14}
width={14}
className="fill-current text-custom-text-100"
/>
<ColorPickerIcon height={14} width={14} className="fill-current text-custom-text-100" />
)}
</Popover.Button>
<Transition
as={React.Fragment}
as={Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"

View File

@ -5,8 +5,8 @@ import { useTheme } from "next-themes";
import { useForm } from "react-hook-form";
// ui
import { PrimaryButton } from "components/ui";
import { ColorPickerInput } from "components/core";
import { Button } from "@plane/ui";
// types
import { ICustomTheme } from "types";
// mobx react lite
@ -33,11 +33,14 @@ export const CustomThemeSelector: React.FC<Props> = observer(({ preLoadedData })
const store: any = useMobxStore();
const { setTheme } = useTheme();
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [darkPalette, setDarkPalette] = useState(false);
const {
register,
formState: { errors, isSubmitting },
handleSubmit,
control,
watch,
setValue,
reset,
@ -78,12 +81,11 @@ export const CustomThemeSelector: React.FC<Props> = observer(({ preLoadedData })
<div className="space-y-4">
<div className="grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-2 md:grid-cols-3">
<div className="flex flex-col items-start gap-2">
<h3 className="text-left text-sm font-medium text-custom-text-200">
Background color
</h3>
<h3 className="text-left text-sm font-medium text-custom-text-200">Background color</h3>
<ColorPickerInput
name="background"
position="right"
control={control}
error={errors.background}
watch={watch}
setValue={setValue}
@ -95,6 +97,7 @@ export const CustomThemeSelector: React.FC<Props> = observer(({ preLoadedData })
<h3 className="text-left text-sm font-medium text-custom-text-200">Text color</h3>
<ColorPickerInput
name="text"
control={control}
error={errors.text}
watch={watch}
setValue={setValue}
@ -103,12 +106,11 @@ export const CustomThemeSelector: React.FC<Props> = observer(({ preLoadedData })
</div>
<div className="flex flex-col items-start gap-2">
<h3 className="text-left text-sm font-medium text-custom-text-200">
Primary(Theme) color
</h3>
<h3 className="text-left text-sm font-medium text-custom-text-200">Primary(Theme) color</h3>
<ColorPickerInput
name="primary"
error={errors.primary}
control={control}
watch={watch}
setValue={setValue}
register={register}
@ -116,12 +118,11 @@ export const CustomThemeSelector: React.FC<Props> = observer(({ preLoadedData })
</div>
<div className="flex flex-col items-start gap-2">
<h3 className="text-left text-sm font-medium text-custom-text-200">
Sidebar background color
</h3>
<h3 className="text-left text-sm font-medium text-custom-text-200">Sidebar background color</h3>
<ColorPickerInput
name="sidebarBackground"
position="right"
control={control}
error={errors.sidebarBackground}
watch={watch}
setValue={setValue}
@ -130,11 +131,10 @@ export const CustomThemeSelector: React.FC<Props> = observer(({ preLoadedData })
</div>
<div className="flex flex-col items-start gap-2">
<h3 className="text-left text-sm font-medium text-custom-text-200">
Sidebar text color
</h3>
<h3 className="text-left text-sm font-medium text-custom-text-200">Sidebar text color</h3>
<ColorPickerInput
name="sidebarText"
control={control}
error={errors.sidebarText}
watch={watch}
setValue={setValue}
@ -145,9 +145,9 @@ export const CustomThemeSelector: React.FC<Props> = observer(({ preLoadedData })
</div>
</div>
<div className="mt-5 flex justify-end gap-2">
<PrimaryButton type="submit" loading={isSubmitting}>
<Button variant="primary" type="submit" loading={isSubmitting}>
{isSubmitting ? "Creating Theme..." : "Set Theme"}
</PrimaryButton>
</Button>
</div>
</form>
);

View File

@ -24,7 +24,7 @@ export const ThemeSwitch: React.FC<Props> = observer(
({ setPreLoadedData, customThemeSelectorOptions, setCustomThemeSelectorOptions }) => {
const store: any = useMobxStore();
const { user, mutateUser } = useUser();
const { user } = useUser();
const { theme, setTheme } = useTheme();
const updateUserTheme = (newTheme: string) => {
@ -72,19 +72,15 @@ export const ThemeSwitch: React.FC<Props> = observer(
}
onChange={({ value, type }: { value: string; type: string }) => {
if (value === "custom") {
if (user?.theme.palette) {
if (user?.theme?.palette) {
setPreLoadedData({
background: user.theme.background !== "" ? user.theme.background : "#0d101b",
background: user.theme?.background !== "" ? user.theme.background : "#0d101b",
text: user.theme.text !== "" ? user.theme.text : "#c5c5c5",
primary: user.theme.primary !== "" ? user.theme.primary : "#3f76ff",
sidebarBackground:
user.theme.sidebarBackground !== "" ? user.theme.sidebarBackground : "#0d101b",
sidebarBackground: user.theme.sidebarBackground !== "" ? user.theme.sidebarBackground : "#0d101b",
sidebarText: user.theme.sidebarText !== "" ? user.theme.sidebarText : "#c5c5c5",
darkPalette: false,
palette:
user.theme.palette !== ",,,,"
? user.theme.palette
: "#0d101b,#c5c5c5,#3f76ff,#0d101b,#c5c5c5",
palette: user.theme.palette !== ",,,," ? user.theme.palette : "#0d101b,#c5c5c5,#3f76ff,#0d101b,#c5c5c5",
theme: "custom",
});
}

View File

@ -1,229 +1,220 @@
import React, { useCallback, useState } from "react";
// import React, { useCallback, useState } from "react";
// import { useRouter } from "next/router";
// import useSWR from "swr";
// import { DragDropContext, DropResult } from "react-beautiful-dnd";
// // services
// import { ProjectStateService } from "services/project";
// // hooks
// import useUser from "hooks/use-user";
// import { useProjectMyMembership } from "contexts/project-member.context";
// import useSpreadsheetIssuesView from "hooks/use-spreadsheet-issues-view";
// // components
// import StrictModeDroppable from "components/dnd/StrictModeDroppable";
// import { AllLists, AllBoards, CalendarView, SpreadsheetView, GanttChartView } from "components/core";
// import { EmptyState } from "components/common";
// // ui
// import { Spinner } from "components/ui";
// // icons
// import { TrashIcon } from "@heroicons/react/24/outline";
// // images
// import emptyIssue from "public/empty-state/issue.svg";
// import emptyIssueArchive from "public/empty-state/issue-archive.svg";
// // helpers
// import { getStatesList } from "helpers/state.helper";
// // types
// import { IIssue, IIssueViewProps } from "types";
// // fetch-keys
// import { STATES_LIST } from "constants/fetch-keys";
import { useRouter } from "next/router";
// type Props = {
// addIssueToDate: (date: string) => void;
// addIssueToGroup: (groupTitle: string) => void;
// disableUserActions: boolean;
// dragDisabled?: boolean;
// emptyState: {
// title: string;
// description?: string;
// primaryButton?: {
// icon: any;
// text: string;
// onClick: () => void;
// };
// secondaryButton?: React.ReactNode;
// };
// handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void;
// handleDraftIssueAction?: (issue: IIssue, action: "edit" | "delete") => void;
// handleOnDragEnd: (result: DropResult) => Promise<void>;
// openIssuesListModal: (() => void) | null;
// removeIssue: ((bridgeId: string, issueId: string) => void) | null;
// disableAddIssueOption?: boolean;
// trashBox: boolean;
// setTrashBox: React.Dispatch<React.SetStateAction<boolean>>;
// viewProps: IIssueViewProps;
// };
import useSWR from "swr";
// const projectStateService = new ProjectStateService();
// react-beautiful-dnd
import { DragDropContext, DropResult } from "react-beautiful-dnd";
import StrictModeDroppable from "components/dnd/StrictModeDroppable";
// services
import stateService from "services/state.service";
// hooks
import useUser from "hooks/use-user";
import { useProjectMyMembership } from "contexts/project-member.context";
import useSpreadsheetIssuesView from "hooks/use-spreadsheet-issues-view";
// components
import {
AllLists,
AllBoards,
CalendarView,
SpreadsheetView,
GanttChartView,
} from "components/core";
// ui
import { EmptyState, Spinner } from "components/ui";
// icons
import { TrashIcon } from "@heroicons/react/24/outline";
// images
import emptyIssue from "public/empty-state/issue.svg";
import emptyIssueArchive from "public/empty-state/issue-archive.svg";
// helpers
import { getStatesList } from "helpers/state.helper";
// types
import { IIssue, IIssueViewProps } from "types";
// fetch-keys
import { STATES_LIST } from "constants/fetch-keys";
// export const AllViews: React.FC<Props> = ({
// addIssueToDate,
// addIssueToGroup,
// disableUserActions,
// dragDisabled = false,
// emptyState,
// handleIssueAction,
// handleDraftIssueAction,
// handleOnDragEnd,
// openIssuesListModal,
// removeIssue,
// disableAddIssueOption = false,
// trashBox,
// setTrashBox,
// viewProps,
// }) => {
// const router = useRouter();
// const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
type Props = {
addIssueToDate: (date: string) => void;
addIssueToGroup: (groupTitle: string) => void;
disableUserActions: boolean;
dragDisabled?: boolean;
emptyState: {
title: string;
description?: string;
primaryButton?: {
icon: any;
text: string;
onClick: () => void;
};
secondaryButton?: React.ReactNode;
};
handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void;
handleDraftIssueAction?: (issue: IIssue, action: "edit" | "delete") => void;
handleOnDragEnd: (result: DropResult) => Promise<void>;
openIssuesListModal: (() => void) | null;
removeIssue: ((bridgeId: string, issueId: string) => void) | null;
disableAddIssueOption?: boolean;
trashBox: boolean;
setTrashBox: React.Dispatch<React.SetStateAction<boolean>>;
viewProps: IIssueViewProps;
};
// const [myIssueProjectId, setMyIssueProjectId] = useState<string | null>(null);
export const AllViews: React.FC<Props> = ({
addIssueToDate,
addIssueToGroup,
disableUserActions,
dragDisabled = false,
emptyState,
handleIssueAction,
handleDraftIssueAction,
handleOnDragEnd,
openIssuesListModal,
removeIssue,
disableAddIssueOption = false,
trashBox,
setTrashBox,
viewProps,
}) => {
const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
// const { user } = useUser();
// const { memberRole } = useProjectMyMembership();
const [myIssueProjectId, setMyIssueProjectId] = useState<string | null>(null);
// const { groupedIssues, isEmpty, displayFilters } = viewProps;
const { user } = useUser();
const { memberRole } = useProjectMyMembership();
// const { spreadsheetIssues, mutateIssues } = useSpreadsheetIssuesView();
const { groupedIssues, isEmpty, displayFilters } = viewProps;
// const { data: stateGroups } = useSWR(
// workspaceSlug && projectId ? STATES_LIST(projectId as string) : null,
// workspaceSlug ? () => projectStateService.getStates(workspaceSlug as string, projectId as string) : null
// );
// const states = getStatesList(stateGroups);
const { spreadsheetIssues, mutateIssues } = useSpreadsheetIssuesView();
// const handleMyIssueOpen = (issue: IIssue) => {
// setMyIssueProjectId(issue.project);
// };
const { data: stateGroups } = useSWR(
workspaceSlug && projectId ? STATES_LIST(projectId as string) : null,
workspaceSlug
? () => stateService.getStates(workspaceSlug as string, projectId as string)
: null
);
const states = getStatesList(stateGroups);
// const handleTrashBox = useCallback(
// (isDragging: boolean) => {
// if (isDragging && !trashBox) setTrashBox(true);
// },
// [trashBox, setTrashBox]
// );
const handleMyIssueOpen = (issue: IIssue) => {
setMyIssueProjectId(issue.project);
};
// return (
// <DragDropContext onDragEnd={handleOnDragEnd}>
// <StrictModeDroppable droppableId="trashBox">
// {(provided, snapshot) => (
// <div
// className={`${
// trashBox ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0"
// } fixed top-4 left-1/2 -translate-x-1/2 z-40 w-72 flex items-center justify-center gap-2 rounded border-2 border-red-500/20 bg-custom-background-100 px-3 py-5 text-xs font-medium italic text-red-500 ${
// snapshot.isDraggingOver ? "bg-red-500 blur-2xl opacity-70" : ""
// } transition duration-300`}
// ref={provided.innerRef}
// {...provided.droppableProps}
// >
// <TrashIcon className="h-4 w-4" />
// Drop here to delete the issue.
// </div>
// )}
// </StrictModeDroppable>
// {groupedIssues ? (
// !isEmpty ||
// displayFilters?.layout === "kanban" ||
// displayFilters?.layout === "calendar" ||
// displayFilters?.layout === "gantt_chart" ? (
// <>
// {displayFilters?.layout === "list" ? (
// <AllLists
// states={states}
// addIssueToGroup={addIssueToGroup}
// handleIssueAction={handleIssueAction}
// handleDraftIssueAction={handleDraftIssueAction}
// openIssuesListModal={cycleId || moduleId ? openIssuesListModal : null}
// removeIssue={removeIssue}
// myIssueProjectId={myIssueProjectId}
// handleMyIssueOpen={handleMyIssueOpen}
// disableUserActions={disableUserActions}
// disableAddIssueOption={disableAddIssueOption}
// user={user}
// userAuth={memberRole}
// viewProps={viewProps}
// />
// ) : displayFilters?.layout === "kanban" ? (
// <AllBoards
// addIssueToGroup={addIssueToGroup}
// disableUserActions={disableUserActions}
// disableAddIssueOption={disableAddIssueOption}
// dragDisabled={dragDisabled}
// handleIssueAction={handleIssueAction}
// handleDraftIssueAction={handleDraftIssueAction}
// handleTrashBox={handleTrashBox}
// openIssuesListModal={cycleId || moduleId ? openIssuesListModal : null}
// myIssueProjectId={myIssueProjectId}
// handleMyIssueOpen={handleMyIssueOpen}
// removeIssue={removeIssue}
// states={states}
// user={user}
// userAuth={memberRole}
// viewProps={viewProps}
// />
// ) : displayFilters?.layout === "calendar" ? (
// <CalendarView
// handleIssueAction={handleIssueAction}
// addIssueToDate={addIssueToDate}
// disableUserActions={disableUserActions}
// user={user}
// userAuth={memberRole}
// />
// ) : displayFilters?.layout === "spreadsheet" ? (
// <SpreadsheetView
// handleIssueAction={handleIssueAction}
// spreadsheetIssues={spreadsheetIssues}
// mutateIssues={mutateIssues}
// openIssuesListModal={cycleId || moduleId ? openIssuesListModal : null}
// disableUserActions={disableUserActions}
// user={user}
// userAuth={memberRole}
// />
// ) : (
// displayFilters?.layout === "gantt_chart" && <GanttChartView disableUserActions={disableUserActions} />
// )}
// </>
// ) : router.pathname.includes("archived-issues") ? (
// <EmptyState
// title="Archived Issues will be shown here"
// description="All the issues that have been in the completed or canceled groups for the configured period of time can be viewed here."
// image={emptyIssueArchive}
// primaryButton={{
// text: "Go to Automation Settings",
// onClick: () => {
// router.push(`/${workspaceSlug}/projects/${projectId}/settings/automations`);
// },
// }}
// />
// ) : (
// <EmptyState
// title={emptyState.title}
// description={emptyState.description}
// image={emptyIssue}
// primaryButton={
// emptyState.primaryButton
// ? {
// icon: emptyState.primaryButton.icon,
// text: emptyState.primaryButton.text,
// onClick: emptyState.primaryButton.onClick,
// }
// : undefined
// }
// secondaryButton={emptyState.secondaryButton}
// />
// )
// ) : (
// <div className="flex h-full w-full items-center justify-center">
// <Spinner />
// </div>
// )}
// </DragDropContext>
// );
// };
const handleTrashBox = useCallback(
(isDragging: boolean) => {
if (isDragging && !trashBox) setTrashBox(true);
},
[trashBox, setTrashBox]
);
return (
<DragDropContext onDragEnd={handleOnDragEnd}>
<StrictModeDroppable droppableId="trashBox">
{(provided, snapshot) => (
<div
className={`${
trashBox ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0"
} fixed top-4 left-1/2 -translate-x-1/2 z-40 w-72 flex items-center justify-center gap-2 rounded border-2 border-red-500/20 bg-custom-background-100 px-3 py-5 text-xs font-medium italic text-red-500 ${
snapshot.isDraggingOver ? "bg-red-500 blur-2xl opacity-70" : ""
} transition duration-300`}
ref={provided.innerRef}
{...provided.droppableProps}
>
<TrashIcon className="h-4 w-4" />
Drop here to delete the issue.
</div>
)}
</StrictModeDroppable>
{groupedIssues ? (
!isEmpty ||
displayFilters?.layout === "kanban" ||
displayFilters?.layout === "calendar" ||
displayFilters?.layout === "gantt_chart" ? (
<>
{displayFilters?.layout === "list" ? (
<AllLists
states={states}
addIssueToGroup={addIssueToGroup}
handleIssueAction={handleIssueAction}
handleDraftIssueAction={handleDraftIssueAction}
openIssuesListModal={cycleId || moduleId ? openIssuesListModal : null}
removeIssue={removeIssue}
myIssueProjectId={myIssueProjectId}
handleMyIssueOpen={handleMyIssueOpen}
disableUserActions={disableUserActions}
disableAddIssueOption={disableAddIssueOption}
user={user}
userAuth={memberRole}
viewProps={viewProps}
/>
) : displayFilters?.layout === "kanban" ? (
<AllBoards
addIssueToGroup={addIssueToGroup}
disableUserActions={disableUserActions}
disableAddIssueOption={disableAddIssueOption}
dragDisabled={dragDisabled}
handleIssueAction={handleIssueAction}
handleDraftIssueAction={handleDraftIssueAction}
handleTrashBox={handleTrashBox}
openIssuesListModal={cycleId || moduleId ? openIssuesListModal : null}
myIssueProjectId={myIssueProjectId}
handleMyIssueOpen={handleMyIssueOpen}
removeIssue={removeIssue}
states={states}
user={user}
userAuth={memberRole}
viewProps={viewProps}
/>
) : displayFilters?.layout === "calendar" ? (
<CalendarView
handleIssueAction={handleIssueAction}
addIssueToDate={addIssueToDate}
disableUserActions={disableUserActions}
user={user}
userAuth={memberRole}
/>
) : displayFilters?.layout === "spreadsheet" ? (
<SpreadsheetView
handleIssueAction={handleIssueAction}
spreadsheetIssues={spreadsheetIssues}
mutateIssues={mutateIssues}
openIssuesListModal={cycleId || moduleId ? openIssuesListModal : null}
disableUserActions={disableUserActions}
user={user}
userAuth={memberRole}
/>
) : (
displayFilters?.layout === "gantt_chart" && (
<GanttChartView disableUserActions={disableUserActions} />
)
)}
</>
) : router.pathname.includes("archived-issues") ? (
<EmptyState
title="Archived Issues will be shown here"
description="All the issues that have been in the completed or canceled groups for the configured period of time can be viewed here."
image={emptyIssueArchive}
primaryButton={{
text: "Go to Automation Settings",
onClick: () => {
router.push(`/${workspaceSlug}/projects/${projectId}/settings/automations`);
},
}}
/>
) : (
<EmptyState
title={emptyState.title}
description={emptyState.description}
image={emptyIssue}
primaryButton={
emptyState.primaryButton
? {
icon: emptyState.primaryButton.icon,
text: emptyState.primaryButton.text,
onClick: emptyState.primaryButton.onClick,
}
: undefined
}
secondaryButton={emptyState.secondaryButton}
/>
)
) : (
<div className="flex h-full w-full items-center justify-center">
<Spinner />
</div>
)}
</DragDropContext>
);
};
export const AllViews = () => <></>;

View File

@ -1,152 +0,0 @@
import { useRouter } from "next/router";
//hook
import useMyIssues from "hooks/my-issues/use-my-issues";
import useIssuesView from "hooks/use-issues-view";
import useProfileIssues from "hooks/use-profile-issues";
// components
import { SingleBoard } from "components/core/views/board-view/single-board";
import { IssuePeekOverview } from "components/issues";
// icons
import { StateGroupIcon } from "components/icons";
// helpers
import { addSpaceIfCamelCase } from "helpers/string.helper";
// types
import { ICurrentUserResponse, IIssue, IIssueViewProps, IState, UserAuth } from "types";
type Props = {
addIssueToGroup: (groupTitle: string) => void;
disableUserActions: boolean;
disableAddIssueOption?: boolean;
dragDisabled: boolean;
handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void;
handleDraftIssueAction?: (issue: IIssue, action: "edit" | "delete") => void;
handleTrashBox: (isDragging: boolean) => void;
openIssuesListModal?: (() => void) | null;
removeIssue: ((bridgeId: string, issueId: string) => void) | null;
myIssueProjectId?: string | null;
handleMyIssueOpen?: (issue: IIssue) => void;
states: IState[] | undefined;
user: ICurrentUserResponse | undefined;
userAuth: UserAuth;
viewProps: IIssueViewProps;
};
export const AllBoards: React.FC<Props> = ({
addIssueToGroup,
disableUserActions,
disableAddIssueOption = false,
dragDisabled,
handleIssueAction,
handleDraftIssueAction,
handleTrashBox,
openIssuesListModal,
myIssueProjectId,
handleMyIssueOpen,
removeIssue,
states,
user,
userAuth,
viewProps,
}) => {
const router = useRouter();
const { workspaceSlug, projectId, userId } = router.query;
const isProfileIssue =
router.pathname.includes("assigned") ||
router.pathname.includes("created") ||
router.pathname.includes("subscribed");
const isMyIssue = router.pathname.includes("my-issues");
const { mutateIssues } = useIssuesView();
const { mutateMyIssues } = useMyIssues(workspaceSlug?.toString());
const { mutateProfileIssues } = useProfileIssues(workspaceSlug?.toString(), userId?.toString());
const { displayFilters, groupedIssues } = viewProps;
return (
<>
<IssuePeekOverview
handleMutation={() =>
isMyIssue ? mutateMyIssues() : isProfileIssue ? mutateProfileIssues() : mutateIssues()
}
projectId={myIssueProjectId ? myIssueProjectId : projectId?.toString() ?? ""}
workspaceSlug={workspaceSlug?.toString() ?? ""}
readOnly={disableUserActions}
/>
{groupedIssues ? (
<div className="horizontal-scroll-enable flex h-full gap-x-4 p-8 bg-custom-background-90">
{Object.keys(groupedIssues).map((singleGroup, index) => {
const currentState =
displayFilters?.group_by === "state"
? states?.find((s) => s.id === singleGroup)
: null;
if (!displayFilters?.show_empty_groups && groupedIssues[singleGroup].length === 0)
return null;
return (
<SingleBoard
key={index}
addIssueToGroup={() => addIssueToGroup(singleGroup)}
currentState={currentState}
disableUserActions={disableUserActions}
disableAddIssueOption={disableAddIssueOption}
dragDisabled={dragDisabled}
groupTitle={singleGroup}
handleIssueAction={handleIssueAction}
handleDraftIssueAction={handleDraftIssueAction}
handleTrashBox={handleTrashBox}
openIssuesListModal={openIssuesListModal ?? null}
handleMyIssueOpen={handleMyIssueOpen}
removeIssue={removeIssue}
user={user}
userAuth={userAuth}
viewProps={viewProps}
/>
);
})}
{!displayFilters?.show_empty_groups && (
<div className="h-full w-96 flex-shrink-0 space-y-2 p-1">
<h2 className="text-lg font-semibold">Hidden groups</h2>
<div className="space-y-3">
{Object.keys(groupedIssues).map((singleGroup, index) => {
const currentState =
displayFilters?.group_by === "state"
? states?.find((s) => s.id === singleGroup)
: null;
if (groupedIssues[singleGroup].length === 0)
return (
<div
key={index}
className="flex items-center justify-between gap-2 rounded bg-custom-background-90 p-2 shadow"
>
<div className="flex items-center gap-2">
{currentState && (
<StateGroupIcon
stateGroup={currentState.group}
color={currentState.color}
height="16px"
width="16px"
/>
)}
<h4 className="text-sm capitalize">
{displayFilters?.group_by === "state"
? addSpaceIfCamelCase(currentState?.name ?? "")
: addSpaceIfCamelCase(singleGroup)}
</h4>
</div>
<span className="text-xs text-custom-text-200">0</span>
</div>
);
})}
</div>
</div>
)}
</div>
) : null}
</>
);
};

View File

@ -1,231 +0,0 @@
import React from "react";
import { useRouter } from "next/router";
import useSWR from "swr";
// services
import issuesService from "services/issues.service";
import projectService from "services/project.service";
// hooks
import useProjects from "hooks/use-projects";
// component
import { Avatar, Icon } from "components/ui";
// icons
import { PlusIcon } from "@heroicons/react/24/outline";
import { PriorityIcon, StateGroupIcon } from "components/icons";
// helpers
import { addSpaceIfCamelCase } from "helpers/string.helper";
import { renderEmoji } from "helpers/emoji.helper";
// types
import { IIssueViewProps, IState, TIssuePriorities, TStateGroups } from "types";
// fetch-keys
import { PROJECT_ISSUE_LABELS, PROJECT_MEMBERS, WORKSPACE_LABELS } from "constants/fetch-keys";
// constants
import { STATE_GROUP_COLORS } from "constants/state";
type Props = {
currentState?: IState | null;
groupTitle: string;
addIssueToGroup: () => void;
isCollapsed: boolean;
setIsCollapsed: React.Dispatch<React.SetStateAction<boolean>>;
disableUserActions: boolean;
disableAddIssue: boolean;
viewProps: IIssueViewProps;
};
export const BoardHeader: React.FC<Props> = ({
currentState,
groupTitle,
addIssueToGroup,
isCollapsed,
setIsCollapsed,
disableUserActions,
disableAddIssue,
viewProps,
}) => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { displayFilters, groupedIssues } = viewProps;
const { data: issueLabels } = useSWR(
workspaceSlug && projectId && displayFilters?.group_by === "labels"
? PROJECT_ISSUE_LABELS(projectId.toString())
: null,
workspaceSlug && projectId && displayFilters?.group_by === "labels"
? () => issuesService.getIssueLabels(workspaceSlug.toString(), projectId.toString())
: null
);
const { data: workspaceLabels } = useSWR(
workspaceSlug && displayFilters?.group_by === "labels"
? WORKSPACE_LABELS(workspaceSlug.toString())
: null,
workspaceSlug && displayFilters?.group_by === "labels"
? () => issuesService.getWorkspaceLabels(workspaceSlug.toString())
: null
);
const { data: members } = useSWR(
workspaceSlug &&
projectId &&
(displayFilters?.group_by === "created_by" || displayFilters?.group_by === "assignees")
? PROJECT_MEMBERS(projectId.toString())
: null,
workspaceSlug &&
projectId &&
(displayFilters?.group_by === "created_by" || displayFilters?.group_by === "assignees")
? () => projectService.projectMembers(workspaceSlug.toString(), projectId.toString())
: null
);
const { projects } = useProjects();
const getGroupTitle = () => {
let title = addSpaceIfCamelCase(groupTitle);
switch (displayFilters?.group_by) {
case "state":
title = addSpaceIfCamelCase(currentState?.name ?? "");
break;
case "labels":
title =
[...(issueLabels ?? []), ...(workspaceLabels ?? [])]?.find(
(label) => label.id === groupTitle
)?.name ?? "None";
break;
case "project":
title = projects?.find((p) => p.id === groupTitle)?.name ?? "None";
break;
case "assignees":
case "created_by":
const member = members?.find((member) => member.member.id === groupTitle)?.member;
title = member ? member.display_name : "None";
break;
}
return title;
};
const getGroupIcon = () => {
let icon;
switch (displayFilters?.group_by) {
case "state":
icon = currentState && (
<StateGroupIcon
stateGroup={currentState.group}
color={currentState.color}
height="16px"
width="16px"
/>
);
break;
case "state_detail.group":
icon = (
<StateGroupIcon
stateGroup={groupTitle as TStateGroups}
color={STATE_GROUP_COLORS[groupTitle as TStateGroups]}
height="16px"
width="16px"
/>
);
break;
case "priority":
icon = <PriorityIcon priority={groupTitle as TIssuePriorities} className="text-lg" />;
break;
case "project":
const project = projects?.find((p) => p.id === groupTitle);
icon =
project &&
(project.emoji !== null
? renderEmoji(project.emoji)
: project.icon_prop !== null
? renderEmoji(project.icon_prop)
: null);
break;
case "labels":
const labelColor =
[...(issueLabels ?? []), ...(workspaceLabels ?? [])]?.find(
(label) => label.id === groupTitle
)?.color ?? "#000000";
icon = (
<span
className="h-3.5 w-3.5 flex-shrink-0 rounded-full"
style={{ backgroundColor: labelColor }}
/>
);
break;
case "assignees":
case "created_by":
const member = members?.find((member) => member.member.id === groupTitle)?.member;
icon = member ? <Avatar user={member} height="24px" width="24px" fontSize="12px" /> : <></>;
break;
}
return icon;
};
return (
<div
className={`flex items-center justify-between px-1 ${
!isCollapsed ? "flex-col rounded-md bg-custom-background-90" : ""
}`}
>
<div className={`flex items-center ${isCollapsed ? "gap-1" : "flex-col gap-2"}`}>
<div
className={`flex cursor-pointer items-center gap-x-2 max-w-[316px] ${
!isCollapsed ? "mb-2 flex-col gap-y-2 py-2" : ""
}`}
>
<span className="flex items-center">{getGroupIcon()}</span>
<h2
className={`text-lg font-semibold truncate ${
displayFilters?.group_by === "created_by" ? "" : "capitalize"
}`}
style={{
writingMode: isCollapsed ? "horizontal-tb" : "vertical-rl",
}}
>
{getGroupTitle()}
</h2>
<span className={`${isCollapsed ? "ml-0.5" : ""} py-1 text-center text-sm`}>
{groupedIssues?.[groupTitle].length ?? 0}
</span>
</div>
</div>
<div className={`flex items-center ${!isCollapsed ? "flex-col pb-2" : ""}`}>
<button
type="button"
className="grid h-7 w-7 place-items-center rounded p-1 text-custom-text-200 outline-none duration-300 hover:bg-custom-background-80"
onClick={() => {
setIsCollapsed((prevData) => !prevData);
}}
>
{isCollapsed ? (
<Icon
iconName="close_fullscreen"
className="text-base font-medium text-custom-text-900"
/>
) : (
<Icon iconName="open_in_full" className="text-base font-medium text-custom-text-900" />
)}
</button>
{!disableAddIssue && !disableUserActions && displayFilters?.group_by !== "created_by" && (
<button
type="button"
className="grid h-7 w-7 place-items-center rounded p-1 text-custom-text-200 outline-none duration-300 hover:bg-custom-background-80"
onClick={addIssueToGroup}
>
<PlusIcon className="h-4 w-4" />
</button>
)}
</div>
</div>
);
};

View File

@ -1,5 +0,0 @@
export * from "./all-boards";
export * from "./board-header";
export * from "./single-board";
export * from "./single-issue";
export * from "./inline-create-issue-form";

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