Compare commits

...

105 Commits

Author SHA1 Message Date
Henit Chobisa cd9cebc7af chore: modified dockerfiles for space and web (#1987) 2023-08-28 13:37:45 +05:30
sriramveeraghanta b77ddf9c95 eslint and tsconfig fixes 2023-08-23 16:10:36 +05:30
sriram veeraghanta 4133798a81 docker fixes 2023-08-23 15:30:18 +05:30
sriram veeraghanta 761aefa73b merge conflicts resolved 2023-08-23 14:43:20 +05:30
Aaryan Khandelwal 561fb9815b chore: update export services icons (#1946)
Co-authored-by: Aaryan Khandelwal <aaryan610@Aaryans-MacBook-Pro.local>
2023-08-23 12:29:59 +05:30
Bavisetti Narayan 2cc67f6498 fix: date validation in cycle and module (#1945) 2023-08-23 12:17:20 +05:30
sriram veeraghanta fa403ef4ae dev: remove turbo configs and setup independent packages 2023-08-22 20:15:46 +05:30
Nikhil eee6658cc2 dev: deploy docker containers (#1939) 2023-08-22 19:33:29 +05:30
Bavisetti Narayan 68b438ab1a fix: aws region changed for exporter (#1933)
Co-authored-by: Bavisetti Narayan <narayan@Bavisettis-MacBook-Pro.local>
2023-08-22 13:18:15 +05:30
sriram veeraghanta 3ce0aa8ebc restructring 2023-08-22 13:00:23 +05:30
guru_sainath b406a70e72 fix: access environment variables is changed in services (#1930)
Co-authored-by: Sainath <sainath@Sainaths-MacBook-Pro.local>
2023-08-22 01:13:51 +05:30
Aaryan Khandelwal b02417120b chore: hide new issue button from my subscribed issues page (#1927) 2023-08-21 20:50:17 +05:30
Aaryan Khandelwal d040394826 fix: create project button not appearing on the sidebar (#1926) 2023-08-21 20:44:51 +05:30
Nikhil f7682c57ba fix: plane space start up command (#1925) 2023-08-21 20:25:12 +05:30
guru_sainath 9bb6254515 chore: updated default api base_url (#1922) 2023-08-21 18:17:32 +05:30
Aaryan Khandelwal ae052f1890 chore: update restricted workspace slugs (#1920) 2023-08-21 18:13:08 +05:30
Henit Chobisa cfc7049343 Dockerrizing space project (#1921)
* chore: Added Dockerfile for Space Project

* fix: next js config to standalone mode

* fix: workedaround build error with rename 404 page

* chore: modified dockerfile with new conventions

* chore: modified dockercompose file for new plane-deploy

* fix: handled ts errors with possibly undefined states

* chore: updated main dockerfile with plane-deploy

* feat: included space project to start.sh

* chore: modified space project port while running in production

* chore: restored changes inside space project

* chore: added ngnix config for space project running :4000

* fix: Updated docker-compose files

* chore: added space url for ngnix config

* chore: Updated ngnix template

* chore: updated space url in compose hub file

* dev: updated dockerfile.space and start and replace script

* dev: equate hub and build docker files

* dev: revert workspace space page

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
2023-08-21 18:12:41 +05:30
guru_sainath 41e55dff85 fix: build error for 404 and search params null check (#1919) 2023-08-21 12:43:41 +05:30
Aaryan Khandelwal 0bccb63a9f fix: module start and target date validations (#1914) 2023-08-21 11:46:02 +05:30
Aaryan Khandelwal 2eb956e97e refactor: project and workspace delete modals (#1915) 2023-08-21 11:44:59 +05:30
M. Palanikannan d470adf262 fix: Image resize, Link selector in Modals, Delete/ sync images and so much more (#1896)
* added image-resizing support

* link form removed

* updated image upload logic and 35% default width on upload

* removed shadow, added alert if not saved and ts errors

* prevent enter key on Link selector to trigger modal submit

* added workspace slug to all tiptap instances

* added delete plugin with loading indicator

* added better syncing of "Saved" state of editor and Image uploads

* removed redundant description_html check
2023-08-19 18:58:54 +05:30
Aaryan Khandelwal cebc8bdc8d fix: context menu dynamic positioning, multiple context menus opening issue (#1913)
* fix: context menu positioning

* fix: close already opened context menu on outside click
2023-08-19 18:50:12 +05:30
Anmol Singh Bhatia 64b5ba196f style: responsive empty state for profile stats (#1911) 2023-08-18 20:18:03 +05:30
Anmol Singh Bhatia 8d5018318d chore: add favorite project from sidebar (#1909) 2023-08-18 18:42:50 +05:30
Anmol Singh Bhatia 0fbdc0b157 style: consistent ui for create update issue modal (#1907) 2023-08-18 18:42:04 +05:30
Bavisetti Narayan 2f39181eb7 fix: priority ordering (#1908) 2023-08-18 18:27:29 +05:30
Dakshesh Jain d825dc5579 style: showing first name for bot profile (#1894) 2023-08-18 17:15:20 +05:30
Aaryan Khandelwal 1f8117c987 fix: dashboard upcoming issues list (#1904) 2023-08-18 17:10:12 +05:30
Bavisetti Narayan 125e9090ea chore: module link model (#1905)
* chore: module link model

* chore: added migration
2023-08-18 15:50:48 +05:30
Bavisetti Narayan 02ac4cee22 chore: renamed target date to start date (#1902) 2023-08-18 15:25:42 +05:30
Anmol Singh Bhatia 93164755e2 style: analytics stats empty state (#1903) 2023-08-18 15:15:33 +05:30
Aaryan Khandelwal 6344f6f562 chore: set order by to manual on gantt chart (#1886) 2023-08-18 15:12:12 +05:30
Aaryan Khandelwal b67e30fd9c chore: analytics start date property for x-axis and group (#1888) 2023-08-18 15:11:25 +05:30
Anmol Singh Bhatia 93fec2c678 fix: completed cycle validation , style: assignee count alignment fix (#1901)
* style: assignee count alignment fix

* fix: completed cycle validation
2023-08-18 14:23:13 +05:30
Anmol Singh Bhatia c3c6ba9e34 chore: link edit functionality (#1895) 2023-08-18 12:03:31 +05:30
Anmol Singh Bhatia d74ec7bda9 fix: ui improvement and bug fixes (#1883) 2023-08-18 12:01:51 +05:30
Henit Chobisa e593a8d4bd chore: Edited Setup Script to take TipTap Auth Token and Generate .npmrc (#1897) 2023-08-18 11:47:58 +05:30
guru_sainath abb8782c44 fix: handled default view on plane deploy (#1893)
* fix: handled default view on plane deploy

* fix: handled default view on refresh
2023-08-17 14:24:33 +05:30
guru_sainath 0afd72db95 fix: updated theming in workspace preferences (#1890) 2023-08-16 20:35:17 +05:30
guru_sainath 65295f6c6f chore: updating the theme using MobX from command k (#1879)
* chore: updating the theme using mobx from command k

* feat: Showing the project published status in the app header

* dev: updated validation and redirection the project publish modal and added redirection on the app header
2023-08-16 18:26:36 +05:30
Aaryan Khandelwal 5b6b43fb83 fix: quick action buttons: (#1884) 2023-08-16 18:25:11 +05:30
Dakshesh Jain f8497125db style: fixed display name coming twice on profile page (#1889) 2023-08-16 18:18:57 +05:30
Bavisetti Narayan b24622e5ef fix: validation for issue activity description (#1887) 2023-08-16 17:12:09 +05:30
guru_sainath 10dface85d chore: updated error pages 404 and project-not-found in plane deploy (#1885)
* dev: custom error messages.

* dev: updated next version in yarn.lock

* dev: updated project-not-published icon
2023-08-16 17:05:40 +05:30
Aditi Patel 2b6debaa3e Updated setup document to include tiptap pro install (#1871) 2023-08-16 14:42:37 +05:30
Nikhil 1750ba344b fix: my issue duplication (#1882)
Co-authored-by: Plane Team <planeteam@srirams-Mac-mini.local>
2023-08-16 14:40:10 +05:30
Nikhil 550473bb02 Merge pull request #1876 from makeplane/stage/merge-fixes
Promote: Develop to Stage Release
2023-08-16 13:18:26 +05:30
sriramveeraghanta fde978861c Merge branch 'develop' of github.com:makeplane/plane into stage/merge-fixes 2023-08-16 13:17:21 +05:30
guru_sainath f44d142f2c chore: tip-tap editor update in workspace user activity (#1877) 2023-08-16 13:16:20 +05:30
guru_sainath 1ded8f486f chore: updated meta tags for project issues (#1875)
* dev: updated meta tags for project issues

* dev: updated project description in meta tags in plane deploy.
2023-08-16 13:15:57 +05:30
Bavisetti Narayan 2c43a15515 chore: added new filed in serializer (#1874)
Co-authored-by: NarayanBavisetti <narayan311@gmail.com>
2023-08-16 13:10:03 +05:30
Dakshesh Jain 0979acc1a4 fix: notification card not redirecting to archive issue detail for archived issue (#1861) 2023-08-16 13:09:56 +05:30
sriramveeraghanta 9003c58d89 merge conflicts resolved 2023-08-16 13:00:58 +05:30
Nikhil 55e2f00ffe fix: members list filtering for workspace and projects (#1872) 2023-08-16 12:21:56 +05:30
Nikhil 08382f88b4 chore: updated migration files for 0.11 (#1851) 2023-08-16 10:37:22 +05:30
Bavisetti Narayan 72419447ec fix: added slug in filename for export issues (#1870)
* chore: file name changed for exported issues

* fix: added slug in filename for export issues

---------

Co-authored-by: NarayanBavisetti <narayan311@gmail.com>
2023-08-16 09:39:17 +05:30
Nikhil b554087b1f chore: deploy board status for project (#1866) 2023-08-16 01:00:22 +05:30
Bavisetti Narayan 07717e9a93 chore: file name changed for exported issues (#1865)
Co-authored-by: NarayanBavisetti <narayan311@gmail.com>
2023-08-15 19:49:17 +05:30
Nikhil df46a45afc fix: analytics export (#1862)
* fix: analytics export

* dev: export analytics assignee indexing

* dev: total counts
2023-08-15 15:48:22 +05:30
sriram veeraghanta e1ae0d3b56 feat : Tiptap integration (#1832)
* remirror instances commented out to avoid prosemirror conflicts

* styles migrated for remirror to tiptap transition

* added bubblemenu support with extensions

* fixed css for task lists and code with syntax highlighting

* added support for slash command

* fixed bubble menu to match styles and added better seperation in UI

* saving with debounce logic added and it's stored in backend

* added migration support by updating to html

* Image uploads done

* improved file structure and delete image function implemented

* Integrated tiptap with Issue Modal

* added additional props and Tiptap Integration with Comments

* added tiptap integration with user activity feeds

* added ref control support and bubble menu support for readonly editor

* added tiptap support for plane pages

* added tiptap support to gpt assistant modal (yet to be tested)

* removed remirror instances and cleaned up code

* improved code structure for extracting props in Tiptap

* fixing ts errors for next build

* fixing node ts error for Horizontal Rule

* added ts fix for node types

* temp fix

* temp fix

* added min height for issue description in modal

* added resolutions to prosemirror-model version

* trying pnpm overrides

* explicitly added prosemirror deps

* bugfixes

* removed extra gap at the top and moved saved indicator to the bottom

* fix: slash command scroll position

* chore: update custom css variables

* matched theme colours

* fixed gpt-assistant modal

* updated yarn lock

* added debounced updates for the title and removed saved state after timeout

* added css animations for saved state

* build fixes and remove remirror instances

* minor commenting fixes

---------

Co-authored-by: Palanikannan1437 <73993394+Palanikannan1437@users.noreply.github.com>
Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>
2023-08-15 15:04:46 +05:30
guru_sainath daa8f7d79b feat / public deploy settings workflow (#1863)
* Feat: Implemented project publish settings

* dev: updated the env dependancy in turbo and enabling the publish access to admin
2023-08-14 19:18:38 +05:30
Nikhil d2cdaaccb9 fix: slack queryset (#1860) 2023-08-14 18:53:09 +05:30
Anmol Singh Bhatia 6774eddb66 style: sidebar select date width (#1859) 2023-08-14 18:10:21 +05:30
Dakshesh Jain 8ccc1b3fcc fix: gray background on png image (#1856) 2023-08-14 16:23:10 +05:30
Anmol Singh Bhatia 5e76e03a55 style: profile activity loader (#1858) 2023-08-14 16:20:53 +05:30
srinivas pendem 77fb50faa4 fix: route fix in imports and exports (#1857) 2023-08-14 16:19:13 +05:30
Bavisetti Narayan 5ddfee12bc fix: changed the display of date format (#1855)
Co-authored-by: NarayanBavisetti <narayan311@gmail.com>
2023-08-14 16:10:57 +05:30
Aaryan Khandelwal f7a596c113 fix: gantt chart block left drag flicker (#1854)
* fix: left drag flicker

* fix: opposite side manual scroll
2023-08-14 14:27:02 +05:30
Dakshesh Jain f73239be92 refactor: using webp image format instead of svg (#1852) 2023-08-14 13:26:16 +05:30
Anmol Singh Bhatia dc2438b2d3 style: image upload modal aspect ratio (#1853) 2023-08-14 13:06:00 +05:30
srinivas pendem ddd3301d17 feat: csv, json and, xlsx exporter (#1840)
* feat : csv, jason and, xlxs exporter

* handeling the export fail

* adding expired state to exports

* typo update

* header change

* improvement: added validation for the expired date

---------

Co-authored-by: srinivaspendem <you@example.comsrinivaspendem2612@gmail.com>
2023-08-14 11:44:17 +05:30
Vamsi Kurama 816f00d956 Merge pull request #1848 from ryota-murakami/fix/license 2023-08-12 17:12:14 +05:30
ryota-murakami 70e2509d52 package.json; fix license 2023-08-12 20:30:01 +09:00
Bavisetti Narayan 1a9faa025a fix: export issues in CSV, JSON and XLSX (#1794)
* fix: file name change

* feat: added xml json and csv export

* chore: added openpyxl package

* fix: added initiated_by field

* fix: added initiated by details

* dev: refactoring

* fix: rendering assignee name and labels in sheet

* fix: handeled exception in label

* feat: implemented link expiration scheduler(8 days)

* fix: removed the expired field

---------

Co-authored-by: NarayanBavisetti <narayan311@gmail.com>
Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
2023-08-11 20:09:52 +05:30
Nikhil feba1cc4d0 feat: project public boards (#1772)
* feat: project public boards

* dev: public issue and comment reactions

* dev: public issue comments

* dev: public comments

* dev: inbox for public boards

* dev: inbox issues for public board

* dev: public inbox issue

* dev: migrations

* dev: update api endpoints

* dev: project boards and views

* dev: state and label details

* dev: public issue voting

* dev: issue voting

* dev: workspace details

* dev: project icon and emoji
2023-08-11 19:27:44 +05:30
Anmol Singh Bhatia 079a5b28d8 style: custom theming color picker position (#1846) 2023-08-11 19:17:59 +05:30
Aaryan Khandelwal c6eea9c7a9 fix: empty groups not appearing in the kanban view (#1843) 2023-08-11 19:17:02 +05:30
Anmol Singh Bhatia 5f5790ebf9 chore: start date option added in spreadsheet view (#1824) 2023-08-11 19:14:54 +05:30
Anmol Singh Bhatia a3d99100ee fix: sub issue progress indicator fix (#1847) 2023-08-11 19:05:13 +05:30
Anmol Singh Bhatia ac6d2b0139 chore: updated user permission for project and workspace settings (#1807)
* fix: updated user permission for project and workspace settings

* chore: workspace delete modal fix
2023-08-11 18:29:24 +05:30
Dakshesh Jain 7becec4ee9 feat: randomize color on label create (#1839)
fix: create label state being persisted after edit label
2023-08-11 17:42:47 +05:30
guru_sainath cd5e5b96da feat: Mobx integration, List and Kanban boards implementation in plane space (#1844)
* feat: init mobx and issue filter

* feat: Implemented list and kanban views in plane space and integrated mobx.

* feat: updated store type check
2023-08-11 17:18:33 +05:30
Dakshesh Jain ad4cdcc512 fix: cmdk modal not closing when choosing an option (#1833) 2023-08-11 17:16:37 +05:30
Nikhil 6617049983 fix: sub issue endpoint for state distribution (#1845) 2023-08-11 16:38:03 +05:30
Dakshesh Jain abec46a725 fix: cmdk not changing theme if active theme is custom-theme (#1842) 2023-08-11 16:16:20 +05:30
Nikhil 88e987b902 feat: cycle and module sort order (#1841)
* dev: cycle and module ordering

* dev: sort order for smallest
2023-08-11 16:03:15 +05:30
Aaryan Khandelwal 785a6e8871 chore: gantt chart resizable blocks, y-axis drag and drop (#1810)
* chore: gantt chart resizable blocks

* chore: right scroll added

* chore: left scroll added

* fix: build errors

* chore: remove unnecessary console logs

* chore: add block type and remove info toggle

* feat: gantt chart blocks y-axis drag and drop

* chore: disable drag flag

* fix: y-axis drag mutation

* fix: scroll container undefined error

* fix: negative scroll

* fix: negative scroll

* style: blocks tooltip consistency
2023-08-11 15:59:13 +05:30
Anmol Singh Bhatia 762ef422ca chore: project search added in project list page (#1837) 2023-08-11 15:57:11 +05:30
Anmol Singh Bhatia e2b5657c3e chore: profile cover photo (#1836) 2023-08-11 15:56:26 +05:30
Anmol Singh Bhatia dbb53a663e style: sidebar ui improvement (#1816)
* style: sidebar ui improvement

* style: sidebar menu spacing

* style: sidebar consistent spacing

* style: notification improvement

* style: remove from favorite filled icon added

* chore: settings option added in sidebar context menu

* chore: update delete project option visibility for sidebar

* style: sidebar project list display border top on scroll
2023-08-11 15:53:10 +05:30
Anmol Singh Bhatia 5c964d144a style: ui improvement (#1806)
* style: kanban board theming

* style: assignee ui revamp

* style: kanban board header, label , priority , state and avatar ui revamp

* style: kanban card state dropdown

* style: sidebar profile dropdown

* style: sidebar dropdown icon

* style: sidebar workspace dropdown

* style: assignee select component

* fix: state select

* style: consistent app header button
2023-08-11 15:50:05 +05:30
Anmol Singh Bhatia 289e81d6eb chore: issue activity user redirection added (#1805)
* feat: issue activity user redirection added

* feat: analytics page user redirection
2023-08-11 15:48:52 +05:30
Nikhil def10af1e2 fix: issue date filtering (#1834) 2023-08-11 12:48:30 +05:30
Nikhil edaeae1b69 chore: sort order for cycle and modules (#1835) 2023-08-11 12:48:02 +05:30
Nikhil e06ee25800 fix: project states create (#1830) 2023-08-11 12:46:50 +05:30
Nikhil be86a7d38e feat: project member role (#1828) 2023-08-11 12:46:29 +05:30
Nikhil 0a1483c482 chore: issue list date filters and properties (#1820)
* dev: start date and target date validation and filter for null dated issues

* dev: remove print logs

* dev: issue property dates
2023-08-11 12:45:51 +05:30
Nikhil 11abd3cadf fix: user id for default analytics (#1808) 2023-08-11 12:45:04 +05:30
Nikhil 085cd1960e fix: project members endpoint email (#1804)
* fix: project members endpoint email

* dev: cycle and module assignee display name
2023-08-11 12:44:20 +05:30
Nikhil 2769a73898 remove: auto start date configuration (#1799) 2023-08-11 12:43:43 +05:30
Nikhil 8373f20944 fix: issues n plus 1 (#1785) 2023-08-11 12:43:00 +05:30
Nikhil 9b4aebc385 promote: develop to stage-release v0.10-patch (#1783)
* chore: show message if dragging unjoined project (#1763)

* fix: invalid project selection in create issue modal (#1766)

* style: sidebar project list improvement (#1767)

* fix: comment reaction mutation (#1768)

* fix: user profiles n plus 1 (#1765)

* fix: bulk issue import (#1773)

* style: profile activity (#1771)

* style: profile activity comment log styling

* chore: profile feed activity refactor

* style: sidebar project list

* chore: add non existing states for project entities (#1770)

* fix: notification read status being toggled when click on link (#1769)

* fix: custom theme persisting after signing out (#1780)

* fix: custom theme persistence

* chore: remove console logs

* fix: build error

* fix: change theme from command k

---------

Co-authored-by: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com>
Co-authored-by: Dakshesh Jain <65905942+dakshesh14@users.noreply.github.com>
Co-authored-by: Anmol Singh Bhatia <121005188+anmolsinghbhatia@users.noreply.github.com>
2023-08-03 15:28:22 +05:30
Nikhil 9828d2332a Merge pull request #1761 from makeplane/develop
promote: develop to stage-release
2023-08-01 22:07:03 +05:30
Nikhil 9f69fe6060 Merge pull request #1750 from makeplane/develop
promote: develop to stage-release
2023-08-01 19:35:54 +05:30
Aaryan Khandelwal d9339b8f8e Merge pull request #1729 from makeplane/develop
promote: develop to stage-release
2023-08-01 15:46:56 +05:30
963 changed files with 21326 additions and 7568 deletions
+2
View File
@@ -21,6 +21,8 @@ NEXT_PUBLIC_TRACK_EVENTS=0
NEXT_PUBLIC_SLACK_CLIENT_ID=""
# For Telemetry, set it to "app.plane.so"
NEXT_PUBLIC_PLAUSIBLE_DOMAIN=""
# public boards deploy url
NEXT_PUBLIC_DEPLOY_URL=""
# Backend
# Debug value for api server use it as 0 for production use
+1 -1
View File
@@ -4,7 +4,7 @@ module.exports = {
extends: ["custom"],
settings: {
next: {
rootDir: ["apps/*"],
rootDir: ["web/", "deploy/"],
},
},
};
+3 -1
View File
@@ -70,4 +70,6 @@ package-lock.json
# lock files
package-lock.json
pnpm-lock.yaml
pnpm-workspace.yaml
pnpm-workspace.yaml
.npmrc
+10 -3
View File
@@ -5,9 +5,11 @@ WORKDIR /app
ENV NEXT_PUBLIC_API_BASE_URL=http://NEXT_PUBLIC_API_BASE_URL_PLACEHOLDER
RUN yarn global add turbo
RUN apk add tree
COPY . .
RUN turbo prune --scope=app --docker
RUN turbo prune --scope=app --scope=plane-deploy --docker
CMD tree -I node_modules/
# Add lockfile and package.json's of isolated subworkspace
FROM node:18-alpine AS installer
@@ -21,14 +23,14 @@ COPY --from=builder /app/out/json/ .
COPY --from=builder /app/out/yarn.lock ./yarn.lock
RUN yarn install
# Build the project
# # Build the project
COPY --from=builder /app/out/full/ .
COPY turbo.json turbo.json
COPY replace-env-vars.sh /usr/local/bin/
USER root
RUN chmod +x /usr/local/bin/replace-env-vars.sh
RUN yarn turbo run build --filter=app
RUN yarn turbo run build
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL \
BUILT_NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
@@ -96,11 +98,16 @@ RUN adduser --system --uid 1001 captain
COPY --from=installer /app/apps/app/next.config.js .
COPY --from=installer /app/apps/app/package.json .
COPY --from=installer /app/apps/space/next.config.js .
COPY --from=installer /app/apps/space/package.json .
COPY --from=installer --chown=captain:plane /app/apps/app/.next/standalone ./
COPY --from=installer --chown=captain:plane /app/apps/app/.next/static ./apps/app/.next/static
COPY --from=installer --chown=captain:plane /app/apps/space/.next/standalone ./
COPY --from=installer --chown=captain:plane /app/apps/space/.next ./apps/space/.next
ENV NEXT_TELEMETRY_DISABLED 1
# RUN rm /etc/nginx/conf.d/default.conf
+10
View File
@@ -61,6 +61,16 @@ chmod +x setup.sh
> If running in a cloud env replace localhost with public facing IP address of the VM
- Setup Tiptap Pro
Visit [Tiptap Pro](https://collab.tiptap.dev/pro-extensions) and signup (it is free).
Create a **`.npmrc`** file, copy the following and replace your registry token generated from Tiptap Pro.
```
@tiptap-pro:registry=https://registry.tiptap.dev/
//registry.tiptap.dev/:_authToken=YOUR_REGISTRY_TOKEN
```
- Run Docker compose up
```bash
@@ -18,6 +18,8 @@ from .project import (
ProjectFavoriteSerializer,
ProjectLiteSerializer,
ProjectMemberLiteSerializer,
ProjectDeployBoardSerializer,
ProjectMemberAdminSerializer,
)
from .state import StateSerializer, StateLiteSerializer
from .view import IssueViewSerializer, IssueViewFavoriteSerializer
@@ -41,6 +43,7 @@ from .issue import (
IssueSubscriberSerializer,
IssueReactionSerializer,
CommentReactionSerializer,
IssueVoteSerializer,
)
from .module import (
@@ -78,3 +81,5 @@ from .inbox import InboxSerializer, InboxIssueSerializer, IssueStateInboxSeriali
from .analytic import AnalyticViewSerializer
from .notification import NotificationSerializer
from .exporter import ExporterHistorySerializer
+11
View File
@@ -14,6 +14,11 @@ from plane.db.models import Cycle, CycleIssue, CycleFavorite
class CycleWriteSerializer(BaseSerializer):
def validate(self, data):
if data.get("start_date", None) is not None and data.get("end_date", None) is not None and data.get("start_date", None) > data.get("end_date", None):
raise serializers.ValidationError("Start date cannot exceed end date")
return data
class Meta:
model = Cycle
fields = "__all__"
@@ -35,12 +40,18 @@ class CycleSerializer(BaseSerializer):
started_estimates = serializers.IntegerField(read_only=True)
workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace")
project_detail = ProjectLiteSerializer(read_only=True, source="project")
def validate(self, data):
if data.get("start_date", None) is not None and data.get("end_date", None) is not None and data.get("start_date", None) > data.get("end_date", None):
raise serializers.ValidationError("Start date cannot exceed end date")
return data
def get_assignees(self, obj):
members = [
{
"avatar": assignee.avatar,
"first_name": assignee.first_name,
"display_name": assignee.display_name,
"id": assignee.id,
}
for issue_cycle in obj.issue_cycle.all()
@@ -0,0 +1,26 @@
# Module imports
from .base import BaseSerializer
from plane.db.models import ExporterHistory
from .user import UserLiteSerializer
class ExporterHistorySerializer(BaseSerializer):
initiated_by_detail = UserLiteSerializer(source="initiated_by", read_only=True)
class Meta:
model = ExporterHistory
fields = [
"id",
"created_at",
"updated_at",
"project",
"provider",
"status",
"url",
"initiated_by",
"initiated_by_detail",
"token",
"created_by",
"updated_by",
]
read_only_fields = fields
+15
View File
@@ -31,6 +31,7 @@ from plane.db.models import (
IssueAttachment,
IssueReaction,
CommentReaction,
IssueVote,
)
@@ -111,6 +112,11 @@ class IssueCreateSerializer(BaseSerializer):
"updated_at",
]
def validate(self, data):
if data.get("start_date", None) is not None and data.get("target_date", None) is not None and data.get("start_date", None) > data.get("target_date", None):
raise serializers.ValidationError("Start date cannot exceed target date")
return data
def create(self, validated_data):
blockers = validated_data.pop("blockers_list", None)
assignees = validated_data.pop("assignees_list", None)
@@ -549,6 +555,14 @@ class CommentReactionSerializer(BaseSerializer):
class IssueVoteSerializer(BaseSerializer):
class Meta:
model = IssueVote
fields = ["issue", "vote", "workspace_id", "project_id", "actor"]
read_only_fields = fields
class IssueCommentSerializer(BaseSerializer):
actor_detail = UserLiteSerializer(read_only=True, source="actor")
issue_detail = IssueFlatSerializer(read_only=True, source="issue")
@@ -568,6 +582,7 @@ class IssueCommentSerializer(BaseSerializer):
"updated_by",
"created_at",
"updated_at",
"access",
]
@@ -40,6 +40,11 @@ class ModuleWriteSerializer(BaseSerializer):
"updated_at",
]
def validate(self, data):
if data.get("start_date", None) is not None and data.get("target_date", None) is not None and data.get("start_date", None) > data.get("target_date", None):
raise serializers.ValidationError("Start date cannot exceed target date")
return data
def create(self, validated_data):
members = validated_data.pop("members_list", None)
+25 -4
View File
@@ -14,6 +14,7 @@ from plane.db.models import (
ProjectMemberInvite,
ProjectIdentifier,
ProjectFavorite,
ProjectDeployBoard,
)
@@ -80,7 +81,15 @@ class ProjectSerializer(BaseSerializer):
class ProjectLiteSerializer(BaseSerializer):
class Meta:
model = Project
fields = ["id", "identifier", "name"]
fields = [
"id",
"identifier",
"name",
"cover_image",
"icon_prop",
"emoji",
"description",
]
read_only_fields = fields
@@ -94,6 +103,8 @@ class ProjectDetailSerializer(BaseSerializer):
total_modules = serializers.IntegerField(read_only=True)
is_member = serializers.BooleanField(read_only=True)
sort_order = serializers.FloatField(read_only=True)
member_role = serializers.IntegerField(read_only=True)
is_deployed = serializers.BooleanField(read_only=True)
class Meta:
model = Project
@@ -115,7 +126,6 @@ class ProjectMemberAdminSerializer(BaseSerializer):
project = ProjectLiteSerializer(read_only=True)
member = UserAdminLiteSerializer(read_only=True)
class Meta:
model = ProjectMember
fields = "__all__"
@@ -148,8 +158,6 @@ class ProjectFavoriteSerializer(BaseSerializer):
]
class ProjectMemberLiteSerializer(BaseSerializer):
member = UserLiteSerializer(read_only=True)
is_subscribed = serializers.BooleanField(read_only=True)
@@ -158,3 +166,16 @@ class ProjectMemberLiteSerializer(BaseSerializer):
model = ProjectMember
fields = ["member", "id", "is_subscribed"]
read_only_fields = fields
class ProjectDeployBoardSerializer(BaseSerializer):
project_details = ProjectLiteSerializer(read_only=True, source="project")
workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace")
class Meta:
model = ProjectDeployBoard
fields = "__all__"
read_only_fields = [
"workspace",
"project" "anchor",
]
+134
View File
@@ -86,6 +86,7 @@ from plane.api.views import (
IssueAttachmentEndpoint,
IssueArchiveViewSet,
IssueSubscriberViewSet,
IssueCommentPublicViewSet,
IssueReactionViewSet,
CommentReactionViewSet,
ExportIssuesEndpoint,
@@ -165,6 +166,15 @@ from plane.api.views import (
NotificationViewSet,
UnreadNotificationEndpoint,
## End Notification
# Public Boards
ProjectDeployBoardViewSet,
ProjectDeployBoardIssuesPublicEndpoint,
ProjectDeployBoardPublicSettingsEndpoint,
IssueReactionPublicViewSet,
CommentReactionPublicViewSet,
InboxIssuePublicViewSet,
IssueVotePublicViewSet,
## End Public Boards
)
@@ -1481,4 +1491,128 @@ urlpatterns = [
name="unread-notifications",
),
## End Notification
# Public Boards
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/project-deploy-boards/",
ProjectDeployBoardViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
name="project-deploy-board",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/project-deploy-boards/<uuid:pk>/",
ProjectDeployBoardViewSet.as_view(
{
"get": "retrieve",
"patch": "partial_update",
"delete": "destroy",
}
),
name="project-deploy-board",
),
path(
"public/workspaces/<str:slug>/project-boards/<uuid:project_id>/settings/",
ProjectDeployBoardPublicSettingsEndpoint.as_view(),
name="project-deploy-board-settings",
),
path(
"public/workspaces/<str:slug>/project-boards/<uuid:project_id>/issues/",
ProjectDeployBoardIssuesPublicEndpoint.as_view(),
name="project-deploy-board",
),
path(
"public/workspaces/<str:slug>/project-boards/<uuid:project_id>/issues/<uuid:issue_id>/comments/",
IssueCommentPublicViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
name="issue-comments-project-board",
),
path(
"public/workspaces/<str:slug>/project-boards/<uuid:project_id>/issues/<uuid:issue_id>/comments/<uuid:pk>/",
IssueCommentPublicViewSet.as_view(
{
"get": "retrieve",
"patch": "partial_update",
"delete": "destroy",
}
),
name="issue-comments-project-board",
),
path(
"public/workspaces/<str:slug>/project-boards/<uuid:project_id>/issues/<uuid:issue_id>/reactions/",
IssueReactionPublicViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
name="issue-reactions-project-board",
),
path(
"public/workspaces/<str:slug>/project-boards/<uuid:project_id>/issues/<uuid:issue_id>/reactions/<str:reaction_code>/",
IssueReactionPublicViewSet.as_view(
{
"delete": "destroy",
}
),
name="issue-reactions-project-board",
),
path(
"public/workspaces/<str:slug>/project-boards/<uuid:project_id>/comments/<uuid:comment_id>/reactions/",
CommentReactionPublicViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
name="comment-reactions-project-board",
),
path(
"public/workspaces/<str:slug>/project-boards/<uuid:project_id>/comments/<uuid:comment_id>/reactions/<str:reaction_code>/",
CommentReactionPublicViewSet.as_view(
{
"delete": "destroy",
}
),
name="comment-reactions-project-board",
),
path(
"public/workspaces/<str:slug>/project-boards/<uuid:project_id>/inboxes/<uuid:inbox_id>/inbox-issues/",
InboxIssuePublicViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
name="inbox-issue",
),
path(
"public/workspaces/<str:slug>/project-boards/<uuid:project_id>/inboxes/<uuid:inbox_id>/inbox-issues/<uuid:pk>/",
InboxIssuePublicViewSet.as_view(
{
"get": "retrieve",
"patch": "partial_update",
"delete": "destroy",
}
),
name="inbox-issue",
),
path(
"public/workspaces/<str:slug>/project-boards/<uuid:project_id>/issues/<uuid:issue_id>/votes/",
IssueVotePublicViewSet.as_view(
{
"get": "list",
"post": "create",
"delete": "destroy",
}
),
name="issue-vote-project-board",
),
## End Public Boards
]
+13 -3
View File
@@ -12,6 +12,9 @@ from .project import (
ProjectUserViewsEndpoint,
ProjectMemberUserEndpoint,
ProjectFavoritesViewSet,
ProjectDeployBoardIssuesPublicEndpoint,
ProjectDeployBoardViewSet,
ProjectDeployBoardPublicSettingsEndpoint,
ProjectMemberEndpoint,
)
from .user import (
@@ -75,9 +78,12 @@ from .issue import (
IssueAttachmentEndpoint,
IssueArchiveViewSet,
IssueSubscriberViewSet,
IssueCommentPublicViewSet,
CommentReactionViewSet,
IssueReactionViewSet,
ExportIssuesEndpoint
IssueReactionPublicViewSet,
CommentReactionPublicViewSet,
IssueVotePublicViewSet,
)
from .auth_extended import (
@@ -145,7 +151,7 @@ from .estimate import (
from .release import ReleaseNotesEndpoint
from .inbox import InboxViewSet, InboxIssueViewSet
from .inbox import InboxViewSet, InboxIssueViewSet, InboxIssuePublicViewSet
from .analytic import (
AnalyticsEndpoint,
@@ -155,4 +161,8 @@ from .analytic import (
DefaultAnalyticsEndpoint,
)
from .notification import NotificationViewSet, UnreadNotificationEndpoint
from .notification import NotificationViewSet, UnreadNotificationEndpoint
from .exporter import (
ExportIssuesEndpoint,
)
+3 -3
View File
@@ -243,21 +243,21 @@ class DefaultAnalyticsEndpoint(BaseAPIView):
)
most_issue_created_user = (
queryset.exclude(created_by=None)
.values("created_by__first_name", "created_by__last_name", "created_by__avatar", "created_by__display_name")
.values("created_by__first_name", "created_by__last_name", "created_by__avatar", "created_by__display_name", "created_by__id")
.annotate(count=Count("id"))
.order_by("-count")
)[:5]
most_issue_closed_user = (
queryset.filter(completed_at__isnull=False, assignees__isnull=False)
.values("assignees__first_name", "assignees__last_name", "assignees__avatar", "assignees__display_name")
.values("assignees__first_name", "assignees__last_name", "assignees__avatar", "assignees__display_name", "assignees__id")
.annotate(count=Count("id"))
.order_by("-count")
)[:5]
pending_issue_user = (
queryset.filter(completed_at__isnull=True)
.values("assignees__first_name", "assignees__last_name", "assignees__avatar", "assignees__display_name")
.values("assignees__first_name", "assignees__last_name", "assignees__avatar", "assignees__display_name", "assignees__id")
.annotate(count=Count("id"))
.order_by("-count")
)
+5 -1
View File
@@ -165,6 +165,9 @@ class CycleViewSet(BaseViewSet):
try:
queryset = self.get_queryset()
cycle_view = request.GET.get("cycle_view", "all")
order_by = request.GET.get("order_by", "sort_order")
queryset = queryset.order_by(order_by)
# All Cycles
if cycle_view == "all":
@@ -370,7 +373,8 @@ class CycleViewSet(BaseViewSet):
.annotate(last_name=F("assignees__last_name"))
.annotate(assignee_id=F("assignees__id"))
.annotate(avatar=F("assignees__avatar"))
.values("first_name", "last_name", "assignee_id", "avatar")
.annotate(display_name=F("assignees__display_name"))
.values("first_name", "last_name", "assignee_id", "avatar", "display_name")
.annotate(total_issues=Count("assignee_id"))
.annotate(
completed_issues=Count(
+100
View File
@@ -0,0 +1,100 @@
# Third Party imports
from rest_framework.response import Response
from rest_framework import status
from sentry_sdk import capture_exception
# Module imports
from . import BaseAPIView
from plane.api.permissions import WorkSpaceAdminPermission
from plane.bgtasks.export_task import issue_export_task
from plane.db.models import Project, ExporterHistory, Workspace
from plane.api.serializers import ExporterHistorySerializer
class ExportIssuesEndpoint(BaseAPIView):
permission_classes = [
WorkSpaceAdminPermission,
]
model = ExporterHistory
serializer_class = ExporterHistorySerializer
def post(self, request, slug):
try:
# Get the workspace
workspace = Workspace.objects.get(slug=slug)
provider = request.data.get("provider", False)
multiple = request.data.get("multiple", False)
project_ids = request.data.get("project", [])
if provider in ["csv", "xlsx", "json"]:
if not project_ids:
project_ids = Project.objects.filter(
workspace__slug=slug
).values_list("id", flat=True)
project_ids = [str(project_id) for project_id in project_ids]
exporter = ExporterHistory.objects.create(
workspace=workspace,
project=project_ids,
initiated_by=request.user,
provider=provider,
)
issue_export_task.delay(
provider=exporter.provider,
workspace_id=workspace.id,
project_ids=project_ids,
token_id=exporter.token,
multiple=multiple,
slug=slug,
)
return Response(
{
"message": f"Once the export is ready you will be able to download it"
},
status=status.HTTP_200_OK,
)
else:
return Response(
{"error": f"Provider '{provider}' not found."},
status=status.HTTP_400_BAD_REQUEST,
)
except Workspace.DoesNotExist:
return Response(
{"error": "Workspace does not exists"},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
def get(self, request, slug):
try:
exporter_history = ExporterHistory.objects.filter(
workspace__slug=slug
).select_related("workspace","initiated_by")
if request.GET.get("per_page", False) and request.GET.get("cursor", False):
return self.paginate(
request=request,
queryset=exporter_history,
on_results=lambda exporter_history: ExporterHistorySerializer(
exporter_history, many=True
).data,
)
else:
return Response(
{"error": "per_page and cursor are required"},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
+267 -2
View File
@@ -15,7 +15,6 @@ from sentry_sdk import capture_exception
from .base import BaseViewSet
from plane.api.permissions import ProjectBasePermission, ProjectLitePermission
from plane.db.models import (
Project,
Inbox,
InboxIssue,
Issue,
@@ -23,6 +22,7 @@ from plane.db.models import (
IssueLink,
IssueAttachment,
ProjectMember,
ProjectDeployBoard,
)
from plane.api.serializers import (
IssueSerializer,
@@ -377,4 +377,269 @@ class InboxIssueViewSet(BaseViewSet):
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
)
class InboxIssuePublicViewSet(BaseViewSet):
serializer_class = InboxIssueSerializer
model = InboxIssue
filterset_fields = [
"status",
]
def get_queryset(self):
project_deploy_board = ProjectDeployBoard.objects.get(workspace__slug=self.kwargs.get("slug"), project_id=self.kwargs.get("project_id"))
if project_deploy_board is not None:
return self.filter_queryset(
super()
.get_queryset()
.filter(
Q(snoozed_till__gte=timezone.now()) | Q(snoozed_till__isnull=True),
project_id=self.kwargs.get("project_id"),
workspace__slug=self.kwargs.get("slug"),
inbox_id=self.kwargs.get("inbox_id"),
)
.select_related("issue", "workspace", "project")
)
else:
return InboxIssue.objects.none()
def list(self, request, slug, project_id, inbox_id):
try:
project_deploy_board = ProjectDeployBoard.objects.get(workspace__slug=slug, project_id=project_id)
if project_deploy_board.inbox is None:
return Response({"error": "Inbox is not enabled for this Project Board"}, status=status.HTTP_400_BAD_REQUEST)
filters = issue_filters(request.query_params, "GET")
issues = (
Issue.objects.filter(
issue_inbox__inbox_id=inbox_id,
workspace__slug=slug,
project_id=project_id,
)
.filter(**filters)
.annotate(bridge_id=F("issue_inbox__id"))
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels")
.order_by("issue_inbox__snoozed_till", "issue_inbox__status")
.annotate(
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(
issue=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.prefetch_related(
Prefetch(
"issue_inbox",
queryset=InboxIssue.objects.only(
"status", "duplicate_to", "snoozed_till", "source"
),
)
)
)
issues_data = IssueStateInboxSerializer(issues, many=True).data
return Response(
issues_data,
status=status.HTTP_200_OK,
)
except ProjectDeployBoard.DoesNotExist:
return Response({"error": "Project Deploy Board does not exist"}, status=status.HTTP_400_BAD_REQUEST)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
def create(self, request, slug, project_id, inbox_id):
try:
project_deploy_board = ProjectDeployBoard.objects.get(workspace__slug=slug, project_id=project_id)
if project_deploy_board.inbox is None:
return Response({"error": "Inbox is not enabled for this Project Board"}, status=status.HTTP_400_BAD_REQUEST)
if not request.data.get("issue", {}).get("name", False):
return Response(
{"error": "Name is required"}, status=status.HTTP_400_BAD_REQUEST
)
# Check for valid priority
if not request.data.get("issue", {}).get("priority", None) in [
"low",
"medium",
"high",
"urgent",
None,
]:
return Response(
{"error": "Invalid priority"}, status=status.HTTP_400_BAD_REQUEST
)
# Create or get state
state, _ = State.objects.get_or_create(
name="Triage",
group="backlog",
description="Default state for managing all Inbox Issues",
project_id=project_id,
color="#ff7700",
)
# create an issue
issue = Issue.objects.create(
name=request.data.get("issue", {}).get("name"),
description=request.data.get("issue", {}).get("description", {}),
description_html=request.data.get("issue", {}).get(
"description_html", "<p></p>"
),
priority=request.data.get("issue", {}).get("priority", "low"),
project_id=project_id,
state=state,
)
# Create an Issue Activity
issue_activity.delay(
type="issue.activity.created",
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
actor_id=str(request.user.id),
issue_id=str(issue.id),
project_id=str(project_id),
current_instance=None,
)
# create an inbox issue
InboxIssue.objects.create(
inbox_id=inbox_id,
project_id=project_id,
issue=issue,
source=request.data.get("source", "in-app"),
)
serializer = IssueStateInboxSerializer(issue)
return Response(serializer.data, status=status.HTTP_200_OK)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
def partial_update(self, request, slug, project_id, inbox_id, pk):
try:
project_deploy_board = ProjectDeployBoard.objects.get(workspace__slug=slug, project_id=project_id)
if project_deploy_board.inbox is None:
return Response({"error": "Inbox is not enabled for this Project Board"}, status=status.HTTP_400_BAD_REQUEST)
inbox_issue = InboxIssue.objects.get(
pk=pk, workspace__slug=slug, project_id=project_id, inbox_id=inbox_id
)
# Get the project member
if str(inbox_issue.created_by_id) != str(request.user.id):
return Response({"error": "You cannot edit inbox issues"}, status=status.HTTP_400_BAD_REQUEST)
# Get issue data
issue_data = request.data.pop("issue", False)
issue = Issue.objects.get(
pk=inbox_issue.issue_id, workspace__slug=slug, project_id=project_id
)
# viewers and guests since only viewers and guests
issue_data = {
"name": issue_data.get("name", issue.name),
"description_html": issue_data.get("description_html", issue.description_html),
"description": issue_data.get("description", issue.description)
}
issue_serializer = IssueCreateSerializer(
issue, data=issue_data, partial=True
)
if issue_serializer.is_valid():
current_instance = issue
# Log all the updates
requested_data = json.dumps(issue_data, cls=DjangoJSONEncoder)
if issue is not None:
issue_activity.delay(
type="issue.activity.updated",
requested_data=requested_data,
actor_id=str(request.user.id),
issue_id=str(issue.id),
project_id=str(project_id),
current_instance=json.dumps(
IssueSerializer(current_instance).data,
cls=DjangoJSONEncoder,
),
)
issue_serializer.save()
return Response(issue_serializer.data, status=status.HTTP_200_OK)
return Response(issue_serializer.errors, status=status.HTTP_400_BAD_REQUEST)
except InboxIssue.DoesNotExist:
return Response(
{"error": "Inbox Issue does not exist"},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
def retrieve(self, request, slug, project_id, inbox_id, pk):
try:
project_deploy_board = ProjectDeployBoard.objects.get(workspace__slug=slug, project_id=project_id)
if project_deploy_board.inbox is None:
return Response({"error": "Inbox is not enabled for this Project Board"}, status=status.HTTP_400_BAD_REQUEST)
inbox_issue = InboxIssue.objects.get(
pk=pk, workspace__slug=slug, project_id=project_id, inbox_id=inbox_id
)
issue = Issue.objects.get(
pk=inbox_issue.issue_id, workspace__slug=slug, project_id=project_id
)
serializer = IssueStateInboxSerializer(issue)
return Response(serializer.data, status=status.HTTP_200_OK)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
def destroy(self, request, slug, project_id, inbox_id, pk):
try:
project_deploy_board = ProjectDeployBoard.objects.get(workspace__slug=slug, project_id=project_id)
if project_deploy_board.inbox is None:
return Response({"error": "Inbox is not enabled for this Project Board"}, status=status.HTTP_400_BAD_REQUEST)
inbox_issue = InboxIssue.objects.get(
pk=pk, workspace__slug=slug, project_id=project_id, inbox_id=inbox_id
)
if str(inbox_issue.created_by_id) != str(request.user.id):
return Response({"error": "You cannot delete inbox issue"}, status=status.HTTP_400_BAD_REQUEST)
inbox_issue.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
except InboxIssue.DoesNotExist:
return Response({"error": "Inbox Issue does not exists"}, status=status.HTTP_400_BAD_REQUEST)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
+15 -1
View File
@@ -20,6 +20,17 @@ class SlackProjectSyncViewSet(BaseViewSet):
serializer_class = SlackProjectSyncSerializer
model = SlackProjectSync
def get_queryset(self):
return (
super()
.get_queryset()
.filter(
workspace__slug=self.kwargs.get("slug"),
project_id=self.kwargs.get("project_id"),
)
.filter(project__project_projectmember__member=self.request.user)
)
def create(self, request, slug, project_id, workspace_integration_id):
try:
serializer = SlackProjectSyncSerializer(data=request.data)
@@ -45,7 +56,10 @@ class SlackProjectSyncViewSet(BaseViewSet):
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
except IntegrityError:
return Response({"error": "Slack is already enabled for the project"}, status=status.HTTP_400_BAD_REQUEST)
return Response(
{"error": "Slack is already enabled for the project"},
status=status.HTTP_400_BAD_REQUEST,
)
except WorkspaceIntegration.DoesNotExist:
return Response(
{"error": "Workspace Integration does not exist"},
+391 -12
View File
@@ -48,6 +48,7 @@ from plane.api.serializers import (
ProjectMemberLiteSerializer,
IssueReactionSerializer,
CommentReactionSerializer,
IssueVoteSerializer,
)
from plane.api.permissions import (
WorkspaceEntityPermission,
@@ -70,11 +71,12 @@ from plane.db.models import (
ProjectMember,
IssueReaction,
CommentReaction,
ProjectDeployBoard,
IssueVote,
)
from plane.bgtasks.issue_activites_task import issue_activity
from plane.utils.grouper import group_results
from plane.utils.issue_filters import issue_filters
from plane.bgtasks.project_issue_export import issue_export_task
class IssueViewSet(BaseViewSet):
@@ -169,7 +171,6 @@ class IssueViewSet(BaseViewSet):
def list(self, request, slug, project_id):
try:
filters = issue_filters(request.query_params, "GET")
print(filters)
# Custom ordering for priority and state
priority_order = ["urgent", "high", "medium", "low", None]
@@ -362,8 +363,14 @@ class UserWorkSpaceIssues(BaseAPIView):
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.prefetch_related(
Prefetch(
"issue_reactions",
queryset=IssueReaction.objects.select_related("actor"),
)
)
.filter(**filters)
)
).distinct()
# Priority Ordering
if order_by_param == "priority" or order_by_param == "-priority":
@@ -744,21 +751,25 @@ class SubIssuesEndpoint(BaseAPIView):
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.prefetch_related(
Prefetch(
"issue_reactions",
queryset=IssueReaction.objects.select_related("actor"),
)
)
)
state_distribution = (
State.objects.filter(~Q(name="Triage"), workspace__slug=slug)
.annotate(
state_count=Count(
"state_issue",
filter=Q(state_issue__parent_id=issue_id),
)
State.objects.filter(
workspace__slug=slug, state_issue__parent_id=issue_id
)
.order_by("group")
.values("group", "state_count")
.annotate(state_group=F("group"))
.values("state_group")
.annotate(state_count=Count("state_group"))
.order_by("state_group")
)
result = {item["group"]: item["state_count"] for item in state_distribution}
result = {item["state_group"]: item["state_count"] for item in state_distribution}
serializer = IssueLiteSerializer(
sub_issues,
@@ -1448,6 +1459,374 @@ class CommentReactionViewSet(BaseViewSet):
)
class IssueCommentPublicViewSet(BaseViewSet):
serializer_class = IssueCommentSerializer
model = IssueComment
filterset_fields = [
"issue__id",
"workspace__id",
]
def get_queryset(self):
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=self.kwargs.get("slug"),
project_id=self.kwargs.get("project_id"),
)
if project_deploy_board.comments:
return self.filter_queryset(
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(issue_id=self.kwargs.get("issue_id"))
.select_related("project")
.select_related("workspace")
.select_related("issue")
.distinct()
)
else:
return IssueComment.objects.none()
def create(self, request, slug, project_id, issue_id):
try:
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=slug, project_id=project_id
)
if not project_deploy_board.comments:
return Response(
{"error": "Comments are not enabled for this project"},
status=status.HTTP_400_BAD_REQUEST,
)
access = (
"INTERNAL"
if ProjectMember.objects.filter(
project_id=project_id, member=request.user
).exists()
else "EXTERNAL"
)
serializer = IssueCommentSerializer(data=request.data)
if serializer.is_valid():
serializer.save(
project_id=project_id,
issue_id=issue_id,
actor=request.user,
access=access,
)
issue_activity.delay(
type="comment.activity.created",
requested_data=json.dumps(serializer.data, cls=DjangoJSONEncoder),
actor_id=str(request.user.id),
issue_id=str(issue_id),
project_id=str(project_id),
current_instance=None,
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
def partial_update(self, request, slug, project_id, issue_id, pk):
try:
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=slug, project_id=project_id
)
if not project_deploy_board.comments:
return Response(
{"error": "Comments are not enabled for this project"},
status=status.HTTP_400_BAD_REQUEST,
)
comment = IssueComment.objects.get(
workspace__slug=slug, pk=pk, actor=request.user
)
serializer = IssueCommentSerializer(
comment, data=request.data, partial=True
)
if serializer.is_valid():
serializer.save()
issue_activity.delay(
type="comment.activity.updated",
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
actor_id=str(request.user.id),
issue_id=str(issue_id),
project_id=str(project_id),
current_instance=json.dumps(
IssueCommentSerializer(comment).data,
cls=DjangoJSONEncoder,
),
)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
except (IssueComment.DoesNotExist, ProjectDeployBoard.DoesNotExist):
return Response(
{"error": "IssueComent Does not exists"},
status=status.HTTP_400_BAD_REQUEST,)
def destroy(self, request, slug, project_id, issue_id, pk):
try:
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=slug, project_id=project_id
)
if not project_deploy_board.comments:
return Response(
{"error": "Comments are not enabled for this project"},
status=status.HTTP_400_BAD_REQUEST,
)
comment = IssueComment.objects.get(
workspace__slug=slug, pk=pk, project_id=project_id, actor=request.user
)
issue_activity.delay(
type="comment.activity.deleted",
requested_data=json.dumps({"comment_id": str(pk)}),
actor_id=str(request.user.id),
issue_id=str(issue_id),
project_id=str(project_id),
current_instance=json.dumps(
IssueCommentSerializer(comment).data,
cls=DjangoJSONEncoder,
),
)
comment.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
except (IssueComment.DoesNotExist, ProjectDeployBoard.DoesNotExist):
return Response(
{"error": "IssueComent Does not exists"},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class IssueReactionPublicViewSet(BaseViewSet):
serializer_class = IssueReactionSerializer
model = IssueReaction
def get_queryset(self):
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=self.kwargs.get("slug"),
project_id=self.kwargs.get("project_id"),
)
if project_deploy_board.reactions:
return (
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id"))
.filter(issue_id=self.kwargs.get("issue_id"))
.order_by("-created_at")
.distinct()
)
else:
return IssueReaction.objects.none()
def create(self, request, slug, project_id, issue_id):
try:
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=slug, project_id=project_id
)
if not project_deploy_board.reactions:
return Response(
{"error": "Reactions are not enabled for this project board"},
status=status.HTTP_400_BAD_REQUEST,
)
serializer = IssueReactionSerializer(data=request.data)
if serializer.is_valid():
serializer.save(
project_id=project_id, issue_id=issue_id, actor=request.user
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
except ProjectDeployBoard.DoesNotExist:
return Response(
{"error": "Project board does not exist"},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
def destroy(self, request, slug, project_id, issue_id, reaction_code):
try:
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=slug, project_id=project_id
)
if not project_deploy_board.reactions:
return Response(
{"error": "Reactions are not enabled for this project board"},
status=status.HTTP_400_BAD_REQUEST,
)
issue_reaction = IssueReaction.objects.get(
workspace__slug=slug,
issue_id=issue_id,
reaction=reaction_code,
actor=request.user,
)
issue_reaction.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
except IssueReaction.DoesNotExist:
return Response(
{"error": "Issue reaction does not exist"},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class CommentReactionPublicViewSet(BaseViewSet):
serializer_class = CommentReactionSerializer
model = CommentReaction
def get_queryset(self):
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=self.kwargs.get("slug"),
project_id=self.kwargs.get("project_id"),
)
if project_deploy_board.reactions:
return (
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id"))
.filter(comment_id=self.kwargs.get("comment_id"))
.order_by("-created_at")
.distinct()
)
else:
return CommentReaction.objects.none()
def create(self, request, slug, project_id, comment_id):
try:
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=slug, project_id=project_id
)
if not project_deploy_board.reactions:
return Response(
{"error": "Reactions are not enabled for this board"},
status=status.HTTP_400_BAD_REQUEST,
)
serializer = CommentReactionSerializer(data=request.data)
if serializer.is_valid():
serializer.save(
project_id=project_id, comment_id=comment_id, actor=request.user
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
except ProjectDeployBoard.DoesNotExist:
return Response(
{"error": "Project board does not exist"},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
def destroy(self, request, slug, project_id, comment_id, reaction_code):
try:
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=slug, project_id=project_id
)
if not project_deploy_board.reactions:
return Response(
{"error": "Reactions are not enabled for this board"},
status=status.HTTP_400_BAD_REQUEST,
)
comment_reaction = CommentReaction.objects.get(
project_id=project_id,
workspace__slug=slug,
comment_id=comment_id,
reaction=reaction_code,
actor=request.user,
)
comment_reaction.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
except CommentReaction.DoesNotExist:
return Response(
{"error": "Comment reaction does not exist"},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class IssueVotePublicViewSet(BaseViewSet):
model = IssueVote
serializer_class = IssueVoteSerializer
def get_queryset(self):
return (
super()
.get_queryset()
.filter(issue_id=self.kwargs.get("issue_id"))
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id"))
)
def create(self, request, slug, project_id, issue_id):
try:
issue_vote, _ = IssueVote.objects.get_or_create(
actor_id=request.user.id,
project_id=project_id,
issue_id=issue_id,
vote=request.data.get("vote", 1),
)
serializer = IssueVoteSerializer(issue_vote)
return Response(serializer.data, status=status.HTTP_201_CREATED)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
def destroy(self, request, slug, project_id, issue_id):
try:
issue_vote = IssueVote.objects.get(
workspace__slug=slug,
project_id=project_id,
issue_id=issue_id,
actor_id=request.user.id,
)
issue_vote.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class ExportIssuesEndpoint(BaseAPIView):
permission_classes = [
+5 -2
View File
@@ -53,6 +53,8 @@ class ModuleViewSet(BaseViewSet):
)
def get_queryset(self):
order_by = self.request.GET.get("order_by", "sort_order")
subquery = ModuleFavorite.objects.filter(
user=self.request.user,
module_id=OuterRef("pk"),
@@ -106,7 +108,7 @@ class ModuleViewSet(BaseViewSet):
filter=Q(issue_module__issue__state__group="backlog"),
)
)
.order_by("-is_favorite", "name")
.order_by(order_by, "name")
)
def perform_destroy(self, instance):
@@ -173,8 +175,9 @@ class ModuleViewSet(BaseViewSet):
.annotate(first_name=F("assignees__first_name"))
.annotate(last_name=F("assignees__last_name"))
.annotate(assignee_id=F("assignees__id"))
.annotate(display_name=F("assignees__display_name"))
.annotate(avatar=F("assignees__avatar"))
.values("first_name", "last_name", "assignee_id", "avatar")
.values("first_name", "last_name", "assignee_id", "avatar", "display_name")
.annotate(total_issues=Count("assignee_id"))
.annotate(
completed_issues=Count(
+289 -7
View File
@@ -5,7 +5,21 @@ from datetime import datetime
# Django imports
from django.core.exceptions import ValidationError
from django.db import IntegrityError
from django.db.models import Q, Exists, OuterRef, Func, F, Min, Subquery
from django.db.models import (
Q,
Exists,
OuterRef,
Func,
F,
Max,
CharField,
Func,
Subquery,
Prefetch,
When,
Case,
Value,
)
from django.core.validators import validate_email
from django.conf import settings
@@ -13,6 +27,7 @@ from django.conf import settings
from rest_framework.response import Response
from rest_framework import status
from rest_framework import serializers
from rest_framework.permissions import AllowAny
from sentry_sdk import capture_exception
# Module imports
@@ -23,9 +38,16 @@ from plane.api.serializers import (
ProjectDetailSerializer,
ProjectMemberInviteSerializer,
ProjectFavoriteSerializer,
IssueLiteSerializer,
ProjectDeployBoardSerializer,
ProjectMemberAdminSerializer,
)
from plane.api.permissions import ProjectBasePermission, ProjectEntityPermission
from plane.api.permissions import (
ProjectBasePermission,
ProjectEntityPermission,
ProjectMemberPermission,
)
from plane.db.models import (
Project,
@@ -48,9 +70,17 @@ from plane.db.models import (
IssueAssignee,
ModuleMember,
Inbox,
ProjectDeployBoard,
Issue,
IssueReaction,
IssueLink,
IssueAttachment,
Label,
)
from plane.bgtasks.project_invitation_task import project_invitation
from plane.utils.grouper import group_results
from plane.utils.issue_filters import issue_filters
class ProjectViewSet(BaseViewSet):
@@ -92,7 +122,9 @@ class ProjectViewSet(BaseViewSet):
)
)
.annotate(
total_members=ProjectMember.objects.filter(project_id=OuterRef("id"))
total_members=ProjectMember.objects.filter(
project_id=OuterRef("id"), member__is_bot=False
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
@@ -109,6 +141,20 @@ class ProjectViewSet(BaseViewSet):
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
member_role=ProjectMember.objects.filter(
project_id=OuterRef("pk"),
member_id=self.request.user.id,
).values("role")
)
.annotate(
is_deployed=Exists(
ProjectDeployBoard.objects.filter(
project_id=OuterRef("pk"),
workspace__slug=self.kwargs.get("slug"),
)
)
)
.distinct()
)
@@ -180,7 +226,9 @@ class ProjectViewSet(BaseViewSet):
project_id=serializer.data["id"], member=request.user, role=20
)
if serializer.data["project_lead"] is not None:
if serializer.data["project_lead"] is not None and str(
serializer.data["project_lead"]
) != str(request.user.id):
ProjectMember.objects.create(
project_id=serializer.data["id"],
member_id=serializer.data["project_lead"],
@@ -347,7 +395,9 @@ class InviteProjectEndpoint(BaseAPIView):
validate_email(email)
# Check if user is already a member of workspace
if ProjectMember.objects.filter(
project_id=project_id, member__email=email
project_id=project_id,
member__email=email,
member__is_bot=False,
).exists():
return Response(
{"error": "User is already member of workspace"},
@@ -451,7 +501,7 @@ class UserProjectInvitationsViewset(BaseViewSet):
class ProjectMemberViewSet(BaseViewSet):
serializer_class = ProjectMemberSerializer
serializer_class = ProjectMemberAdminSerializer
model = ProjectMember
permission_classes = [
ProjectBasePermission,
@@ -986,6 +1036,63 @@ class ProjectFavoritesViewSet(BaseViewSet):
)
class ProjectDeployBoardViewSet(BaseViewSet):
permission_classes = [
ProjectMemberPermission,
]
serializer_class = ProjectDeployBoardSerializer
model = ProjectDeployBoard
def get_queryset(self):
return (
super()
.get_queryset()
.filter(
workspace__slug=self.kwargs.get("slug"),
project_id=self.kwargs.get("project_id"),
)
.select_related("project")
)
def create(self, request, slug, project_id):
try:
comments = request.data.get("comments", False)
reactions = request.data.get("reactions", False)
inbox = request.data.get("inbox", None)
votes = request.data.get("votes", False)
views = request.data.get(
"views",
{
"list": True,
"kanban": True,
"calendar": True,
"gantt": True,
"spreadsheet": True,
},
)
project_deploy_board, _ = ProjectDeployBoard.objects.get_or_create(
anchor=f"{slug}/{project_id}",
project_id=project_id,
)
project_deploy_board.comments = comments
project_deploy_board.reactions = reactions
project_deploy_board.inbox = inbox
project_deploy_board.votes = votes
project_deploy_board.views = views
project_deploy_board.save()
serializer = ProjectDeployBoardSerializer(project_deploy_board)
return Response(serializer.data, status=status.HTTP_200_OK)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class ProjectMemberEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
@@ -994,7 +1101,9 @@ class ProjectMemberEndpoint(BaseAPIView):
def get(self, request, slug, project_id):
try:
project_members = ProjectMember.objects.filter(
project_id=project_id, workspace__slug=slug
project_id=project_id,
workspace__slug=slug,
member__is_bot=False,
).select_related("project", "member")
serializer = ProjectMemberSerializer(project_members, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
@@ -1004,3 +1113,176 @@ class ProjectMemberEndpoint(BaseAPIView):
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class ProjectDeployBoardPublicSettingsEndpoint(BaseAPIView):
permission_classes = [
AllowAny,
]
def get(self, request, slug, project_id):
try:
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=slug, project_id=project_id
)
serializer = ProjectDeployBoardSerializer(project_deploy_board)
return Response(serializer.data, status=status.HTTP_200_OK)
except ProjectDeployBoard.DoesNotExist:
return Response(
{"error": "Project Deploy Board does not exists"},
status=status.HTTP_404_NOT_FOUND,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class ProjectDeployBoardIssuesPublicEndpoint(BaseAPIView):
permission_classes = [
AllowAny,
]
def get(self, request, slug, project_id):
try:
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=slug, project_id=project_id
)
filters = issue_filters(request.query_params, "GET")
# Custom ordering for priority and state
priority_order = ["urgent", "high", "medium", "low", None]
state_order = ["backlog", "unstarted", "started", "completed", "cancelled"]
order_by_param = request.GET.get("order_by", "-created_at")
issue_queryset = (
Issue.issue_objects.annotate(
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.filter(project_id=project_id)
.filter(workspace__slug=slug)
.select_related("project", "workspace", "state", "parent")
.prefetch_related("assignees", "labels")
.prefetch_related(
Prefetch(
"issue_reactions",
queryset=IssueReaction.objects.select_related("actor"),
)
)
.filter(**filters)
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(module_id=F("issue_module__module_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(
issue=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
)
# Priority Ordering
if order_by_param == "priority" or order_by_param == "-priority":
priority_order = (
priority_order
if order_by_param == "priority"
else priority_order[::-1]
)
issue_queryset = issue_queryset.annotate(
priority_order=Case(
*[
When(priority=p, then=Value(i))
for i, p in enumerate(priority_order)
],
output_field=CharField(),
)
).order_by("priority_order")
# State Ordering
elif order_by_param in [
"state__name",
"state__group",
"-state__name",
"-state__group",
]:
state_order = (
state_order
if order_by_param in ["state__name", "state__group"]
else state_order[::-1]
)
issue_queryset = issue_queryset.annotate(
state_order=Case(
*[
When(state__group=state_group, then=Value(i))
for i, state_group in enumerate(state_order)
],
default=Value(len(state_order)),
output_field=CharField(),
)
).order_by("state_order")
# assignee and label ordering
elif order_by_param in [
"labels__name",
"-labels__name",
"assignees__first_name",
"-assignees__first_name",
]:
issue_queryset = issue_queryset.annotate(
max_values=Max(
order_by_param[1::]
if order_by_param.startswith("-")
else order_by_param
)
).order_by(
"-max_values" if order_by_param.startswith("-") else "max_values"
)
else:
issue_queryset = issue_queryset.order_by(order_by_param)
issues = IssueLiteSerializer(issue_queryset, many=True).data
states = State.objects.filter(
workspace__slug=slug, project_id=project_id
).values("name", "group", "color", "id")
labels = Label.objects.filter(
workspace__slug=slug, project_id=project_id
).values("id", "name", "color", "parent")
## Grouping the results
group_by = request.GET.get("group_by", False)
if group_by:
issues = group_results(issues, group_by)
return Response(
{
"issues": issues,
"states": states,
"labels": labels,
},
status=status.HTTP_200_OK,
)
except ProjectDeployBoard.DoesNotExist:
return Response(
{"error": "Board does not exists"}, status=status.HTTP_404_NOT_FOUND
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
+7
View File
@@ -19,6 +19,7 @@ from plane.db.models import (
IssueView,
Issue,
IssueViewFavorite,
IssueReaction,
)
from plane.utils.issue_filters import issue_filters
@@ -77,6 +78,12 @@ class ViewIssuesEndpoint(BaseAPIView):
.select_related("parent")
.prefetch_related("assignees")
.prefetch_related("labels")
.prefetch_related(
Prefetch(
"issue_reactions",
queryset=IssueReaction.objects.select_related("actor"),
)
)
)
serializer = IssueLiteSerializer(issues, many=True)
+15 -7
View File
@@ -47,7 +47,7 @@ from plane.api.serializers import (
WorkspaceThemeSerializer,
IssueActivitySerializer,
IssueLiteSerializer,
WorkspaceMemberAdminSerializer
WorkspaceMemberAdminSerializer,
)
from plane.api.views.base import BaseAPIView
from . import BaseViewSet
@@ -107,7 +107,9 @@ class WorkSpaceViewSet(BaseViewSet):
def get_queryset(self):
member_count = (
WorkspaceMember.objects.filter(workspace=OuterRef("id"))
WorkspaceMember.objects.filter(
workspace=OuterRef("id"), member__is_bot=False
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
@@ -192,7 +194,9 @@ class UserWorkSpacesEndpoint(BaseAPIView):
def get(self, request):
try:
member_count = (
WorkspaceMember.objects.filter(workspace=OuterRef("id"))
WorkspaceMember.objects.filter(
workspace=OuterRef("id"), member__is_bot=False
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
@@ -625,7 +629,9 @@ class WorkSpaceMemberViewSet(BaseViewSet):
if (
workspace_member.role == 20
and WorkspaceMember.objects.filter(
workspace__slug=slug, role=20
workspace__slug=slug,
role=20,
member__is_bot=False,
).count()
== 1
):
@@ -988,11 +994,11 @@ class UserWorkspaceDashboardEndpoint(BaseAPIView):
upcoming_issues = Issue.issue_objects.filter(
~Q(state__group__in=["completed", "cancelled"]),
target_date__gte=timezone.now(),
start_date__gte=timezone.now(),
workspace__slug=slug,
assignees__in=[request.user],
completed_at__isnull=True,
).values("id", "name", "workspace__slug", "project_id", "target_date")
).values("id", "name", "workspace__slug", "project_id", "start_date")
return Response(
{
@@ -1077,6 +1083,7 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
.filter(**filters)
.values("priority")
.annotate(priority_count=Count("priority"))
.filter(priority_count__gte=1)
.annotate(
priority_order=Case(
*[
@@ -1455,7 +1462,8 @@ class WorkspaceMembersEndpoint(BaseAPIView):
def get(self, request, slug):
try:
workspace_members = WorkspaceMember.objects.filter(
workspace__slug=slug
workspace__slug=slug,
member__is_bot=False,
).select_related("workspace", "member")
serialzier = WorkSpaceMemberSerializer(workspace_members, many=True)
return Response(serialzier.data, status=status.HTTP_200_OK)
@@ -51,12 +51,12 @@ def analytic_export_task(email, data, slug):
segmented = segment
assignee_details = {}
if x_axis in ["assignees__display_name"] or segment in ["assignees__display_name"]:
if x_axis in ["assignees__id"] or segment in ["assignees__id"]:
assignee_details = (
Issue.issue_objects.filter(workspace__slug=slug, **filters, assignees__avatar__isnull=False)
.order_by("assignees__id")
.distinct("assignees__id")
.values("assignees__avatar", "assignees__display_name", "assignees__first_name", "assignees__last_name")
.values("assignees__avatar", "assignees__display_name", "assignees__first_name", "assignees__last_name", "assignees__id")
)
if segment:
@@ -93,19 +93,19 @@ def analytic_export_task(email, data, slug):
else:
generated_row.append("0")
# x-axis replacement for names
if x_axis in ["assignees__display_name"]:
assignee = [user for user in assignee_details if str(user.get("assignees__display_name")) == str(item)]
if x_axis in ["assignees__id"]:
assignee = [user for user in assignee_details if str(user.get("assignees__id")) == str(item)]
if len(assignee):
generated_row[0] = str(assignee[0].get("assignees__first_name")) + " " + str(assignee[0].get("assignees__last_name"))
rows.append(tuple(generated_row))
# If segment is ["assignees__display_name"] then replace segment_zero rows with first and last names
if segmented in ["assignees__display_name"]:
if segmented in ["assignees__id"]:
for index, segm in enumerate(row_zero[2:]):
# find the name of the user
assignee = [user for user in assignee_details if str(user.get("assignees__display_name")) == str(segm)]
assignee = [user for user in assignee_details if str(user.get("assignees__id")) == str(segm)]
if len(assignee):
row_zero[index] = str(assignee[0].get("assignees__first_name")) + " " + str(assignee[0].get("assignees__last_name"))
row_zero[index + 2] = str(assignee[0].get("assignees__first_name")) + " " + str(assignee[0].get("assignees__last_name"))
rows = [tuple(row_zero)] + rows
csv_buffer = io.StringIO()
@@ -141,8 +141,8 @@ def analytic_export_task(email, data, slug):
else distribution.get(item)[0].get("estimate "),
]
# x-axis replacement to names
if x_axis in ["assignees__display_name"]:
assignee = [user for user in assignee_details if str(user.get("assignees__display_name")) == str(item)]
if x_axis in ["assignees__id"]:
assignee = [user for user in assignee_details if str(user.get("assignees__id")) == str(item)]
if len(assignee):
row[0] = str(assignee[0].get("assignees__first_name")) + " " + str(assignee[0].get("assignees__last_name"))
+346
View File
@@ -0,0 +1,346 @@
# Python imports
import csv
import io
import json
import boto3
import zipfile
# Django imports
from django.conf import settings
from django.utils import timezone
# Third party imports
from celery import shared_task
from sentry_sdk import capture_exception
from botocore.client import Config
from openpyxl import Workbook
# Module imports
from plane.db.models import Issue, ExporterHistory
def dateTimeConverter(time):
if time:
return time.strftime("%a, %d %b %Y %I:%M:%S %Z%z")
def dateConverter(time):
if time:
return time.strftime("%a, %d %b %Y")
def create_csv_file(data):
csv_buffer = io.StringIO()
csv_writer = csv.writer(csv_buffer, delimiter=",", quoting=csv.QUOTE_ALL)
for row in data:
csv_writer.writerow(row)
csv_buffer.seek(0)
return csv_buffer.getvalue()
def create_json_file(data):
return json.dumps(data)
def create_xlsx_file(data):
workbook = Workbook()
sheet = workbook.active
for row in data:
sheet.append(row)
xlsx_buffer = io.BytesIO()
workbook.save(xlsx_buffer)
xlsx_buffer.seek(0)
return xlsx_buffer.getvalue()
def create_zip_file(files):
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zipf:
for filename, file_content in files:
zipf.writestr(filename, file_content)
zip_buffer.seek(0)
return zip_buffer
def upload_to_s3(zip_file, workspace_id, token_id, slug):
s3 = boto3.client(
"s3",
region_name=settings.AWS_REGION,
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
config=Config(signature_version="s3v4"),
)
file_name = f"{workspace_id}/export-{slug}-{token_id[:6]}-{timezone.now()}.zip"
s3.upload_fileobj(
zip_file,
settings.AWS_S3_BUCKET_NAME,
file_name,
ExtraArgs={"ACL": "public-read", "ContentType": "application/zip"},
)
expires_in = 7 * 24 * 60 * 60
presigned_url = s3.generate_presigned_url(
"get_object",
Params={"Bucket": settings.AWS_S3_BUCKET_NAME, "Key": file_name},
ExpiresIn=expires_in,
)
exporter_instance = ExporterHistory.objects.get(token=token_id)
if presigned_url:
exporter_instance.url = presigned_url
exporter_instance.status = "completed"
exporter_instance.key = file_name
else:
exporter_instance.status = "failed"
exporter_instance.save(update_fields=["status", "url","key"])
def generate_table_row(issue):
return [
f"""{issue["project__identifier"]}-{issue["sequence_id"]}""",
issue["project__name"],
issue["name"],
issue["description_stripped"],
issue["state__name"],
issue["priority"],
f"{issue['created_by__first_name']} {issue['created_by__last_name']}"
if issue["created_by__first_name"] and issue["created_by__last_name"]
else "",
f"{issue['assignees__first_name']} {issue['assignees__last_name']}"
if issue["assignees__first_name"] and issue["assignees__last_name"]
else "",
issue["labels__name"],
issue["issue_cycle__cycle__name"],
dateConverter(issue["issue_cycle__cycle__start_date"]),
dateConverter(issue["issue_cycle__cycle__end_date"]),
issue["issue_module__module__name"],
dateConverter(issue["issue_module__module__start_date"]),
dateConverter(issue["issue_module__module__target_date"]),
dateTimeConverter(issue["created_at"]),
dateTimeConverter(issue["updated_at"]),
dateTimeConverter(issue["completed_at"]),
dateTimeConverter(issue["archived_at"]),
]
def generate_json_row(issue):
return {
"ID": f"""{issue["project__identifier"]}-{issue["sequence_id"]}""",
"Project": issue["project__name"],
"Name": issue["name"],
"Description": issue["description_stripped"],
"State": issue["state__name"],
"Priority": issue["priority"],
"Created By": f"{issue['created_by__first_name']} {issue['created_by__last_name']}"
if issue["created_by__first_name"] and issue["created_by__last_name"]
else "",
"Assignee": f"{issue['assignees__first_name']} {issue['assignees__last_name']}"
if issue["assignees__first_name"] and issue["assignees__last_name"]
else "",
"Labels": issue["labels__name"],
"Cycle Name": issue["issue_cycle__cycle__name"],
"Cycle Start Date": dateConverter(issue["issue_cycle__cycle__start_date"]),
"Cycle End Date": dateConverter(issue["issue_cycle__cycle__end_date"]),
"Module Name": issue["issue_module__module__name"],
"Module Start Date": dateConverter(issue["issue_module__module__start_date"]),
"Module Target Date": dateConverter(issue["issue_module__module__target_date"]),
"Created At": dateTimeConverter(issue["created_at"]),
"Updated At": dateTimeConverter(issue["updated_at"]),
"Completed At": dateTimeConverter(issue["completed_at"]),
"Archived At": dateTimeConverter(issue["archived_at"]),
}
def update_json_row(rows, row):
matched_index = next(
(
index
for index, existing_row in enumerate(rows)
if existing_row["ID"] == row["ID"]
),
None,
)
if matched_index is not None:
existing_assignees, existing_labels = (
rows[matched_index]["Assignee"],
rows[matched_index]["Labels"],
)
assignee, label = row["Assignee"], row["Labels"]
if assignee is not None and assignee not in existing_assignees:
rows[matched_index]["Assignee"] += f", {assignee}"
if label is not None and label not in existing_labels:
rows[matched_index]["Labels"] += f", {label}"
else:
rows.append(row)
def update_table_row(rows, row):
matched_index = next(
(index for index, existing_row in enumerate(rows) if existing_row[0] == row[0]),
None,
)
if matched_index is not None:
existing_assignees, existing_labels = rows[matched_index][7:9]
assignee, label = row[7:9]
if assignee is not None and assignee not in existing_assignees:
rows[matched_index][7] += f", {assignee}"
if label is not None and label not in existing_labels:
rows[matched_index][8] += f", {label}"
else:
rows.append(row)
def generate_csv(header, project_id, issues, files):
"""
Generate CSV export for all the passed issues.
"""
rows = [
header,
]
for issue in issues:
row = generate_table_row(issue)
update_table_row(rows, row)
csv_file = create_csv_file(rows)
files.append((f"{project_id}.csv", csv_file))
def generate_json(header, project_id, issues, files):
rows = []
for issue in issues:
row = generate_json_row(issue)
update_json_row(rows, row)
json_file = create_json_file(rows)
files.append((f"{project_id}.json", json_file))
def generate_xlsx(header, project_id, issues, files):
rows = [header]
for issue in issues:
row = generate_table_row(issue)
update_table_row(rows, row)
xlsx_file = create_xlsx_file(rows)
files.append((f"{project_id}.xlsx", xlsx_file))
@shared_task
def issue_export_task(provider, workspace_id, project_ids, token_id, multiple, slug):
try:
exporter_instance = ExporterHistory.objects.get(token=token_id)
exporter_instance.status = "processing"
exporter_instance.save(update_fields=["status"])
workspace_issues = (
(
Issue.objects.filter(
workspace__id=workspace_id, project_id__in=project_ids
)
.select_related("project", "workspace", "state", "parent", "created_by")
.prefetch_related(
"assignees", "labels", "issue_cycle__cycle", "issue_module__module"
)
.values(
"id",
"project__identifier",
"project__name",
"project__id",
"sequence_id",
"name",
"description_stripped",
"priority",
"state__name",
"created_at",
"updated_at",
"completed_at",
"archived_at",
"issue_cycle__cycle__name",
"issue_cycle__cycle__start_date",
"issue_cycle__cycle__end_date",
"issue_module__module__name",
"issue_module__module__start_date",
"issue_module__module__target_date",
"created_by__first_name",
"created_by__last_name",
"assignees__first_name",
"assignees__last_name",
"labels__name",
)
)
.order_by("project__identifier","sequence_id")
.distinct()
)
# CSV header
header = [
"ID",
"Project",
"Name",
"Description",
"State",
"Priority",
"Created By",
"Assignee",
"Labels",
"Cycle Name",
"Cycle Start Date",
"Cycle End Date",
"Module Name",
"Module Start Date",
"Module Target Date",
"Created At",
"Updated At",
"Completed At",
"Archived At",
]
EXPORTER_MAPPER = {
"csv": generate_csv,
"json": generate_json,
"xlsx": generate_xlsx,
}
files = []
if multiple:
for project_id in project_ids:
issues = workspace_issues.filter(project__id=project_id)
exporter = EXPORTER_MAPPER.get(provider)
if exporter is not None:
exporter(
header,
project_id,
issues,
files,
)
else:
exporter = EXPORTER_MAPPER.get(provider)
if exporter is not None:
exporter(
header,
workspace_id,
workspace_issues,
files,
)
zip_buffer = create_zip_file(files)
upload_to_s3(zip_buffer, workspace_id, token_id, slug)
except Exception as e:
exporter_instance = ExporterHistory.objects.get(token=token_id)
exporter_instance.status = "failed"
exporter_instance.reason = str(e)
exporter_instance.save(update_fields=["status", "reason"])
# Print logs if in DEBUG mode
if settings.DEBUG:
print(e)
capture_exception(e)
return
@@ -0,0 +1,38 @@
# Python imports
import boto3
from datetime import timedelta
# Django imports
from django.conf import settings
from django.utils import timezone
from django.db.models import Q
# Third party imports
from celery import shared_task
from botocore.client import Config
# Module imports
from plane.db.models import ExporterHistory
@shared_task
def delete_old_s3_link():
# Get a list of keys and IDs to process
expired_exporter_history = ExporterHistory.objects.filter(
Q(url__isnull=False) & Q(created_at__lte=timezone.now() - timedelta(days=8))
).values_list("key", "id")
s3 = boto3.client(
"s3",
region_name="ap-south-1",
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
config=Config(signature_version="s3v4"),
)
for file_name, exporter_id in expired_exporter_history:
# Delete object from S3
if file_name:
s3.delete_object(Bucket=settings.AWS_S3_BUCKET_NAME, Key=file_name)
ExporterHistory.objects.filter(id=exporter_id).update(url=None)
+18 -13
View File
@@ -184,19 +184,24 @@ def track_description(
if current_instance.get("description_html") != requested_data.get(
"description_html"
):
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=current_instance.get("description_html"),
new_value=requested_data.get("description_html"),
field="description",
project=project,
workspace=project.workspace,
comment=f"updated the description to {requested_data.get('description_html')}",
)
)
last_activity = IssueActivity.objects.filter(issue_id=issue_id).order_by("-created_at").first()
if(last_activity is not None and last_activity.field == "description" and actor.id == last_activity.actor_id):
last_activity.created_at = timezone.now()
last_activity.save(update_fields=["created_at"])
else:
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=current_instance.get("description_html"),
new_value=requested_data.get("description_html"),
field="description",
project=project,
workspace=project.workspace,
comment=f"updated the description to {requested_data.get('description_html')}",
)
)
# Track changes in issue target date
@@ -1,191 +0,0 @@
# Python imports
import csv
import io
# Django imports
from django.core.mail import EmailMultiAlternatives
from django.template.loader import render_to_string
from django.utils.html import strip_tags
from django.conf import settings
from django.utils import timezone
# Third party imports
from celery import shared_task
from sentry_sdk import capture_exception
# Module imports
from plane.db.models import Issue
@shared_task
def issue_export_task(email, data, slug, exporter_name):
try:
project_ids = data.get("project_id", [])
issues_filter = {"workspace__slug": slug}
if project_ids:
issues_filter["project_id__in"] = project_ids
issues = (
Issue.objects.filter(**issues_filter)
.select_related("project", "workspace", "state", "parent", "created_by")
.prefetch_related(
"assignees", "labels", "issue_cycle__cycle", "issue_module__module"
)
.values_list(
"project__identifier",
"sequence_id",
"name",
"description_stripped",
"priority",
"start_date",
"target_date",
"state__name",
"project__name",
"created_at",
"updated_at",
"completed_at",
"archived_at",
"issue_cycle__cycle__name",
"issue_cycle__cycle__start_date",
"issue_cycle__cycle__end_date",
"issue_module__module__name",
"issue_module__module__start_date",
"issue_module__module__target_date",
"created_by__first_name",
"created_by__last_name",
"assignees__first_name",
"assignees__last_name",
"labels__name",
)
)
# CSV header
header = [
"Issue ID",
"Project",
"Name",
"Description",
"State",
"Priority",
"Created By",
"Assignee",
"Labels",
"Cycle Name",
"Cycle Start Date",
"Cycle End Date",
"Module Name",
"Module Start Date",
"Module Target Date",
"Created At"
"Updated At"
"Completed At"
"Archived At"
]
# Prepare the CSV data
rows = [header]
# Write data for each issue
for issue in issues:
(
project_identifier,
sequence_id,
name,
description,
priority,
start_date,
target_date,
state_name,
project_name,
created_at,
updated_at,
completed_at,
archived_at,
cycle_name,
cycle_start_date,
cycle_end_date,
module_name,
module_start_date,
module_target_date,
created_by_first_name,
created_by_last_name,
assignees_first_names,
assignees_last_names,
labels_names,
) = issue
created_by_fullname = (
f"{created_by_first_name} {created_by_last_name}"
if created_by_first_name and created_by_last_name
else ""
)
assignees_names = ""
if assignees_first_names and assignees_last_names:
assignees_names = ", ".join(
[
f"{assignees_first_name} {assignees_last_name}"
for assignees_first_name, assignees_last_name in zip(
assignees_first_names, assignees_last_names
)
]
)
labels_names = ", ".join(labels_names) if labels_names else ""
row = [
f"{project_identifier}-{sequence_id}",
project_name,
name,
description,
state_name,
priority,
created_by_fullname,
assignees_names,
labels_names,
cycle_name,
cycle_start_date,
cycle_end_date,
module_name,
module_start_date,
module_target_date,
start_date,
target_date,
created_at,
updated_at,
completed_at,
archived_at,
]
rows.append(row)
# Create CSV file in-memory
csv_buffer = io.StringIO()
writer = csv.writer(csv_buffer, delimiter=",", quoting=csv.QUOTE_ALL)
# Write CSV data to the buffer
for row in rows:
writer.writerow(row)
subject = "Your Issue Export is ready"
context = {
"username": exporter_name,
}
html_content = render_to_string("emails/exports/issues.html", context)
text_content = strip_tags(html_content)
csv_buffer.seek(0)
msg = EmailMultiAlternatives(
subject, text_content, settings.EMAIL_FROM, [email]
)
msg.attach(f"{slug}-issues-{timezone.now().date()}.csv", csv_buffer.read(), "text/csv")
msg.send(fail_silently=False)
except Exception as e:
# Print logs if in DEBUG mode
if settings.DEBUG:
print(e)
capture_exception(e)
return
+4
View File
@@ -20,6 +20,10 @@ app.conf.beat_schedule = {
"task": "plane.bgtasks.issue_automation_task.archive_and_close_old_issues",
"schedule": crontab(hour=0, minute=0),
},
"check-every-day-to-delete_exporter_history": {
"task": "plane.bgtasks.exporter_expired_task.delete_old_s3_link",
"schedule": crontab(hour=0, minute=0),
},
}
# Load task modules from all registered Django app configs.
@@ -0,0 +1,243 @@
# Generated by Django 4.2.3 on 2023-08-14 07:12
from django.conf import settings
import django.contrib.postgres.fields
from django.db import migrations, models
import django.db.models.deletion
import plane.db.models.exporter
import plane.db.models.project
import uuid
import random
import string
def generate_display_name(apps, schema_editor):
UserModel = apps.get_model("db", "User")
updated_users = []
for obj in UserModel.objects.all():
obj.display_name = (
obj.email.split("@")[0]
if len(obj.email.split("@"))
else "".join(random.choice(string.ascii_letters) for _ in range(6))
)
updated_users.append(obj)
UserModel.objects.bulk_update(updated_users, ["display_name"], batch_size=100)
def rectify_field_issue_activity(apps, schema_editor):
Model = apps.get_model("db", "IssueActivity")
updated_activity = []
for obj in Model.objects.filter(field="assignee"):
obj.field = "assignees"
updated_activity.append(obj)
Model.objects.bulk_update(updated_activity, ["field"], batch_size=100)
def update_assignee_issue_activity(apps, schema_editor):
Model = apps.get_model("db", "IssueActivity")
updated_activity = []
# Get all the users
User = apps.get_model("db", "User")
users = User.objects.values("id", "email", "display_name")
for obj in Model.objects.filter(field="assignees"):
if bool(obj.new_value) and not bool(obj.old_value):
# Get user from list
assigned_user = [
user for user in users if user.get("email") == obj.new_value
]
if assigned_user:
obj.new_value = assigned_user[0].get("display_name")
obj.new_identifier = assigned_user[0].get("id")
# Update the comment
words = obj.comment.split()
words[-1] = assigned_user[0].get("display_name")
obj.comment = " ".join(words)
if bool(obj.old_value) and not bool(obj.new_value):
# Get user from list
assigned_user = [
user for user in users if user.get("email") == obj.old_value
]
if assigned_user:
obj.old_value = assigned_user[0].get("display_name")
obj.old_identifier = assigned_user[0].get("id")
# Update the comment
words = obj.comment.split()
words[-1] = assigned_user[0].get("display_name")
obj.comment = " ".join(words)
updated_activity.append(obj)
Model.objects.bulk_update(
updated_activity,
["old_value", "new_value", "old_identifier", "new_identifier", "comment"],
batch_size=200,
)
def update_name_activity(apps, schema_editor):
Model = apps.get_model("db", "IssueActivity")
update_activity = []
for obj in Model.objects.filter(field="name"):
obj.comment = obj.comment.replace("start date", "name")
update_activity.append(obj)
Model.objects.bulk_update(update_activity, ["comment"], batch_size=1000)
def random_cycle_order(apps, schema_editor):
CycleModel = apps.get_model("db", "Cycle")
updated_cycles = []
for obj in CycleModel.objects.all():
obj.sort_order = random.randint(1, 65536)
updated_cycles.append(obj)
CycleModel.objects.bulk_update(updated_cycles, ["sort_order"], batch_size=100)
def random_module_order(apps, schema_editor):
ModuleModel = apps.get_model("db", "Module")
updated_modules = []
for obj in ModuleModel.objects.all():
obj.sort_order = random.randint(1, 65536)
updated_modules.append(obj)
ModuleModel.objects.bulk_update(updated_modules, ["sort_order"], batch_size=100)
def update_user_issue_properties(apps, schema_editor):
IssuePropertyModel = apps.get_model("db", "IssueProperty")
updated_issue_properties = []
for obj in IssuePropertyModel.objects.all():
obj.properties["start_date"] = True
updated_issue_properties.append(obj)
IssuePropertyModel.objects.bulk_update(
updated_issue_properties, ["properties"], batch_size=100
)
def workspace_member_properties(apps, schema_editor):
WorkspaceMemberModel = apps.get_model("db", "WorkspaceMember")
updated_workspace_members = []
for obj in WorkspaceMemberModel.objects.all():
obj.view_props["properties"]["start_date"] = True
obj.default_props["properties"]["start_date"] = True
updated_workspace_members.append(obj)
WorkspaceMemberModel.objects.bulk_update(
updated_workspace_members, ["view_props", "default_props"], batch_size=100
)
class Migration(migrations.Migration):
dependencies = [
('db', '0040_projectmember_preferences_user_cover_image_and_more'),
]
operations = [
migrations.AddField(
model_name='cycle',
name='sort_order',
field=models.FloatField(default=65535),
),
migrations.AddField(
model_name='issuecomment',
name='access',
field=models.CharField(choices=[('INTERNAL', 'INTERNAL'), ('EXTERNAL', 'EXTERNAL')], default='INTERNAL', max_length=100),
),
migrations.AddField(
model_name='module',
name='sort_order',
field=models.FloatField(default=65535),
),
migrations.AddField(
model_name='user',
name='display_name',
field=models.CharField(default='', max_length=255),
),
migrations.CreateModel(
name='ExporterHistory',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
('project', django.contrib.postgres.fields.ArrayField(base_field=models.UUIDField(default=uuid.uuid4), blank=True, null=True, size=None)),
('provider', models.CharField(choices=[('json', 'json'), ('csv', 'csv'), ('xlsx', 'xlsx')], max_length=50)),
('status', models.CharField(choices=[('queued', 'Queued'), ('processing', 'Processing'), ('completed', 'Completed'), ('failed', 'Failed')], default='queued', max_length=50)),
('reason', models.TextField(blank=True)),
('key', models.TextField(blank=True)),
('url', models.URLField(blank=True, max_length=800, null=True)),
('token', models.CharField(default=plane.db.models.exporter.generate_token, max_length=255, unique=True)),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
('initiated_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_exporters', to=settings.AUTH_USER_MODEL)),
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_exporters', to='db.workspace')),
],
options={
'verbose_name': 'Exporter',
'verbose_name_plural': 'Exporters',
'db_table': 'exporters',
'ordering': ('-created_at',),
},
),
migrations.CreateModel(
name='ProjectDeployBoard',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
('anchor', models.CharField(db_index=True, default=plane.db.models.project.get_anchor, max_length=255, unique=True)),
('comments', models.BooleanField(default=False)),
('reactions', models.BooleanField(default=False)),
('votes', models.BooleanField(default=False)),
('views', models.JSONField(default=plane.db.models.project.get_default_views)),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
('inbox', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='bord_inbox', to='db.inbox')),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project')),
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace')),
],
options={
'verbose_name': 'Project Deploy Board',
'verbose_name_plural': 'Project Deploy Boards',
'db_table': 'project_deploy_boards',
'ordering': ('-created_at',),
'unique_together': {('project', 'anchor')},
},
),
migrations.CreateModel(
name='IssueVote',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
('vote', models.IntegerField(choices=[(-1, 'DOWNVOTE'), (1, 'UPVOTE')])),
('actor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='votes', to=settings.AUTH_USER_MODEL)),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
('issue', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='votes', to='db.issue')),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project')),
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace')),
],
options={
'verbose_name': 'Issue Vote',
'verbose_name_plural': 'Issue Votes',
'db_table': 'issue_votes',
'ordering': ('-created_at',),
'unique_together': {('issue', 'actor')},
},
),
migrations.AlterField(
model_name='modulelink',
name='title',
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.RunPython(generate_display_name),
migrations.RunPython(rectify_field_issue_activity),
migrations.RunPython(update_assignee_issue_activity),
migrations.RunPython(update_name_activity),
migrations.RunPython(random_cycle_order),
migrations.RunPython(random_module_order),
migrations.RunPython(update_user_issue_properties),
migrations.RunPython(workspace_member_properties),
]
@@ -1,101 +0,0 @@
# Generated by Django 4.2.3 on 2023-08-04 09:12
import string
import random
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
def generate_display_name(apps, schema_editor):
UserModel = apps.get_model("db", "User")
updated_users = []
for obj in UserModel.objects.all():
obj.display_name = (
obj.email.split("@")[0]
if len(obj.email.split("@"))
else "".join(random.choice(string.ascii_letters) for _ in range(6))
)
updated_users.append(obj)
UserModel.objects.bulk_update(updated_users, ["display_name"], batch_size=100)
def rectify_field_issue_activity(apps, schema_editor):
Model = apps.get_model("db", "IssueActivity")
updated_activity = []
for obj in Model.objects.filter(field="assignee"):
obj.field = "assignees"
updated_activity.append(obj)
Model.objects.bulk_update(updated_activity, ["field"], batch_size=100)
def update_assignee_issue_activity(apps, schema_editor):
Model = apps.get_model("db", "IssueActivity")
updated_activity = []
# Get all the users
User = apps.get_model("db", "User")
users = User.objects.values("id", "email", "display_name")
for obj in Model.objects.filter(field="assignees"):
if bool(obj.new_value) and not bool(obj.old_value):
# Get user from list
assigned_user = [
user for user in users if user.get("email") == obj.new_value
]
if assigned_user:
obj.new_value = assigned_user[0].get("display_name")
obj.new_identifier = assigned_user[0].get("id")
# Update the comment
words = obj.comment.split()
words[-1] = assigned_user[0].get("display_name")
obj.comment = " ".join(words)
if bool(obj.old_value) and not bool(obj.new_value):
# Get user from list
assigned_user = [
user for user in users if user.get("email") == obj.old_value
]
if assigned_user:
obj.old_value = assigned_user[0].get("display_name")
obj.old_identifier = assigned_user[0].get("id")
# Update the comment
words = obj.comment.split()
words[-1] = assigned_user[0].get("display_name")
obj.comment = " ".join(words)
updated_activity.append(obj)
Model.objects.bulk_update(
updated_activity,
["old_value", "new_value", "old_identifier", "new_identifier", "comment"],
batch_size=200,
)
def update_name_activity(apps, schema_editor):
Model = apps.get_model("db", "IssueActivity")
update_activity = []
for obj in Model.objects.filter(field="name"):
obj.comment = obj.comment.replace("start date", "name")
update_activity.append(obj)
Model.objects.bulk_update(update_activity, ["comment"], batch_size=1000)
class Migration(migrations.Migration):
dependencies = [
("db", "0040_projectmember_preferences_user_cover_image_and_more"),
]
operations = [
migrations.AddField(
model_name="user",
name="display_name",
field=models.CharField(default="", max_length=255),
),
migrations.RunPython(generate_display_name),
migrations.RunPython(rectify_field_issue_activity),
migrations.RunPython(update_assignee_issue_activity),
migrations.RunPython(update_name_activity),
]
+5 -1
View File
@@ -18,6 +18,7 @@ from .project import (
ProjectMemberInvite,
ProjectIdentifier,
ProjectFavorite,
ProjectDeployBoard,
)
from .issue import (
@@ -36,6 +37,7 @@ from .issue import (
IssueSubscriber,
IssueReaction,
CommentReaction,
IssueVote,
)
from .asset import FileAsset
@@ -72,4 +74,6 @@ from .inbox import Inbox, InboxIssue
from .analytic import AnalyticView
from .notification import Notification
from .notification import Notification
from .exporter import ExporterHistory
+12
View File
@@ -17,6 +17,7 @@ class Cycle(ProjectBaseModel):
related_name="owned_by_cycle",
)
view_props = models.JSONField(default=dict)
sort_order = models.FloatField(default=65535)
class Meta:
verbose_name = "Cycle"
@@ -24,6 +25,17 @@ class Cycle(ProjectBaseModel):
db_table = "cycles"
ordering = ("-created_at",)
def save(self, *args, **kwargs):
if self._state.adding:
smallest_sort_order = Cycle.objects.filter(
project=self.project
).aggregate(smallest=models.Min("sort_order"))["smallest"]
if smallest_sort_order is not None:
self.sort_order = smallest_sort_order - 10000
super(Cycle, self).save(*args, **kwargs)
def __str__(self):
"""Return name of the cycle"""
return f"{self.name} <{self.project.name}>"
+56
View File
@@ -0,0 +1,56 @@
import uuid
# Python imports
from uuid import uuid4
# Django imports
from django.db import models
from django.conf import settings
from django.contrib.postgres.fields import ArrayField
# Module imports
from . import BaseModel
def generate_token():
return uuid4().hex
class ExporterHistory(BaseModel):
workspace = models.ForeignKey(
"db.WorkSpace", on_delete=models.CASCADE, related_name="workspace_exporters"
)
project = ArrayField(models.UUIDField(default=uuid.uuid4), blank=True, null=True)
provider = models.CharField(
max_length=50,
choices=(
("json", "json"),
("csv", "csv"),
("xlsx", "xlsx"),
),
)
status = models.CharField(
max_length=50,
choices=(
("queued", "Queued"),
("processing", "Processing"),
("completed", "Completed"),
("failed", "Failed"),
),
default="queued",
)
reason = models.TextField(blank=True)
key = models.TextField(blank=True)
url = models.URLField(max_length=800, blank=True, null=True)
token = models.CharField(max_length=255, default=generate_token, unique=True)
initiated_by = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="workspace_exporters"
)
class Meta:
verbose_name = "Exporter"
verbose_name_plural = "Exporters"
db_table = "exporters"
ordering = ("-created_at",)
def __str__(self):
"""Return name of the service"""
return f"{self.provider} <{self.workspace.name}>"
+35 -13
View File
@@ -108,11 +108,7 @@ class Issue(ProjectBaseModel):
~models.Q(name="Triage"), project=self.project
).first()
self.state = random_state
if random_state.group == "started":
self.start_date = timezone.now().date()
else:
if default_state.group == "started":
self.start_date = timezone.now().date()
self.state = default_state
except ImportError:
pass
@@ -127,8 +123,6 @@ class Issue(ProjectBaseModel):
PageBlock.objects.filter(issue_id=self.id).filter().update(
completed_at=timezone.now()
)
elif self.state.group == "started":
self.start_date = timezone.now().date()
else:
PageBlock.objects.filter(issue_id=self.id).filter().update(
completed_at=None
@@ -153,9 +147,6 @@ class Issue(ProjectBaseModel):
if largest_sort_order is not None:
self.sort_order = largest_sort_order + 10000
# If adding it to started state
if self.state.group == "started":
self.start_date = timezone.now().date()
# Strip the html tags using html parser
self.description_stripped = (
None
@@ -310,6 +301,14 @@ class IssueComment(ProjectBaseModel):
related_name="comments",
null=True,
)
access = models.CharField(
choices=(
("INTERNAL", "INTERNAL"),
("EXTERNAL", "EXTERNAL"),
),
default="INTERNAL",
max_length=100,
)
def save(self, *args, **kwargs):
self.comment_stripped = (
@@ -425,13 +424,14 @@ class IssueSubscriber(ProjectBaseModel):
class IssueReaction(ProjectBaseModel):
actor = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="issue_reactions",
)
issue = models.ForeignKey(Issue, on_delete=models.CASCADE, related_name="issue_reactions")
issue = models.ForeignKey(
Issue, on_delete=models.CASCADE, related_name="issue_reactions"
)
reaction = models.CharField(max_length=20)
class Meta:
@@ -446,13 +446,14 @@ class IssueReaction(ProjectBaseModel):
class CommentReaction(ProjectBaseModel):
actor = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="comment_reactions",
)
comment = models.ForeignKey(IssueComment, on_delete=models.CASCADE, related_name="comment_reactions")
comment = models.ForeignKey(
IssueComment, on_delete=models.CASCADE, related_name="comment_reactions"
)
reaction = models.CharField(max_length=20)
class Meta:
@@ -466,6 +467,27 @@ class CommentReaction(ProjectBaseModel):
return f"{self.issue.name} {self.actor.email}"
class IssueVote(ProjectBaseModel):
issue = models.ForeignKey(Issue, on_delete=models.CASCADE, related_name="votes")
actor = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="votes"
)
vote = models.IntegerField(
choices=(
(-1, "DOWNVOTE"),
(1, "UPVOTE"),
)
)
class Meta:
unique_together = ["issue", "actor"]
verbose_name = "Issue Vote"
verbose_name_plural = "Issue Votes"
db_table = "issue_votes"
ordering = ("-created_at",)
def __str__(self):
return f"{self.issue.name} {self.actor.email}"
# TODO: Find a better method to save the model
@receiver(post_save, sender=Issue)
+13 -1
View File
@@ -40,6 +40,7 @@ class Module(ProjectBaseModel):
through_fields=("module", "member"),
)
view_props = models.JSONField(default=dict)
sort_order = models.FloatField(default=65535)
class Meta:
unique_together = ["name", "project"]
@@ -48,6 +49,17 @@ class Module(ProjectBaseModel):
db_table = "modules"
ordering = ("-created_at",)
def save(self, *args, **kwargs):
if self._state.adding:
smallest_sort_order = Module.objects.filter(
project=self.project
).aggregate(smallest=models.Min("sort_order"))["smallest"]
if smallest_sort_order is not None:
self.sort_order = smallest_sort_order - 10000
super(Module, self).save(*args, **kwargs)
def __str__(self):
return f"{self.name} {self.start_date} {self.target_date}"
@@ -86,7 +98,7 @@ class ModuleIssue(ProjectBaseModel):
class ModuleLink(ProjectBaseModel):
title = models.CharField(max_length=255, null=True)
title = models.CharField(max_length=255, blank=True, null=True)
url = models.URLField()
module = models.ForeignKey(
Module, on_delete=models.CASCADE, related_name="link_module"
+43 -6
View File
@@ -1,3 +1,6 @@
# Python imports
from uuid import uuid4
# Django imports
from django.db import models
from django.conf import settings
@@ -31,12 +34,9 @@ def get_default_props():
"showEmptyGroups": True,
}
def get_default_preferences():
return {
"pages": {
"block_display": True
}
}
return {"pages": {"block_display": True}}
class Project(BaseModel):
@@ -157,7 +157,6 @@ class ProjectMember(ProjectBaseModel):
preferences = models.JSONField(default=get_default_preferences)
sort_order = models.FloatField(default=65535)
def save(self, *args, **kwargs):
if self._state.adding:
smallest_sort_order = ProjectMember.objects.filter(
@@ -217,3 +216,41 @@ class ProjectFavorite(ProjectBaseModel):
def __str__(self):
"""Return user of the project"""
return f"{self.user.email} <{self.project.name}>"
def get_anchor():
return uuid4().hex
def get_default_views():
return {
"list": True,
"kanban": True,
"calendar": True,
"gantt": True,
"spreadsheet": True,
}
class ProjectDeployBoard(ProjectBaseModel):
anchor = models.CharField(
max_length=255, default=get_anchor, unique=True, db_index=True
)
comments = models.BooleanField(default=False)
reactions = models.BooleanField(default=False)
inbox = models.ForeignKey(
"db.Inbox", related_name="bord_inbox", on_delete=models.SET_NULL, null=True
)
votes = models.BooleanField(default=False)
views = models.JSONField(default=get_default_views)
class Meta:
unique_together = ["project", "anchor"]
verbose_name = "Project Deploy Board"
verbose_name_plural = "Project Deploy Boards"
db_table = "project_deploy_boards"
ordering = ("-created_at",)
def __str__(self):
"""Return project and anchor"""
return f"{self.anchor} <{self.project.name}>"
+1
View File
@@ -33,6 +33,7 @@ def get_default_props():
"estimate": True,
"created_on": True,
"updated_on": True,
"start_date": True,
},
"showEmptyGroups": True,
}
+1 -1
View File
@@ -214,4 +214,4 @@ SIMPLE_JWT = {
CELERY_TIMEZONE = TIME_ZONE
CELERY_TASK_SERIALIZER = 'json'
CELERY_ACCEPT_CONTENT = ['application/json']
CELERY_IMPORTS = ("plane.bgtasks.issue_automation_task",)
CELERY_IMPORTS = ("plane.bgtasks.issue_automation_task","plane.bgtasks.exporter_expired_task")
+29 -16
View File
@@ -124,10 +124,11 @@ def filter_created_at(params, filter, method):
else:
if params.get("created_at", None) and len(params.get("created_at")):
for query in params.get("created_at"):
if query.get("timeline", "after") == "after":
filter["created_at__date__gte"] = query.get("datetime")
created_at_query = query.split(";")
if len(created_at_query) == 2 and "after" in created_at_query:
filter["created_at__date__gte"] = created_at_query[0]
else:
filter["created_at__date__lte"] = query.get("datetime")
filter["created_at__date__lte"] = created_at_query[0]
return filter
@@ -144,10 +145,11 @@ def filter_updated_at(params, filter, method):
else:
if params.get("updated_at", None) and len(params.get("updated_at")):
for query in params.get("updated_at"):
if query.get("timeline", "after") == "after":
filter["updated_at__date__gte"] = query.get("datetime")
updated_at_query = query.split(";")
if len(updated_at_query) == 2 and "after" in updated_at_query:
filter["updated_at__date__gte"] = updated_at_query[0]
else:
filter["updated_at__date__lte"] = query.get("datetime")
filter["updated_at__date__lte"] = updated_at_query[0]
return filter
@@ -164,10 +166,11 @@ def filter_start_date(params, filter, method):
else:
if params.get("start_date", None) and len(params.get("start_date")):
for query in params.get("start_date"):
if query.get("timeline", "after") == "after":
filter["start_date__gte"] = query.get("datetime")
start_date_query = query.split(";")
if len(start_date_query) == 2 and "after" in start_date_query:
filter["start_date__gte"] = start_date_query[0]
else:
filter["start_date__lte"] = query.get("datetime")
filter["start_date__lte"] = start_date_query[0]
return filter
@@ -184,10 +187,11 @@ def filter_target_date(params, filter, method):
else:
if params.get("target_date", None) and len(params.get("target_date")):
for query in params.get("target_date"):
if query.get("timeline", "after") == "after":
filter["target_date__gt"] = query.get("datetime")
target_date_query = query.split(";")
if len(target_date_query) == 2 and "after" in target_date_query:
filter["target_date__gt"] = target_date_query[0]
else:
filter["target_date__lt"] = query.get("datetime")
filter["target_date__lt"] = target_date_query[0]
return filter
@@ -205,10 +209,11 @@ def filter_completed_at(params, filter, method):
else:
if params.get("completed_at", None) and len(params.get("completed_at")):
for query in params.get("completed_at"):
if query.get("timeline", "after") == "after":
filter["completed_at__date__gte"] = query.get("datetime")
completed_at_query = query.split(";")
if len(completed_at_query) == 2 and "after" in completed_at_query:
filter["completed_at__date__gte"] = completed_at_query[0]
else:
filter["completed_at__lte"] = query.get("datetime")
filter["completed_at__lte"] = completed_at_query[0]
return filter
@@ -292,9 +297,16 @@ def filter_subscribed_issues(params, filter, method):
return filter
def filter_start_target_date_issues(params, filter, method):
start_target_date = params.get("start_target_date", "false")
if start_target_date == "true":
filter["target_date__isnull"] = False
filter["start_date__isnull"] = False
return filter
def issue_filters(query_params, method):
filter = dict()
print(query_params)
ISSUE_FILTER = {
"state": filter_state,
@@ -318,6 +330,7 @@ def issue_filters(query_params, method):
"inbox_status": filter_inbox_status,
"sub_issue": filter_sub_issue_toggle,
"subscriber": filter_subscribed_issues,
"start_target_date": filter_start_target_date_issues,
}
for key, value in ISSUE_FILTER.items():
+2 -1
View File
@@ -32,4 +32,5 @@ celery==5.3.1
django_celery_beat==2.5.0
psycopg-binary==3.1.9
psycopg-c==3.1.9
scout-apm==2.26.1
scout-apm==2.26.1
openpyxl==3.1.2
-85
View File
@@ -1,85 +0,0 @@
{
"name": "Plane",
"description": "Plane helps you track your issues, epics, and product roadmaps.",
"repository": "http://github.com/makeplane/plane",
"logo": "https://avatars.githubusercontent.com/u/115727700?s=200&v=4",
"website": "https://plane.so/",
"success_url": "/",
"stack": "heroku-22",
"keywords": [
"plane",
"project management",
"django",
"next"
],
"addons": [
"heroku-postgresql:mini",
"heroku-redis:mini"
],
"buildpacks": [
{
"url": "https://github.com/heroku/heroku-buildpack-python.git"
},
{
"url": "https://github.com/heroku/heroku-buildpack-nodejs#v176"
}
],
"env": {
"EMAIL_HOST": {
"description": "Email host to send emails from",
"value": ""
},
"EMAIL_HOST_USER": {
"description": "Email host to send emails from",
"value": ""
},
"EMAIL_HOST_PASSWORD": {
"description": "Email host to send emails from",
"value": ""
},
"EMAIL_FROM": {
"description": "Email Sender",
"value": ""
},
"EMAIL_PORT": {
"description": "The default Email PORT to use",
"value": "587"
},
"AWS_REGION": {
"description": "AWS Region to use for S3",
"value": "false"
},
"AWS_ACCESS_KEY_ID": {
"description": "AWS Access Key ID to use for S3",
"value": ""
},
"AWS_SECRET_ACCESS_KEY": {
"description": "AWS Secret Access Key to use for S3",
"value": ""
},
"AWS_S3_BUCKET_NAME": {
"description": "AWS Bucket Name to use for S3",
"value": ""
},
"SENTRY_DSN": {
"description": "",
"value": ""
},
"WEB_URL": {
"description": "Web URL for Plane this will be used for redirections in the emails",
"value": ""
},
"GITHUB_CLIENT_SECRET": {
"description": "Github Client Secret",
"value": ""
},
"NEXT_PUBLIC_API_BASE_URL": {
"description": "Next Public API Base URL",
"value": ""
},
"SECRET_KEY": {
"description": "Django Secret Key",
"value": ""
}
}
}
-7
View File
@@ -1,7 +0,0 @@
module.exports = {
root: true,
extends: ["custom"],
rules: {
"@next/next/no-img-element": "off",
},
};
-11
View File
@@ -1,11 +0,0 @@
FROM node:18-alpine
RUN apk add --no-cache libc6-compat
# Set working directory
WORKDIR /app
COPY . .
RUN yarn global add turbo
RUN yarn install
EXPOSE 3000
CMD ["yarn","dev"]
@@ -1,81 +0,0 @@
import { FC } from "react";
// next imports
import Link from "next/link";
import { useRouter } from "next/router";
// components
import { GanttChartRoot } from "components/gantt-chart";
// ui
import { Tooltip } from "components/ui";
// types
import { ICycle } from "types";
type Props = {
cycles: ICycle[];
};
export const CyclesListGanttChartView: FC<Props> = ({ cycles }) => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
// rendering issues on gantt sidebar
const GanttSidebarBlockView = ({ data }: any) => (
<div className="relative flex w-full h-full items-center p-1 overflow-hidden gap-1">
<div
className="rounded-sm flex-shrink-0 w-[10px] h-[10px] flex justify-center items-center"
style={{ backgroundColor: "rgb(var(--color-primary-100))" }}
/>
<div className="text-custom-text-100 text-sm">{data?.name}</div>
</div>
);
// rendering issues on gantt card
const GanttBlockView = ({ data }: { data: ICycle }) => (
<Link href={`/${workspaceSlug}/projects/${projectId}/cycles/${data?.id}`}>
<a className="relative flex items-center w-full h-full overflow-hidden shadow-sm">
<div
className="flex-shrink-0 w-[4px] h-full"
style={{ backgroundColor: "rgb(var(--color-primary-100))" }}
/>
<Tooltip tooltipContent={data?.name} className={`z-[999999]`}>
<div className="text-custom-text-100 text-[15px] whitespace-nowrap py-[4px] px-2.5 overflow-hidden w-full">
{data?.name}
</div>
</Tooltip>
</a>
</Link>
);
// handle gantt issue start date and target date
const handleUpdateDates = async (data: any) => {
const payload = {
id: data?.id,
start_date: data?.start_date,
target_date: data?.target_date,
};
};
const blockFormat = (blocks: any) =>
blocks && blocks.length > 0
? blocks.map((_block: any) => {
if (_block?.start_date && _block.target_date) console.log("_block", _block);
return {
start_date: new Date(_block.created_at),
target_date: new Date(_block.updated_at),
data: _block,
};
})
: [];
return (
<div className="w-full h-full overflow-y-auto">
<GanttChartRoot
title={"Cycles"}
loaderTitle="Cycles"
blocks={cycles ? blockFormat(cycles) : null}
blockUpdateHandler={handleUpdateDates}
sidebarBlockRender={(data: any) => <GanttSidebarBlockView data={data} />}
blockRender={(data: any) => <GanttBlockView data={data} />}
/>
</div>
);
};
-109
View File
@@ -1,109 +0,0 @@
import { FC } from "react";
// next imports
import Link from "next/link";
import { useRouter } from "next/router";
// components
import { GanttChartRoot } from "components/gantt-chart";
// ui
import { Tooltip } from "components/ui";
// hooks
import useGanttChartCycleIssues from "hooks/gantt-chart/cycle-issues-view";
type Props = {};
export const CycleIssuesGanttChartView: FC<Props> = ({}) => {
const router = useRouter();
const { workspaceSlug, projectId, cycleId } = router.query;
const { ganttIssues, mutateGanttIssues } = useGanttChartCycleIssues(
workspaceSlug as string,
projectId as string,
cycleId as string
);
// rendering issues on gantt sidebar
const GanttSidebarBlockView = ({ data }: any) => (
<div className="relative flex w-full h-full items-center p-1 overflow-hidden gap-1">
<div
className="rounded-sm flex-shrink-0 w-[10px] h-[10px] flex justify-center items-center"
style={{ backgroundColor: data?.state_detail?.color || "rgb(var(--color-primary-100))" }}
/>
<div className="text-custom-text-100 text-sm">{data?.name}</div>
</div>
);
// rendering issues on gantt card
const GanttBlockView = ({ data }: any) => (
<Link href={`/${workspaceSlug}/projects/${projectId}/issues/${data?.id}`}>
<a className="relative flex items-center w-full h-full overflow-hidden shadow-sm">
<div
className="flex-shrink-0 w-[4px] h-full"
style={{ backgroundColor: data?.state_detail?.color || "rgb(var(--color-primary-100))" }}
/>
<Tooltip tooltipContent={data?.name} className={`z-[999999]`}>
<div className="text-custom-text-100 text-[15px] whitespace-nowrap py-[4px] px-2.5 overflow-hidden w-full">
{data?.name}
</div>
</Tooltip>
{data.infoToggle && (
<Tooltip
tooltipContent={`No due-date set, rendered according to last updated date.`}
className={`z-[999999]`}
>
<div className="flex-shrink-0 mx-2 w-[18px] h-[18px] overflow-hidden flex justify-center items-center">
<span className="material-symbols-rounded text-custom-text-200 text-[18px]">
info
</span>
</div>
</Tooltip>
)}
</a>
</Link>
);
// handle gantt issue start date and target date
const handleUpdateDates = async (data: any) => {
const payload = {
id: data?.id,
start_date: data?.start_date,
target_date: data?.target_date,
};
console.log("payload", payload);
};
const blockFormat = (blocks: any) =>
blocks && blocks.length > 0
? blocks.map((_block: any) => {
let startDate = new Date(_block.created_at);
let targetDate = new Date(_block.updated_at);
let infoToggle = true;
if (_block?.start_date && _block.target_date) {
startDate = _block?.start_date;
targetDate = _block.target_date;
infoToggle = false;
}
return {
start_date: new Date(startDate),
target_date: new Date(targetDate),
infoToggle: infoToggle,
data: _block,
};
})
: [];
return (
<div className="w-full h-full p-3">
<GanttChartRoot
title="Cycles"
loaderTitle="Cycles"
blocks={ganttIssues ? blockFormat(ganttIssues) : null}
blockUpdateHandler={handleUpdateDates}
sidebarBlockRender={(data: any) => <GanttSidebarBlockView data={data} />}
blockRender={(data: any) => <GanttBlockView data={data} />}
/>
</div>
);
};
@@ -1,82 +0,0 @@
import { FC, useEffect, useState } from "react";
// helpers
import { ChartDraggable } from "../helpers/draggable";
// data
import { datePreview } from "../data";
export const GanttChartBlocks: FC<{
itemsContainerWidth: number;
blocks: null | any[];
sidebarBlockRender: FC;
blockRender: FC;
}> = ({ itemsContainerWidth, blocks, sidebarBlockRender, blockRender }) => {
const handleChartBlockPosition = (block: any) => {
// setChartBlocks((prevData: any) =>
// prevData.map((_block: any) => (_block?.data?.id == block?.data?.id ? block : _block))
// );
};
return (
<div
className="relative z-[5] mt-[58px] h-full w-[4000px] divide-x divide-gray-300 overflow-hidden overflow-y-auto"
style={{ width: `${itemsContainerWidth}px` }}
>
<div className="w-full">
{blocks &&
blocks.length > 0 &&
blocks.map((block: any, _idx: number) => (
<>
{block.start_date && block.target_date && (
<ChartDraggable
className="relative flex h-[40px] items-center"
key={`blocks-${_idx}`}
block={block}
handleBlock={handleChartBlockPosition}
>
<div
className="relative group inline-flex cursor-pointer items-center font-medium transition-all"
style={{ marginLeft: `${block?.position?.marginLeft}px` }}
>
<div className="flex-shrink-0 relative w-0 h-0 flex items-center invisible group-hover:visible whitespace-nowrap">
<div className="absolute right-0 mr-[5px] rounded-sm bg-custom-background-90 px-2 py-0.5 text-xs font-medium">
{block?.start_date ? datePreview(block?.start_date) : "-"}
</div>
</div>
<div
className="rounded shadow-sm bg-custom-background-100 overflow-hidden relative flex items-center h-[34px] border border-custom-border-200"
style={{
width: `${block?.position?.width}px`,
}}
>
{blockRender({
...block?.data,
infoToggle: block?.infoToggle ? true : false,
})}
</div>
<div className="flex-shrink-0 relative w-0 h-0 flex items-center invisible group-hover:visible whitespace-nowrap">
<div className="absolute left-0 ml-[5px] mr-[5px] rounded-sm bg-custom-background-90 px-2 py-0.5 text-xs font-medium">
{block?.target_date ? datePreview(block?.target_date) : "-"}
</div>
</div>
</div>
</ChartDraggable>
)}
</>
))}
</div>
{/* sidebar */}
{/* <div className="fixed top-0 bottom-0 w-[300px] flex-shrink-0 divide-y divide-custom-border-200 border-r border-custom-border-200 overflow-y-auto">
{blocks &&
blocks.length > 0 &&
blocks.map((block: any, _idx: number) => (
<div className="relative h-[40px] bg-custom-background-100" key={`sidebar-blocks-${_idx}`}>
{sidebarBlockRender(block?.data)}
</div>
))}
</div> */}
</div>
);
};
@@ -1,138 +0,0 @@
import { useState, useRef } from "react";
export const ChartDraggable = ({ children, block, handleBlock, className }: any) => {
const [dragging, setDragging] = useState(false);
const [chartBlockPositionLeft, setChartBlockPositionLeft] = useState(0);
const [blockPositionLeft, setBlockPositionLeft] = useState(0);
const [dragBlockOffsetX, setDragBlockOffsetX] = useState(0);
const handleMouseDown = (event: any) => {
const chartBlockPositionLeft: number = block.position.marginLeft;
const blockPositionLeft: number = event.target.getBoundingClientRect().left;
const dragBlockOffsetX: number = event.clientX - event.target.getBoundingClientRect().left;
console.log("--------------------");
console.log("chartBlockPositionLeft", chartBlockPositionLeft);
console.log("blockPositionLeft", blockPositionLeft);
console.log("dragBlockOffsetX", dragBlockOffsetX);
console.log("-->");
setDragging(true);
setChartBlockPositionLeft(chartBlockPositionLeft);
setBlockPositionLeft(blockPositionLeft);
setDragBlockOffsetX(dragBlockOffsetX);
};
const handleMouseMove = (event: any) => {
if (!dragging) return;
const currentBlockPosition = event.clientX - dragBlockOffsetX;
console.log("currentBlockPosition", currentBlockPosition);
if (currentBlockPosition <= blockPositionLeft) {
const updatedPosition = chartBlockPositionLeft - (blockPositionLeft - currentBlockPosition);
console.log("updatedPosition", updatedPosition);
handleBlock({ ...block, position: { ...block.position, marginLeft: updatedPosition } });
} else {
const updatedPosition = chartBlockPositionLeft + (blockPositionLeft - currentBlockPosition);
console.log("updatedPosition", updatedPosition);
handleBlock({ ...block, position: { ...block.position, marginLeft: updatedPosition } });
}
console.log("--------------------");
};
const handleMouseUp = () => {
setDragging(false);
setChartBlockPositionLeft(0);
setBlockPositionLeft(0);
setDragBlockOffsetX(0);
};
return (
<div
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
className={`${className ? className : ``}`}
>
{children}
</div>
);
};
// import { useState } from "react";
// export const ChartDraggable = ({ children, id, className = "", style }: any) => {
// const [dragging, setDragging] = useState(false);
// const [chartBlockPositionLeft, setChartBlockPositionLeft] = useState(0);
// const [blockPositionLeft, setBlockPositionLeft] = useState(0);
// const [dragBlockOffsetX, setDragBlockOffsetX] = useState(0);
// const handleDragStart = (event: any) => {
// // event.dataTransfer.setData("text/plain", event.target.id);
// const chartBlockPositionLeft: number = parseInt(event.target.style.left.slice(0, -2));
// const blockPositionLeft: number = event.target.getBoundingClientRect().left;
// const dragBlockOffsetX: number = event.clientX - event.target.getBoundingClientRect().left;
// console.log("chartBlockPositionLeft", chartBlockPositionLeft);
// console.log("blockPositionLeft", blockPositionLeft);
// console.log("dragBlockOffsetX", dragBlockOffsetX);
// console.log("--------------------");
// setDragging(true);
// setChartBlockPositionLeft(chartBlockPositionLeft);
// setBlockPositionLeft(blockPositionLeft);
// setDragBlockOffsetX(dragBlockOffsetX);
// };
// const handleDragEnd = () => {
// setDragging(false);
// setChartBlockPositionLeft(0);
// setBlockPositionLeft(0);
// setDragBlockOffsetX(0);
// };
// const handleDragOver = (event: any) => {
// event.preventDefault();
// if (dragging) {
// const scrollContainer = document.getElementById(`block-parent-${id}`) as HTMLElement;
// const currentBlockPosition = event.clientX - dragBlockOffsetX;
// console.log('currentBlockPosition')
// if (currentBlockPosition <= blockPositionLeft) {
// const updatedPosition = chartBlockPositionLeft - (blockPositionLeft - currentBlockPosition);
// console.log("updatedPosition", updatedPosition);
// if (scrollContainer) scrollContainer.style.left = `${updatedPosition}px`;
// } else {
// const updatedPosition = chartBlockPositionLeft + (blockPositionLeft - currentBlockPosition);
// console.log("updatedPosition", updatedPosition);
// if (scrollContainer) scrollContainer.style.left = `${updatedPosition}px`;
// }
// console.log("--------------------");
// }
// };
// const handleDrop = (event: any) => {
// event.preventDefault();
// setDragging(false);
// setChartBlockPositionLeft(0);
// setBlockPositionLeft(0);
// setDragBlockOffsetX(0);
// };
// return (
// <div
// id={id}
// draggable
// onDragStart={handleDragStart}
// onDragEnd={handleDragEnd}
// onDragOver={handleDragOver}
// onDrop={handleDrop}
// className={`${className} ${dragging ? "dragging" : ""}`}
// style={style}
// >
// {children}
// </div>
// );
// };
-1
View File
@@ -1 +0,0 @@
export * from "./root";
-107
View File
@@ -1,107 +0,0 @@
import { FC } from "react";
// next imports
import Link from "next/link";
import { useRouter } from "next/router";
// components
import { GanttChartRoot } from "components/gantt-chart";
// ui
import { Tooltip } from "components/ui";
// hooks
import useGanttChartIssues from "hooks/gantt-chart/issue-view";
type Props = {};
export const IssueGanttChartView: FC<Props> = ({}) => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { ganttIssues, mutateGanttIssues } = useGanttChartIssues(
workspaceSlug as string,
projectId as string
);
// rendering issues on gantt sidebar
const GanttSidebarBlockView = ({ data }: any) => (
<div className="relative flex w-full h-full items-center p-1 overflow-hidden gap-1">
<div
className="rounded-sm flex-shrink-0 w-[10px] h-[10px] flex justify-center items-center"
style={{ backgroundColor: data?.state_detail?.color || "#rgb(var(--color-primary-100))" }}
/>
<div className="text-custom-text-100 text-sm">{data?.name}</div>
</div>
);
// rendering issues on gantt card
const GanttBlockView = ({ data }: any) => (
<Link href={`/${workspaceSlug}/projects/${projectId}/issues/${data?.id}`}>
<a className="relative flex items-center w-full h-full overflow-hidden shadow-sm font-normal">
<div
className="flex-shrink-0 w-[4px] h-full"
style={{ backgroundColor: data?.state_detail?.color || "rgb(var(--color-primary-100))" }}
/>
<Tooltip tooltipContent={data?.name} className={`z-[999999]`}>
<div className="text-custom-text-100 text-[15px] whitespace-nowrap py-[4px] px-2.5 overflow-hidden w-full">
{data?.name}
</div>
</Tooltip>
{data.infoToggle && (
<Tooltip
tooltipContent={`No due-date set, rendered according to last updated date.`}
className={`z-[999999]`}
>
<div className="flex-shrink-0 mx-2 w-[18px] h-[18px] overflow-hidden flex justify-center items-center">
<span className="material-symbols-rounded text-custom-text-200 text-[18px]">
info
</span>
</div>
</Tooltip>
)}
</a>
</Link>
);
// handle gantt issue start date and target date
const handleUpdateDates = async (data: any) => {
const payload = {
id: data?.id,
start_date: data?.start_date,
target_date: data?.target_date,
};
};
const blockFormat = (blocks: any) =>
blocks && blocks.length > 0
? blocks.map((_block: any) => {
let startDate = new Date(_block.created_at);
let targetDate = new Date(_block.updated_at);
let infoToggle = true;
if (_block?.start_date && _block.target_date) {
startDate = _block?.start_date;
targetDate = _block.target_date;
infoToggle = false;
}
return {
start_date: new Date(startDate),
target_date: new Date(targetDate),
infoToggle: infoToggle,
data: _block,
};
})
: [];
return (
<div className="w-full h-full">
<GanttChartRoot
border={false}
title="Issues"
loaderTitle="Issues"
blocks={ganttIssues ? blockFormat(ganttIssues) : null}
blockUpdateHandler={handleUpdateDates}
sidebarBlockRender={(data: any) => <GanttSidebarBlockView data={data} />}
blockRender={(data: any) => <GanttBlockView data={data} />}
/>
</div>
);
};
@@ -1,201 +0,0 @@
import React, { forwardRef, useEffect } from "react";
import { useRouter } from "next/router";
import { mutate } from "swr";
// react-hook-form
import { Controller, SubmitHandler, useForm } from "react-hook-form";
// hooks
import useUserAuth from "hooks/use-user-auth";
// react-color
import { TwitterPicker } from "react-color";
// headless ui
import { Popover, Transition } from "@headlessui/react";
// services
import issuesService from "services/issues.service";
// ui
import { Input, PrimaryButton, SecondaryButton } from "components/ui";
// icons
import { ChevronDownIcon } from "@heroicons/react/24/outline";
// types
import { IIssueLabels } from "types";
// fetch-keys
import { PROJECT_ISSUE_LABELS } from "constants/fetch-keys";
type Props = {
labelForm: boolean;
setLabelForm: React.Dispatch<React.SetStateAction<boolean>>;
isUpdating: boolean;
labelToUpdate: IIssueLabels | null;
};
const defaultValues: Partial<IIssueLabels> = {
name: "",
color: "rgb(var(--color-text-200))",
};
type Ref = HTMLDivElement;
export const CreateUpdateLabelInline = forwardRef<Ref, Props>(function CreateUpdateLabelInline(
{ labelForm, setLabelForm, isUpdating, labelToUpdate },
ref
) {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { user } = useUserAuth();
const {
handleSubmit,
control,
register,
reset,
formState: { errors, isSubmitting },
watch,
setValue,
} = useForm<IIssueLabels>({
defaultValues,
});
const handleLabelCreate: SubmitHandler<IIssueLabels> = async (formData) => {
if (!workspaceSlug || !projectId || isSubmitting) return;
await issuesService
.createIssueLabel(workspaceSlug as string, projectId as string, formData, user)
.then((res) => {
mutate<IIssueLabels[]>(
PROJECT_ISSUE_LABELS(projectId as string),
(prevData) => [res, ...(prevData ?? [])],
false
);
reset(defaultValues);
setLabelForm(false);
});
};
const handleLabelUpdate: SubmitHandler<IIssueLabels> = async (formData) => {
if (!workspaceSlug || !projectId || isSubmitting) return;
await issuesService
.patchIssueLabel(
workspaceSlug as string,
projectId as string,
labelToUpdate?.id ?? "",
formData,
user
)
.then(() => {
reset(defaultValues);
mutate<IIssueLabels[]>(
PROJECT_ISSUE_LABELS(projectId as string),
(prevData) =>
prevData?.map((p) => (p.id === labelToUpdate?.id ? { ...p, ...formData } : p)),
false
);
setLabelForm(false);
});
};
useEffect(() => {
if (!labelForm && isUpdating) return;
reset();
}, [labelForm, isUpdating, reset]);
useEffect(() => {
if (!labelToUpdate) return;
setValue(
"color",
labelToUpdate.color && labelToUpdate.color !== "" ? labelToUpdate.color : "#000"
);
setValue("name", labelToUpdate.name);
}, [labelToUpdate, setValue]);
return (
<div
className={`flex scroll-m-8 items-center gap-2 rounded-[10px] border border-custom-border-200 bg-custom-background-100 p-5 ${
labelForm ? "" : "hidden"
}`}
ref={ref}
>
<div className="flex-shrink-0">
<Popover className="relative z-10 flex h-full w-full items-center justify-center">
{({ open }) => (
<>
<Popover.Button
className={`group inline-flex items-center text-base font-medium focus:outline-none ${
open ? "text-custom-text-100" : "text-custom-text-200"
}`}
>
<span
className="h-5 w-5 rounded"
style={{
backgroundColor: watch("color"),
}}
/>
<ChevronDownIcon
className={`ml-2 h-5 w-5 group-hover:text-custom-text-200 ${
open ? "text-gray-600" : "text-gray-400"
}`}
aria-hidden="true"
/>
</Popover.Button>
<Transition
as={React.Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute top-full left-0 z-20 mt-3 w-screen max-w-xs px-2 sm:px-0">
<Controller
name="color"
control={control}
render={({ field: { value, onChange } }) => (
<TwitterPicker color={value} onChange={(value) => onChange(value.hex)} />
)}
/>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
</div>
<div className="flex flex-1 flex-col justify-center">
<Input
type="text"
id="labelName"
name="name"
register={register}
placeholder="Label title"
validations={{
required: "Label title is required",
}}
error={errors.name}
/>
</div>
<SecondaryButton
onClick={() => {
reset();
setLabelForm(false);
}}
>
Cancel
</SecondaryButton>
{isUpdating ? (
<PrimaryButton onClick={handleSubmit(handleLabelUpdate)} loading={isSubmitting}>
{isSubmitting ? "Updating" : "Update"}
</PrimaryButton>
) : (
<PrimaryButton onClick={handleSubmit(handleLabelCreate)} loading={isSubmitting}>
{isSubmitting ? "Adding" : "Add"}
</PrimaryButton>
)}
</div>
);
});
-109
View File
@@ -1,109 +0,0 @@
import { FC } from "react";
// next imports
import Link from "next/link";
import { useRouter } from "next/router";
// components
import { GanttChartRoot } from "components/gantt-chart";
// ui
import { Tooltip } from "components/ui";
// hooks
import useGanttChartModuleIssues from "hooks/gantt-chart/module-issues-view";
type Props = {};
export const ModuleIssuesGanttChartView: FC<Props> = ({}) => {
const router = useRouter();
const { workspaceSlug, projectId, moduleId } = router.query;
const { ganttIssues, mutateGanttIssues } = useGanttChartModuleIssues(
workspaceSlug as string,
projectId as string,
moduleId as string
);
// rendering issues on gantt sidebar
const GanttSidebarBlockView = ({ data }: any) => (
<div className="relative flex w-full h-full items-center p-1 overflow-hidden gap-1">
<div
className="rounded-sm flex-shrink-0 w-[10px] h-[10px] flex justify-center items-center"
style={{ backgroundColor: data?.state_detail?.color || "rgb(var(--color-primary-100))" }}
/>
<div className="text-custom-text-100 text-sm">{data?.name}</div>
</div>
);
// rendering issues on gantt card
const GanttBlockView = ({ data }: any) => (
<Link href={`/${workspaceSlug}/projects/${projectId}/issues/${data?.id}`}>
<a className="relative flex items-center w-full h-full overflow-hidden shadow-sm">
<div
className="flex-shrink-0 w-[4px] h-full"
style={{ backgroundColor: data?.state_detail?.color || "rgb(var(--color-primary-100))" }}
/>
<Tooltip tooltipContent={data?.name} className={`z-[999999]`}>
<div className="text-custom-text-100 text-[15px] whitespace-nowrap py-[4px] px-2.5 overflow-hidden w-full">
{data?.name}
</div>
</Tooltip>
{data.infoToggle && (
<Tooltip
tooltipContent={`No due-date set, rendered according to last updated date.`}
className={`z-[999999]`}
>
<div className="flex-shrink-0 mx-2 w-[18px] h-[18px] overflow-hidden flex justify-center items-center">
<span className="material-symbols-rounded text-custom-text-200 text-[18px]">
info
</span>
</div>
</Tooltip>
)}
</a>
</Link>
);
// handle gantt issue start date and target date
const handleUpdateDates = async (data: any) => {
const payload = {
id: data?.id,
start_date: data?.start_date,
target_date: data?.target_date,
};
console.log("payload", payload);
};
const blockFormat = (blocks: any) =>
blocks && blocks.length > 0
? blocks.map((_block: any) => {
let startDate = new Date(_block.created_at);
let targetDate = new Date(_block.updated_at);
let infoToggle = true;
if (_block?.start_date && _block.target_date) {
startDate = _block?.start_date;
targetDate = _block.target_date;
infoToggle = false;
}
return {
start_date: new Date(startDate),
target_date: new Date(targetDate),
infoToggle: infoToggle,
data: _block,
};
})
: [];
return (
<div className="w-full h-full p-3">
<GanttChartRoot
title="Modules"
loaderTitle="Modules"
blocks={ganttIssues ? blockFormat(ganttIssues) : null}
blockUpdateHandler={handleUpdateDates}
sidebarBlockRender={(data: any) => <GanttSidebarBlockView data={data} />}
blockRender={(data: any) => <GanttBlockView data={data} />}
/>
</div>
);
};
@@ -1,85 +0,0 @@
import { FC } from "react";
// next imports
import Link from "next/link";
import { useRouter } from "next/router";
// components
import { GanttChartRoot } from "components/gantt-chart";
// ui
import { Tooltip } from "components/ui";
// types
import { IModule } from "types";
// constants
import { MODULE_STATUS } from "constants/module";
type Props = {
modules: IModule[];
};
export const ModulesListGanttChartView: FC<Props> = ({ modules }) => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
// rendering issues on gantt sidebar
const GanttSidebarBlockView = ({ data }: any) => (
<div className="relative flex w-full h-full items-center p-1 overflow-hidden gap-1">
<div
className="rounded-sm flex-shrink-0 w-[10px] h-[10px] flex justify-center items-center"
style={{
backgroundColor: MODULE_STATUS.find((s) => s.value === data.status)?.color,
}}
/>
<div className="text-custom-text-100 text-sm">{data?.name}</div>
</div>
);
// rendering issues on gantt card
const GanttBlockView = ({ data }: { data: IModule }) => (
<Link href={`/${workspaceSlug}/projects/${projectId}/modules/${data?.id}`}>
<a className="relative flex items-center w-full h-full overflow-hidden shadow-sm">
<div
className="flex-shrink-0 w-[4px] h-full"
style={{ backgroundColor: MODULE_STATUS.find((s) => s.value === data.status)?.color }}
/>
<Tooltip tooltipContent={data?.name} className={`z-[999999]`}>
<div className="text-custom-text-100 text-[15px] whitespace-nowrap py-[4px] px-2.5 overflow-hidden w-full">
{data?.name}
</div>
</Tooltip>
</a>
</Link>
);
// handle gantt issue start date and target date
const handleUpdateDates = async (data: any) => {
const payload = {
id: data?.id,
start_date: data?.start_date,
target_date: data?.target_date,
};
};
const blockFormat = (blocks: any) =>
blocks && blocks.length > 0
? blocks.map((_block: any) => {
if (_block?.start_date && _block.target_date) console.log("_block", _block);
return {
start_date: new Date(_block.created_at),
target_date: new Date(_block.updated_at),
data: _block,
};
})
: [];
return (
<div className="w-full h-full overflow-y-auto">
<GanttChartRoot
title="Modules"
loaderTitle="Modules"
blocks={modules ? blockFormat(modules) : null}
blockUpdateHandler={handleUpdateDates}
sidebarBlockRender={(data: any) => <GanttSidebarBlockView data={data} />}
blockRender={(data: any) => <GanttBlockView data={data} />}
/>
</div>
);
};
@@ -1,313 +0,0 @@
import Link from "next/link";
import { useRouter } from "next/router";
import { mutate } from "swr";
// react-beautiful-dnd
import { DraggableProvided, DraggableStateSnapshot } from "react-beautiful-dnd";
// headless ui
import { Disclosure, Transition } from "@headlessui/react";
// services
import projectService from "services/project.service";
// hooks
import useToast from "hooks/use-toast";
// ui
import { CustomMenu, Tooltip } from "components/ui";
// icons
import { EllipsisVerticalIcon, LinkIcon, StarIcon, TrashIcon } from "@heroicons/react/24/outline";
import {
ArchiveOutlined,
ArticleOutlined,
ContrastOutlined,
DatasetOutlined,
ExpandMoreOutlined,
FilterNoneOutlined,
PhotoFilterOutlined,
SettingsOutlined,
} from "@mui/icons-material";
// helpers
import { truncateText } from "helpers/string.helper";
import { renderEmoji } from "helpers/emoji.helper";
// types
import { IProject } from "types";
// fetch-keys
import { PROJECTS_LIST } from "constants/fetch-keys";
type Props = {
project: IProject;
sidebarCollapse: boolean;
provided?: DraggableProvided;
snapshot?: DraggableStateSnapshot;
handleDeleteProject: () => void;
handleCopyText: () => void;
shortContextMenu?: boolean;
};
const navigation = (workspaceSlug: string, projectId: string) => [
{
name: "Issues",
href: `/${workspaceSlug}/projects/${projectId}/issues`,
Icon: FilterNoneOutlined,
},
{
name: "Cycles",
href: `/${workspaceSlug}/projects/${projectId}/cycles`,
Icon: ContrastOutlined,
},
{
name: "Modules",
href: `/${workspaceSlug}/projects/${projectId}/modules`,
Icon: DatasetOutlined,
},
{
name: "Views",
href: `/${workspaceSlug}/projects/${projectId}/views`,
Icon: PhotoFilterOutlined,
},
{
name: "Pages",
href: `/${workspaceSlug}/projects/${projectId}/pages`,
Icon: ArticleOutlined,
},
{
name: "Settings",
href: `/${workspaceSlug}/projects/${projectId}/settings`,
Icon: SettingsOutlined,
},
];
export const SingleSidebarProject: React.FC<Props> = ({
project,
sidebarCollapse,
provided,
snapshot,
handleDeleteProject,
handleCopyText,
shortContextMenu = false,
}) => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { setToastAlert } = useToast();
const handleAddToFavorites = () => {
if (!workspaceSlug) return;
mutate<IProject[]>(
PROJECTS_LIST(workspaceSlug as string, { is_favorite: "all" }),
(prevData) =>
(prevData ?? []).map((p) => (p.id === project.id ? { ...p, is_favorite: true } : p)),
false
);
projectService
.addProjectToFavorites(workspaceSlug as string, {
project: project.id,
})
.catch(() =>
setToastAlert({
type: "error",
title: "Error!",
message: "Couldn't remove the project from favorites. Please try again.",
})
);
};
const handleRemoveFromFavorites = () => {
if (!workspaceSlug) return;
mutate<IProject[]>(
PROJECTS_LIST(workspaceSlug as string, { is_favorite: "all" }),
(prevData) =>
(prevData ?? []).map((p) => (p.id === project.id ? { ...p, is_favorite: false } : p)),
false
);
projectService.removeProjectFromFavorites(workspaceSlug as string, project.id).catch(() =>
setToastAlert({
type: "error",
title: "Error!",
message: "Couldn't remove the project from favorites. Please try again.",
})
);
};
return (
<Disclosure key={project.id} defaultOpen={projectId === project.id}>
{({ open }) => (
<>
<div
className={`group relative text-custom-sidebar-text-10 px-2 py-1 w-full flex items-center hover:bg-custom-sidebar-background-80 rounded-md ${
snapshot?.isDragging ? "opacity-60" : ""
}`}
>
{provided && (
<Tooltip
tooltipContent={
project.sort_order === null
? "Join the project to rearrange"
: "Drag to rearrange"
}
position="top-right"
>
<button
type="button"
className={`absolute top-1/2 -translate-y-1/2 -left-4 hidden rounded p-0.5 ${
sidebarCollapse ? "" : "group-hover:!flex"
} ${project.sort_order === null ? "opacity-60 cursor-not-allowed" : ""}`}
{...provided?.dragHandleProps}
>
<EllipsisVerticalIcon className="h-4" />
<EllipsisVerticalIcon className="-ml-5 h-4" />
</button>
</Tooltip>
)}
<Tooltip
tooltipContent={`${project.name}`}
position="right"
className="ml-2"
disabled={!sidebarCollapse}
>
<Disclosure.Button
as="div"
className={`flex items-center flex-grow truncate cursor-pointer select-none text-left text-sm font-medium ${
sidebarCollapse ? "justify-center" : `justify-between`
}`}
>
<div
className={`flex items-center flex-grow w-full truncate gap-x-2 ${
sidebarCollapse ? "justify-center" : ""
}`}
>
{project.emoji ? (
<span className="grid h-7 w-7 flex-shrink-0 place-items-center rounded uppercase">
{renderEmoji(project.emoji)}
</span>
) : project.icon_prop ? (
<div className="h-7 w-7 flex-shrink-0 grid place-items-center">
{renderEmoji(project.icon_prop)}
</div>
) : (
<span className="grid h-7 w-7 flex-shrink-0 place-items-center rounded bg-gray-700 uppercase text-white">
{project?.name.charAt(0)}
</span>
)}
{!sidebarCollapse && (
<p className={`truncate ${open ? "" : "text-custom-sidebar-text-200"}`}>
{project.name}
</p>
)}
</div>
{!sidebarCollapse && (
<ExpandMoreOutlined
fontSize="small"
className={`flex-shrink-0 ${
open ? "rotate-180" : ""
} !hidden group-hover:!block text-custom-sidebar-text-200 duration-300`}
/>
)}
</Disclosure.Button>
</Tooltip>
{!sidebarCollapse && (
<CustomMenu className="hidden group-hover:block flex-shrink-0" ellipsis>
{!shortContextMenu && (
<CustomMenu.MenuItem onClick={handleDeleteProject}>
<span className="flex items-center justify-start gap-2 ">
<TrashIcon className="h-4 w-4" />
<span>Delete project</span>
</span>
</CustomMenu.MenuItem>
)}
{!project.is_favorite && (
<CustomMenu.MenuItem onClick={handleAddToFavorites}>
<span className="flex items-center justify-start gap-2">
<StarIcon className="h-4 w-4" />
<span>Add to favorites</span>
</span>
</CustomMenu.MenuItem>
)}
{project.is_favorite && (
<CustomMenu.MenuItem onClick={handleRemoveFromFavorites}>
<span className="flex items-center justify-start gap-2">
<StarIcon className="h-4 w-4" />
<span>Remove from favorites</span>
</span>
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem onClick={handleCopyText}>
<span className="flex items-center justify-start gap-2">
<LinkIcon className="h-4 w-4" />
<span>Copy project link</span>
</span>
</CustomMenu.MenuItem>
{project.archive_in > 0 && (
<CustomMenu.MenuItem
onClick={() =>
router.push(`/${workspaceSlug}/projects/${project?.id}/archived-issues/`)
}
>
<div className="flex items-center justify-start gap-2">
<ArchiveOutlined fontSize="small" />
<span>Archived Issues</span>
</div>
</CustomMenu.MenuItem>
)}
</CustomMenu>
)}
</div>
<Transition
enter="transition duration-100 ease-out"
enterFrom="transform scale-95 opacity-0"
enterTo="transform scale-100 opacity-100"
leave="transition duration-75 ease-out"
leaveFrom="transform scale-100 opacity-100"
leaveTo="transform scale-95 opacity-0"
>
<Disclosure.Panel className={`space-y-2 mt-1 ${sidebarCollapse ? "" : "ml-[2.25rem]"}`}>
{navigation(workspaceSlug as string, project?.id).map((item) => {
if (
(item.name === "Cycles" && !project.cycle_view) ||
(item.name === "Modules" && !project.module_view) ||
(item.name === "Views" && !project.issue_views_view) ||
(item.name === "Pages" && !project.page_view)
)
return;
return (
<Link key={item.name} href={item.href}>
<a className="block w-full">
<Tooltip
tooltipContent={`${project?.name}: ${item.name}`}
position="right"
className="ml-2"
disabled={!sidebarCollapse}
>
<div
className={`group flex items-center rounded-md px-2 py-1.5 gap-2.5 text-xs font-medium outline-none ${
router.asPath.includes(item.href)
? "bg-custom-primary-100/10 text-custom-primary-100"
: "text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80 focus:bg-custom-sidebar-background-80"
} ${sidebarCollapse ? "justify-center" : ""}`}
>
<item.Icon
sx={{
fontSize: 18,
}}
/>
{!sidebarCollapse && item.name}
</div>
</Tooltip>
</a>
</Link>
);
})}
</Disclosure.Panel>
</Transition>
</>
)}
</Disclosure>
);
};
@@ -1,236 +0,0 @@
import { useCallback, useState, useImperativeHandle } from "react";
import { useRouter } from "next/router";
import { InvalidContentHandler } from "remirror";
import {
BoldExtension,
ItalicExtension,
CalloutExtension,
PlaceholderExtension,
CodeBlockExtension,
CodeExtension,
HistoryExtension,
LinkExtension,
UnderlineExtension,
HeadingExtension,
OrderedListExtension,
ListItemExtension,
BulletListExtension,
ImageExtension,
DropCursorExtension,
StrikeExtension,
MentionAtomExtension,
FontSizeExtension,
} from "remirror/extensions";
import {
Remirror,
useRemirror,
EditorComponent,
OnChangeJSON,
OnChangeHTML,
FloatingToolbar,
FloatingWrapper,
} from "@remirror/react";
import { TableExtension } from "@remirror/extension-react-tables";
// tlds
import tlds from "tlds";
// services
import fileService from "services/file.service";
// components
import { CustomFloatingToolbar } from "./toolbar/float-tool-tip";
import { MentionAutoComplete } from "./mention-autocomplete";
export interface IRemirrorRichTextEditor {
placeholder?: string;
mentions?: any[];
tags?: any[];
onBlur?: (jsonValue: any, htmlValue: any) => void;
onJSONChange?: (jsonValue: any) => void;
onHTMLChange?: (htmlValue: any) => void;
value?: any;
showToolbar?: boolean;
editable?: boolean;
customClassName?: string;
gptOption?: boolean;
noBorder?: boolean;
borderOnFocus?: boolean;
forwardedRef?: any;
}
const RemirrorRichTextEditor: React.FC<IRemirrorRichTextEditor> = (props) => {
const {
placeholder,
mentions = [],
tags = [],
onBlur = () => {},
onJSONChange = () => {},
onHTMLChange = () => {},
value = "",
showToolbar = true,
editable = true,
customClassName,
gptOption = false,
noBorder = false,
borderOnFocus = true,
forwardedRef,
} = props;
const [disableToolbar, setDisableToolbar] = useState(false);
const router = useRouter();
const { workspaceSlug } = router.query;
// remirror error handler
const onError: InvalidContentHandler = useCallback(
({ json, invalidContent, transformers }: any) =>
// Automatically remove all invalid nodes and marks.
transformers.remove(json, invalidContent),
[]
);
const uploadImageHandler = (value: any): any => {
try {
const formData = new FormData();
formData.append("asset", value[0].file);
formData.append("attributes", JSON.stringify({}));
return [
() =>
new Promise(async (resolve, reject) => {
const imageUrl = await fileService
.uploadFile(workspaceSlug as string, formData)
.then((response) => response.asset);
resolve({
align: "left",
alt: "Not Found",
height: "100%",
width: "35%",
src: imageUrl,
});
}),
];
} catch {
return [];
}
};
// remirror manager
const { manager, state } = useRemirror({
extensions: () => [
new BoldExtension(),
new ItalicExtension(),
new UnderlineExtension(),
new HeadingExtension({ levels: [1, 2, 3] }),
new FontSizeExtension({ defaultSize: "16", unit: "px" }),
new OrderedListExtension(),
new ListItemExtension(),
new BulletListExtension({ enableSpine: true }),
new CalloutExtension({ defaultType: "warn" }),
new CodeBlockExtension(),
new CodeExtension(),
new PlaceholderExtension({
placeholder: placeholder || "Enter text...",
emptyNodeClass: "empty-node",
}),
new HistoryExtension(),
new LinkExtension({
autoLink: true,
autoLinkAllowedTLDs: tlds,
selectTextOnClick: true,
defaultTarget: "_blank",
}),
new ImageExtension({
enableResizing: true,
uploadHandler: uploadImageHandler,
createPlaceholder() {
const div = document.createElement("div");
div.className =
"w-[35%] aspect-video bg-custom-background-80 text-custom-text-200 animate-pulse";
return div;
},
}),
new DropCursorExtension(),
new StrikeExtension(),
new MentionAtomExtension({
matchers: [
{ name: "at", char: "@" },
{ name: "tag", char: "#" },
],
}),
new TableExtension(),
],
content: value,
selection: "start",
stringHandler: "html",
onError,
});
useImperativeHandle(forwardedRef, () => ({
clearEditor: () => {
manager.view.updateState(manager.createState({ content: "", selection: "start" }));
},
setEditorValue: (value: any) => {
manager.view.updateState(
manager.createState({
content: value,
selection: "end",
})
);
},
}));
return (
<div className="relative">
<Remirror
manager={manager}
initialContent={state}
classNames={[
`p-3 relative focus:outline-none rounded-md focus:border-custom-border-200 ${
noBorder ? "" : "border border-custom-border-200"
} ${
borderOnFocus ? "focus:border border-custom-border-200" : "focus:border-0"
} ${customClassName}`,
]}
editable={editable}
onBlur={(event) => {
const html = event.helpers.getHTML();
const json = event.helpers.getJSON();
setDisableToolbar(true);
onBlur(json, html);
}}
onFocus={() => setDisableToolbar(false)}
>
<div className="prose prose-brand max-w-full prose-p:my-1">
<EditorComponent />
</div>
{editable && !disableToolbar && (
<FloatingWrapper
positioner="always"
renderOutsideEditor
floatingLabel="Custom Floating Toolbar"
>
<FloatingToolbar className="z-50 overflow-hidden rounded">
<CustomFloatingToolbar
gptOption={gptOption}
editorState={state}
setDisableToolbar={setDisableToolbar}
/>
</FloatingToolbar>
</FloatingWrapper>
)}
<MentionAutoComplete mentions={mentions} tags={tags} />
{<OnChangeJSON onChange={onJSONChange} />}
{<OnChangeHTML onChange={onHTMLChange} />}
</Remirror>
</div>
);
};
RemirrorRichTextEditor.displayName = "RemirrorRichTextEditor";
export default RemirrorRichTextEditor;
@@ -1,64 +0,0 @@
import { useState, useEffect, FC } from "react";
// remirror imports
import { cx } from "@remirror/core";
import { useMentionAtom, MentionAtomNodeAttributes, FloatingWrapper } from "@remirror/react";
// export const;
export interface IMentionAutoComplete {
mentions?: any[];
tags?: any[];
}
export const MentionAutoComplete: FC<IMentionAutoComplete> = (props) => {
const { mentions = [], tags = [] } = props;
// states
const [options, setOptions] = useState<MentionAtomNodeAttributes[]>([]);
const { state, getMenuProps, getItemProps, indexIsHovered, indexIsSelected } = useMentionAtom({
items: options,
});
useEffect(() => {
if (!state) {
return;
}
const searchTerm = state.query.full.toLowerCase();
let filteredOptions: MentionAtomNodeAttributes[] = [];
if (state.name === "tag") {
filteredOptions = tags.filter((tag) => tag?.label.toLowerCase().includes(searchTerm));
} else if (state.name === "at") {
filteredOptions = mentions.filter((user) => user?.label.toLowerCase().includes(searchTerm));
}
filteredOptions = filteredOptions.sort().slice(0, 5);
setOptions(filteredOptions);
}, [state, mentions, tags]);
const enabled = Boolean(state);
return (
<FloatingWrapper positioner="cursor" enabled={enabled} placement="bottom-start">
<div {...getMenuProps()} className="suggestions">
{enabled &&
options.map((user, index) => {
const isHighlighted = indexIsSelected(index);
const isHovered = indexIsHovered(index);
return (
<div
key={user.id}
className={cx("suggestion", isHighlighted && "highlighted", isHovered && "hovered")}
{...getItemProps({
item: user,
index,
})}
>
{user.label}
</div>
);
})}
</div>
</FloatingWrapper>
);
};
@@ -1,145 +0,0 @@
import React, { useEffect, useState } from "react";
import { TableExtension } from "@remirror/extension-react-tables";
import {
EditorComponent,
ReactComponentExtension,
Remirror,
TableComponents,
tableControllerPluginKey,
ThemeProvider,
useCommands,
useRemirror,
useRemirrorContext,
} from "@remirror/react";
import type { AnyExtension } from "remirror";
const CommandMenu: React.FC = () => {
const { createTable, ...commands } = useCommands();
return (
<div>
<p>commands:</p>
<p
style={{
display: "flex",
flexDirection: "column",
justifyItems: "flex-start",
alignItems: "flex-start",
}}
>
<button
onMouseDown={(event) => event.preventDefault()}
data-testid="btn-3-3"
onClick={() => createTable({ rowsCount: 3, columnsCount: 3, withHeaderRow: false })}
>
insert a 3*3 table
</button>
<button
onMouseDown={(event) => event.preventDefault()}
data-testid="btn-3-3-headers"
onClick={() => createTable({ rowsCount: 3, columnsCount: 3, withHeaderRow: true })}
>
insert a 3*3 table with headers
</button>
<button
onMouseDown={(event) => event.preventDefault()}
data-testid="btn-4-10"
onClick={() => createTable({ rowsCount: 10, columnsCount: 4, withHeaderRow: false })}
>
insert a 4*10 table
</button>
<button
onMouseDown={(event) => event.preventDefault()}
data-testid="btn-3-30"
onClick={() => createTable({ rowsCount: 30, columnsCount: 3, withHeaderRow: false })}
>
insert a 3*30 table
</button>
<button
onMouseDown={(event) => event.preventDefault()}
data-testid="btn-8-100"
onClick={() => createTable({ rowsCount: 100, columnsCount: 8, withHeaderRow: false })}
>
insert a 8*100 table
</button>
<button
onMouseDown={(event) => event.preventDefault()}
onClick={() => commands.addTableColumnAfter()}
>
add a column after the current one
</button>
<button
onMouseDown={(event) => event.preventDefault()}
onClick={() => commands.addTableRowBefore()}
>
add a row before the current one
</button>
<button
onMouseDown={(event) => event.preventDefault()}
onClick={() => commands.deleteTable()}
>
delete the table
</button>
</p>
</div>
);
};
const ProsemirrorDocData: React.FC = () => {
const ctx = useRemirrorContext({ autoUpdate: false });
const [jsonPluginState, setJsonPluginState] = useState("");
const [jsonDoc, setJsonDoc] = useState("");
const { addHandler, view } = ctx;
useEffect(() => {
addHandler("updated", () => {
setJsonDoc(JSON.stringify(view.state.doc.toJSON(), null, 2));
const pluginStateValues = tableControllerPluginKey.getState(view.state)?.values;
setJsonPluginState(
JSON.stringify({ ...pluginStateValues, tableNodeResult: "hidden" }, null, 2)
);
});
}, [addHandler, view]);
return (
<div>
<p>tableControllerPluginKey.getState(view.state)</p>
<pre style={{ fontSize: "12px", lineHeight: "12px" }}>
<code>{jsonPluginState}</code>
</pre>
<p>view.state.doc.toJSON()</p>
<pre style={{ fontSize: "12px", lineHeight: "12px" }}>
<code>{jsonDoc}</code>
</pre>
</div>
);
};
const Table = ({
children,
extensions,
}: {
children?: React.ReactElement;
extensions: () => AnyExtension[];
}): JSX.Element => {
const { manager, state } = useRemirror({ extensions });
return (
<ThemeProvider>
<Remirror manager={manager} initialContent={state}>
<EditorComponent />
<TableComponents />
<CommandMenu />
<ProsemirrorDocData />
{children}
</Remirror>
</ThemeProvider>
);
};
const Basic = (): JSX.Element => <Table extensions={defaultExtensions} />;
const defaultExtensions = () => [new ReactComponentExtension(), new TableExtension()];
export default Basic;
@@ -1,316 +0,0 @@
import React, {
ChangeEvent,
HTMLProps,
KeyboardEvent,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { createMarkPositioner, LinkExtension, ShortcutHandlerProps } from "remirror/extensions";
// buttons
import {
ToggleBoldButton,
ToggleItalicButton,
ToggleUnderlineButton,
ToggleStrikeButton,
ToggleOrderedListButton,
ToggleBulletListButton,
ToggleCodeButton,
ToggleHeadingButton,
useActive,
CommandButton,
useAttrs,
useChainedCommands,
useCurrentSelection,
useExtensionEvent,
useUpdateReason,
} from "@remirror/react";
import { EditorState } from "remirror";
type Props = {
gptOption?: boolean;
editorState: Readonly<EditorState>;
setDisableToolbar: React.Dispatch<React.SetStateAction<boolean>>;
};
const useLinkShortcut = () => {
const [linkShortcut, setLinkShortcut] = useState<ShortcutHandlerProps | undefined>();
const [isEditing, setIsEditing] = useState(false);
useExtensionEvent(
LinkExtension,
"onShortcut",
useCallback(
(props) => {
if (!isEditing) {
setIsEditing(true);
}
return setLinkShortcut(props);
},
[isEditing]
)
);
return { linkShortcut, isEditing, setIsEditing };
};
const useFloatingLinkState = () => {
const chain = useChainedCommands();
const { isEditing, linkShortcut, setIsEditing } = useLinkShortcut();
const { to, empty } = useCurrentSelection();
const url = (useAttrs().link()?.href as string) ?? "";
const [href, setHref] = useState<string>(url);
// A positioner which only shows for links.
const linkPositioner = useMemo(() => createMarkPositioner({ type: "link" }), []);
const onRemove = useCallback(() => chain.removeLink().focus().run(), [chain]);
const updateReason = useUpdateReason();
useLayoutEffect(() => {
if (!isEditing) {
return;
}
if (updateReason.doc || updateReason.selection) {
setIsEditing(false);
}
}, [isEditing, setIsEditing, updateReason.doc, updateReason.selection]);
useEffect(() => {
setHref(url);
}, [url]);
const submitHref = useCallback(() => {
setIsEditing(false);
const range = linkShortcut ?? undefined;
if (href === "") {
chain.removeLink();
} else {
chain.updateLink({ href, auto: false }, range);
}
chain.focus(range?.to ?? to).run();
}, [setIsEditing, linkShortcut, chain, href, to]);
const cancelHref = useCallback(() => {
setIsEditing(false);
}, [setIsEditing]);
const clickEdit = useCallback(() => {
if (empty) {
chain.selectLink();
}
setIsEditing(true);
}, [chain, empty, setIsEditing]);
return useMemo(
() => ({
href,
setHref,
linkShortcut,
linkPositioner,
isEditing,
setIsEditing,
clickEdit,
onRemove,
submitHref,
cancelHref,
}),
[
href,
linkShortcut,
linkPositioner,
isEditing,
clickEdit,
onRemove,
submitHref,
cancelHref,
setIsEditing,
]
);
};
const DelayAutoFocusInput = ({
autoFocus,
setDisableToolbar,
...rest
}: HTMLProps<HTMLInputElement> & {
setDisableToolbar: React.Dispatch<React.SetStateAction<boolean>>;
}) => {
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!autoFocus) {
return;
}
setDisableToolbar(false);
const frame = window.requestAnimationFrame(() => {
inputRef.current?.focus();
});
return () => {
window.cancelAnimationFrame(frame);
};
}, [autoFocus, setDisableToolbar]);
useEffect(() => {
setDisableToolbar(false);
}, [setDisableToolbar]);
return (
<>
<label htmlFor="link-input" className="text-sm">
Add Link
</label>
<input
ref={inputRef}
{...rest}
onKeyDown={(e) => {
if (rest.onKeyDown) rest.onKeyDown(e);
setDisableToolbar(false);
}}
className={`${rest.className} mt-1`}
onFocus={() => {
setDisableToolbar(false);
}}
onBlur={() => {
setDisableToolbar(true);
}}
/>
</>
);
};
export const CustomFloatingToolbar: React.FC<Props> = ({
gptOption,
editorState,
setDisableToolbar,
}) => {
const { isEditing, setIsEditing, clickEdit, onRemove, submitHref, href, setHref, cancelHref } =
useFloatingLinkState();
const active = useActive();
const activeLink = active.link();
const handleClickEdit = useCallback(() => {
clickEdit();
}, [clickEdit]);
return (
<div className="z-[99999] flex flex-col items-center gap-y-2 divide-x divide-y divide-custom-border-200 rounded border border-custom-border-200 bg-custom-background-80 p-1 px-0.5 shadow-md">
<div className="flex items-center gap-y-2 divide-x divide-custom-border-200">
<div className="flex items-center gap-x-1 px-2">
<ToggleHeadingButton
attrs={{
level: 1,
}}
/>
<ToggleHeadingButton
attrs={{
level: 2,
}}
/>
<ToggleHeadingButton
attrs={{
level: 3,
}}
/>
</div>
<div className="flex items-center gap-x-1 px-2">
<ToggleBoldButton />
<ToggleItalicButton />
<ToggleUnderlineButton />
<ToggleStrikeButton />
</div>
<div className="flex items-center gap-x-1 px-2">
<ToggleOrderedListButton />
<ToggleBulletListButton />
</div>
{gptOption && (
<div className="flex items-center gap-x-1 px-2">
<button
type="button"
className="rounded py-1 px-1.5 text-xs hover:bg-custom-background-90"
onClick={() => console.log(editorState.selection.$anchor.nodeBefore)}
>
AI
</button>
</div>
)}
<div className="flex items-center gap-x-1 px-2">
<ToggleCodeButton />
</div>
{activeLink ? (
<div className="flex items-center gap-x-1 px-2">
<CommandButton
commandName="openLink"
onSelect={() => {
window.open(href, "_blank");
}}
icon="externalLinkFill"
enabled
/>
<CommandButton
commandName="updateLink"
onSelect={handleClickEdit}
icon="pencilLine"
enabled
/>
<CommandButton commandName="removeLink" onSelect={onRemove} icon="linkUnlink" enabled />
</div>
) : (
<CommandButton
commandName="updateLink"
onSelect={() => {
if (isEditing) {
setIsEditing(false);
} else {
handleClickEdit();
}
}}
icon="link"
enabled
active={isEditing}
/>
)}
</div>
{isEditing && (
<div className="p-2 w-full">
<DelayAutoFocusInput
autoFocus
placeholder="Paste your link here..."
id="link-input"
setDisableToolbar={setDisableToolbar}
className="w-full px-2 py-0.5"
onChange={(e: ChangeEvent<HTMLInputElement>) => setHref(e.target.value)}
value={href}
onKeyDown={(e: KeyboardEvent<HTMLInputElement>) => {
const { code } = e;
if (code === "Enter") {
submitHref();
}
if (code === "Escape") {
cancelHref();
}
}}
/>
</div>
)}
</div>
);
};
@@ -1,57 +0,0 @@
// remirror
import { useCommands, useActive } from "@remirror/react";
// ui
import { CustomMenu } from "components/ui";
const HeadingControls = () => {
const { toggleHeading, focus } = useCommands();
const active = useActive();
return (
<div className="flex items-center gap-1">
<CustomMenu
width="lg"
label={`${
active.heading({ level: 1 })
? "Heading 1"
: active.heading({ level: 2 })
? "Heading 2"
: active.heading({ level: 3 })
? "Heading 3"
: "Normal text"
}`}
>
<CustomMenu.MenuItem
onClick={() => {
toggleHeading({ level: 1 });
focus();
}}
className={`${active.heading({ level: 1 }) ? "bg-indigo-50" : ""}`}
>
Heading 1
</CustomMenu.MenuItem>
<CustomMenu.MenuItem
onClick={() => {
toggleHeading({ level: 2 });
focus();
}}
className={`${active.heading({ level: 2 }) ? "bg-indigo-50" : ""}`}
>
Heading 2
</CustomMenu.MenuItem>
<CustomMenu.MenuItem
onClick={() => {
toggleHeading({ level: 3 });
focus();
}}
className={`${active.heading({ level: 3 }) ? "bg-indigo-50" : ""}`}
>
Heading 3
</CustomMenu.MenuItem>
</CustomMenu>
</div>
);
};
export default HeadingControls;
@@ -1,35 +0,0 @@
// buttons
import {
ToggleBoldButton,
ToggleItalicButton,
ToggleUnderlineButton,
ToggleStrikeButton,
ToggleOrderedListButton,
ToggleBulletListButton,
RedoButton,
UndoButton,
} from "@remirror/react";
// headings
import HeadingControls from "./heading-controls";
export const RichTextToolbar: React.FC = () => (
<div className="flex items-center gap-y-2 divide-x">
<div className="flex items-center gap-x-1 px-2">
<RedoButton />
<UndoButton />
</div>
<div className="px-2">
<HeadingControls />
</div>
<div className="flex items-center gap-x-1 px-2">
<ToggleBoldButton />
<ToggleItalicButton />
<ToggleUnderlineButton />
<ToggleStrikeButton />
</div>
<div className="flex items-center gap-x-1 px-2">
<ToggleOrderedListButton />
<ToggleBulletListButton />
</div>
</div>
);
@@ -1,215 +0,0 @@
import React, {
ChangeEvent,
HTMLProps,
KeyboardEvent,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { createMarkPositioner, LinkExtension, ShortcutHandlerProps } from "remirror/extensions";
import {
CommandButton,
FloatingToolbar,
FloatingWrapper,
useActive,
useAttrs,
useChainedCommands,
useCurrentSelection,
useExtensionEvent,
useUpdateReason,
} from "@remirror/react";
const useLinkShortcut = () => {
const [linkShortcut, setLinkShortcut] = useState<ShortcutHandlerProps | undefined>();
const [isEditing, setIsEditing] = useState(false);
useExtensionEvent(
LinkExtension,
"onShortcut",
useCallback(
(props) => {
if (!isEditing) {
setIsEditing(true);
}
return setLinkShortcut(props);
},
[isEditing]
)
);
return { linkShortcut, isEditing, setIsEditing };
};
const useFloatingLinkState = () => {
const chain = useChainedCommands();
const { isEditing, linkShortcut, setIsEditing } = useLinkShortcut();
const { to, empty } = useCurrentSelection();
const url = (useAttrs().link()?.href as string) ?? "";
const [href, setHref] = useState<string>(url);
// A positioner which only shows for links.
const linkPositioner = useMemo(() => createMarkPositioner({ type: "link" }), []);
const onRemove = useCallback(() => chain.removeLink().focus().run(), [chain]);
const updateReason = useUpdateReason();
useLayoutEffect(() => {
if (!isEditing) {
return;
}
if (updateReason.doc || updateReason.selection) {
setIsEditing(false);
}
}, [isEditing, setIsEditing, updateReason.doc, updateReason.selection]);
useEffect(() => {
setHref(url);
}, [url]);
const submitHref = useCallback(() => {
setIsEditing(false);
const range = linkShortcut ?? undefined;
if (href === "") {
chain.removeLink();
} else {
chain.updateLink({ href, auto: false }, range);
}
chain.focus(range?.to ?? to).run();
}, [setIsEditing, linkShortcut, chain, href, to]);
const cancelHref = useCallback(() => {
setIsEditing(false);
}, [setIsEditing]);
const clickEdit = useCallback(() => {
if (empty) {
chain.selectLink();
}
setIsEditing(true);
}, [chain, empty, setIsEditing]);
return useMemo(
() => ({
href,
setHref,
linkShortcut,
linkPositioner,
isEditing,
clickEdit,
onRemove,
submitHref,
cancelHref,
}),
[href, linkShortcut, linkPositioner, isEditing, clickEdit, onRemove, submitHref, cancelHref]
);
};
const DelayAutoFocusInput = ({ autoFocus, ...rest }: HTMLProps<HTMLInputElement>) => {
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!autoFocus) {
return;
}
const frame = window.requestAnimationFrame(() => {
inputRef.current?.focus();
});
return () => {
window.cancelAnimationFrame(frame);
};
}, [autoFocus]);
return <input ref={inputRef} {...rest} />;
};
export const FloatingLinkToolbar = () => {
const { isEditing, linkPositioner, clickEdit, onRemove, submitHref, href, setHref, cancelHref } =
useFloatingLinkState();
const active = useActive();
const activeLink = active.link();
const { empty } = useCurrentSelection();
const handleClickEdit = useCallback(() => {
clickEdit();
}, [clickEdit]);
const linkEditButtons = activeLink ? (
<>
<CommandButton
commandName="openLink"
onSelect={() => {
window.open(href, "_blank");
}}
icon="externalLinkFill"
enabled
/>
<CommandButton
commandName="updateLink"
onSelect={handleClickEdit}
icon="pencilLine"
enabled
/>
<CommandButton commandName="removeLink" onSelect={onRemove} icon="linkUnlink" enabled />
</>
) : (
<CommandButton commandName="updateLink" onSelect={handleClickEdit} icon="link" enabled />
);
return (
<>
{!isEditing && (
<FloatingToolbar className="rounded bg-custom-background-80 p-1 shadow-lg">
{linkEditButtons}
</FloatingToolbar>
)}
{!isEditing && empty && (
<FloatingToolbar
positioner={linkPositioner}
className="rounded bg-custom-background-80 p-1 shadow-lg"
>
{linkEditButtons}
</FloatingToolbar>
)}
<FloatingWrapper
positioner="always"
placement="bottom"
enabled={isEditing}
renderOutsideEditor
>
<DelayAutoFocusInput
autoFocus
placeholder="Enter link..."
onChange={(e: ChangeEvent<HTMLInputElement>) => setHref(e.target.value)}
value={href}
onKeyDown={(e: KeyboardEvent<HTMLInputElement>) => {
const { code } = e;
if (code === "Enter") {
submitHref();
}
if (code === "Escape") {
cancelHref();
}
}}
/>
</FloatingWrapper>
</>
);
};
@@ -1,55 +0,0 @@
import { useCommands } from "@remirror/react";
export const TableControls = () => {
const { createTable, ...commands } = useCommands();
return (
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => createTable({ rowsCount: 3, columnsCount: 3, withHeaderRow: false })}
className="rounded p-1 hover:bg-custom-background-90"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="icon icon-tabler icon-tabler-table"
width="18"
height="18"
viewBox="0 0 24 24"
stroke="#2c3e50"
fill="none"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<rect x="4" y="4" width="16" height="16" rx="2" />
<line x1="4" y1="10" x2="20" y2="10" />
<line x1="10" y1="4" x2="10" y2="20" />
</svg>
</button>
<button
type="button"
onClick={() => commands.deleteTable()}
className="rounded p-1 hover:bg-custom-background-90"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="icon icon-tabler icon-tabler-trash"
width="18"
height="18"
viewBox="0 0 24 24"
strokeWidth="1.5"
stroke="#2c3e50"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<line x1="4" y1="7" x2="20" y2="7" />
<line x1="10" y1="11" x2="10" y2="17" />
<line x1="14" y1="11" x2="14" y2="17" />
<path d="M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2 -2l1 -12" />
<path d="M9 7v-3a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v3" />
</svg>
</button>
</div>
);
};
@@ -1,19 +0,0 @@
import { ExclamationIcon } from "components/icons";
type Props = {
bannerName: string;
};
export const IntegrationAndImportExportBanner: React.FC<Props> = ({ bannerName }) => (
<div className="flex flex-col items-start gap-3">
<h3 className="text-2xl font-semibold">{bannerName}</h3>
<div className="flex items-center gap-3 rounded-[10px] border border-custom-primary/75 bg-custom-primary/5 p-4 text-sm text-custom-text-100">
<ExclamationIcon height={24} width={24} className="fill-current text-custom-text-100" />
<p className="leading-5">
Integrations and importers are only available on the cloud version. We plan to open-source
our SDKs in the near future so that the community can request or contribute integrations as
needed.
</p>
</div>
</div>
);
-32
View File
@@ -1,32 +0,0 @@
import React from "react";
type IssueLabelsListProps = {
labels?: (string | undefined)[];
length?: number;
showLength?: boolean;
};
export const IssueLabelsList: React.FC<IssueLabelsListProps> = ({
labels,
length = 5,
showLength = true,
}) => (
<>
{labels && (
<>
{labels.slice(0, length).map((color, index) => (
<div className={`flex h-4 w-4 rounded-full ${index ? "-ml-3.5" : ""}`}>
<span
className={`h-4 w-4 flex-shrink-0 rounded-full border border-custom-border-200
`}
style={{
backgroundColor: color && color !== "" ? color : "#000000",
}}
/>
</div>
))}
{labels.length > length ? <span>+{labels.length - length}</span> : null}
</>
)}
</>
);
-109
View File
@@ -1,109 +0,0 @@
import { FC } from "react";
// next imports
import Link from "next/link";
import { useRouter } from "next/router";
// components
import { GanttChartRoot } from "components/gantt-chart";
// ui
import { Tooltip } from "components/ui";
// hooks
import useGanttChartViewIssues from "hooks/gantt-chart/view-issues-view";
type Props = {};
export const ViewIssuesGanttChartView: FC<Props> = ({}) => {
const router = useRouter();
const { workspaceSlug, projectId, viewId } = router.query;
const { ganttIssues, mutateGanttIssues } = useGanttChartViewIssues(
workspaceSlug as string,
projectId as string,
viewId as string
);
// rendering issues on gantt sidebar
const GanttSidebarBlockView = ({ data }: any) => (
<div className="relative flex w-full h-full items-center p-1 overflow-hidden gap-1">
<div
className="rounded-sm flex-shrink-0 w-[10px] h-[10px] flex justify-center items-center"
style={{ backgroundColor: data?.state_detail?.color || "rgb(var(--color-primary-100))" }}
/>
<div className="text-custom-text-100 text-sm">{data?.name}</div>
</div>
);
// rendering issues on gantt card
const GanttBlockView = ({ data }: any) => (
<Link href={`/${workspaceSlug}/projects/${projectId}/issues/${data?.id}`}>
<a className="relative flex items-center w-full h-full overflow-hidden shadow-sm">
<div
className="flex-shrink-0 w-[4px] h-full"
style={{ backgroundColor: data?.state_detail?.color || "rgb(var(--color-primary-100))" }}
/>
<Tooltip tooltipContent={data?.name} className={`z-[999999]`}>
<div className="text-custom-text-100 text-[15px] whitespace-nowrap py-[4px] px-2.5 overflow-hidden w-full">
{data?.name}
</div>
</Tooltip>
{data.infoToggle && (
<Tooltip
tooltipContent={`No due-date set, rendered according to last updated date.`}
className={`z-[999999]`}
>
<div className="flex-shrink-0 mx-2 w-[18px] h-[18px] overflow-hidden flex justify-center items-center">
<span className="material-symbols-rounded text-custom-text-200 text-[18px]">
info
</span>
</div>
</Tooltip>
)}
</a>
</Link>
);
// handle gantt issue start date and target date
const handleUpdateDates = async (data: any) => {
const payload = {
id: data?.id,
start_date: data?.start_date,
target_date: data?.target_date,
};
console.log("payload", payload);
};
const blockFormat = (blocks: any) =>
blocks && blocks.length > 0
? blocks.map((_block: any) => {
let startDate = new Date(_block.created_at);
let targetDate = new Date(_block.updated_at);
let infoToggle = true;
if (_block?.start_date && _block.target_date) {
startDate = _block?.start_date;
targetDate = _block.target_date;
infoToggle = false;
}
return {
start_date: new Date(startDate),
target_date: new Date(targetDate),
infoToggle: infoToggle,
data: _block,
};
})
: [];
return (
<div className="w-full h-full p-3">
<GanttChartRoot
title="Issue Views"
loaderTitle="Issue Views"
blocks={ganttIssues ? blockFormat(ganttIssues) : null}
blockUpdateHandler={handleUpdateDates}
sidebarBlockRender={(data: any) => <GanttSidebarBlockView data={data} />}
blockRender={(data: any) => <GanttBlockView data={data} />}
/>
</div>
);
};
@@ -1,35 +0,0 @@
// icons
import { Bars3Icon } from "@heroicons/react/24/outline";
type Props = {
breadcrumbs?: JSX.Element;
left?: JSX.Element;
right?: JSX.Element;
setToggleSidebar: React.Dispatch<React.SetStateAction<boolean>>;
noHeader: boolean;
};
const Header: React.FC<Props> = ({ breadcrumbs, left, right, setToggleSidebar, noHeader }) => (
<div
className={`relative flex w-full flex-shrink-0 flex-row z-10 items-center justify-between gap-x-2 gap-y-4 border-b border-custom-border-200 bg-custom-sidebar-background-100 px-5 py-4 ${
noHeader ? "md:hidden" : ""
}`}
>
<div className="flex items-center gap-2 flex-grow w-full whitespace-nowrap overflow-ellipsis">
<div className="block md:hidden">
<button
type="button"
className="grid h-8 w-8 place-items-center rounded border border-custom-border-200"
onClick={() => setToggleSidebar((prevData) => !prevData)}
>
<Bars3Icon className="h-5 w-5" />
</button>
</div>
{breadcrumbs}
<div className="flex-shrink-0">{left}</div>
</div>
<div className="flex-shrink-0">{right}</div>
</div>
);
export default Header;
@@ -1,3 +0,0 @@
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.3704 21.7617C11.349 21.5892 9.64994 20.7655 8.27325 19.2907C6.89653 17.816 6.20817 16.0515 6.20817 13.9974C6.20817 11.8239 6.96342 9.98197 8.47392 8.47148C9.98441 6.96098 11.8264 6.20573 13.9998 6.20573C16.0539 6.20573 17.8184 6.89181 19.2932 8.26397C20.768 9.63613 21.5916 11.3402 21.7642 13.3762L20.0506 12.8449C19.7825 11.3974 19.0895 10.1969 17.9714 9.24335C16.8532 8.28984 15.5294 7.81308 13.9998 7.81308C12.2835 7.81308 10.8237 8.4147 9.62039 9.61795C8.41714 10.8212 7.81552 12.281 7.81552 13.9974C7.81552 15.5157 8.29456 16.8389 9.25262 17.9668C10.2107 19.0946 11.4089 19.793 12.8474 20.0618L13.3704 21.7617ZM13.9998 27.4557C12.1381 27.4557 10.3885 27.1024 8.75109 26.396C7.11366 25.6896 5.68932 24.7308 4.47807 23.5198C3.26682 22.3088 2.30791 20.8847 1.60135 19.2475C0.894785 17.6104 0.541504 15.8611 0.541504 13.9997C0.541504 12.1383 0.894726 10.3887 1.60117 8.75084C2.30762 7.11296 3.26634 5.68825 4.47736 4.47669C5.6884 3.26511 7.11249 2.30594 8.74964 1.59919C10.3868 0.89244 12.136 0.539062 13.9974 0.539062C15.8588 0.539062 17.6085 0.892345 19.2463 1.59891C20.8842 2.30547 22.3089 3.26438 23.5205 4.47563C24.7321 5.68688 25.6912 7.11122 26.398 8.74865C27.1047 10.3861 27.4581 12.1356 27.4581 13.9974C27.4581 14.1735 27.4545 14.3497 27.4472 14.5259C27.4399 14.702 27.4245 14.8782 27.4009 15.0544L25.8508 14.5804V13.9974C25.8508 10.6991 24.7002 7.89934 22.399 5.59816C20.0978 3.297 17.2981 2.14641 13.9998 2.14641C10.7015 2.14641 7.90178 3.297 5.6006 5.59816C3.29944 7.89934 2.14885 10.6991 2.14885 13.9974C2.14885 17.2956 3.29944 20.0954 5.6006 22.3966C7.90178 24.6977 10.7015 25.8483 13.9998 25.8483H14.5828L15.0704 27.3984C14.892 27.422 14.7136 27.4375 14.5351 27.4448C14.3567 27.452 14.1783 27.4557 13.9998 27.4557ZM25.156 27.284L19.1079 21.2223L18.0999 24.2953C18.0033 24.555 17.8256 24.679 17.567 24.6672C17.3084 24.6554 17.1437 24.5196 17.0728 24.2599L14.2831 14.94C14.2214 14.7366 14.265 14.5604 14.4139 14.4115C14.5628 14.2625 14.739 14.219 14.9424 14.2807L24.2759 17.0704C24.5266 17.1412 24.6578 17.306 24.6696 17.5646C24.6814 17.8232 24.562 17.9963 24.3113 18.0838L21.2247 19.1055L27.2864 25.1536C27.3954 25.2592 27.4499 25.3837 27.4499 25.527C27.4499 25.6704 27.3954 25.7965 27.2864 25.9055L25.9079 27.284C25.8023 27.393 25.6778 27.4474 25.5344 27.4474C25.3911 27.4474 25.265 27.393 25.156 27.284Z" fill="#9FBBFF"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.4 KiB

-96
View File
@@ -1,96 +0,0 @@
.empty-node::after {
content: attr(data-placeholder);
color: rgb(var(--color-text-400));
position: absolute;
pointer-events: none;
top: 15px;
margin-left: 1px;
}
.ProseMirror {
position: relative;
word-wrap: break-word;
white-space: pre-wrap;
-moz-tab-size: 4;
tab-size: 4;
-webkit-user-select: text;
-moz-user-select: text;
-ms-user-select: text;
user-select: text;
outline: none;
cursor: text;
line-height: 1.2;
font-family: inherit;
font-size: 14px;
color: inherit;
-moz-box-sizing: border-box;
box-sizing: border-box;
appearance: textfield;
-webkit-appearance: textfield;
-moz-appearance: textfield;
}
.ProseMirror-icon {
display: inline-block;
line-height: 0.8;
vertical-align: -2px;
color: #666;
cursor: pointer;
margin: 0 3px;
padding: 3px 8px;
border-radius: 3px;
border: 1px solid transparent;
transition: background 50ms ease-in-out;
-moz-user-select: none;
-webkit-user-select: none;
-ms-user-select: none;
user-select: none;
}
.ProseMirror-menu-disabled.ProseMirror-icon {
opacity: 0.3;
cursor: default;
pointer-events: none;
}
.ProseMirror-icon svg {
fill: currentColor;
height: 1em;
}
.ProseMirror-icon span {
vertical-align: text-top;
}
.remirror-editor-wrapper .remirror-editor {
min-height: 150px;
}
.issue-comments-section .remirror-editor-wrapper .remirror-editor,
.page-block-section .remirror-editor-wrapper .remirror-editor {
min-height: 50px;
}
.remirror-section .remirror-editor-wrapper .remirror-editor {
min-height: 0 !important;
}
.remirror-editor-wrapper {
padding-top: 8px;
}
.MuiButtonBase-root {
border: none !important;
border-radius: 0.25rem !important;
padding: 0.25rem !important;
}
.MuiButtonBase-root svg {
fill: rgb(var(--color-text-100)) !important;
}
.MuiButtonBase-root.Mui-selected,
.MuiButtonBase-root:hover {
background-color: rgb(var(--color-background-100)) !important;
}
-9
View File
@@ -1,9 +0,0 @@
{
"extends": "tsconfig/nextjs.json",
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"],
"compilerOptions": {
"baseUrl": ".",
"jsx": "preserve"
}
}
-1
View File
@@ -1 +0,0 @@
NEXT_PUBLIC_VERCEL_ENV=local
-12
View File
@@ -1,12 +0,0 @@
// root styles
import "styles/globals.css";
const RootLayout = ({ children }: { children: React.ReactNode }) => (
<html lang="en">
<body className="antialiased w-100">
<main>{children}</main>
</body>
</html>
);
export default RootLayout;
-9
View File
@@ -1,9 +0,0 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
appDir: true,
},
};
module.exports = nextConfig;
-1
View File
@@ -1 +0,0 @@
export const init = {};
-4
View File
@@ -1,4 +0,0 @@
# Deploy the Plane image
FROM makeplane/plane
LABEL maintainer="engineering@plane.so"
+2 -1
View File
@@ -38,11 +38,12 @@ services:
container_name: planefrontend
image: makeplane/plane-frontend:latest
restart: always
command: /usr/local/bin/start.sh
command: /usr/local/bin/start.sh apps/app/server.js app
env_file:
- .env
environment:
NEXT_PUBLIC_API_BASE_URL: ${NEXT_PUBLIC_API_BASE_URL}
NEXT_PUBLIC_DEPLOY_URL: ${NEXT_PUBLIC_DEPLOY_URL}
NEXT_PUBLIC_GOOGLE_CLIENTID: "0"
NEXT_PUBLIC_GITHUB_APP_NAME: "0"
NEXT_PUBLIC_GITHUB_ID: "0"
+3 -1
View File
@@ -41,12 +41,14 @@ services:
dockerfile: ./apps/app/Dockerfile.web
args:
NEXT_PUBLIC_API_BASE_URL: http://localhost:8000
NEXT_PUBLIC_DEPLOY_URL: http://localhost/spaces
restart: always
command: /usr/local/bin/start.sh
command: /usr/local/bin/start.sh apps/app/server.js app
env_file:
- .env
environment:
NEXT_PUBLIC_API_BASE_URL: ${NEXT_PUBLIC_API_BASE_URL}
NEXT_PUBLIC_DEPLOY_URL: ${NEXT_PUBLIC_DEPLOY_URL}
NEXT_PUBLIC_GOOGLE_CLIENTID: "0"
NEXT_PUBLIC_GITHUB_APP_NAME: "0"
NEXT_PUBLIC_GITHUB_ID: "0"
-3
View File
@@ -1,3 +0,0 @@
build:
docker:
web: deploy/heroku/Dockerfile
+5
View File
@@ -18,6 +18,11 @@ server {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location /space/ {
proxy_pass http://localhost:4000/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
+5 -11
View File
@@ -1,22 +1,16 @@
{
"name": "plane",
"repository": "https://github.com/makeplane/plane.git",
"license": "MIT",
"license": "AGPL-3.0",
"private": true,
"workspaces": [
"apps/*",
"web/",
"space/",
"packages/*"
],
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev",
"start": "turbo run start",
"lint": "turbo run lint",
"clean": "turbo run clean"
},
"devDependencies": {
"eslint-config-custom": "*",
"prettier": "latest",
"turbo": "latest"
"prettier": "latest"
},
"packageManager": "yarn@1.22.19"
}
+2 -1
View File
@@ -1,6 +1,7 @@
#!/bin/sh
FROM=$1
TO=$2
DIRECTORY=$3
if [ "${FROM}" = "${TO}" ]; then
echo "Nothing to replace, the value is already set to ${TO}."
@@ -11,4 +12,4 @@ fi
# Only perform action if $FROM and $TO are different.
echo "Replacing all statically built instances of $FROM with this string $TO ."
grep -R -la "${FROM}" apps/app/.next | xargs -I{} sed -i "s|$FROM|$TO|g" "{}"
grep -R -la "${FROM}" apps/$DIRECTORY/.next | xargs -I{} sed -i "s|$FROM|$TO|g" "{}"
+13
View File
@@ -14,3 +14,16 @@ echo -e "SECRET_KEY=\"$(tr -dc 'a-z0-9' < /dev/urandom | head -c50)\"" >> ./.en
# WEB_URL for email redirection and image saving
echo -e "WEB_URL=$1" >> ./.env
# Generate Prompt for taking tiptap auth key
echo -e "\n\e[1;38m Instructions for generating TipTap Pro Extensions Auth Token \e[0m \n"
echo -e "\e[1;38m 1. Head over to TipTap cloud's Pro Extensions Page, https://collab.tiptap.dev/pro-extensions \e[0m"
echo -e "\e[1;38m 2. Copy the token given to you under the first paragraph, after 'Here it is' \e[0m \n"
read -p $'\e[1;32m Please Enter Your TipTap Pro Extensions Authentication Token: \e[0m \e[1;36m' authToken
echo "@tiptap-pro:registry=https://registry.tiptap.dev/
//registry.tiptap.dev/:_authToken=${authToken}" > .npmrc
+1
View File
@@ -0,0 +1 @@
NEXT_PUBLIC_API_BASE_URL=''
+70
View File
@@ -0,0 +1,70 @@
FROM node:18-alpine AS builder
RUN apk add --no-cache libc6-compat
# Set working directory
WORKDIR /app
ENV NEXT_PUBLIC_API_BASE_URL=http://NEXT_PUBLIC_API_BASE_URL_PLACEHOLDER
RUN yarn global add turbo
COPY . .
RUN turbo prune --scope=space --docker
# Add lockfile and package.json's of isolated subworkspace
FROM node:18-alpine AS installer
RUN apk add --no-cache libc6-compat
WORKDIR /app
ARG NEXT_PUBLIC_API_BASE_URL=http://localhost:8000
# First install the dependencies (as they change less often)
COPY .gitignore .gitignore
COPY --from=builder /app/out/json/ .
COPY --from=builder /app/out/yarn.lock ./yarn.lock
RUN yarn install --network-timeout 500000
# Build the project
COPY --from=builder /app/out/full/ .
COPY turbo.json turbo.json
COPY replace-env-vars.sh /usr/local/bin/
USER root
RUN chmod +x /usr/local/bin/replace-env-vars.sh
RUN yarn turbo run build --filter=space
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL \
BUILT_NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
RUN /usr/local/bin/replace-env-vars.sh http://NEXT_PUBLIC_WEBAPP_URL_PLACEHOLDER ${NEXT_PUBLIC_API_BASE_URL} space
FROM node:18-alpine AS runner
WORKDIR /app
# Don't run production as root
RUN addgroup --system --gid 1001 plane
RUN adduser --system --uid 1001 captain
USER captain
COPY --from=installer /app/apps/space/next.config.js .
COPY --from=installer /app/apps/space/package.json .
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=installer --chown=captain:plane /app/apps/space/.next/standalone ./
COPY --from=installer --chown=captain:plane /app/apps/space/.next ./apps/space/.next
ARG NEXT_PUBLIC_API_BASE_URL=http://localhost:8000
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL \
BUILT_NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
USER root
COPY replace-env-vars.sh /usr/local/bin/
COPY start.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/replace-env-vars.sh
RUN chmod +x /usr/local/bin/start.sh
USER captain
ENV NEXT_TELEMETRY_DISABLED 1
EXPOSE 3000
+56
View File
@@ -0,0 +1,56 @@
FROM node:18-alpine AS base
# Install dependencies only when needed
FROM base AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat
WORKDIR /space
# Install dependencies based on the preferred package manager
COPY package.json yarn.lock* ./
RUN yarn
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /space
COPY . .
COPY --from=deps /space/node_modules ./node_modules
COPY --from=deps /space/yarn.lock .
ENV NEXT_TELEMETRY_DISABLED 1
RUN yarn build
# If using npm comment out above and use below instead
# RUN npm run build
# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /space
ENV NODE_ENV production
# Uncomment the following line in case you want to disable telemetry during runtime.
# ENV NEXT_TELEMETRY_DISABLED 1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /space/public ./public
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /space/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /space/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT 3000
# set hostname to localhost
ENV HOSTNAME "0.0.0.0"
CMD ["node", "space/server.js"]
@@ -0,0 +1,66 @@
// next imports
import Link from "next/link";
import Image from "next/image";
import { Metadata, ResolvingMetadata } from "next";
// components
import IssueNavbar from "components/issues/navbar";
import IssueFilter from "components/issues/filters-render";
// service
import ProjectService from "services/project.service";
import { redirect } from "next/navigation";
type LayoutProps = {
params: { workspace_slug: string; project_slug: string };
};
export async function generateMetadata({ params }: LayoutProps): Promise<Metadata> {
// read route params
const { workspace_slug, project_slug } = params;
const projectServiceInstance = new ProjectService();
try {
const project = await projectServiceInstance?.getProjectSettingsAsync(workspace_slug, project_slug);
return {
title: `${project?.project_details?.name} | ${workspace_slug}`,
description: `${
project?.project_details?.description || `${project?.project_details?.name} | ${workspace_slug}`
}`,
icons: `data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>${
typeof project?.project_details?.emoji != "object"
? String.fromCodePoint(parseInt(project?.project_details?.emoji))
: "✈️"
}</text></svg>`,
};
} catch (error: any) {
if (error?.data?.error) {
redirect(`/project-not-published`);
}
return {};
}
}
const RootLayout = ({ children }: { children: React.ReactNode }) => (
<div className="relative w-screen min-h-[500px] h-screen overflow-hidden flex flex-col">
<div className="flex-shrink-0 h-[60px] border-b border-gray-300 relative flex items-center bg-white select-none">
<IssueNavbar />
</div>
{/* <div className="flex-shrink-0 min-h-[50px] h-auto py-1.5 border-b border-gray-300 relative flex items-center shadow-md bg-white select-none">
<IssueFilter />
</div> */}
<div className="w-full h-full relative bg-gray-100/50 overflow-hidden">{children}</div>
<div className="absolute z-[99999] bottom-[10px] right-[10px] bg-white rounded-sm shadow-lg border border-gray-100">
<Link href="https://plane.so" className="p-1 px-2 flex items-center gap-1" target="_blank">
<div className="w-[24px] h-[24px] relative flex justify-center items-center">
<Image src="/plane-logo.webp" alt="plane logo" className="w-[24px] h-[24px]" height="24" width="24" />
</div>
<div className="text-xs">
Powered by <b>Plane Deploy</b>
</div>
</Link>
</div>
</div>
);
export default RootLayout;
@@ -0,0 +1,113 @@
"use client";
import { useEffect } from "react";
// next imports
import { useRouter, useParams, useSearchParams } from "next/navigation";
// mobx
import { observer } from "mobx-react-lite";
// components
import { IssueListView } from "components/issues/board-views/list";
import { IssueKanbanView } from "components/issues/board-views/kanban";
import { IssueCalendarView } from "components/issues/board-views/calendar";
import { IssueSpreadsheetView } from "components/issues/board-views/spreadsheet";
import { IssueGanttView } from "components/issues/board-views/gantt";
// mobx store
import { RootStore } from "store/root";
import { useMobxStore } from "lib/mobx/store-provider";
// types
import { TIssueBoardKeys } from "store/types";
const WorkspaceProjectPage = observer(() => {
const store: RootStore = useMobxStore();
const router = useRouter();
const routerParams = useParams();
const routerSearchparams = useSearchParams();
const { workspace_slug, project_slug } = routerParams as { workspace_slug: string; project_slug: string };
const board =
routerSearchparams &&
routerSearchparams.get("board") != null &&
(routerSearchparams.get("board") as TIssueBoardKeys | "");
// updating default board view when we are in the issues page
useEffect(() => {
if (workspace_slug && project_slug && store?.project?.workspaceProjectSettings) {
const workspacePRojectSettingViews = store?.project?.workspaceProjectSettings?.views;
const userAccessViews: TIssueBoardKeys[] = [];
Object.keys(workspacePRojectSettingViews).filter((_key) => {
if (_key === "list" && workspacePRojectSettingViews.list === true) userAccessViews.push(_key);
if (_key === "kanban" && workspacePRojectSettingViews.kanban === true) userAccessViews.push(_key);
if (_key === "calendar" && workspacePRojectSettingViews.calendar === true) userAccessViews.push(_key);
if (_key === "spreadsheet" && workspacePRojectSettingViews.spreadsheet === true) userAccessViews.push(_key);
if (_key === "gantt" && workspacePRojectSettingViews.gantt === true) userAccessViews.push(_key);
});
if (userAccessViews && userAccessViews.length > 0) {
if (!board) {
store.issue.setCurrentIssueBoardView(userAccessViews[0]);
router.replace(`/${workspace_slug}/${project_slug}?board=${userAccessViews[0]}`);
} else {
if (userAccessViews.includes(board)) {
if (store.issue.currentIssueBoardView === null) store.issue.setCurrentIssueBoardView(board);
else {
if (board === store.issue.currentIssueBoardView)
router.replace(`/${workspace_slug}/${project_slug}?board=${board}`);
else {
store.issue.setCurrentIssueBoardView(board);
router.replace(`/${workspace_slug}/${project_slug}?board=${board}`);
}
}
} else {
store.issue.setCurrentIssueBoardView(userAccessViews[0]);
router.replace(`/${workspace_slug}/${project_slug}?board=${userAccessViews[0]}`);
}
}
}
}
}, [workspace_slug, project_slug, board, router, store?.issue, store?.project?.workspaceProjectSettings]);
useEffect(() => {
if (workspace_slug && project_slug) {
store?.project?.getProjectSettingsAsync(workspace_slug, project_slug);
store?.issue?.getIssuesAsync(workspace_slug, project_slug);
}
}, [workspace_slug, project_slug, store?.project, store?.issue]);
return (
<div className="relative w-full h-full overflow-hidden">
{store?.issue?.loader && !store.issue.issues ? (
<div className="text-sm text-center py-10 text-gray-500">Loading...</div>
) : (
<>
{store?.issue?.error ? (
<div className="text-sm text-center py-10 text-gray-500">Something went wrong.</div>
) : (
store?.issue?.currentIssueBoardView && (
<>
{store?.issue?.currentIssueBoardView === "list" && (
<div className="relative w-full h-full overflow-y-auto">
<div className="container mx-auto px-5 py-3">
<IssueListView />
</div>
</div>
)}
{store?.issue?.currentIssueBoardView === "kanban" && (
<div className="relative w-full h-full mx-auto px-5">
<IssueKanbanView />
</div>
)}
{store?.issue?.currentIssueBoardView === "calendar" && <IssueCalendarView />}
{store?.issue?.currentIssueBoardView === "spreadsheet" && <IssueSpreadsheetView />}
{store?.issue?.currentIssueBoardView === "gantt" && <IssueGanttView />}
</>
)
)}
</>
)}
</div>
);
});
export default WorkspaceProjectPage;
@@ -1,9 +1,7 @@
import React from "react";
"use client";
const WorkspaceProjectPage = () => (
<div className="relative w-screen h-screen flex justify-center items-center text-5xl">
Plane Workspace project Space
</div>
<div className="relative w-screen h-screen flex justify-center items-center text-5xl">Plane Workspace Space</div>
);
export default WorkspaceProjectPage;
+20
View File
@@ -0,0 +1,20 @@
"use client";
// root styles
import "styles/globals.css";
// mobx store provider
import { MobxStoreProvider } from "lib/mobx/store-provider";
import MobxStoreInit from "lib/mobx/store-init";
const RootLayout = ({ children }: { children: React.ReactNode }) => (
<html lang="en">
<body className="antialiased w-100">
<MobxStoreProvider>
<MobxStoreInit />
<main>{children}</main>
</MobxStoreProvider>
</body>
</html>
);
export default RootLayout;
@@ -1,7 +1,9 @@
"use client";
import React from "react";
const HomePage = () => (
<div className="relative w-screen h-screen flex justify-center items-center text-5xl">Plane Space</div>
<div className="relative w-screen h-screen flex justify-center items-center text-5xl">Plane Deploy</div>
);
export default HomePage;
+31
View File
@@ -0,0 +1,31 @@
// next imports
import Image from "next/image";
const CustomProjectNotPublishedError = () => (
<div className="relative w-screen min-h-screen h-full flex justify-center items-center py-5">
<div className="max-w-[700px] space-y-5">
<div className="flex items-center flex-col gap-3 text-center">
<div className="relative w-[240px] h-[240px]">
<Image src={`/project-not-published.svg`} layout="fill" alt="404- Page not found" />
</div>
<div className="text-xl font-medium">
Oops! The page you{`'`}re looking for isn{`'`}t live at the moment.
</div>
<div className="text-sm text-custom-text-200">
If this is your project, login to your workspace to adjust its visibility settings and make it public.
</div>
</div>
<div className="text-center flex justify-center items-center">
<a
href={`https://app.plane.so/`}
className="transition-all border border-gray-200 bg-gray-50 hover:bg-gray-100 text-gray-700 hover:text-gray-800 cursor-pointer p-1.5 px-2.5 rounded-sm text-sm font-medium hover:scale-105 select-none"
>
Go to your Workspace
</a>
</div>
</div>
</div>
);
export default CustomProjectNotPublishedError;
+5
View File
@@ -0,0 +1,5 @@
export * from "./issue-group/backlog-state-icon";
export * from "./issue-group/unstarted-state-icon";
export * from "./issue-group/started-state-icon";
export * from "./issue-group/completed-state-icon";
export * from "./issue-group/cancelled-state-icon";
@@ -0,0 +1,23 @@
import React from "react";
// types
import type { Props } from "../types";
// constants
import { issueGroupColors } from "constants/data";
export const BacklogStateIcon: React.FC<Props> = ({
width = "14",
height = "14",
className,
color = issueGroupColors["backlog"],
}) => (
<svg
width={width}
height={height}
className={className}
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<circle cx="10" cy="10" r="9" stroke={color} strokeLinecap="round" strokeDasharray="4 4" />
</svg>
);
@@ -0,0 +1,74 @@
import React from "react";
// types
import type { Props } from "../types";
// constants
import { issueGroupColors } from "constants/data";
export const CancelledStateIcon: React.FC<Props> = ({
width = "14",
height = "14",
className,
color = issueGroupColors["cancelled"],
}) => (
<svg width={width} height={height} className={className} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 84.36 84.36">
<g id="Layer_2" data-name="Layer 2">
<g id="Layer_1-2" data-name="Layer 1">
<path
className="cls-1"
fill="none"
strokeWidth={3}
stroke={color}
strokeLinecap="round"
strokeLinejoin="round"
d="M20.45,7.69a39.74,39.74,0,0,1,43.43.54"
/>
<path
className="cls-1"
fill="none"
strokeWidth={3}
stroke={color}
strokeLinecap="round"
strokeLinejoin="round"
d="M76.67,20.45a39.76,39.76,0,0,1-.53,43.43"
/>
<path
className="cls-1"
fill="none"
strokeWidth={3}
stroke={color}
strokeLinecap="round"
strokeLinejoin="round"
d="M63.92,76.67a39.78,39.78,0,0,1-43.44-.53"
/>
<path
className="cls-1"
fill="none"
strokeWidth={3}
stroke={color}
strokeLinecap="round"
strokeLinejoin="round"
d="M7.69,63.92a39.75,39.75,0,0,1,.54-43.44"
/>
<circle className="cls-2" fill={color} cx="42.18" cy="42.18" r="31.04" />
<path
className="cls-3"
fill="none"
strokeWidth={3}
stroke="#ffffff"
strokeLinecap="square"
strokeMiterlimit={10}
d="M32.64,32.44q9.54,9.75,19.09,19.48"
/>
<path
className="cls-3"
fill="none"
strokeWidth={3}
stroke="#ffffff"
strokeLinecap="square"
strokeMiterlimit={10}
d="M32.64,51.92,51.73,32.44"
/>
</g>
</g>
</svg>
);
@@ -0,0 +1,65 @@
import React from "react";
// types
import type { Props } from "../types";
// constants
import { issueGroupColors } from "constants/data";
export const CompletedStateIcon: React.FC<Props> = ({
width = "14",
height = "14",
className,
color = issueGroupColors["completed"],
}) => (
<svg width={width} height={height} className={className} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 84.36 84.36">
<g id="Layer_2" data-name="Layer 2">
<g id="Layer_1-2" data-name="Layer 1">
<path
className="cls-1"
fill="none"
strokeWidth={3}
stroke={color}
strokeLinecap="round"
strokeLinejoin="round"
d="M20.45,7.69a39.74,39.74,0,0,1,43.43.54"
/>
<path
className="cls-1"
fill="none"
strokeWidth={3}
stroke={color}
strokeLinecap="round"
strokeLinejoin="round"
d="M76.67,20.45a39.76,39.76,0,0,1-.53,43.43"
/>
<path
className="cls-1"
fill="none"
strokeWidth={3}
stroke={color}
strokeLinecap="round"
strokeLinejoin="round"
d="M63.92,76.67a39.78,39.78,0,0,1-43.44-.53"
/>
<path
className="cls-1"
fill="none"
strokeWidth={3}
stroke={color}
strokeLinecap="round"
strokeLinejoin="round"
d="M7.69,63.92a39.75,39.75,0,0,1,.54-43.44"
/>
<circle className="cls-2" fill={color} cx="42.18" cy="42.18" r="31.04" />
<path
className="cls-3"
fill="none"
strokeWidth={3}
stroke="#ffffff"
strokeLinecap="square"
strokeMiterlimit={10}
d="M30.45,43.75l6.61,6.61L53.92,34"
/>
</g>
</g>
</svg>
);
@@ -0,0 +1,73 @@
import React from "react";
// types
import type { Props } from "../types";
// constants
import { issueGroupColors } from "constants/data";
export const StartedStateIcon: React.FC<Props> = ({
width = "14",
height = "14",
className,
color = issueGroupColors["started"],
}) => (
<svg width={width} height={height} className={className} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 83.36 83.36">
<g id="Layer_2" data-name="Layer 2">
<g id="Layer_1-2" data-name="Layer 1">
<path
className="cls-1"
fill="none"
stroke={color}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={3}
d="M20,7.19a39.74,39.74,0,0,1,43.43.54"
/>
<path
className="cls-1"
fill="none"
stroke={color}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={3}
d="M76.17,20a39.76,39.76,0,0,1-.53,43.43"
/>
<path
className="cls-1"
fill="none"
stroke={color}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={3}
d="M63.42,76.17A39.78,39.78,0,0,1,20,75.64"
/>
<path
className="cls-1"
fill="none"
stroke={color}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={3}
d="M7.19,63.42A39.75,39.75,0,0,1,7.73,20"
/>
<path
className="cls-2"
fill={color}
stroke={color}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={3}
d="M42.32,41.21q9.57-14.45,19.13-28.9a35.8,35.8,0,0,0-39.09,0Z"
/>
<path
className="cls-2"
fill={color}
stroke={color}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={3}
d="M42.32,41.7,61.45,70.6a35.75,35.75,0,0,1-39.09,0Z"
/>
</g>
</g>
</svg>
);
@@ -0,0 +1,55 @@
import React from "react";
// types
import type { Props } from "../types";
// constants
import { issueGroupColors } from "constants/data";
export const UnstartedStateIcon: React.FC<Props> = ({
width = "14",
height = "14",
className,
color = issueGroupColors["unstarted"],
}) => (
<svg width={width} height={height} className={className} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 84.36 84.36">
<g id="Layer_2" data-name="Layer 2">
<g id="Layer_1-2" data-name="Layer 1">
<path
className="cls-1"
fill="none"
stroke={color}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={3}
d="M20.45,7.69a39.74,39.74,0,0,1,43.43.54"
/>
<path
className="cls-1"
fill="none"
stroke={color}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={3}
d="M76.67,20.45a39.76,39.76,0,0,1-.53,43.43"
/>
<path
className="cls-1"
fill="none"
stroke={color}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={3}
d="M63.92,76.67a39.78,39.78,0,0,1-43.44-.53"
/>
<path
className="cls-1"
fill="none"
stroke={color}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={3}
d="M7.69,63.92a39.75,39.75,0,0,1,.54-43.44"
/>
</g>
</g>
</svg>
);

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