Compare commits

...
209 Commits
Author SHA1 Message Date
Mitch Harvey 1b28892cf5 fix: Fix Helm template for dburl secret (#770) 2024-05-21 23:32:07 +02:00
Maksim Eltyshev 013ea1b869 chore: Update version 2024-05-21 13:58:28 +02:00
Blyamur 4975fa2eeb fix: Update Russian translation (#767) 2024-05-21 13:51:57 +02:00
Blyamur 3e28ad87c7 fix: Update Russian translation (#766) 2024-05-21 13:51:34 +02:00
Smiley3112 2d6666d693 feat: Add SMTP_NAME environment variable (#761)
Closes #758
2024-05-18 15:02:21 +02:00
Maksim Eltyshev 4cbd2f85c7 chore: Update version 2024-05-16 21:06:30 +02:00
Maksim Eltyshev fae9de4e1c fix: Prevent loading fonts from third-party resources
Closes #538, closes #712, closes #742, closes #755
2024-05-16 21:02:58 +02:00
IT Creativity + Art Team 89c1ed71e1 feat: Add Bulgarian translation (#753) 2024-05-13 13:05:20 +02:00
Mitch Harvey 1725f3cb8a feat: Ability to specify existing secret as database url (#751)
Closes #750
2024-05-13 11:07:57 +02:00
Christopher Greaves f7be49e402 docs: Update Kubernetes README (#740) 2024-05-03 21:04:34 +02:00
Maksim Eltyshev c8d732d936 chore: Update version 2024-05-03 12:44:51 +02:00
Maksim Eltyshev 5a32b6327c fix: Fix actions with members
Closes #737
2024-05-03 12:42:51 +02:00
Emmanuel Guyot 8488105810 feat: Search in card descriptions (#729) 2024-04-26 21:52:57 +02:00
Niccolò Pedrini 57ebfa51d8 fix: Update Italian translation (#727) 2024-04-24 18:49:27 +02:00
Maksim Eltyshev 40170fdf90 chore: Update version 2024-04-23 15:57:14 +02:00
leroyloren 1629f62d09 fix: Update Czech translation (#725) 2024-04-23 15:53:10 +02:00
Maksim Eltyshev fff0552081 chore: Update dependencies
Closes #726
2024-04-23 15:45:47 +02:00
Maksim Eltyshev e792cb26b1 chore: Update version 2024-04-22 23:25:09 +02:00
Maksim Eltyshev 7ee2d76be4 feat: Display avatar next to user name in top bar 2024-04-22 23:23:36 +02:00
Emmanuel Guyot eb56b2147b feat: Filter cards by keyword with advanced capabilities (#713)
Closes #706
2024-04-22 23:15:31 +02:00
Maksim Eltyshev 8747aa59de fix: Fix title of sort menu item 2024-04-22 22:02:51 +02:00
Samuel 934dcdf39b feat: Sort cards within list (#717)
Closes #390
2024-04-22 21:56:07 +02:00
Felipe 2c84316fe0 fix: Limit amount of displayed board members (#715)
Closes #617
2024-04-22 15:28:57 +02:00
Daniel Hiller 03825c3bed CI: Fix dev Build 2024-04-16 03:43:54 +02:00
ItzAndriss 200ea3bbc0 feat: Add Hungarian translation (#590) 2024-04-15 18:30:18 +02:00
Robson Ventura 5b4b0cd635 feat: Development environment with docker compose (#709) 2024-04-15 16:22:51 +02:00
Gavin Mogan a1323cd516 feat: Set labels when building docker image 2024-04-15 16:19:53 +02:00
Maksim Eltyshev 92f75789fa chore: Update version 2024-04-12 12:09:22 +02:00
Maksim Eltyshev 8a8c1fee0c fix: Fix error output when sending email or message to Slack 2024-04-12 12:07:19 +02:00
Maksim Eltyshev 16499052f7 chore: Update version 2024-04-10 15:57:15 +02:00
HannesOberreiter cc32daa009 feat: Display clickable links in tasks (#694)
Closes #330
2024-04-10 15:53:05 +02:00
Stan M ea1b3b7f92 feat: Ability to run with read-only root filesystem in Helm (#695) 2024-04-10 12:56:21 +02:00
Maksim Eltyshev 4e2863faa7 feat: Automatic logout when session expires
Closes #693
2024-04-09 15:12:46 +02:00
Maksim Eltyshev b46fb43e6f chore: Cleanup 2024-04-08 01:47:24 +02:00
Maksim Eltyshev bcacfa8fe2 Merge branch 'GlitchSecure-slack-bot-integration' 2024-04-08 01:29:28 +02:00
Niccolò Pedrini 4df2becd6f fix: Update Italian translation (#688) 2024-04-08 01:27:19 +02:00
Matthieu Bollot 2990ea593a feat: Slack bot notifications (#676) 2024-04-08 01:27:10 +02:00
Blyamur b976673826 fix: Update Russian translation (#686) 2024-04-08 01:24:50 +02:00
leroyloren 16e034efcb fix: Update Czech translation (#679) 2024-04-08 01:24:50 +02:00
Maksim Eltyshev 6712625c3f chore: Update version 2024-04-08 01:24:50 +02:00
Matthieu Bollot b5bbf6a6a4 feat: Add ability to duplicate card (#668) 2024-04-08 01:24:50 +02:00
Brian Stinson 3d3e7761f5 fix: Allow specifying external dburl in Helm (#672) 2024-04-08 01:24:50 +02:00
piero fa56b9c01f fix: Update HorizontalPodAutoscaler version and spec in chart (#666)
Closes #667
2024-04-08 01:24:50 +02:00
Maksim Eltyshev 882213784f chore: Update version 2024-04-08 01:24:50 +02:00
Maksim Eltyshev 251bd793f3 fix: Fix OIDC authentication error when redirecting from another tab
Closes #650
2024-04-08 01:24:50 +02:00
Maksim Eltyshev 088b0e760b fix: Fix scrollbar styles 2024-04-08 01:24:50 +02:00
Maksim Eltyshev cc1fa1cc3b chore: Update version 2024-04-08 01:24:50 +02:00
Edouard bcd3ea86e8 feat: SMTP integration and email notifications (#631) 2024-04-08 01:24:50 +02:00
Maksim Eltyshev 0176650f67 fix: Add date-fns locale to Portuguese translation 2024-04-08 01:24:50 +02:00
Jeffrey f3963aded3 feat: Add Dutch translation (#644) 2024-04-08 01:24:50 +02:00
Reuben Hu bbe720481a feat: Add Indonesian translation (#643) 2024-04-08 01:24:50 +02:00
IV16SL 04f9030474 fix: Fix Chinese translation (#641) 2024-04-08 01:24:50 +02:00
Chris 6d135be859 feat: Add Portuguese translation (#640) 2024-04-08 01:24:50 +02:00
Maksim Eltyshev 63cbe7063e docs: Update features, add contributors 2024-04-08 01:24:50 +02:00
Maksim Eltyshev 7985bea2b8 chore: Update version 2024-04-08 01:24:50 +02:00
Maksim Eltyshev 733abccaa9 fix: Fix case sensitivity of default admin environment variables 2024-04-08 01:24:50 +02:00
Daniel Hiller 119cce82b4 fix: Container build windows 2024-04-08 01:24:50 +02:00
Maksim Eltyshev 988e70ec40 fix: Fix nullable boolean inputs 2024-04-08 01:24:50 +02:00
Daniel Hiller 5e3fca3771 Update build-and-push-docker-image-dev.yml 2024-04-08 01:24:50 +02:00
Maksim Eltyshev 6d9ce56f4a fix: Include due date when importing from Trello
Closes #598
2024-04-08 01:24:50 +02:00
Daniel Hiller 3c8c04fd37 ci: update workflow 2024-04-08 01:24:50 +02:00
Daniel Hiller 790a1d5cc9 feat: Add healthcheck for docker deployments
fix: Missing healthcheck.js

fix: missing healthcheck.js

fix: HEALTHCHECK command
2024-04-08 01:24:50 +02:00
Maksim Eltyshev 4b352fd878 fix: Remove overflow of attachment dropzone 2024-04-08 01:24:50 +02:00
Maksim Eltyshev a196f2ef11 chore: Update version 2024-04-08 01:24:50 +02:00
Maksim Eltyshev 6c65d135f7 feat: Add ability to enforce SSO
Closes #543, closes #545
2024-04-08 01:24:10 +02:00
Maksim Eltyshev a1a1e9a86a chore: Update version 2024-04-08 01:18:26 +02:00
Maksim Eltyshev 0d39a7567f feat: Add ability to map OIDC attributes and ignore username
Closes #554
2024-04-08 01:18:26 +02:00
Maksim Eltyshev 76a18d06d6 chore: Update version 2024-04-08 01:18:26 +02:00
Maksim Eltyshev ce52fc5af7 fix: Fix images becoming black and white when resizing
Closes #574, closes #585
2024-04-08 01:18:26 +02:00
Maksim Eltyshev 0a2863a79c chore: Bump sharp version 2024-04-08 01:18:26 +02:00
Niccolò Pedrini 39e320fe4b fix: Update Italian translation (#688) 2024-04-08 00:50:52 +02:00
Matthieu Bollot 2f3dfe775e feat: Slack bot notifications (#676) 2024-04-08 00:33:29 +02:00
Blyamur 22964cb375 fix: Update Russian translation (#686) 2024-04-07 22:05:22 +02:00
leroyloren fd89cf7f2b fix: Update Czech translation (#679) 2024-04-07 21:58:27 +02:00
Maksim Eltyshev 5acf84c20b chore: Update version 2024-04-05 22:49:02 +02:00
Matthieu Bollot 6cd9da844f feat: Add ability to duplicate card (#668) 2024-04-05 22:40:35 +02:00
Brian Stinson d7847fe89b fix: Allow specifying external dburl in Helm (#672) 2024-04-05 10:38:46 +02:00
piero d033520927 fix: Update HorizontalPodAutoscaler version and spec in chart (#666)
Closes #667
2024-04-03 20:59:23 +02:00
Maksim Eltyshev d911030831 chore: Update version 2024-03-26 14:08:59 +01:00
Maksim Eltyshev 9d95ed6c41 fix: Fix OIDC authentication error when redirecting from another tab
Closes #650
2024-03-26 14:07:07 +01:00
Maksim Eltyshev 1a5a853fa3 fix: Fix scrollbar styles 2024-03-22 14:26:23 +01:00
Maksim Eltyshev bb358e01dd chore: Update version 2024-03-22 00:52:45 +01:00
Edouard 9f0fce098e feat: SMTP integration and email notifications (#631) 2024-03-22 00:14:09 +01:00
Maksim Eltyshev 60ab09771d fix: Add date-fns locale to Portuguese translation 2024-03-21 12:41:31 +01:00
Jeffrey cb3ef783ba feat: Add Dutch translation (#644) 2024-03-21 12:38:26 +01:00
Reuben Hu fe376b10c1 feat: Add Indonesian translation (#643) 2024-03-21 11:46:33 +01:00
IV16SL 786441cda0 fix: Fix Chinese translation (#641) 2024-03-20 19:13:21 +01:00
Chris 0d3cf0a32d feat: Add Portuguese translation (#640) 2024-03-20 14:55:17 +01:00
Maksim Eltyshev b4e01d7256 docs: Update features, add contributors 2024-03-13 11:02:52 +01:00
Maksim Eltyshev 73a641b935 chore: Update version 2024-03-12 20:44:27 +01:00
Maksim Eltyshev 6802a0dc69 fix: Fix case sensitivity of default admin environment variables 2024-03-12 20:40:46 +01:00
Daniel Hiller 81d6c43cd9 fix: Container build windows 2024-02-14 03:15:34 +01:00
Maksim Eltyshev ca6a1a14f5 fix: Fix nullable boolean inputs 2024-02-08 16:42:10 +01:00
Daniel Hiller c096444acb Update build-and-push-docker-image-dev.yml 2024-02-08 15:34:43 +01:00
Maksim Eltyshev 51d05f3cd3 fix: Include due date when importing from Trello
Closes #598
2024-02-08 13:30:07 +01:00
Daniel Hiller 221da3c455 ci: update workflow 2024-02-06 02:07:27 +01:00
Daniel Hiller 92098e136c feat: Add healthcheck for docker deployments
fix: Missing healthcheck.js

fix: missing healthcheck.js

fix: HEALTHCHECK command
2024-02-06 01:51:00 +01:00
Maksim Eltyshev fd5c33dd5d fix: Remove overflow of attachment dropzone 2024-02-05 13:24:50 +01:00
Maksim Eltyshev 0c1c7e2192 chore: Update version 2024-02-01 00:34:31 +01:00
Maksim Eltyshev b8d262f745 feat: Add ability to enforce SSO
Closes #543, closes #545
2024-02-01 00:31:15 +01:00
Maksim Eltyshev a1fd694248 chore: Update version 2024-01-31 15:58:57 +01:00
Maksim Eltyshev 634d6ceab1 feat: Add ability to map OIDC attributes and ignore username
Closes #554
2024-01-25 23:01:59 +01:00
Maksim Eltyshev 31d4d5f38d chore: Update version 2024-01-18 11:00:20 +01:00
Maksim Eltyshev 3f99438402 fix: Fix images becoming black and white when resizing
Closes #574, closes #585
2024-01-18 10:58:26 +01:00
Maksim Eltyshev 7e7727e3e1 chore: Bump sharp version 2024-01-03 13:59:14 +05:00
GlitchWitch 273d994750 Remove unused code and comments 2023-12-28 12:01:38 -06:00
Brad Bahls 86d21d6abd updated to use currentUser.name for messages 2023-12-28 09:03:41 -07:00
GlitchWitch a9c200cc3b Merge branch 'master' into slack-bot-integration 2023-12-23 14:41:54 -06:00
GlitchWitch 3fedc14062 Rename custom.js -> slack.js 2023-12-23 10:52:15 -06:00
GlitchWitch c42e1fb5dd Add slack variables to docker-compose 2023-12-23 10:49:02 -06:00
GlitchWitch 9961340118 Replace plankaProdUrl with one set by environment. Remove hardcoded channel and unused variables. 2023-12-23 10:48:50 -06:00
Brad Bahls f54bd22757 removed test webhook url 2023-12-23 08:58:22 -07:00
Brad Bahls 1043dacd67 added new custom service with slack integration helper functions; added axios package; added notifications for card create, delete, and update (move); added notifications for comment create 2023-12-23 08:52:07 -07:00
Daniel Hiller 616304a1fa Update branch 2023-12-21 00:51:17 +01:00
Daniel Hiller 1e01148138 ci: Add retry on failure 2023-12-20 23:50:51 +01:00
Daniel Hiller ede56e1ae3 ci: Switch to matrix workflow 2023-12-20 23:28:35 +01:00
Maksim Eltyshev b2e0c45539 chore: Update version 2023-12-20 13:49:07 +01:00
Maksim Eltyshev 389648b546 fix: Add missing date formats to Ukrainian translation 2023-12-20 12:58:43 +01:00
Denis Polishchuk f4789c28db feat: Add Ukrainian translation (#571) 2023-12-20 12:39:51 +01:00
ossdate 668092de7d fix: Update Chinese translation (#569) 2023-12-20 12:33:37 +01:00
Yu Inoue 920f9c097d fix: Display year when date is outside of current year (#567) 2023-12-20 12:26:01 +01:00
Maksim Eltyshev da63598de3 chore: Update version 2023-12-11 00:18:14 +01:00
Gilberto Vidal 517cb34cfe fix: Fix eslint rules for Windows (#508) 2023-12-11 00:13:04 +01:00
NavyStack 079b8eba53 fix: Fine-tune Korean and Japanese translations, fix full stops (#561) 2023-12-10 23:47:04 +01:00
Maksim Eltyshev af02cdd8bf docs: Little readme fixes 2023-12-10 22:20:23 +01:00
Maksim Eltyshev e1bc189b88 chore: Bump chart-releaser-action version 2023-12-10 17:55:15 +01:00
Maksim Eltyshev 5f2723623a chore: Update version 2023-12-09 17:23:01 +01:00
Maksim Eltyshev fee6841d0a fix: Attempt to fix error that occurs during reconnection 2023-12-09 17:19:40 +01:00
Maksim Eltyshev ca6fb3b962 fix: Add state parameter to OIDC authorization url
Closes #558
2023-12-09 17:06:45 +01:00
Maksim Eltyshev 28c3f28e01 fix: Add issuer to OIDC callback parameters
Closes #562
2023-12-09 16:28:24 +01:00
Maksim Eltyshev 80d12aaeeb fix: Fix Korean translation linting 2023-12-04 11:42:15 +01:00
NavyStack 89af209a2e fix: Update Korean translation (#560) 2023-12-04 11:40:04 +01:00
Maksim Eltyshev f1fed3e533 build: Stop using base image 2023-12-01 18:02:19 +01:00
Maksim Eltyshev eddeb96b16 fix: Fix Romanian translation linting 2023-11-24 12:43:00 +01:00
Maksim Eltyshev 9e8ff3b579 chore: Revert remark-gfm update 2023-11-23 14:40:44 +01:00
Maksim Eltyshev fc1278011c build: Use pnpm import before installing dependencies 2023-11-23 11:54:28 +01:00
Andrei Covali 24163463fc feat: Add Romanian translation (#555) 2023-11-22 14:58:13 +01:00
Yu Inoue fdef87e3b9 fix: Add support for OIDC configuration thru Helm (#550)
Closes #548
2023-11-20 14:16:19 +01:00
Maksim Eltyshev 964498dbaa chore: Use Node v18 2023-11-18 09:26:31 +01:00
Maksim Eltyshev 5b519cb62f chore: Update version 2023-11-17 22:13:04 +01:00
Yu Inoue 11fc3b4485 fix: Separate service port and container port in Helm (#549) 2023-11-17 22:11:35 +01:00
Maksim Eltyshev 37c0b59f82 chore: Update vips version 2023-11-17 21:48:26 +01:00
Maksim Eltyshev 239611ad51 chore: Update dependencies 2023-11-17 14:34:10 +01:00
Yu Inoue 0575e665fe fix: Fix time format in Japanese locale (#546)
Closes #542
2023-11-13 12:51:00 +01:00
Maksim Eltyshev 019766dced chore: Update version 2023-10-25 23:41:48 +02:00
Maksim Eltyshev 6dc9e4ed99 fix: Disable role change when OIDC roles are not ignored 2023-10-25 23:39:34 +02:00
Balthasar Hofer d4b64b90fc feat: Add ability to ignore roles when logging in with SSO (#534)
Closes #533
2023-10-26 02:01:35 +05:00
Daniel Hiller 1a49826b85 docs: adjust filenames to match the docs 2023-10-24 13:17:49 +02:00
Daniel Hiller 815643eb2c ci: ignore some paths on push 2023-10-22 22:47:39 +02:00
Daniel Hiller bcc4c0d923 chore: update version 2023-10-22 22:31:10 +02:00
Daniel Hiller cf7810b067 fix: add admin user variables 2023-10-22 22:27:25 +02:00
Maksim Eltyshev f1c72df8b6 chore: Update version 2023-10-20 22:38:43 +02:00
Maksim Eltyshev 5056d38848 fix: Fix logging out with invalid token 2023-10-20 22:34:58 +02:00
Maksim Eltyshev 3ef5b3ead8 fix: Make default admin environment variables optional
Closes #526
2023-10-20 21:52:12 +02:00
Vishnu Kaushik ded58dc8b0 fix: Add task counter in card modal (#525)
Closes #467
2023-10-21 00:20:04 +05:00
Maksim Eltyshev d8ee1ecdd1 ci: Do not mark released chart as latest 2023-10-19 23:30:58 +02:00
Maksim Eltyshev fc2f4cd5d3 chore: Update version 2023-10-19 23:08:40 +02:00
Maksim Eltyshev 7ef457157e fix: Fallback to query if there is no fragment 2023-10-19 23:06:37 +02:00
Maksim Eltyshev 23ee815200 chore: Update version 2023-10-19 16:07:52 +02:00
Maksim Eltyshev 40c04c35ff ref: Refactoring 2023-10-19 16:05:34 +02:00
Lorenz Brun 743f2956c8 feat: Improve OIDC SSO (#524)
The OIDC implementation merged in https://github.com/plankanban/planka/pull/491 is flawed for multiple reasons.

It assumes that the access_token returned by the IDP has to be a JWT parseable by the RP which is not the case [1].
Many major IDPs do issue tokens which are not JWTs and RPs should not rely on the contents of these at all.
The only signed token which has a standardized format for direct RP consumption is the OIDC ID token (id_token), but this by default doesn't contain many claims, especially role claims are omitted from them by default for size reasons. To get these additional claims into the ID token, one needs an IDP with support for the "claims" parameter.

It requires manual specification of the JWKS URL which is mandatory in any OIDC discovery document and thus never needs to be manually specified.

It also makes the questionable decision to use a client-side code flow with PKCE where a normal code flow would be much more appropriate as all user data is processed in the backend which can securely hold a client secret (confidential client). This has far wider IDP support, is safer (due to direct involvement of the IDP in obtaining user information) and doesn't require working with ID tokens and claim parameters.

By using a server-side code flow we can also offload most complexity to the server alone, no longer requiring an additional OIDC library on the web client.

Also silent logout doesn't work on most IDPs for security reasons, one needs to actually redirect the user over to the IDP, which then prompts them once more if they actually want to log out.

This implementation should work with any OIDC-compliant IDP and even OAuth 2.0-only IDPs as long as they serve and OIDC discovery document.

[1] rfc-editor.org/rfc/rfc6749#section-5.1
2023-10-19 17:39:21 +05:00
Maksim Eltyshev 5bf5db27d8 chore: Update chart version 2023-10-18 23:33:30 +02:00
Maksim Eltyshev 667c5daf25 chore: Update version 2023-10-18 23:20:30 +02:00
Maksim Eltyshev 6662b0a717 fix: Fix order of checks when logging in 2023-10-18 23:07:57 +02:00
Maksim Eltyshev 348bf4d6c4 Merge branch 'master' of github.com:gorrilla10101/planka 2023-10-17 22:31:16 +02:00
Maksim Eltyshev 3ac2d289d0 chore: Update version 2023-10-17 19:31:21 +02:00
Maksim Eltyshev 3bc73c43ea chore: Bump sails version 2023-10-17 19:23:09 +02:00
Maksim Eltyshev 8e0c60f5be fix: OIDC finalization and refactoring 2023-10-17 19:18:19 +02:00
Daniel Hiller aae69cb5e4 CI: Add prebuild planka package on release 2023-09-30 01:59:09 +02:00
Daniel Hiller 782c0356c8 Meta: Add missing return 2023-09-23 03:48:32 +02:00
Daniel Hiller 757cdb14a4 Meta: Add Automated installation 2023-09-23 03:47:46 +02:00
gorrilla10101 8a4bdbd1fe Merge branch 'plankanban:master' into master 2023-09-16 22:15:54 -05:00
Jeffrey 5bf42be786 dynamic environment variables 2023-09-16 21:37:12 -05:00
Daniel Hiller d896c3183b chore: fix docker dev build 2023-09-14 01:27:29 +02:00
Daniel Hiller 13ee0c3466 chore: fix runs-on name 2023-09-13 22:55:09 +02:00
Daniel Hiller 211c841379 chore: Switch to self hostet action runner 2023-09-13 22:47:08 +02:00
Daniel Hiller 90a30961ff chore: Add dev container, based on latest commit 2023-09-13 22:39:37 +02:00
Daniel Hiller 7cb9774e05 chore: Switch to pnpm package manager 2023-09-13 22:38:37 +02:00
Maksim Eltyshev 875895b331 fix: Fix starting without OIDC environment variables
Closes #503
2023-09-12 01:20:21 +02:00
Maksim Eltyshev 91bc889fed feat: Use environment variables for default admin configuration 2023-09-12 01:12:38 +02:00
Gilberto Vidal a34d8e9753 fix: Fix Turkish translation linting (#504) 2023-09-11 22:21:46 +05:00
gorrilla10101 107cb85ba2 feat: OIDC with PKCE flow (#491) 2023-09-04 20:06:59 +05:00
creador30 9333ef562e feat: Add Turkish translation (#501) 2023-09-04 19:41:11 +05:00
RocketProto 7d28abc7e6 fix: Decouple BASE_URL from Ingress host (#498)
Closes #496
2023-08-27 02:26:45 +05:00
Andrey 5317950b5c fix: Update Russian translation (#497) 2023-08-22 14:34:45 +05:00
Maksim Eltyshev 7501619b7e fix: Fix Czech translation linting
Closes #487
2023-07-31 11:54:50 +02:00
leroyloren 080e29aa03 fix: Update Czech translation (#485) 2023-07-27 04:07:47 +05:00
[object Object] 48c532b2c2 feat: Add edit icon to project name (#457) 2023-07-09 20:49:08 +05:00
Maksim Eltyshev a62815f825 ref: Little refactoring 2023-06-13 12:06:28 +02:00
Maksim Eltyshev 6938232dc4 fix: Keep task edits when textarea loses focus 2023-06-13 12:04:49 +02:00
[object Object] 4c4cfa9c66 fix: Keep comment edits when textarea loses focus (#443)
Closes #441
2023-06-13 14:49:54 +05:00
Maksim Eltyshev ba89d41b19 chore: Update chart version 2023-06-13 00:46:17 +02:00
Maksim Eltyshev 5c435414f7 chore: Update version 2023-06-13 00:25:40 +02:00
Maksim Eltyshev 2d7812a679 fix: Use creation timestamp instead of id for ordering
Closes #465
2023-06-13 00:19:17 +02:00
Maksim Eltyshev d386c82973 fix: Fix saving milliseconds for timestamps 2023-06-12 23:54:57 +02:00
ULiiAn 6c4db967c2 fix: Fix Chinese translation (#464)
Closes #463
2023-06-12 14:51:31 +05:00
Christopher Greaves 932d8225ed feat: Add official Helm Chart (#442)
Closes #192
2023-05-25 20:23:11 +05:00
orbatschow 0d481703da feat: Allow postgres connections that require ssl mode (#409)
Closes #261
2023-03-06 17:54:52 +05:00
Zsh 23e2b4a622 fix: Update Chinese translation (#410) 2023-03-06 17:12:59 +05:00
LuisLiu f4cc51e1c0 feat: Add Chinese translation (#398) 2023-03-06 17:07:00 +05:00
Maksim Eltyshev 793ddd5e20 chore: Update version 2023-02-27 22:57:52 +01:00
Maksim Eltyshev 3d84888eb1 revert: Allow postgres connections that require ssl mode (#408)
This reverts commit eea57ff121.
2023-02-28 02:56:16 +05:00
Maksim Eltyshev 02eabd203c chore: Update version 2023-02-27 19:12:59 +01:00
Maksim Eltyshev d0a2734161 fix: Rename timer to stopwatch
Closes #392
2023-02-27 19:09:51 +01:00
orbatschow eea57ff121 feat: Allow postgres connections that require ssl mode (#404)
Closes #261
2023-02-27 21:16:51 +05:00
Maksim Eltyshev 7e2b74627c fix: Prevent board from scrolling when it starts with non-left-click 2023-02-25 15:09:46 +01:00
Maksim Eltyshev bee199c13b docs: Add installation link for windows 2023-02-23 16:40:16 +01:00
Maksim Eltyshev 8dd331b895 fix: Fix paths in css files when building on windows 2023-02-22 19:59:40 +01:00
Maksim Eltyshev 13f26b4be1 docs: Update information, links to documentation 2023-02-20 23:36:52 +01:00
276 changed files with 25489 additions and 33543 deletions
@@ -0,0 +1,57 @@
name: Build and publish release package
on:
release:
types: [created]
jobs:
build-and-publish-release-package:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v3
with:
node-version: '18'
cache: 'npm'
- name: Workflow install pnpm
run: npm install pnpm -g
- name: Client install dependencies
run: pnpm install
- name: Server install dependencies
run: pnpm install
- name: Server include into dist
run: mv server/ dist/
- name: Client build production
run: |
npm run build
working-directory: ./client
- name: Client include into dist
run: |
mv build/index.html ../dist/views/index.ejs
mv build/* ../dist/public/
working-directory: ./client
- name: Dist include README.md SECURITY.md LICENSE start.sh
run: mv README.md SECURITY.md LICENSE start.sh dist/
- name: Dist Remove node modules
run: rm -R dist/node_modules
- name: Dist create .zip file
run: |
mv dist/ planka/
zip -r planka-prebuild-${{ github.event.release.tag_name }}.zip planka
- name: Dist upload assets
run: |
gh release upload ${{ github.event.release.tag_name }} planka-prebuild-${{ github.event.release.tag_name }}.zip
env:
GH_TOKEN: ${{ github.token }}
@@ -13,26 +13,26 @@ env:
jobs: jobs:
build-and-push-docker-base-image: build-and-push-docker-base-image:
runs-on: ubuntu-latest runs-on: self-hosted
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v3 uses: actions/checkout@v4
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v1 uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1 uses: docker/setup-buildx-action@v2
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@v1 uses: docker/login-action@v2
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push - name: Build and push
uses: docker/build-push-action@v2 uses: docker/build-push-action@v4
with: with:
context: . context: .
platforms: linux/amd64,linux/arm64,linux/arm/v7 platforms: linux/amd64,linux/arm64,linux/arm/v7
@@ -40,5 +40,5 @@ jobs:
build-args: ALPINE_VERSION=${{ env.ALPINE_VERSION }} build-args: ALPINE_VERSION=${{ env.ALPINE_VERSION }}
push: true push: true
tags: | tags: |
ghcr.io/plankanban/planka:base-latest ghcr.io/${{ github.repository }}:base-latest
ghcr.io/plankanban/planka:base-${{ env.ALPINE_VERSION }} ghcr.io/${{ github.repository }}:base-${{ env.ALPINE_VERSION }}
@@ -0,0 +1,53 @@
# https://docs.docker.com/build/ci/github-actions/multi-platform/
name: Build and push Docker DEV image
on:
push:
paths-ignore:
- '.github/**'
- 'charts/**'
- 'docker-*.sh'
- '*.md'
branches: [master]
workflow_dispatch:
env:
REGISTRY_IMAGE: ghcr.io/${{ github.repository }}
jobs:
build:
runs-on: self-hosted
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Login to GitHub Container Registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Generate docker image tags
id: metadata
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY_IMAGE }}
tags: |
type=raw,value=dev
- name: Build and push
uses: docker/build-push-action@v4
with:
context: .
platforms: linux/amd64,linux/arm64,linux/arm/v7
push: true
tags: ${{ steps.metadata.outputs.tags }}
labels: ${{ steps.metadata.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
@@ -6,19 +6,19 @@ on:
jobs: jobs:
build-and-push-docker-image: build-and-push-docker-image:
runs-on: ubuntu-latest runs-on: self-hosted
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v3 uses: actions/checkout@v4
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v1 uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1 uses: docker/setup-buildx-action@v2
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@v1 uses: docker/login-action@v2
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
@@ -31,12 +31,23 @@ jobs:
result-encoding: string result-encoding: string
script: return context.payload.release.tag_name.replace('v', '') script: return context.payload.release.tag_name.replace('v', '')
- name: Generate docker image tags
id: metadata
uses: docker/metadata-action@v5
with:
images: |
name=ghcr.io/${{ github.repository }}
tags: |
type=raw,value=${{ steps.set-version.outputs.result }}
type=raw,value=latest
- name: Build and push - name: Build and push
uses: docker/build-push-action@v2 uses: docker/build-push-action@v4
with: with:
context: . context: .
platforms: linux/amd64,linux/arm64,linux/arm/v7 platforms: linux/amd64,linux/arm64,linux/arm/v7
push: true push: true
tags: | tags: ${{ steps.metadata.outputs.tags }}
ghcr.io/plankanban/planka:latest labels: ${{ steps.metadata.outputs.labels }}
ghcr.io/plankanban/planka:${{ steps.set-version.outputs.result }} cache-from: type=gha
cache-to: type=gha,mode=max
+43
View File
@@ -0,0 +1,43 @@
name: Release Charts
on:
push:
paths:
- "charts/**"
branches:
- master
jobs:
release:
# depending on default permission settings for your org (contents being read-only or read-write for workloads), you will have to add permissions
# see: https://docs.github.com/en/actions/security-guides/automatic-token-authentication#modifying-the-permissions-for-the-github_token
permissions:
contents: write
runs-on: self-hosted
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Configure Git
run: |
git config user.name "$GITHUB_ACTOR"
git config user.email "$GITHUB_ACTOR@users.noreply.github.com"
- name: Install Helm
uses: azure/setup-helm@v3
- name: Add repositories
run: |
for dir in $(ls -d charts/*/); do
helm dependency list $dir 2> /dev/null | tail +2 | head -n -1 | awk '{ print "helm repo add " $1 " " $3 }' | while read cmd; do $cmd; done
done
- name: Run chart-releaser for stable
uses: helm/chart-releaser-action@v1.6.0
with:
charts_dir: charts
mark_as_latest: false
env:
CR_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
+3
View File
@@ -10,3 +10,6 @@ docker-compose.override.yml
# `npm i --package-lock-only` # `npm i --package-lock-only`
pnpm-lock.yaml pnpm-lock.yaml
yarn.lock yarn.lock
# Chart dependencies
**/charts/*.tgz
+23 -8
View File
@@ -1,33 +1,44 @@
FROM ghcr.io/plankanban/planka:base-latest as server-dependencies FROM node:18-alpine as server-dependencies
RUN apk -U upgrade \
&& apk add build-base python3 \
--no-cache
WORKDIR /app WORKDIR /app
COPY server/package.json server/package-lock.json . COPY server/package.json server/package-lock.json ./
RUN npm install npm@latest --global \ RUN npm install npm@latest --global \
&& npm clean-install --omit=dev && npm install pnpm --global \
&& pnpm import \
&& pnpm install --prod
FROM node:lts AS client FROM node:lts AS client
WORKDIR /app WORKDIR /app
COPY client/package.json client/package-lock.json . COPY client/package.json client/package-lock.json ./
RUN npm install npm@latest --global \ RUN npm install npm@latest --global \
&& npm clean-install --omit=dev && npm install pnpm --global \
&& pnpm import \
&& pnpm install --prod
COPY client . COPY client .
RUN DISABLE_ESLINT_PLUGIN=true npm run build RUN DISABLE_ESLINT_PLUGIN=true npm run build
FROM ghcr.io/plankanban/planka:base-latest FROM node:18-alpine
RUN apk del vips-dependencies --purge RUN apk -U upgrade \
&& apk add bash \
--no-cache
USER node USER node
WORKDIR /app WORKDIR /app
COPY --chown=node:node start.sh . COPY --chown=node:node start.sh .
COPY --chown=node:node server . COPY --chown=node:node server .
COPY --chown=node:node healthcheck.js .
RUN mv .env.sample .env RUN mv .env.sample .env
@@ -42,4 +53,8 @@ VOLUME /app/private/attachments
EXPOSE 1337 EXPOSE 1337
CMD ["./start.sh"] HEALTHCHECK --interval=10s --timeout=2s --start-period=15s \
CMD node ./healthcheck.js
CMD [ "bash", "start.sh" ]
+12 -17
View File
@@ -1,27 +1,22 @@
FROM node:lts-alpine FROM node:18-alpine
ARG ALPINE_VERSION=3.16 ARG VIPS_VERSION=8.14.5
ARG VIPS_VERSION=8.13.3
RUN apk -U upgrade \ RUN apk -U upgrade \
&& apk add \ && apk add \
bash giflib glib lcms2 libexif \ bash pkgconf \
libgsf libjpeg-turbo libpng librsvg libwebp \ libjpeg-turbo libexif librsvg cgif tiff libspng libimagequant \
orc pango tiff \
--repository https://alpine.global.ssl.fastly.net/alpine/v${ALPINE_VERSION}/community/ \
--repository https://alpine.global.ssl.fastly.net/alpine/v${ALPINE_VERSION}/main/ \
--no-cache \ --no-cache \
&& apk add \ && apk add \
build-base giflib-dev glib-dev lcms2-dev libexif-dev \ build-base gobject-introspection-dev meson \
libgsf-dev libjpeg-turbo-dev libpng-dev librsvg-dev libwebp-dev \ libjpeg-turbo-dev libexif-dev librsvg-dev cgif-dev tiff-dev libspng-dev libimagequant-dev \
orc-dev pango-dev tiff-dev \
--virtual vips-dependencies \ --virtual vips-dependencies \
--repository https://alpine.global.ssl.fastly.net/alpine/v${ALPINE_VERSION}/community/ \
--repository https://alpine.global.ssl.fastly.net/alpine/v${ALPINE_VERSION}/main/ \
--no-cache \ --no-cache \
&& wget -O- https://github.com/libvips/libvips/releases/download/v${VIPS_VERSION}/vips-${VIPS_VERSION}.tar.gz | tar xzC /tmp \ && wget -O- https://github.com/libvips/libvips/releases/download/v${VIPS_VERSION}/vips-${VIPS_VERSION}.tar.xz | tar xJC /tmp \
&& cd /tmp/vips-${VIPS_VERSION} \ && cd /tmp/vips-${VIPS_VERSION} \
&& ./configure \ && meson setup build-dir \
&& make \ && cd build-dir \
&& make install-strip \ && ninja \
&& ninja test \
&& ninja install \
&& rm -rf /tmp/vips-${VIPS_VERSION} && rm -rf /tmp/vips-${VIPS_VERSION}
+19 -299
View File
@@ -1,9 +1,8 @@
# Planka # Planka
#### Elegant open source project tracking.
![David (path)](https://img.shields.io/github/package-json/v/plankanban/planka) ![Docker Pulls](https://img.shields.io/docker/pulls/meltyshev/planka) ![GitHub](https://img.shields.io/github/license/plankanban/planka) ![David (path)](https://img.shields.io/github/package-json/v/plankanban/planka) ![Docker Pulls](https://img.shields.io/docker/pulls/meltyshev/planka) ![GitHub](https://img.shields.io/github/license/plankanban/planka)
A Trello-like kanban board built with React and Redux.
![](https://raw.githubusercontent.com/plankanban/planka/master/demo.gif) ![](https://raw.githubusercontent.com/plankanban/planka/master/demo.gif)
[**Client demo**](https://plankanban.github.io/planka) (without server features). [**Client demo**](https://plankanban.github.io/planka) (without server features).
@@ -11,314 +10,31 @@ A Trello-like kanban board built with React and Redux.
## Features ## Features
- Create projects, boards, lists, cards, labels and tasks - Create projects, boards, lists, cards, labels and tasks
- Add card members, track time, set a due date, add attachments, write comments - Add card members, track time, set due dates, add attachments, write comments
- Markdown support in a card description and comment - Markdown support in card description and comments
- Filter by members and labels - Filter by members and labels
- Customize project background - Customize project backgrounds
- Real-time updates - Real-time updates
- User notifications - Internal notifications
- Internationalization - Multiple interface languages
- Single sign-on via OpenID Connect
## Deploy ## How to deploy Planka
There are 2 types of installation: There are many ways to install Planka, [check them out](https://docs.planka.cloud/docs/intro).
1. [Dockerized](#1-docker-compose)
2. [Without Docker](#2-without-docker)
### 1. Docker Compose For configuration, please see the [configuration section](https://docs.planka.cloud/docs/category/configuration).
[![](https://d207aa93qlcgug.cloudfront.net/1.95.5.qa/img/nav/docker-logo-loggedout.png)](https://github.com/plankanban/planka/pkgs/container/planka) ## Contact
- Make sure you have [Docker](https://docs.docker.com/install/) and [Docker Compose](https://docs.docker.com/compose/install/) installed and operational. - If you want to get a hosted version of Planka, you can contact us via email contact@planka.cloud
- Create `docker-compose.yml` based on [the example](https://raw.githubusercontent.com/plankanban/planka/master/docker-compose.yml). This is the ONLY file you will need. You can create this file on your own machine by copy and pasting the content. - For any security issues, please do not create a public issue on GitHub, instead please write to security@planka.cloud
- Edit `BASE_URL` to match your domain name or IP address.
- Edit `SECRET_KEY` with random value. You can generate it by `openssl rand -hex 64`.
Download the docker-compose.yml: We do NOT offer any public support via email, please use GitHub.
```
curl -L https://raw.githubusercontent.com/plankanban/planka/master/docker-compose.yml -o docker-compose.yml
```
Pull images and start services:
```
docker-compose up -d
```
Demo user: demo@demo.demo demo
### 2. Without Docker
Installing without Docker is a bit more complicated, here's what you need to do:
1. Clone this repository into a directory of your choice. (e.g. `/var/www/planka`)
```bash
mkdir -p /var/www/planka
cd /var/www/planka
git clone https://github.com/plankanban/planka.git .
```
2. Install dependencies and build the client.
```bash
npm install
npm run client:build
```
**Note**: You can use `yarn` or `pnpm` instead of `npm`.
3. Copy the content of the `client/build` directory to the server.
```bash
cp -r client/build/. server/public
cp client/build/index.html server/views/index.ejs
```
4. Copy the start script and navigate to the `server` directory.
```bash
cp start.sh server
cd server
```
5. Configure environment variables.
```bash
cp .env.sample .env
# Edit .env file (You could use nano, vim, etc.)
nano .env
```
**Note**: Before continuing, make sure you have your selected database created and running.
6. Start the server.
```bash
./start.sh
```
**Note**: You can use `pm2` or `systemd` to run the server in the background.
## Additional information
### Nginx configuration
Here is an example of Nginx configuration for Planka, make sure to replace `<domain>` with your domain name, and make sure to configure the SSL.
```nginx
upstream planka {
server localhost:1337;
keepalive 32;
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name <domain>;
access_log /var/log/nginx/planka-access.log;
error_log /var/log/nginx/planka-error.log error;
# SSL Configuration - Replace the example <domain> with your domain
ssl_certificate /etc/letsencrypt/live/<domain>/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/<domain>/privkey.pem;
ssl_session_cache shared:SSL:10m;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers "ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384";
ssl_prefer_server_ciphers on;
client_max_body_size 120M;
add_header Access-Control-Allow-Origin *;
add_header Access-Control-Max-Age 3600;
add_header Access-Control-Expose-Headers Content-Length;
add_header Access-Control-Allow-Headers Range;
# Make sure to allow socket.io connections
location ~* \.io {
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
client_max_body_size 50M;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Frame-Options SAMEORIGIN;
proxy_buffers 256 16k;
proxy_buffer_size 16k;
client_body_timeout 60;
send_timeout 300;
lingering_timeout 5;
proxy_connect_timeout 1d;
proxy_send_timeout 1d;
proxy_read_timeout 1d;
proxy_pass http://planka;
}
location / {
client_max_body_size 50M;
proxy_set_header Connection "";
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Frame-Options SAMEORIGIN;
proxy_buffers 256 16k;
proxy_buffer_size 16k;
proxy_read_timeout 600s;
proxy_cache_revalidate on;
proxy_cache_min_uses 2;
proxy_cache_use_stale timeout;
proxy_cache_lock on;
proxy_http_version 1.1;
proxy_pass http://planka;
}
}
```
### Logging
Planka currently allows you to expose the application's logfile directory to the host machine via a shared volume. This feature is not enabled by default.
To expose the logfile director to the host machine, add the item `./logs/:/app/logs/` under `services.planka.volumes`.
Note that the directory to the left of the semicolon is regarding the host machine while the directory to the right of the semicolon is regarding the Docker container.
For example, in the above step, `./logs/:/app/logs/` will create the folder `logs` in the same directory where the `docker-compose.yml` file lives.
### Rotating Logs
Logrotate is designed to ease administration of systems that generate large numbers of log files. It allows automatic rotation, compression, removal, and mailing of log files. Each log file may be handled daily, weekly, monthly, or when it grows too large.
#### Setup logrotate for Planka logs
Create a file in `/etc/logrotate.d` named `planka` with the following contents:
```
/path/to/planka/logs/planka.log {
daily
missingok
rotate 14
compress
delaycompress
notifempty
create 640 root adm
sharedscripts
}
```
Ensure to replace logfile directory with your installation’s `/logs/planka.log` location.
Restart the logrotate service.
### Fail2ban
Fail2ban is a service that uses iptables to automatically drop connections for a pre-defined amount of time from IPs that continuously failed to authenticate to the configured services.
#### Setup a filter and a jail for Planka
A filter defines regex rules to identify when users fail to authenticate on Planka's user interface.
Create a file in `/etc/fail2ban/filter.d` named `planka.conf` with the following contents:
```conf
[Definition]
failregex = ^(.*) Invalid (email or username:|password!) (\"(.*)\"!)? ?\(IP: <ADDR>\)$
ignoreregex =
```
The jail file defines how to handle the failed authentication attempts found by the Planka filter.
Create a file in `/etc/fail2ban/jail.d` named `planka.local` with the following contents:
```conf
[planka]
enabled = true
port = http,https
filter = planka
logpath = /path/to/planka/logs/planka.log
maxretry = 5
bantime = 900
```
Ensure to replace `logpath`'s value with your installation’s `/logs/planka.log` location. If you are using ports other than 80 and 443 for your Web server you should replace those too. The bantime and findtime are defined in seconds.
Restart the fail2ban service. You can check the status of your Planka jail by running:
```bash
fail2ban-client status planka
```
### Import from Trello
It's already available in Planka, or you can also use the great tool [trello2planka](https://github.com/christophenne/trello2planka) to do the import.
### Backup and Restore
Planka comes with two scripts that allow for manual backup and restore when running Planka with docker-compose.yml.
Backups can be triggered with `docker-backup.sh` which will export the Database, User Avatars, Project Backgrounds and Attachments into a single tgz file.
```
$ ./docker-backup.sh
Exporting postgres database ... Success!
Exporting user-avatars ... Success!
Exporting project-background-images ... Success!
Exporting attachments ... Success!
Creating final tarball 2023-01-17T15-37-22Z-backup.tgz ... Success!
Cleaning up temporary files and folders ... Success!
Backup Complete!
```
The resulting backup can be restored using the `docker-restore.sh` script.
```
$ ./docker-restore.sh 2023-01-17T15-37-22Z-backup.tgz
Extracting tarball 2023-01-17T11-10-54Z-backup.tgz ... Success!
Importing postgres database ...
[Many lines of postgres output]
...
Success!
Importing user-avatars ... Success!
Importing project-background-images ... Success!
Importing attachments ... Success!
Cleaning up temporary files and folders ... Success!
Restore complete!
```
## Development ## Development
Clone the repository and install dependencies: See the [development section](https://docs.planka.cloud/docs/Development).
```
git clone https://github.com/plankanban/planka.git
cd planka
npm install
```
Either use a local database or start the provided development database:
```
docker-compose -f docker-compose-dev.yml up
```
Create `server/.env` based on `server/.env.sample` and edit `DATABASE_URL` if needed, then initialize the database:
```
npm run server:db:init
```
Start the development server:
```
npm start
```
Demo user: demo@demo.demo demo
## Tech stack ## Tech stack
@@ -329,3 +45,7 @@ Demo user: demo@demo.demo demo
## License ## License
Planka is [AGPL-3.0 licensed](https://github.com/plankanban/planka/blob/master/LICENSE). Planka is [AGPL-3.0 licensed](https://github.com/plankanban/planka/blob/master/LICENSE).
## Contributors
[![](https://contrib.rocks/image?repo=plankanban/planka)](https://github.com/plankanban/planka/graphs/contributors)
+1 -1
View File
@@ -6,7 +6,7 @@ Most recent release.
## Reporting a Vulnerability ## Reporting a Vulnerability
Please report any security issues you discovered to meltyshev@gmail.com. If the issue is confirmed, we will release a patch as soon as possible depending on complexity. Please report any security issues you discovered to security@planka.cloud. If the issue is confirmed, we will release a patch as soon as possible depending on complexity.
**Do NOT create public issues on GitHub for security vulnerabilities.** **Do NOT create public issues on GitHub for security vulnerabilities.**
+23
View File
@@ -0,0 +1,23 @@
# Patterns to ignore when building packages.
# This supports shell glob matching, relative path matching, and
# negation (prefixed with !). Only one pattern per line.
.DS_Store
# Common VCS dirs
.git/
.gitignore
.bzr/
.bzrignore
.hg/
.hgignore
.svn/
# Common backup files
*.swp
*.bak
*.tmp
*.orig
*~
# Various IDEs
.project
.idea/
*.tmproj
.vscode/
+6
View File
@@ -0,0 +1,6 @@
dependencies:
- name: postgresql
repository: https://charts.bitnami.com/bitnami
version: 12.5.1
digest: sha256:01dfb2d07ab6800b4a5a6c81f20f3377a758124b2b96b891d0cd6b4f64cf783b
generated: "2023-05-15T00:54:48.1308917+01:00"
+31
View File
@@ -0,0 +1,31 @@
apiVersion: v2
name: planka
description: A Helm chart to deploy Planka and it's dependencies.
# A chart can be either an 'application' or a 'library' chart.
#
# Application charts are a collection of templates that can be packaged into versioned archives
# to be deployed.
#
# Library charts provide useful utilities or functions for the chart developer. They're included as
# a dependency of application charts to inject those utilities and functions into the rendering
# pipeline. Library charts do not define any templates and therefore cannot be deployed.
type: application
# This is the chart version. This version number should be incremented each time you make changes
# to the chart and its templates, including the app version.
# Versions are expected to follow Semantic Versioning (https://semver.org/)
version: 0.1.32
# This is the version number of the application being deployed. This version number should be
# incremented each time you make changes to the application. Versions are not expected to
# follow Semantic Versioning. They should reflect the version the application is using.
# It is recommended to use it with quotes.
appVersion: "1.17.4"
dependencies:
- alias: postgresql
condition: postgresql.enabled
name: postgresql
repository: &bitnami-repo https://charts.bitnami.com/bitnami
version: 12.5.1
+111
View File
@@ -0,0 +1,111 @@
# Planka Helm Chart
[Planka](https://github.com/plankanban/planka) is an OSS alternative to Trello that you can host yourself, and this is a Helm Chart to make it easier to deploy to K8s.
Shoutout to [this issue](https://github.com/plankanban/planka/issues/192) who have been asking for this Helm Chart.
## Issues
By using the Bitnami chart for PostgreSQL, there is an issue where once deployed, if trying to use a different password then it will be ignored as the Persistant Volume (PV) will already exist with the previous password. See warning from Bitnami below:
> **Warning!** Setting a password will be ignored on new installation in the case when previous Posgresql release was deleted through the helm command. In that case, old PVC will have an old password, and setting it through helm won't take effect. Deleting persistent volumes (PVs) will solve the issue. Refer to [issue 2061](https://github.com/bitnami/charts/issues/2061) for more details
If you want to fully uninstall this chart including the data, follow [these steps](https://github.com/bitnami/charts/blob/main/bitnami/postgresql/README.md#uninstalling-the-chart) from the Bitnami Chart's docs.
## Usage
If you just want to spin up an instance using help, please see [these docs](https://docs.planka.cloud/docs/installation/kubernetes/helm_chart/). If you want to make changes to the chart locally, and deploy them, see the below section.
## Local Building and Using the Chart
The basic usage of the chart can be found below:
```bash
git clone https://github.com/plankanban/planka.git
cd planka/charts/planka
helm dependency build
export SECRETKEY=$(openssl rand -hex 64)
helm install planka . --set secretkey=$SECRETKEY \
--set admin_email="demo@demo.demo" \
--set admin_password="demo" \
--set admin_name="Demo Demo" \
--set admin_username="demo"
```
> **NOTE:** The command `openssl rand -hex 64` is needed to create a random hexadecimal key for planka. On Windows you can use Git Bash to run that command.
To access Planka you can port forward using the following command:
```bash
kubectl port-forward $POD_NAME 3000:1337
```
### Accessing Externally
To access Planka externally you can use the following configuration
```bash
# HTTP only
helm install planka . --set secretkey=$SECRETKEY \
--set admin_email="demo@demo.demo" \
--set admin_password="demo" \
--set admin_name="Demo Demo" \
--set admin_username="demo" \
--set ingress.enabled=true \
--set ingress.hosts[0].host=planka.example.dev \
# HTTPS
helm install planka . --set secretkey=$SECRETKEY \
--set admin_email="demo@demo.demo" \
--set admin_password="demo" \
--set admin_name="Demo Demo" \
--set admin_username="demo" \
--set ingress.enabled=true \
--set ingress.hosts[0].host=planka.example.dev \
--set ingress.tls[0].secretName=planka-tls \
--set ingress.tls[0].hosts[0]=planka.example.dev \
```
or create a values.yaml file like:
```yaml
secretkey: "<InsertSecretKey>"
# The admin section needs to be present for new instances of Planka, after the first start you can remove the lines starting with admin_. If you want the admin user to be unchangeable admin_email: has to stay
# After changing the config you have to run ```helm upgrade planka . -f values.yaml```
# Admin user
admin_email: "demo@demo.demo" # Do not remove if you want to prevent this user from being edited/deleted
admin_password: "demo"
admin_name: "Demo Demo"
admin_username: "demo"
# Admin user
# Ingress
ingress:
enabled: true
hosts:
- host: planka.example.dev
paths:
- path: /
pathType: ImplementationSpecific
# Needed for HTTPS
tls:
- secretName: planka-tls # existing TLS secret in k8s
hosts:
- planka.example.dev
```
```bash
helm install planka . -f values.yaml
```
### Things to consider if production hosting
If you want to host Planka for more than just playing around with, you might want to do the following things:
- Create a `values.yaml` with your config, as this will make applying upgrades much easier in the future.
- Create your `secretkey` once and store it either in a secure vault, or in your `values.yaml` file so it will be the same for upgrading in the future.
- Specify a password for `postgresql.auth.password` as there have been issues with the postgresql chart generating new passwords locking you out of the data you've already stored. (see [this issue](https://github.com/bitnami/charts/issues/2061))
Any questions or concerns, [raise an issue](https://github.com/Chris-Greaves/planka-helm-chart/issues/new).
+22
View File
@@ -0,0 +1,22 @@
1. Get the application URL by running these commands:
{{- if .Values.ingress.enabled }}
{{- range $host := .Values.ingress.hosts }}
{{- range .paths }}
http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ .path }}
{{- end }}
{{- end }}
{{- else if contains "NodePort" .Values.service.type }}
export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "planka.fullname" . }})
export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}")
echo http://$NODE_IP:$NODE_PORT
{{- else if contains "LoadBalancer" .Values.service.type }}
NOTE: It may take a few minutes for the LoadBalancer IP to be available.
You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "planka.fullname" . }}'
export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "planka.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}")
echo http://$SERVICE_IP:{{ .Values.service.port }}
{{- else if contains "ClusterIP" .Values.service.type }}
export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "planka.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}")
export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}")
echo "Visit http://localhost:3000 to use your application"
kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 3000:$CONTAINER_PORT
{{- end }}
+62
View File
@@ -0,0 +1,62 @@
{{/*
Expand the name of the chart.
*/}}
{{- define "planka.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Create a default fully qualified app name.
We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
If release name contains chart name it will be used as a full name.
*/}}
{{- define "planka.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := default .Chart.Name .Values.nameOverride }}
{{- if contains $name .Release.Name }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- end }}
{{/*
Create chart name and version as used by the chart label.
*/}}
{{- define "planka.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Common labels
*/}}
{{- define "planka.labels" -}}
helm.sh/chart: {{ include "planka.chart" . }}
{{ include "planka.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
{{/*
Selector labels
*/}}
{{- define "planka.selectorLabels" -}}
app.kubernetes.io/name: {{ include "planka.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
{{/*
Create the name of the service account to use
*/}}
{{- define "planka.serviceAccountName" -}}
{{- if .Values.serviceAccount.create }}
{{- default (include "planka.fullname" .) .Values.serviceAccount.name }}
{{- else }}
{{- default "default" .Values.serviceAccount.name }}
{{- end }}
{{- end }}
+158
View File
@@ -0,0 +1,158 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "planka.fullname" . }}
labels:
{{- include "planka.labels" . | nindent 4 }}
spec:
{{- if not .Values.autoscaling.enabled }}
replicas: {{ .Values.replicaCount }}
{{- end }}
selector:
matchLabels:
{{- include "planka.selectorLabels" . | nindent 6 }}
template:
metadata:
{{- with .Values.podAnnotations }}
annotations:
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
{{- include "planka.selectorLabels" . | nindent 8 }}
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
serviceAccountName: {{ include "planka.serviceAccountName" . }}
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 8 }}
containers:
- name: {{ .Chart.Name }}
securityContext:
{{- toYaml .Values.securityContext | nindent 12 }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: {{ .Values.service.containerPort | default 1337 }}
protocol: TCP
livenessProbe:
httpGet:
path: /
port: http
readinessProbe:
httpGet:
path: /
port: http
volumeMounts:
- mountPath: /app/public/user-avatars
subPath: user-avatars
name: planka
- mountPath: /app/public/project-background-images
subPath: project-background-images
name: planka
- mountPath: /app/private/attachments
subPath: attachments
name: planka
{{- if .Values.securityContext.readOnlyRootFilesystem }}
- mountPath: /app/logs
subPath: app-logs
name: emptydir
{{- end }}
resources:
{{- toYaml .Values.resources | nindent 12 }}
env:
{{- if not .Values.postgresql.enabled }}
{{- if .Values.existingDburlSecret }}
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: {{ .Values.existingDburlSecret }}
key: uri
{{- else }}
- name: DATABASE_URL
value: {{ required "If the included postgresql deployment is disabled you need to provide an existing secret in .Values.existingDburlSecret or define a Database URL in 'dburl'" .Values.dburl }}
{{- end }}
{{- else }}
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: planka-postgresql-svcbind-custom-user
key: uri
{{- end }}
- name: BASE_URL
{{- if .Values.baseUrl }}
value: {{ .Values.baseUrl }}
{{- else if .Values.ingress.enabled }}
value: {{ printf "https://%s" (first .Values.ingress.hosts).host }}
{{- else }}
value: http://localhost:3000
{{- end }}
- name: SECRET_KEY
value: {{ required "A secret key needs to be generated using 'openssl rand -hex 64' and assigned to secretkey." .Values.secretkey }}
- name: TRUST_PROXY
value: "0"
- name: DEFAULT_ADMIN_EMAIL
value: {{ .Values.admin_email }}
- name: DEFAULT_ADMIN_PASSWORD
value: {{ .Values.admin_password }}
- name: DEFAULT_ADMIN_NAME
value: {{ .Values.admin_name }}
- name: DEFAULT_ADMIN_USERNAME
value: {{ .Values.admin_username }}
{{ range $k, $v := .Values.env }}
- name: {{ $k | quote }}
value: {{ $v | quote }}
{{- end }}
{{- if .Values.oidc.enabled }}
{{- $secretName := default (printf "%s-oidc" (include "planka.fullname" .)) .Values.oidc.existingSecret }}
- name: OIDC_CLIENT_ID
valueFrom:
secretKeyRef:
key: clientId
name: {{ $secretName }}
- name: OIDC_CLIENT_SECRET
valueFrom:
secretKeyRef:
key: clientSecret
name: {{ $secretName }}
- name: OIDC_ISSUER
value: {{ required "issuerUrl is required when configuring OIDC" .Values.oidc.issuerUrl | quote }}
- name: OIDC_SCOPES
value: {{ join " " .Values.oidc.scopes | default "openid profile email" | quote }}
{{- if .Values.oidc.admin.roles }}
- name: OIDC_ADMIN_ROLES
value: {{ join "," .Values.oidc.admin.roles | quote }}
{{- end }}
- name: OIDC_ROLES_ATTRIBUTE
value: {{ .Values.oidc.admin.rolesAttribute | default "groups" | quote }}
{{- if .Values.oidc.admin.ignoreRoles }}
- name: OIDC_IGNORE_ROLES
value: {{ .Values.oidc.admin.ignoreRoles | quote }}
{{- end }}
{{- end }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
volumes:
- name: planka
{{- if .Values.persistence.enabled }}
persistentVolumeClaim:
claimName: {{ .Values.persistence.existingClaim | default (include "planka.fullname" .) }}
{{- else }}
emptyDir: {}
{{- end }}
{{- if .Values.securityContext.readOnlyRootFilesystem }}
- name: emptydir
emptyDir: {}
{{- end }}
+32
View File
@@ -0,0 +1,32 @@
{{- if .Values.autoscaling.enabled }}
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: {{ include "planka.fullname" . }}
labels:
{{- include "planka.labels" . | nindent 4 }}
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: {{ include "planka.fullname" . }}
minReplicas: {{ .Values.autoscaling.minReplicas }}
maxReplicas: {{ .Values.autoscaling.maxReplicas }}
metrics:
{{- if .Values.autoscaling.targetCPUUtilizationPercentage }}
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}
{{- end }}
{{- if .Values.autoscaling.targetMemoryUtilizationPercentage }}
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }}
{{- end }}
{{- end }}
+61
View File
@@ -0,0 +1,61 @@
{{- if .Values.ingress.enabled -}}
{{- $fullName := include "planka.fullname" . -}}
{{- $svcPort := .Values.service.port -}}
{{- if and .Values.ingress.className (not (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion)) }}
{{- if not (hasKey .Values.ingress.annotations "kubernetes.io/ingress.class") }}
{{- $_ := set .Values.ingress.annotations "kubernetes.io/ingress.class" .Values.ingress.className}}
{{- end }}
{{- end }}
{{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion -}}
apiVersion: networking.k8s.io/v1
{{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}}
apiVersion: networking.k8s.io/v1beta1
{{- else -}}
apiVersion: extensions/v1beta1
{{- end }}
kind: Ingress
metadata:
name: {{ $fullName }}
labels:
{{- include "planka.labels" . | nindent 4 }}
{{- with .Values.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if and .Values.ingress.className (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }}
ingressClassName: {{ .Values.ingress.className }}
{{- end }}
{{- if .Values.ingress.tls }}
tls:
{{- range .Values.ingress.tls }}
- hosts:
{{- range .hosts }}
- {{ . | quote }}
{{- end }}
secretName: {{ .secretName }}
{{- end }}
{{- end }}
rules:
{{- range .Values.ingress.hosts }}
- host: {{ .host | quote }}
http:
paths:
{{- range .paths }}
- path: {{ .path }}
{{- if and .pathType (semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion) }}
pathType: {{ .pathType }}
{{- end }}
backend:
{{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }}
service:
name: {{ $fullName }}
port:
number: {{ $svcPort }}
{{- else }}
serviceName: {{ $fullName }}
servicePort: {{ $svcPort }}
{{- end }}
{{- end }}
{{- end }}
{{- end }}
+25
View File
@@ -0,0 +1,25 @@
{{- if and .Values.persistence.enabled (not .Values.persistence.existingClaim) }}
---
kind: PersistentVolumeClaim
apiVersion: v1
metadata:
name: {{ include "planka.fullname" . }}
labels:
app.kubernetes.io/name: {{ include "planka.name" . }}
helm.sh/chart: {{ include "planka.chart" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
spec:
accessModes:
- {{ .Values.persistence.accessMode }}
resources:
requests:
storage: {{ .Values.persistence.size | quote }}
{{- if .Values.persistence.storageClass }}
{{- if (eq "-" .Values.persistence.storageClass) }}
storageClassName: ""
{{- else }}
storageClassName: "{{ .Values.persistence.storageClass }}"
{{- end }}
{{- end }}
{{- end }}
+17
View File
@@ -0,0 +1,17 @@
{{- if .Values.oidc.enabled }}
{{- if eq (and (not (empty .Values.oidc.clientId)) (not (empty .Values.oidc.clientSecret))) (not (empty .Values.oidc.existingSecret)) -}}
{{- fail "Either specify inline `clientId` and `clientSecret` or refer to them via `existingSecret`" -}}
{{- end }}
{{- if (and (and (not (empty .Values.oidc.clientId)) (not (empty .Values.oidc.clientSecret))) (empty .Values.oidc.existingSecret)) -}}
apiVersion: v1
kind: Secret
metadata:
name: {{ include "planka.fullname" . }}-oidc
labels:
{{- include "planka.labels" . | nindent 4 }}
type: Opaque
data:
clientId: {{ .Values.oidc.clientId | b64enc | quote }}
clientSecret: {{ .Values.oidc.clientSecret | b64enc | quote }}
{{- end }}
{{- end }}
+15
View File
@@ -0,0 +1,15 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "planka.fullname" . }}
labels:
{{- include "planka.labels" . | nindent 4 }}
spec:
type: {{ .Values.service.type }}
ports:
- port: {{ .Values.service.port }}
targetPort: http
protocol: TCP
name: http
selector:
{{- include "planka.selectorLabels" . | nindent 4 }}
@@ -0,0 +1,12 @@
{{- if .Values.serviceAccount.create -}}
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ include "planka.serviceAccountName" . }}
labels:
{{- include "planka.labels" . | nindent 4 }}
{{- with .Values.serviceAccount.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
{{- end }}
@@ -0,0 +1,15 @@
apiVersion: v1
kind: Pod
metadata:
name: "{{ include "planka.fullname" . }}-test-connection"
labels:
{{- include "planka.labels" . | nindent 4 }}
annotations:
"helm.sh/hook": test
spec:
containers:
- name: wget
image: busybox
command: ['wget']
args: ['{{ include "planka.fullname" . }}:{{ .Values.service.port }}']
restartPolicy: Never
+188
View File
@@ -0,0 +1,188 @@
# Default values for planka.
# This is a YAML-formatted file.
# Declare variables to be passed into your templates.
replicaCount: 1
image:
repository: ghcr.io/plankanban/planka
pullPolicy: IfNotPresent
# Overrides the image tag whose default is the chart appVersion.
tag: ""
imagePullSecrets: []
nameOverride: ""
fullnameOverride: ""
# Generate a secret using openssl rand -base64 45
secretkey: ""
# Base url for Planka. Will override `ingress.hosts[0].host`
# Defaults to `http://localhost:3000` if ingress is disabled.
baseUrl: ""
serviceAccount:
# Specifies whether a service account should be created
create: true
# Annotations to add to the service account
annotations: {}
# The name of the service account to use.
# If not set and create is true, a name is generated using the fullname template
name: ""
podAnnotations: {}
podSecurityContext: {}
# fsGroup: 2000
securityContext: {}
# capabilities:
# drop:
# - ALL
# readOnlyRootFilesystem: true
# runAsNonRoot: true
# runAsUser: 1000
service:
type: ClusterIP
port: 1337
## @param service.containerPort Planka HTTP container port
## If empty will default to 1337
##
containerPort: 1337
ingress:
enabled: false
className: ""
annotations: {}
# kubernetes.io/ingress.class: nginx
# kubernetes.io/tls-acme: "true"
hosts:
# Used to set planka BASE_URL if no `baseurl` is provided.
- host: planka.local
paths:
- path: /
pathType: ImplementationSpecific
tls: []
# - secretName: planka-tls
# hosts:
# - planka.local
resources: {}
# We usually recommend not to specify default resources and to leave this as a conscious
# choice for the user. This also increases chances charts run on environments with little
# resources, such as Minikube. If you do want to specify resources, uncomment the following
# lines, adjust them as necessary, and remove the curly braces after 'resources:'.
# limits:
# cpu: 100m
# memory: 128Mi
# requests:
# cpu: 100m
# memory: 128Mi
autoscaling:
enabled: false
minReplicas: 1
maxReplicas: 100
targetCPUUtilizationPercentage: 80
# targetMemoryUtilizationPercentage: 80
nodeSelector: {}
tolerations: []
affinity: {}
postgresql:
enabled: true
auth:
database: planka
username: planka
password: ""
postgresPassword: ""
replicationPassword: ""
# existingSecret: planka-postgresql
serviceBindings:
enabled: true
## Set this or existingDburlSecret if you disable the built-in postgresql deployment
dburl:
## @param existingDburlSecret Name of an existing secret containing a DBurl connection string
## NOTE: Must contain key `uri`
## NOTE: When it's set, the `dburl` parameter is ignored
##
existingDburlSecret: ""
## PVC-based data storage configuration
persistence:
enabled: false
# existingClaim: netbox-data
# storageClass: "-"
accessMode: ReadWriteOnce
size: 10Gi
## OpenID Identity Management configuration
##
## Example:
## ---------------
## oidc:
## enabled: true
## clientId: sxxaAIAxVXlCxTmc1YLHBbQr8NL8MqLI2DUbt42d
## clientSecret: om4RTMRVHRszU7bqxB7RZNkHIzA8e4sGYWxeCwIMYQXPwEBWe4SY5a0wwCe9ltB3zrq5f0dnFnp34cEHD7QSMHsKvV9AiV5Z7eqDraMnv0I8IFivmuV5wovAECAYreSI
## issuerUrl: https://auth.local/application/o/planka/
## admin:
## roles:
## - planka-admin
##
## ---------------
## NOTE: A minimal configuration requires setting `clientId`, `clientSecret` and `issuerUrl`. (plus `admin.roles` for administrators)
## ref: https://docs.planka.cloud/docs/Configuration/OIDC
##
oidc:
## @param oidc.enabled Enable single sign-on (SSO) with OpenID Connect (OIDC)
##
enabled: false
## OIDC credentials
## @param oidc.clientId A string unique to the provider that identifies your app.
## @param oidc.clientSecret A secret string that the provider uses to confirm ownership of a client ID.
##
## NOTE: Either specify inline `clientId` and `clientSecret` or refer to them via `existingSecret`
##
clientId: ""
clientSecret: ""
## @param oidc.existingSecret Name of an existing secret containing OIDC credentials
## NOTE: Must contain key `clientId` and `clientSecret`
## NOTE: When it's set, the `clientId` and `clientSecret` parameters are ignored
##
existingSecret: ""
## @param oidc.issuerUrl The OpenID connect metadata document endpoint
##
issuerUrl: ""
## @param oidc.scopes A list of scopes required for OIDC client.
## If empty will default to `openid`, `profile` and `email`
## NOTE: Planka needs the email and name claims
##
scopes: []
## Admin permissions configuration
admin:
## @param oidc.admin.ignoreRoles If set to true, the admin roles will be ignored.
## It is useful if you want to use OIDC for authentication but not for authorization.
## If empty will default to `false`
##
ignoreRoles: false
## @param oidc.admin.rolesAttribute The name of a custom group claim that you have configured in your OIDC provider
## If empty will default to `groups`
##
rolesAttribute: groups
## @param oidc.admin.roles The names of the admin groups
##
roles: []
# - planka-admin
+1 -1
View File
@@ -1 +1 @@
REACT_APP_VERSION=1.10.2 REACT_APP_VERSION=1.17.4
+3 -1
View File
@@ -22,7 +22,9 @@ const replaceBaseUrl = (compiler) => {
if (info.content.indexOf(BASE_URL_PLACEHOLDER) >= 0) { if (info.content.indexOf(BASE_URL_PLACEHOLDER) >= 0) {
if (/\.css$/.exec(info.targetPath)) { if (/\.css$/.exec(info.targetPath)) {
// For CSS 'url(...)' import we can use relative import // For CSS 'url(...)' import we can use relative import
const relPath = path.relative(path.dirname(info.targetPath), info.outputPath); const relPath = path
.relative(path.dirname(info.targetPath), info.outputPath)
.replace(/\\/g, '/');
replaceInFile(info.targetPath, BASE_URL_PLACEHOLDER, `${relPath}/`); replaceInFile(info.targetPath, BASE_URL_PLACEHOLDER, `${relPath}/`);
} else if (/\.js$/.exec(info.targetPath)) { } else if (/\.js$/.exec(info.targetPath)) {
// For JS 'import ... from "some-asset"' we can get the variable injected in the window object // For JS 'import ... from "some-asset"' we can get the variable injected in the window object
+9616 -21683
View File
File diff suppressed because it is too large Load Diff
+39 -30
View File
@@ -49,65 +49,74 @@
"**/*.test.js" "**/*.test.js"
] ]
} }
],
"prettier/prettier": [
"error",
{
"endOfLine": "auto"
}
] ]
} }
}, },
"dependencies": { "dependencies": {
"@juggle/resize-observer": "^3.4.0", "@juggle/resize-observer": "^3.4.0",
"classnames": "^2.3.2", "classnames": "^2.5.1",
"date-fns": "^2.29.3", "date-fns": "^2.30.0",
"dequal": "^2.0.3", "dequal": "^2.0.3",
"easymde": "^2.18.0", "easymde": "^2.18.0",
"history": "^5.3.0", "history": "^5.3.0",
"i18next": "^22.0.6", "i18next": "^23.11.2",
"i18next-browser-languagedetector": "^7.0.1", "i18next-browser-languagedetector": "^7.2.1",
"initials": "^3.1.2", "initials": "^3.1.2",
"js-cookie": "^3.0.1", "js-cookie": "^3.0.5",
"jwt-decode": "^3.1.2", "jwt-decode": "^4.0.0",
"linkify-react": "^4.1.3",
"linkifyjs": "^4.1.3",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"node-sass": "^8.0.0", "nanoid": "^5.0.7",
"photoswipe": "^5.3.3", "node-sass": "^9.0.0",
"photoswipe": "^5.4.3",
"prop-types": "^15.8.1", "prop-types": "^15.8.1",
"react": "^18.2.0", "react": "^18.2.0",
"react-app-rewired": "^2.2.1", "react-app-rewired": "^2.2.1",
"react-beautiful-dnd": "^13.1.1", "react-beautiful-dnd": "^13.1.1",
"react-datepicker": "^4.8.0", "react-datepicker": "^4.25.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-dropzone": "^14.2.3", "react-dropzone": "^14.2.3",
"react-i18next": "^12.0.0", "react-i18next": "^13.5.0",
"react-input-mask": "^2.0.4", "react-input-mask": "^2.0.4",
"react-markdown": "^8.0.3", "react-markdown": "^8.0.7",
"react-photoswipe-gallery": "^2.2.2", "react-photoswipe-gallery": "^2.2.7",
"react-redux": "^8.0.5", "react-redux": "^8.1.3",
"react-router-dom": "^6.4.3", "react-router-dom": "^6.22.3",
"react-scripts": "5.0.1", "react-scripts": "5.0.1",
"react-simplemde-editor": "^5.2.0", "react-simplemde-editor": "^5.2.0",
"react-textarea-autosize": "^8.4.0", "react-textarea-autosize": "^8.5.3",
"redux": "^4.2.0", "redux": "^4.2.1",
"redux-logger": "^3.0.6", "redux-logger": "^3.0.6",
"redux-orm": "^0.16.2", "redux-orm": "^0.16.2",
"redux-saga": "^1.2.1", "redux-saga": "^1.3.0",
"remark-breaks": "^3.0.2", "remark-breaks": "^4.0.0",
"remark-gfm": "^3.0.1", "remark-gfm": "^3.0.1",
"reselect": "^4.1.7", "reselect": "^4.1.8",
"sails.io.js": "^1.2.1", "sails.io.js": "^1.2.1",
"semantic-ui-react": "^2.1.3", "semantic-ui-react": "^2.1.5",
"socket.io-client": "^2.5.0", "socket.io-client": "^2.5.0",
"validator": "^13.7.0", "validator": "^13.11.0",
"whatwg-fetch": "^3.6.2", "whatwg-fetch": "^3.6.20",
"zxcvbn": "^4.4.2" "zxcvbn": "^4.4.2"
}, },
"devDependencies": { "devDependencies": {
"@testing-library/jest-dom": "^5.16.5", "@testing-library/jest-dom": "^6.4.2",
"@testing-library/react": "^13.4.0", "@testing-library/react": "^14.3.1",
"@testing-library/user-event": "^13.5.0", "@testing-library/user-event": "^14.5.2",
"babel-preset-airbnb": "^5.0.0", "babel-preset-airbnb": "^5.0.0",
"chai": "^4.3.7", "chai": "^4.4.1",
"eslint": "^8.28.0", "eslint": "^8.57.0",
"eslint-config-airbnb": "^19.0.4", "eslint-config-airbnb": "^19.0.4",
"eslint-plugin-import": "^2.26.0", "eslint-plugin-import": "^2.29.1",
"eslint-plugin-jsx-a11y": "^6.6.1", "eslint-plugin-jsx-a11y": "^6.8.0",
"eslint-plugin-react": "^7.31.11", "eslint-plugin-react": "^7.34.1",
"eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-hooks": "^4.6.0",
"react-test-renderer": "^18.2.0" "react-test-renderer": "^18.2.0"
} }
+43 -1
View File
@@ -23,10 +23,14 @@ createCard.failure = (localId, error) => ({
}, },
}); });
const handleCardCreate = (card) => ({ const handleCardCreate = (card, cardMemberships, cardLabels, tasks, attachments) => ({
type: ActionTypes.CARD_CREATE_HANDLE, type: ActionTypes.CARD_CREATE_HANDLE,
payload: { payload: {
card, card,
cardMemberships,
cardLabels,
tasks,
attachments,
}, },
}); });
@@ -60,6 +64,34 @@ const handleCardUpdate = (card) => ({
}, },
}); });
const duplicateCard = (id, card, taskIds) => ({
type: ActionTypes.CARD_DUPLICATE,
payload: {
id,
card,
taskIds,
},
});
duplicateCard.success = (localId, card, cardMemberships, cardLabels, tasks) => ({
type: ActionTypes.CARD_DUPLICATE__SUCCESS,
payload: {
localId,
card,
cardMemberships,
cardLabels,
tasks,
},
});
duplicateCard.failure = (id, error) => ({
type: ActionTypes.CARD_DUPLICATE__FAILURE,
payload: {
id,
error,
},
});
const deleteCard = (id) => ({ const deleteCard = (id) => ({
type: ActionTypes.CARD_DELETE, type: ActionTypes.CARD_DELETE,
payload: { payload: {
@@ -89,11 +121,21 @@ const handleCardDelete = (card) => ({
}, },
}); });
const filterText = (boardId, text) => ({
type: ActionTypes.TEXT_FILTER_IN_CURRENT_BOARD,
payload: {
boardId,
text,
},
});
export default { export default {
createCard, createCard,
handleCardCreate, handleCardCreate,
updateCard, updateCard,
handleCardUpdate, handleCardUpdate,
duplicateCard,
deleteCard, deleteCard,
handleCardDelete, handleCardDelete,
filterText,
}; };
+12 -2
View File
@@ -39,9 +39,19 @@ const initializeCore = (
}, },
}); });
const logout = () => ({ // TODO: with success?
initializeCore.fetchConfig = (config) => ({
type: ActionTypes.CORE_INITIALIZE__CONFIG_FETCH,
payload: {
config,
},
});
const logout = (invalidateAccessToken) => ({
type: ActionTypes.LOGOUT, type: ActionTypes.LOGOUT,
payload: {}, payload: {
invalidateAccessToken,
},
}); });
logout.invalidateAccessToken = () => ({ logout.invalidateAccessToken = () => ({
+34
View File
@@ -60,6 +60,38 @@ const handleListUpdate = (list) => ({
}, },
}); });
const sortList = (id, data) => ({
type: ActionTypes.LIST_SORT,
payload: {
id,
data,
},
});
sortList.success = (list, cards) => ({
type: ActionTypes.LIST_SORT__SUCCESS,
payload: {
list,
cards,
},
});
sortList.failure = (id, error) => ({
type: ActionTypes.LIST_SORT__FAILURE,
payload: {
id,
error,
},
});
const handleListSort = (list, cards) => ({
type: ActionTypes.LIST_SORT_HANDLE,
payload: {
list,
cards,
},
});
const deleteList = (id) => ({ const deleteList = (id) => ({
type: ActionTypes.LIST_DELETE, type: ActionTypes.LIST_DELETE,
payload: { payload: {
@@ -94,6 +126,8 @@ export default {
handleListCreate, handleListCreate,
updateList, updateList,
handleListUpdate, handleListUpdate,
sortList,
handleListSort,
deleteList, deleteList,
handleListDelete, handleListDelete,
}; };
+28
View File
@@ -1,5 +1,12 @@
import ActionTypes from '../constants/ActionTypes'; import ActionTypes from '../constants/ActionTypes';
const initializeLogin = (config) => ({
type: ActionTypes.LOGIN_INITIALIZE,
payload: {
config,
},
});
const authenticate = (data) => ({ const authenticate = (data) => ({
type: ActionTypes.AUTHENTICATE, type: ActionTypes.AUTHENTICATE,
payload: { payload: {
@@ -21,12 +28,33 @@ authenticate.failure = (error) => ({
}, },
}); });
const authenticateUsingOidc = () => ({
type: ActionTypes.USING_OIDC_AUTHENTICATE,
payload: {},
});
authenticateUsingOidc.success = (accessToken) => ({
type: ActionTypes.USING_OIDC_AUTHENTICATE__SUCCESS,
payload: {
accessToken,
},
});
authenticateUsingOidc.failure = (error) => ({
type: ActionTypes.USING_OIDC_AUTHENTICATE__FAILURE,
payload: {
error,
},
});
const clearAuthenticateError = () => ({ const clearAuthenticateError = () => ({
type: ActionTypes.AUTHENTICATE_ERROR_CLEAR, type: ActionTypes.AUTHENTICATE_ERROR_CLEAR,
payload: {}, payload: {},
}); });
export default { export default {
initializeLogin,
authenticate, authenticate,
authenticateUsingOidc,
clearAuthenticateError, clearAuthenticateError,
}; };
+4
View File
@@ -5,10 +5,14 @@ import socket from './socket';
const createAccessToken = (data, headers) => http.post('/access-tokens', data, headers); const createAccessToken = (data, headers) => http.post('/access-tokens', data, headers);
const exchangeForAccessTokenUsingOidc = (data, headers) =>
http.post('/access-tokens/exchange-using-oidc', data, headers);
const deleteCurrentAccessToken = (headers) => const deleteCurrentAccessToken = (headers) =>
socket.delete('/access-tokens/me', undefined, headers); socket.delete('/access-tokens/me', undefined, headers);
export default { export default {
createAccessToken, createAccessToken,
exchangeForAccessTokenUsingOidc,
deleteCurrentAccessToken, deleteCurrentAccessToken,
}; };
+5
View File
@@ -1,4 +1,5 @@
import socket from './socket'; import socket from './socket';
import { transformUser } from './users';
/* Transformers */ /* Transformers */
@@ -13,6 +14,10 @@ const getActivities = (cardId, data, headers) =>
socket.get(`/cards/${cardId}/actions`, data, headers).then((body) => ({ socket.get(`/cards/${cardId}/actions`, data, headers).then((body) => ({
...body, ...body,
items: body.items.map(transformActivity), items: body.items.map(transformActivity),
included: {
...body.included,
users: body.included.users.map(transformUser),
},
})); }));
/* Event handlers */ /* Event handlers */
+35 -3
View File
@@ -1,18 +1,50 @@
import socket from './socket'; import socket from './socket';
/* Transformers */
export const transformBoardMembership = (boardMembership) => ({
...boardMembership,
createdAt: new Date(boardMembership.createdAt),
});
/* Actions */ /* Actions */
const createBoardMembership = (boardId, data, headers) => const createBoardMembership = (boardId, data, headers) =>
socket.post(`/boards/${boardId}/memberships`, data, headers); socket.post(`/boards/${boardId}/memberships`, data, headers).then((body) => ({
...body,
item: transformBoardMembership(body.item),
}));
const updateBoardMembership = (id, data, headers) => const updateBoardMembership = (id, data, headers) =>
socket.patch(`/board-memberships/${id}`, data, headers); socket.patch(`/board-memberships/${id}`, data, headers).then((body) => ({
...body,
item: transformBoardMembership(body.item),
}));
const deleteBoardMembership = (id, headers) => const deleteBoardMembership = (id, headers) =>
socket.delete(`/board-memberships/${id}`, undefined, headers); socket.delete(`/board-memberships/${id}`, undefined, headers).then((body) => ({
...body,
item: transformBoardMembership(body.item),
}));
/* Event handlers */
const makeHandleBoardMembershipCreate = (next) => (body) => {
next({
...body,
item: transformBoardMembership(body.item),
});
};
const makeHandleBoardMembershipUpdate = makeHandleBoardMembershipCreate;
const makeHandleBoardMembershipDelete = makeHandleBoardMembershipCreate;
export default { export default {
createBoardMembership, createBoardMembership,
updateBoardMembership, updateBoardMembership,
deleteBoardMembership, deleteBoardMembership,
makeHandleBoardMembershipCreate,
makeHandleBoardMembershipUpdate,
makeHandleBoardMembershipDelete,
}; };
+11 -1
View File
@@ -1,12 +1,20 @@
import socket from './socket'; import socket from './socket';
import http from './http'; import http from './http';
import { transformUser } from './users';
import { transformBoardMembership } from './board-memberships';
import { transformCard } from './cards'; import { transformCard } from './cards';
import { transformAttachment } from './attachments'; import { transformAttachment } from './attachments';
/* Actions */ /* Actions */
const createBoard = (projectId, data, headers) => const createBoard = (projectId, data, headers) =>
socket.post(`/projects/${projectId}/boards`, data, headers); socket.post(`/projects/${projectId}/boards`, data, headers).then((body) => ({
...body,
included: {
...body.included,
boardMemberships: body.included.boardMemberships.map(transformBoardMembership),
},
}));
const createBoardWithImport = (projectId, data, requestId, headers) => const createBoardWithImport = (projectId, data, requestId, headers) =>
http.post(`/projects/${projectId}/boards?requestId=${requestId}`, data, headers); http.post(`/projects/${projectId}/boards?requestId=${requestId}`, data, headers);
@@ -18,6 +26,8 @@ const getBoard = (id, subscribe, headers) =>
...body, ...body,
included: { included: {
...body.included, ...body.included,
users: body.included.users.map(transformUser),
boardMemberships: body.included.boardMemberships.map(transformBoardMembership),
cards: body.included.cards.map(transformCard), cards: body.included.cards.map(transformCard),
attachments: body.included.attachments.map(transformAttachment), attachments: body.included.attachments.map(transformAttachment),
}, },
+17 -10
View File
@@ -8,11 +8,11 @@ export const transformCard = (card) => ({
...(card.dueDate && { ...(card.dueDate && {
dueDate: new Date(card.dueDate), dueDate: new Date(card.dueDate),
}), }),
...(card.timer && { ...(card.stopwatch && {
timer: { stopwatch: {
...card.timer, ...card.stopwatch,
...(card.timer.startedAt && { ...(card.stopwatch.startedAt && {
startedAt: new Date(card.timer.startedAt), startedAt: new Date(card.stopwatch.startedAt),
}), }),
}, },
}), }),
@@ -23,11 +23,11 @@ export const transformCardData = (data) => ({
...(data.dueDate && { ...(data.dueDate && {
dueDate: data.dueDate.toISOString(), dueDate: data.dueDate.toISOString(),
}), }),
...(data.timer && { ...(data.stopwatch && {
timer: { stopwatch: {
...data.timer, ...data.stopwatch,
...(data.timer.startedAt && { ...(data.stopwatch.startedAt && {
startedAt: data.timer.startedAt.toISOString(), startedAt: data.stopwatch.startedAt.toISOString(),
}), }),
}, },
}), }),
@@ -57,6 +57,12 @@ const updateCard = (id, data, headers) =>
item: transformCard(body.item), item: transformCard(body.item),
})); }));
const duplicateCard = (id, data, headers) =>
socket.post(`/cards/${id}/duplicate`, data, headers).then((body) => ({
...body,
item: transformCard(body.item),
}));
const deleteCard = (id, headers) => const deleteCard = (id, headers) =>
socket.delete(`/cards/${id}`, undefined, headers).then((body) => ({ socket.delete(`/cards/${id}`, undefined, headers).then((body) => ({
...body, ...body,
@@ -81,6 +87,7 @@ export default {
getCard, getCard,
updateCard, updateCard,
deleteCard, deleteCard,
duplicateCard,
makeHandleCardCreate, makeHandleCardCreate,
makeHandleCardUpdate, makeHandleCardUpdate,
makeHandleCardDelete, makeHandleCardDelete,
+7 -5
View File
@@ -5,13 +5,15 @@ import Config from '../constants/Config';
const http = {}; const http = {};
// TODO: add all methods // TODO: add all methods
['POST'].forEach((method) => { ['GET', 'POST'].forEach((method) => {
http[method.toLowerCase()] = (url, data, headers) => { http[method.toLowerCase()] = (url, data, headers) => {
const formData = Object.keys(data).reduce((result, key) => { const formData =
result.append(key, data[key]); data &&
Object.keys(data).reduce((result, key) => {
result.append(key, data[key]);
return result; return result;
}, new FormData()); }, new FormData());
return fetch(`${Config.SERVER_BASE_URL}/api${url}`, { return fetch(`${Config.SERVER_BASE_URL}/api${url}`, {
method, method,
+2
View File
@@ -1,5 +1,6 @@
import http from './http'; import http from './http';
import socket from './socket'; import socket from './socket';
import root from './root';
import accessTokens from './access-tokens'; import accessTokens from './access-tokens';
import users from './users'; import users from './users';
import projects from './projects'; import projects from './projects';
@@ -20,6 +21,7 @@ import notifications from './notifications';
export { http, socket }; export { http, socket };
export default { export default {
...root,
...accessTokens, ...accessTokens,
...users, ...users,
...projects, ...projects,
+24
View File
@@ -1,4 +1,5 @@
import socket from './socket'; import socket from './socket';
import { transformCard } from './cards';
/* Actions */ /* Actions */
@@ -7,10 +8,33 @@ const createList = (boardId, data, headers) =>
const updateList = (id, data, headers) => socket.patch(`/lists/${id}`, data, headers); const updateList = (id, data, headers) => socket.patch(`/lists/${id}`, data, headers);
const sortList = (id, data, headers) =>
socket.post(`/lists/${id}/sort`, data, headers).then((body) => ({
...body,
included: {
...body.included,
cards: body.included.cards.map(transformCard),
},
}));
const deleteList = (id, headers) => socket.delete(`/lists/${id}`, undefined, headers); const deleteList = (id, headers) => socket.delete(`/lists/${id}`, undefined, headers);
/* Event handlers */
const makeHandleListSort = (next) => (body) => {
next({
...body,
included: {
...body.included,
cards: body.included.cards.map(transformCard),
},
});
};
export default { export default {
createList, createList,
updateList, updateList,
sortList,
deleteList, deleteList,
makeHandleListSort,
}; };
+3
View File
@@ -1,6 +1,7 @@
import omit from 'lodash/omit'; import omit from 'lodash/omit';
import socket from './socket'; import socket from './socket';
import { transformUser } from './users';
import { transformCard } from './cards'; import { transformCard } from './cards';
import { transformActivity } from './activities'; import { transformActivity } from './activities';
@@ -19,6 +20,7 @@ const getNotifications = (headers) =>
items: body.items.map(transformNotification), items: body.items.map(transformNotification),
included: { included: {
...omit(body.included, 'actions'), ...omit(body.included, 'actions'),
users: body.included.users.map(transformUser),
cards: body.included.cards.map(transformCard), cards: body.included.cards.map(transformCard),
activities: body.included.actions.map(transformActivity), activities: body.included.actions.map(transformActivity),
}, },
@@ -30,6 +32,7 @@ const getNotification = (id, headers) =>
item: transformNotification(body.item), item: transformNotification(body.item),
included: { included: {
...omit(body.included, 'actions'), ...omit(body.included, 'actions'),
users: body.included.users.map(transformUser),
cards: body.included.cards.map(transformCard), cards: body.included.cards.map(transformCard),
activities: body.included.actions.map(transformActivity), activities: body.included.actions.map(transformActivity),
}, },
+28 -2
View File
@@ -1,14 +1,40 @@
import socket from './socket'; import socket from './socket';
/* Transformers */
export const transformProjectManager = (projectManager) => ({
...projectManager,
createdAt: new Date(projectManager.createdAt),
});
/* Actions */ /* Actions */
const createProjectManager = (projectId, data, headers) => const createProjectManager = (projectId, data, headers) =>
socket.post(`/projects/${projectId}/managers`, data, headers); socket.post(`/projects/${projectId}/managers`, data, headers).then((body) => ({
...body,
item: transformProjectManager(body.item),
}));
const deleteProjectManager = (id, headers) => const deleteProjectManager = (id, headers) =>
socket.delete(`/project-managers/${id}`, undefined, headers); socket.delete(`/project-managers/${id}`, undefined, headers).then((body) => ({
...body,
item: transformProjectManager(body.item),
}));
/* Event handlers */
const makeHandleProjectManagerCreate = (next) => (body) => {
next({
...body,
item: transformProjectManager(body.item),
});
};
const makeHandleProjectManagerDelete = makeHandleProjectManagerCreate;
export default { export default {
createProjectManager, createProjectManager,
deleteProjectManager, deleteProjectManager,
makeHandleProjectManagerCreate,
makeHandleProjectManagerDelete,
}; };
+31 -3
View File
@@ -1,13 +1,41 @@
import http from './http'; import http from './http';
import socket from './socket'; import socket from './socket';
import { transformUser } from './users';
import { transformProjectManager } from './project-managers';
import { transformBoardMembership } from './board-memberships';
/* Actions */ /* Actions */
const getProjects = (headers) => socket.get('/projects', undefined, headers); const getProjects = (headers) =>
socket.get('/projects', undefined, headers).then((body) => ({
...body,
included: {
...body.included,
users: body.included.users.map(transformUser),
projectManagers: body.included.projectManagers.map(transformProjectManager),
boardMemberships: body.included.boardMemberships.map(transformBoardMembership),
},
}));
const createProject = (data, headers) => socket.post('/projects', data, headers); const createProject = (data, headers) =>
socket.post('/projects', data, headers).then((body) => ({
...body,
included: {
...body.included,
projectManagers: body.included.projectManagers.map(transformProjectManager),
},
}));
const getProject = (id, headers) => socket.get(`/projects/${id}`, undefined, headers); const getProject = (id, headers) =>
socket.get(`/projects/${id}`, undefined, headers).then((body) => ({
...body,
included: {
...body.included,
users: body.included.users.map(transformUser),
projectManagers: body.included.projectManagers.map(transformProjectManager),
boardMemberships: body.included.boardMemberships.map(transformBoardMembership),
},
}));
const updateProject = (id, data, headers) => socket.patch(`/projects/${id}`, data, headers); const updateProject = (id, data, headers) => socket.patch(`/projects/${id}`, data, headers);
+9
View File
@@ -0,0 +1,9 @@
import http from './http';
/* Actions */
const getConfig = (headers) => http.get('/config', undefined, headers);
export default {
getConfig,
};
+70 -10
View File
@@ -1,30 +1,87 @@
import http from './http'; import http from './http';
import socket from './socket'; import socket from './socket';
/* Transformers */
export const transformUser = (user) => ({
...user,
createdAt: new Date(user.createdAt),
});
/* Actions */ /* Actions */
const getUsers = (headers) => socket.get('/users', undefined, headers); const getUsers = (headers) =>
socket.get('/users', undefined, headers).then((body) => ({
...body,
items: body.items.map(transformUser),
}));
const createUser = (data, headers) => socket.post('/users', data, headers); const createUser = (data, headers) =>
socket.post('/users', data, headers).then((body) => ({
...body,
item: transformUser(body.item),
}));
const getUser = (id, headers) => socket.get(`/users/${id}`, undefined, headers); const getUser = (id, headers) =>
socket.get(`/users/${id}`, undefined, headers).then((body) => ({
...body,
item: transformUser(body.item),
}));
const getCurrentUser = (subscribe, headers) => const getCurrentUser = (subscribe, headers) =>
socket.get(`/users/me${subscribe ? '?subscribe=true' : ''}`, undefined, headers); socket.get(`/users/me${subscribe ? '?subscribe=true' : ''}`, undefined, headers).then((body) => ({
...body,
item: transformUser(body.item),
}));
const updateUser = (id, data, headers) => socket.patch(`/users/${id}`, data, headers); const updateUser = (id, data, headers) =>
socket.patch(`/users/${id}`, data, headers).then((body) => ({
...body,
item: transformUser(body.item),
}));
const updateUserEmail = (id, data, headers) => socket.patch(`/users/${id}/email`, data, headers); const updateUserEmail = (id, data, headers) =>
socket.patch(`/users/${id}/email`, data, headers).then((body) => ({
...body,
item: transformUser(body.item),
}));
const updateUserPassword = (id, data, headers) => const updateUserPassword = (id, data, headers) =>
socket.patch(`/users/${id}/password`, data, headers); socket.patch(`/users/${id}/password`, data, headers).then((body) => ({
...body,
item: transformUser(body.item),
}));
const updateUserUsername = (id, data, headers) => const updateUserUsername = (id, data, headers) =>
socket.patch(`/users/${id}/username`, data, headers); socket.patch(`/users/${id}/username`, data, headers).then((body) => ({
...body,
item: transformUser(body.item),
}));
const updateUserAvatar = (id, data, headers) => http.post(`/users/${id}/avatar`, data, headers); const updateUserAvatar = (id, data, headers) =>
http.post(`/users/${id}/avatar`, data, headers).then((body) => ({
...body,
item: transformUser(body.item),
}));
const deleteUser = (id, headers) => socket.delete(`/users/${id}`, undefined, headers); const deleteUser = (id, headers) =>
socket.delete(`/users/${id}`, undefined, headers).then((body) => ({
...body,
item: transformUser(body.item),
}));
/* Event handlers */
const makeHandleUserCreate = (next) => (body) => {
next({
...body,
item: transformUser(body.item),
});
};
const makeHandleUserUpdate = makeHandleUserCreate;
const makeHandleUserDelete = makeHandleUserCreate;
export default { export default {
getUsers, getUsers,
@@ -37,4 +94,7 @@ export default {
updateUserUsername, updateUserUsername,
updateUserAvatar, updateUserAvatar,
deleteUser, deleteUser,
makeHandleUserCreate,
makeHandleUserUpdate,
makeHandleUserDelete,
}; };
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 434 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
+5
View File
@@ -62,6 +62,11 @@ const Board = React.memo(
const handleMouseDown = useCallback( const handleMouseDown = useCallback(
(event) => { (event) => {
// If button is defined and not equal to 0 (left click)
if (event.button) {
return;
}
if (event.target !== wrapper.current && !event.target.dataset.dragScroller) { if (event.target !== wrapper.current && !event.target.dataset.dragScroller) {
return; return;
} }
@@ -13,6 +13,7 @@ const BoardActions = React.memo(
labels, labels,
filterUsers, filterUsers,
filterLabels, filterLabels,
filterText,
allUsers, allUsers,
canEdit, canEdit,
canEditMemberships, canEditMemberships,
@@ -27,6 +28,7 @@ const BoardActions = React.memo(
onLabelUpdate, onLabelUpdate,
onLabelMove, onLabelMove,
onLabelDelete, onLabelDelete,
onTextFilterUpdate,
}) => { }) => {
return ( return (
<div className={styles.wrapper}> <div className={styles.wrapper}>
@@ -46,6 +48,7 @@ const BoardActions = React.memo(
<Filters <Filters
users={filterUsers} users={filterUsers}
labels={filterLabels} labels={filterLabels}
filterText={filterText}
allBoardMemberships={memberships} allBoardMemberships={memberships}
allLabels={labels} allLabels={labels}
canEdit={canEdit} canEdit={canEdit}
@@ -57,6 +60,7 @@ const BoardActions = React.memo(
onLabelUpdate={onLabelUpdate} onLabelUpdate={onLabelUpdate}
onLabelMove={onLabelMove} onLabelMove={onLabelMove}
onLabelDelete={onLabelDelete} onLabelDelete={onLabelDelete}
onTextFilterUpdate={onTextFilterUpdate}
/> />
</div> </div>
</div> </div>
@@ -71,6 +75,7 @@ BoardActions.propTypes = {
labels: PropTypes.array.isRequired, labels: PropTypes.array.isRequired,
filterUsers: PropTypes.array.isRequired, filterUsers: PropTypes.array.isRequired,
filterLabels: PropTypes.array.isRequired, filterLabels: PropTypes.array.isRequired,
filterText: PropTypes.string.isRequired,
allUsers: PropTypes.array.isRequired, allUsers: PropTypes.array.isRequired,
/* eslint-enable react/forbid-prop-types */ /* eslint-enable react/forbid-prop-types */
canEdit: PropTypes.bool.isRequired, canEdit: PropTypes.bool.isRequired,
@@ -86,6 +91,7 @@ BoardActions.propTypes = {
onLabelUpdate: PropTypes.func.isRequired, onLabelUpdate: PropTypes.func.isRequired,
onLabelMove: PropTypes.func.isRequired, onLabelMove: PropTypes.func.isRequired,
onLabelDelete: PropTypes.func.isRequired, onLabelDelete: PropTypes.func.isRequired,
onTextFilterUpdate: PropTypes.func.isRequired,
}; };
export default BoardActions; export default BoardActions;
@@ -1,5 +1,7 @@
:global(#app) { :global(#app) {
.action { .action {
align-items: center;
display: flex;
flex: 0 0 auto; flex: 0 0 auto;
margin-right: 20px; margin-right: 20px;
} }
+65 -1
View File
@@ -1,7 +1,10 @@
import React, { useCallback } from 'react'; import React, { useCallback, useRef, useState } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Icon } from 'semantic-ui-react';
import { usePopup } from '../../lib/popup'; import { usePopup } from '../../lib/popup';
import { Input } from '../../lib/custom-ui';
import User from '../User'; import User from '../User';
import Label from '../Label'; import Label from '../Label';
@@ -14,6 +17,7 @@ const Filters = React.memo(
({ ({
users, users,
labels, labels,
filterText,
allBoardMemberships, allBoardMemberships,
allLabels, allLabels,
canEdit, canEdit,
@@ -25,8 +29,17 @@ const Filters = React.memo(
onLabelUpdate, onLabelUpdate,
onLabelMove, onLabelMove,
onLabelDelete, onLabelDelete,
onTextFilterUpdate,
}) => { }) => {
const [t] = useTranslation(); const [t] = useTranslation();
const [isSearchFocused, setIsSearchFocused] = useState(false);
const searchFieldRef = useRef(null);
const cancelSearch = useCallback(() => {
onTextFilterUpdate('');
searchFieldRef.current.blur();
}, [onTextFilterUpdate]);
const handleRemoveUserClick = useCallback( const handleRemoveUserClick = useCallback(
(id) => { (id) => {
@@ -42,9 +55,39 @@ const Filters = React.memo(
[onLabelRemove], [onLabelRemove],
); );
const handleSearchChange = useCallback(
(_, { value }) => {
onTextFilterUpdate(value);
},
[onTextFilterUpdate],
);
const handleSearchFocus = useCallback(() => {
setIsSearchFocused(true);
}, []);
const handleSearchKeyDown = useCallback(
(event) => {
if (event.key === 'Escape') {
cancelSearch();
}
},
[cancelSearch],
);
const handleSearchBlur = useCallback(() => {
setIsSearchFocused(false);
}, []);
const handleCancelSearchClick = useCallback(() => {
cancelSearch();
}, [cancelSearch]);
const BoardMembershipsPopup = usePopup(BoardMembershipsStep); const BoardMembershipsPopup = usePopup(BoardMembershipsStep);
const LabelsPopup = usePopup(LabelsStep); const LabelsPopup = usePopup(LabelsStep);
const isSearchActive = filterText || isSearchFocused;
return ( return (
<> <>
<span className={styles.filter}> <span className={styles.filter}>
@@ -100,6 +143,25 @@ const Filters = React.memo(
</span> </span>
))} ))}
</span> </span>
<span className={styles.filter}>
<Input
ref={searchFieldRef}
value={filterText}
placeholder={t('common.searchCards')}
icon={
isSearchActive ? (
<Icon link name="cancel" onClick={handleCancelSearchClick} />
) : (
'search'
)
}
className={classNames(styles.search, !isSearchActive && styles.searchInactive)}
onFocus={handleSearchFocus}
onKeyDown={handleSearchKeyDown}
onChange={handleSearchChange}
onBlur={handleSearchBlur}
/>
</span>
</> </>
); );
}, },
@@ -109,6 +171,7 @@ Filters.propTypes = {
/* eslint-disable react/forbid-prop-types */ /* eslint-disable react/forbid-prop-types */
users: PropTypes.array.isRequired, users: PropTypes.array.isRequired,
labels: PropTypes.array.isRequired, labels: PropTypes.array.isRequired,
filterText: PropTypes.string.isRequired,
allBoardMemberships: PropTypes.array.isRequired, allBoardMemberships: PropTypes.array.isRequired,
allLabels: PropTypes.array.isRequired, allLabels: PropTypes.array.isRequired,
/* eslint-enable react/forbid-prop-types */ /* eslint-enable react/forbid-prop-types */
@@ -121,6 +184,7 @@ Filters.propTypes = {
onLabelUpdate: PropTypes.func.isRequired, onLabelUpdate: PropTypes.func.isRequired,
onLabelMove: PropTypes.func.isRequired, onLabelMove: PropTypes.func.isRequired,
onLabelDelete: PropTypes.func.isRequired, onLabelDelete: PropTypes.func.isRequired,
onTextFilterUpdate: PropTypes.func.isRequired,
}; };
export default Filters; export default Filters;
@@ -43,4 +43,32 @@
line-height: 20px; line-height: 20px;
padding: 2px 12px; padding: 2px 12px;
} }
.search {
height: 30px;
margin: 0 12px;
transition: width 0.2s ease;
width: 280px;
input {
font-size: 13px;
}
}
.searchInactive {
color: #fff;
height: 24px;
width: 220px;
input {
background: rgba(0, 0, 0, 0.24);
border: none;
color: #fff !important;
font-size: 12px;
&::placeholder {
color: #fff;
}
}
}
} }
@@ -4,7 +4,6 @@
max-height: 60vh; max-height: 60vh;
overflow-x: hidden; overflow-x: hidden;
overflow-y: auto; overflow-y: auto;
scrollbar-width: thin;
width: 100%; width: 100%;
&::-webkit-scrollbar { &::-webkit-scrollbar {
@@ -85,7 +85,6 @@
height: 56px; height: 56px;
overflow-x: auto; overflow-x: auto;
overflow-y: hidden; overflow-y: hidden;
scrollbar-width: thin;
&:hover { &:hover {
height: 38px; height: 38px;
+25 -13
View File
@@ -9,7 +9,7 @@ import { useSteps } from '../../hooks';
import BoardMembershipsStep from '../BoardMembershipsStep'; import BoardMembershipsStep from '../BoardMembershipsStep';
import LabelsStep from '../LabelsStep'; import LabelsStep from '../LabelsStep';
import DueDateEditStep from '../DueDateEditStep'; import DueDateEditStep from '../DueDateEditStep';
import TimerEditStep from '../TimerEditStep'; import StopwatchEditStep from '../StopwatchEditStep';
import CardMoveStep from '../CardMoveStep'; import CardMoveStep from '../CardMoveStep';
import DeleteStep from '../DeleteStep'; import DeleteStep from '../DeleteStep';
@@ -19,7 +19,7 @@ const StepTypes = {
USERS: 'USERS', USERS: 'USERS',
LABELS: 'LABELS', LABELS: 'LABELS',
EDIT_DUE_DATE: 'EDIT_DUE_DATE', EDIT_DUE_DATE: 'EDIT_DUE_DATE',
EDIT_TIMER: 'EDIT_TIMER', EDIT_STOPWATCH: 'EDIT_STOPWATCH',
MOVE: 'MOVE', MOVE: 'MOVE',
DELETE: 'DELETE', DELETE: 'DELETE',
}; };
@@ -36,6 +36,7 @@ const ActionsStep = React.memo(
onUpdate, onUpdate,
onMove, onMove,
onTransfer, onTransfer,
onDuplicate,
onDelete, onDelete,
onUserAdd, onUserAdd,
onUserRemove, onUserRemove,
@@ -68,14 +69,19 @@ const ActionsStep = React.memo(
openStep(StepTypes.EDIT_DUE_DATE); openStep(StepTypes.EDIT_DUE_DATE);
}, [openStep]); }, [openStep]);
const handleEditTimerClick = useCallback(() => { const handleEditStopwatchClick = useCallback(() => {
openStep(StepTypes.EDIT_TIMER); openStep(StepTypes.EDIT_STOPWATCH);
}, [openStep]); }, [openStep]);
const handleMoveClick = useCallback(() => { const handleMoveClick = useCallback(() => {
openStep(StepTypes.MOVE); openStep(StepTypes.MOVE);
}, [openStep]); }, [openStep]);
const handleDuplicateClick = useCallback(() => {
onDuplicate();
onClose();
}, [onDuplicate, onClose]);
const handleDeleteClick = useCallback(() => { const handleDeleteClick = useCallback(() => {
openStep(StepTypes.DELETE); openStep(StepTypes.DELETE);
}, [openStep]); }, [openStep]);
@@ -89,10 +95,10 @@ const ActionsStep = React.memo(
[onUpdate], [onUpdate],
); );
const handleTimerUpdate = useCallback( const handleStopwatchUpdate = useCallback(
(timer) => { (stopwatch) => {
onUpdate({ onUpdate({
timer, stopwatch,
}); });
}, },
[onUpdate], [onUpdate],
@@ -133,11 +139,11 @@ const ActionsStep = React.memo(
onClose={onClose} onClose={onClose}
/> />
); );
case StepTypes.EDIT_TIMER: case StepTypes.EDIT_STOPWATCH:
return ( return (
<TimerEditStep <StopwatchEditStep
defaultValue={card.timer} defaultValue={card.stopwatch}
onUpdate={handleTimerUpdate} onUpdate={handleStopwatchUpdate}
onBack={handleBack} onBack={handleBack}
onClose={onClose} onClose={onClose}
/> />
@@ -197,8 +203,8 @@ const ActionsStep = React.memo(
context: 'title', context: 'title',
})} })}
</Menu.Item> </Menu.Item>
<Menu.Item className={styles.menuItem} onClick={handleEditTimerClick}> <Menu.Item className={styles.menuItem} onClick={handleEditStopwatchClick}>
{t('action.editTimer', { {t('action.editStopwatch', {
context: 'title', context: 'title',
})} })}
</Menu.Item> </Menu.Item>
@@ -207,6 +213,11 @@ const ActionsStep = React.memo(
context: 'title', context: 'title',
})} })}
</Menu.Item> </Menu.Item>
<Menu.Item className={styles.menuItem} onClick={handleDuplicateClick}>
{t('action.duplicateCard', {
context: 'title',
})}
</Menu.Item>
<Menu.Item className={styles.menuItem} onClick={handleDeleteClick}> <Menu.Item className={styles.menuItem} onClick={handleDeleteClick}>
{t('action.deleteCard', { {t('action.deleteCard', {
context: 'title', context: 'title',
@@ -232,6 +243,7 @@ ActionsStep.propTypes = {
onUpdate: PropTypes.func.isRequired, onUpdate: PropTypes.func.isRequired,
onMove: PropTypes.func.isRequired, onMove: PropTypes.func.isRequired,
onTransfer: PropTypes.func.isRequired, onTransfer: PropTypes.func.isRequired,
onDuplicate: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired, onDelete: PropTypes.func.isRequired,
onUserAdd: PropTypes.func.isRequired, onUserAdd: PropTypes.func.isRequired,
onUserRemove: PropTypes.func.isRequired, onUserRemove: PropTypes.func.isRequired,
+18 -15
View File
@@ -6,7 +6,7 @@ import { Link } from 'react-router-dom';
import { Draggable } from 'react-beautiful-dnd'; import { Draggable } from 'react-beautiful-dnd';
import { usePopup } from '../../lib/popup'; import { usePopup } from '../../lib/popup';
import { startTimer, stopTimer } from '../../utils/timer'; import { startStopwatch, stopStopwatch } from '../../utils/stopwatch';
import Paths from '../../constants/Paths'; import Paths from '../../constants/Paths';
import Tasks from './Tasks'; import Tasks from './Tasks';
import NameEdit from './NameEdit'; import NameEdit from './NameEdit';
@@ -14,7 +14,7 @@ import ActionsStep from './ActionsStep';
import User from '../User'; import User from '../User';
import Label from '../Label'; import Label from '../Label';
import DueDate from '../DueDate'; import DueDate from '../DueDate';
import Timer from '../Timer'; import Stopwatch from '../Stopwatch';
import styles from './Card.module.scss'; import styles from './Card.module.scss';
@@ -24,7 +24,7 @@ const Card = React.memo(
index, index,
name, name,
dueDate, dueDate,
timer, stopwatch,
coverUrl, coverUrl,
boardId, boardId,
listId, listId,
@@ -41,6 +41,7 @@ const Card = React.memo(
onUpdate, onUpdate,
onMove, onMove,
onTransfer, onTransfer,
onDuplicate,
onDelete, onDelete,
onUserAdd, onUserAdd,
onUserRemove, onUserRemove,
@@ -60,15 +61,15 @@ const Card = React.memo(
} }
}, []); }, []);
const handleToggleTimerClick = useCallback( const handleToggleStopwatchClick = useCallback(
(event) => { (event) => {
event.preventDefault(); event.preventDefault();
onUpdate({ onUpdate({
timer: timer.startedAt ? stopTimer(timer) : startTimer(timer), stopwatch: stopwatch.startedAt ? stopStopwatch(stopwatch) : startStopwatch(stopwatch),
}); });
}, },
[timer, onUpdate], [stopwatch, onUpdate],
); );
const handleNameUpdate = useCallback( const handleNameUpdate = useCallback(
@@ -104,7 +105,7 @@ const Card = React.memo(
)} )}
<div className={styles.name}>{name}</div> <div className={styles.name}>{name}</div>
{tasks.length > 0 && <Tasks items={tasks} />} {tasks.length > 0 && <Tasks items={tasks} />}
{(dueDate || timer || notificationsTotal > 0) && ( {(dueDate || stopwatch || notificationsTotal > 0) && (
<span className={styles.attachments}> <span className={styles.attachments}>
{notificationsTotal > 0 && ( {notificationsTotal > 0 && (
<span <span
@@ -122,14 +123,14 @@ const Card = React.memo(
<DueDate value={dueDate} size="tiny" /> <DueDate value={dueDate} size="tiny" />
</span> </span>
)} )}
{timer && ( {stopwatch && (
<span className={classNames(styles.attachment, styles.attachmentLeft)}> <span className={classNames(styles.attachment, styles.attachmentLeft)}>
<Timer <Stopwatch
as="span" as="span"
startedAt={timer.startedAt} startedAt={stopwatch.startedAt}
total={timer.total} total={stopwatch.total}
size="tiny" size="tiny"
onClick={canEdit ? handleToggleTimerClick : undefined} onClick={canEdit ? handleToggleStopwatchClick : undefined}
/> />
</span> </span>
)} )}
@@ -171,7 +172,7 @@ const Card = React.memo(
<ActionsPopup <ActionsPopup
card={{ card={{
dueDate, dueDate,
timer, stopwatch,
boardId, boardId,
listId, listId,
projectId, projectId,
@@ -185,6 +186,7 @@ const Card = React.memo(
onUpdate={onUpdate} onUpdate={onUpdate}
onMove={onMove} onMove={onMove}
onTransfer={onTransfer} onTransfer={onTransfer}
onDuplicate={onDuplicate}
onDelete={onDelete} onDelete={onDelete}
onUserAdd={onUserAdd} onUserAdd={onUserAdd}
onUserRemove={onUserRemove} onUserRemove={onUserRemove}
@@ -219,7 +221,7 @@ Card.propTypes = {
index: PropTypes.number.isRequired, index: PropTypes.number.isRequired,
name: PropTypes.string.isRequired, name: PropTypes.string.isRequired,
dueDate: PropTypes.instanceOf(Date), dueDate: PropTypes.instanceOf(Date),
timer: PropTypes.object, // eslint-disable-line react/forbid-prop-types stopwatch: PropTypes.object, // eslint-disable-line react/forbid-prop-types
coverUrl: PropTypes.string, coverUrl: PropTypes.string,
boardId: PropTypes.string.isRequired, boardId: PropTypes.string.isRequired,
listId: PropTypes.string.isRequired, listId: PropTypes.string.isRequired,
@@ -238,6 +240,7 @@ Card.propTypes = {
onUpdate: PropTypes.func.isRequired, onUpdate: PropTypes.func.isRequired,
onMove: PropTypes.func.isRequired, onMove: PropTypes.func.isRequired,
onTransfer: PropTypes.func.isRequired, onTransfer: PropTypes.func.isRequired,
onDuplicate: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired, onDelete: PropTypes.func.isRequired,
onUserAdd: PropTypes.func.isRequired, onUserAdd: PropTypes.func.isRequired,
onUserRemove: PropTypes.func.isRequired, onUserRemove: PropTypes.func.isRequired,
@@ -252,7 +255,7 @@ Card.propTypes = {
Card.defaultProps = { Card.defaultProps = {
dueDate: undefined, dueDate: undefined,
timer: undefined, stopwatch: undefined,
coverUrl: undefined, coverUrl: undefined,
}; };
+3 -1
View File
@@ -4,6 +4,8 @@ import classNames from 'classnames';
import { Progress } from 'semantic-ui-react'; import { Progress } from 'semantic-ui-react';
import { useToggle } from '../../lib/hooks'; import { useToggle } from '../../lib/hooks';
import Linkify from '../Linkify';
import styles from './Tasks.module.scss'; import styles from './Tasks.module.scss';
const Tasks = React.memo(({ items }) => { const Tasks = React.memo(({ items }) => {
@@ -48,7 +50,7 @@ const Tasks = React.memo(({ items }) => {
key={item.id} key={item.id}
className={classNames(styles.task, item.isCompleted && styles.taskCompleted)} className={classNames(styles.task, item.isCompleted && styles.taskCompleted)}
> >
{item.name} <Linkify linkStopPropagation>{item.name}</Linkify>
</li> </li>
))} ))}
</ul> </ul>
@@ -55,8 +55,10 @@
display: block; display: block;
font-size: 12px; font-size: 12px;
line-height: 14px; line-height: 14px;
overflow: hidden;
padding-bottom: 6px; padding-bottom: 6px;
padding-left: 14px; padding-left: 14px;
text-overflow: ellipsis;
&:before { &:before {
content: "–"; content: "–";
@@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next';
import TextareaAutosize from 'react-textarea-autosize'; import TextareaAutosize from 'react-textarea-autosize';
import { Button, Form, TextArea } from 'semantic-ui-react'; import { Button, Form, TextArea } from 'semantic-ui-react';
import { useClosableForm, useForm } from '../../../hooks'; import { useForm } from '../../../hooks';
import styles from './CommentEdit.module.scss'; import styles from './CommentEdit.module.scss';
@@ -35,12 +35,7 @@ const CommentEdit = React.forwardRef(({ children, defaultData, onUpdate }, ref)
text: data.text.trim(), text: data.text.trim(),
}; };
if (!cleanData.text) { if (cleanData.text && !dequal(cleanData, defaultData)) {
textField.current.ref.current.select();
return;
}
if (!dequal(cleanData, defaultData)) {
onUpdate(cleanData); onUpdate(cleanData);
} }
@@ -65,10 +60,9 @@ const CommentEdit = React.forwardRef(({ children, defaultData, onUpdate }, ref)
[submit], [submit],
); );
const [handleFieldBlur, handleControlMouseOver, handleControlMouseOut] = useClosableForm( const handleFieldBlur = useCallback(() => {
close, submit();
isOpened, }, [submit]);
);
const handleSubmit = useCallback(() => { const handleSubmit = useCallback(() => {
submit(); submit();
@@ -99,13 +93,7 @@ const CommentEdit = React.forwardRef(({ children, defaultData, onUpdate }, ref)
onBlur={handleFieldBlur} onBlur={handleFieldBlur}
/> />
<div className={styles.controls}> <div className={styles.controls}>
{/* eslint-disable-next-line jsx-a11y/mouse-events-have-key-events */} <Button positive content={t('action.save')} />
<Button
positive
content={t('action.save')}
onMouseOver={handleControlMouseOver}
onMouseOut={handleControlMouseOut}
/>
</div> </div>
</Form> </Form>
); );
@@ -4,6 +4,7 @@ import classNames from 'classnames';
import { useTranslation, Trans } from 'react-i18next'; import { useTranslation, Trans } from 'react-i18next';
import { Comment } from 'semantic-ui-react'; import { Comment } from 'semantic-ui-react';
import getDateFormat from '../../../utils/get-date-format';
import { ActivityTypes } from '../../../constants/Enums'; import { ActivityTypes } from '../../../constants/Enums';
import ItemComment from './ItemComment'; import ItemComment from './ItemComment';
import User from '../../User'; import User from '../../User';
@@ -66,7 +67,7 @@ const Item = React.memo(({ type, data, createdAt, user }) => {
<div className={classNames(styles.content)}> <div className={classNames(styles.content)}>
<div>{contentNode}</div> <div>{contentNode}</div>
<span className={styles.date}> <span className={styles.date}>
{t('format:longDateTime', { {t(`format:${getDateFormat(createdAt)}`, {
postProcess: 'formatDate', postProcess: 'formatDate',
value: createdAt, value: createdAt,
})} })}
@@ -6,6 +6,7 @@ import { Comment } from 'semantic-ui-react';
import { usePopup } from '../../../lib/popup'; import { usePopup } from '../../../lib/popup';
import { Markdown } from '../../../lib/custom-ui'; import { Markdown } from '../../../lib/custom-ui';
import getDateFormat from '../../../utils/get-date-format';
import CommentEdit from './CommentEdit'; import CommentEdit from './CommentEdit';
import User from '../../User'; import User from '../../User';
import DeleteStep from '../../DeleteStep'; import DeleteStep from '../../DeleteStep';
@@ -33,7 +34,7 @@ const ItemComment = React.memo(
<div className={styles.title}> <div className={styles.title}>
<span className={styles.author}>{user.name}</span> <span className={styles.author}>{user.name}</span>
<span className={styles.date}> <span className={styles.date}>
{t('format:longDateTime', { {t(`format:${getDateFormat(createdAt)}`, {
postProcess: 'formatDate', postProcess: 'formatDate',
value: createdAt, value: createdAt,
})} })}
@@ -94,7 +94,7 @@ const AttachmentAddZone = React.memo(({ children, onCreate }) => {
return ( return (
<> <>
{/* eslint-disable-next-line react/jsx-props-no-spreading */} {/* eslint-disable-next-line react/jsx-props-no-spreading */}
<div {...getRootProps()} className={styles.wrapper}> <div {...getRootProps()}>
{isDragActive && <div className={styles.dropzone}>{t('common.dropFileToUpload')}</div>} {isDragActive && <div className={styles.dropzone}>{t('common.dropFileToUpload')}</div>}
{children} {children}
{/* eslint-disable-next-line react/jsx-props-no-spreading */} {/* eslint-disable-next-line react/jsx-props-no-spreading */}
@@ -12,8 +12,4 @@
width: 100%; width: 100%;
z-index: 2001; z-index: 2001;
} }
.wrapper {
overflow: hidden;
}
} }
+42 -25
View File
@@ -6,7 +6,7 @@ import { Button, Grid, Icon, Modal } from 'semantic-ui-react';
import { usePopup } from '../../lib/popup'; import { usePopup } from '../../lib/popup';
import { Markdown } from '../../lib/custom-ui'; import { Markdown } from '../../lib/custom-ui';
import { startTimer, stopTimer } from '../../utils/timer'; import { startStopwatch, stopStopwatch } from '../../utils/stopwatch';
import NameField from './NameField'; import NameField from './NameField';
import DescriptionEdit from './DescriptionEdit'; import DescriptionEdit from './DescriptionEdit';
import Tasks from './Tasks'; import Tasks from './Tasks';
@@ -17,11 +17,11 @@ import Activities from './Activities';
import User from '../User'; import User from '../User';
import Label from '../Label'; import Label from '../Label';
import DueDate from '../DueDate'; import DueDate from '../DueDate';
import Timer from '../Timer'; import Stopwatch from '../Stopwatch';
import BoardMembershipsStep from '../BoardMembershipsStep'; import BoardMembershipsStep from '../BoardMembershipsStep';
import LabelsStep from '../LabelsStep'; import LabelsStep from '../LabelsStep';
import DueDateEditStep from '../DueDateEditStep'; import DueDateEditStep from '../DueDateEditStep';
import TimerEditStep from '../TimerEditStep'; import StopwatchEditStep from '../StopwatchEditStep';
import CardMoveStep from '../CardMoveStep'; import CardMoveStep from '../CardMoveStep';
import DeleteStep from '../DeleteStep'; import DeleteStep from '../DeleteStep';
@@ -32,7 +32,7 @@ const CardModal = React.memo(
name, name,
description, description,
dueDate, dueDate,
timer, stopwatch,
isSubscribed, isSubscribed,
isActivitiesFetching, isActivitiesFetching,
isAllActivitiesFetched, isAllActivitiesFetched,
@@ -55,6 +55,7 @@ const CardModal = React.memo(
onUpdate, onUpdate,
onMove, onMove,
onTransfer, onTransfer,
onDuplicate,
onDelete, onDelete,
onUserAdd, onUserAdd,
onUserRemove, onUserRemove,
@@ -83,11 +84,11 @@ const CardModal = React.memo(
const isGalleryOpened = useRef(false); const isGalleryOpened = useRef(false);
const handleToggleTimerClick = useCallback(() => { const handleToggleStopwatchClick = useCallback(() => {
onUpdate({ onUpdate({
timer: timer.startedAt ? stopTimer(timer) : startTimer(timer), stopwatch: stopwatch.startedAt ? stopStopwatch(stopwatch) : startStopwatch(stopwatch),
}); });
}, [timer, onUpdate]); }, [stopwatch, onUpdate]);
const handleNameUpdate = useCallback( const handleNameUpdate = useCallback(
(newName) => { (newName) => {
@@ -116,10 +117,10 @@ const CardModal = React.memo(
[onUpdate], [onUpdate],
); );
const handleTimerUpdate = useCallback( const handleStopwatchUpdate = useCallback(
(newTimer) => { (newStopwatch) => {
onUpdate({ onUpdate({
timer: newTimer, stopwatch: newStopwatch,
}); });
}, },
[onUpdate], [onUpdate],
@@ -140,6 +141,11 @@ const CardModal = React.memo(
}); });
}, [isSubscribed, onUpdate]); }, [isSubscribed, onUpdate]);
const handleDuplicateClick = useCallback(() => {
onDuplicate();
onClose();
}, [onDuplicate, onClose]);
const handleGalleryOpen = useCallback(() => { const handleGalleryOpen = useCallback(() => {
isGalleryOpened.current = true; isGalleryOpened.current = true;
}, []); }, []);
@@ -160,7 +166,7 @@ const CardModal = React.memo(
const BoardMembershipsPopup = usePopup(BoardMembershipsStep); const BoardMembershipsPopup = usePopup(BoardMembershipsStep);
const LabelsPopup = usePopup(LabelsStep); const LabelsPopup = usePopup(LabelsStep);
const DueDateEditPopup = usePopup(DueDateEditStep); const DueDateEditPopup = usePopup(DueDateEditStep);
const TimerEditPopup = usePopup(TimerEditStep); const StopwatchEditPopup = usePopup(StopwatchEditStep);
const CardMovePopup = usePopup(CardMoveStep); const CardMovePopup = usePopup(CardMoveStep);
const DeletePopup = usePopup(DeleteStep); const DeletePopup = usePopup(DeleteStep);
@@ -185,7 +191,7 @@ const CardModal = React.memo(
</Grid.Row> </Grid.Row>
<Grid.Row className={styles.modalPadding}> <Grid.Row className={styles.modalPadding}>
<Grid.Column width={canEdit ? 12 : 16} className={styles.contentPadding}> <Grid.Column width={canEdit ? 12 : 16} className={styles.contentPadding}>
{(users.length > 0 || labels.length > 0 || dueDate || timer) && ( {(users.length > 0 || labels.length > 0 || dueDate || stopwatch) && (
<div className={styles.moduleWrapper}> <div className={styles.moduleWrapper}>
{users.length > 0 && ( {users.length > 0 && (
<div className={styles.attachments}> <div className={styles.attachments}>
@@ -217,6 +223,7 @@ const CardModal = React.memo(
onUserSelect={onUserAdd} onUserSelect={onUserAdd}
onUserDeselect={onUserRemove} onUserDeselect={onUserRemove}
> >
{/* eslint-disable-next-line jsx-a11y/control-has-associated-label */}
<button <button
type="button" type="button"
className={classNames(styles.attachment, styles.dueDate)} className={classNames(styles.attachment, styles.dueDate)}
@@ -266,6 +273,7 @@ const CardModal = React.memo(
onMove={onLabelMove} onMove={onLabelMove}
onDelete={onLabelDelete} onDelete={onLabelDelete}
> >
{/* eslint-disable-next-line jsx-a11y/control-has-associated-label */}
<button <button
type="button" type="button"
className={classNames(styles.attachment, styles.dueDate)} className={classNames(styles.attachment, styles.dueDate)}
@@ -294,30 +302,34 @@ const CardModal = React.memo(
</span> </span>
</div> </div>
)} )}
{timer && ( {stopwatch && (
<div className={styles.attachments}> <div className={styles.attachments}>
<div className={styles.text}> <div className={styles.text}>
{t('common.timer', { {t('common.stopwatch', {
context: 'title', context: 'title',
})} })}
</div> </div>
<span className={styles.attachment}> <span className={styles.attachment}>
{canEdit ? ( {canEdit ? (
<TimerEditPopup defaultValue={timer} onUpdate={handleTimerUpdate}> <StopwatchEditPopup
<Timer startedAt={timer.startedAt} total={timer.total} /> defaultValue={stopwatch}
</TimerEditPopup> onUpdate={handleStopwatchUpdate}
>
<Stopwatch startedAt={stopwatch.startedAt} total={stopwatch.total} />
</StopwatchEditPopup>
) : ( ) : (
<Timer startedAt={timer.startedAt} total={timer.total} /> <Stopwatch startedAt={stopwatch.startedAt} total={stopwatch.total} />
)} )}
</span> </span>
{canEdit && ( {canEdit && (
// eslint-disable-next-line jsx-a11y/control-has-associated-label
<button <button
onClick={handleToggleTimerClick}
type="button" type="button"
className={classNames(styles.attachment, styles.dueDate)} className={classNames(styles.attachment, styles.dueDate)}
onClick={handleToggleStopwatchClick}
> >
<Icon <Icon
name={timer.startedAt ? 'pause' : 'play'} name={stopwatch.startedAt ? 'pause' : 'play'}
size="small" size="small"
className={styles.addAttachment} className={styles.addAttachment}
/> />
@@ -447,12 +459,12 @@ const CardModal = React.memo(
})} })}
</Button> </Button>
</DueDateEditPopup> </DueDateEditPopup>
<TimerEditPopup defaultValue={timer} onUpdate={handleTimerUpdate}> <StopwatchEditPopup defaultValue={stopwatch} onUpdate={handleStopwatchUpdate}>
<Button fluid className={styles.actionButton}> <Button fluid className={styles.actionButton}>
<Icon name="clock outline" className={styles.actionIcon} /> <Icon name="clock outline" className={styles.actionIcon} />
{t('common.timer')} {t('common.stopwatch')}
</Button> </Button>
</TimerEditPopup> </StopwatchEditPopup>
<AttachmentAddPopup onCreate={onAttachmentCreate}> <AttachmentAddPopup onCreate={onAttachmentCreate}>
<Button fluid className={styles.actionButton}> <Button fluid className={styles.actionButton}>
<Icon name="attach" className={styles.actionIcon} /> <Icon name="attach" className={styles.actionIcon} />
@@ -490,6 +502,10 @@ const CardModal = React.memo(
{t('action.move')} {t('action.move')}
</Button> </Button>
</CardMovePopup> </CardMovePopup>
<Button fluid className={styles.actionButton} onClick={handleDuplicateClick}>
<Icon name="copy outline" className={styles.actionIcon} />
{t('action.duplicate')}
</Button>
<DeletePopup <DeletePopup
title="common.deleteCard" title="common.deleteCard"
content="common.areYouSureYouWantToDeleteThisCard" content="common.areYouSureYouWantToDeleteThisCard"
@@ -524,7 +540,7 @@ CardModal.propTypes = {
name: PropTypes.string.isRequired, name: PropTypes.string.isRequired,
description: PropTypes.string, description: PropTypes.string,
dueDate: PropTypes.instanceOf(Date), dueDate: PropTypes.instanceOf(Date),
timer: PropTypes.object, // eslint-disable-line react/forbid-prop-types stopwatch: PropTypes.object, // eslint-disable-line react/forbid-prop-types
isSubscribed: PropTypes.bool.isRequired, isSubscribed: PropTypes.bool.isRequired,
isActivitiesFetching: PropTypes.bool.isRequired, isActivitiesFetching: PropTypes.bool.isRequired,
isAllActivitiesFetched: PropTypes.bool.isRequired, isAllActivitiesFetched: PropTypes.bool.isRequired,
@@ -549,6 +565,7 @@ CardModal.propTypes = {
onUpdate: PropTypes.func.isRequired, onUpdate: PropTypes.func.isRequired,
onMove: PropTypes.func.isRequired, onMove: PropTypes.func.isRequired,
onTransfer: PropTypes.func.isRequired, onTransfer: PropTypes.func.isRequired,
onDuplicate: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired, onDelete: PropTypes.func.isRequired,
onUserAdd: PropTypes.func.isRequired, onUserAdd: PropTypes.func.isRequired,
onUserRemove: PropTypes.func.isRequired, onUserRemove: PropTypes.func.isRequired,
@@ -577,7 +594,7 @@ CardModal.propTypes = {
CardModal.defaultProps = { CardModal.defaultProps = {
description: undefined, description: undefined,
dueDate: undefined, dueDate: undefined,
timer: undefined, stopwatch: undefined,
}; };
export default CardModal; export default CardModal;
@@ -57,6 +57,7 @@ const DescriptionEdit = React.forwardRef(({ children, defaultValue, onUpdate },
const mdEditorOptions = useMemo( const mdEditorOptions = useMemo(
() => ({ () => ({
autoDownloadFontAwesome: false,
autofocus: true, autofocus: true,
spellChecker: false, spellChecker: false,
status: false, status: false,
@@ -100,7 +101,6 @@ const DescriptionEdit = React.forwardRef(({ children, defaultValue, onUpdate },
onChange={setValue} onChange={setValue}
/> />
<div className={styles.controls}> <div className={styles.controls}>
{/* eslint-disable-next-line jsx-a11y/mouse-events-have-key-events */}
<Button positive content={t('action.save')} /> <Button positive content={t('action.save')} />
</div> </div>
</Form> </Form>
@@ -8,6 +8,7 @@ import { usePopup } from '../../../lib/popup';
import NameEdit from './NameEdit'; import NameEdit from './NameEdit';
import ActionsStep from './ActionsStep'; import ActionsStep from './ActionsStep';
import Linkify from '../../Linkify';
import styles from './Item.module.scss'; import styles from './Item.module.scss';
@@ -65,7 +66,7 @@ const Item = React.memo(
onClick={handleClick} onClick={handleClick}
> >
<span className={classNames(styles.task, isCompleted && styles.taskCompleted)}> <span className={classNames(styles.task, isCompleted && styles.taskCompleted)}>
{name} <Linkify linkStopPropagation>{name}</Linkify>
</span> </span>
</span> </span>
{isPersisted && canEdit && ( {isPersisted && canEdit && (
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
import TextareaAutosize from 'react-textarea-autosize'; import TextareaAutosize from 'react-textarea-autosize';
import { Button, Form, TextArea } from 'semantic-ui-react'; import { Button, Form, TextArea } from 'semantic-ui-react';
import { useClosableForm, useField } from '../../../hooks'; import { useField } from '../../../hooks';
import styles from './NameEdit.module.scss'; import styles from './NameEdit.module.scss';
@@ -28,12 +28,7 @@ const NameEdit = React.forwardRef(({ children, defaultValue, onUpdate }, ref) =>
const submit = useCallback(() => { const submit = useCallback(() => {
const cleanValue = value.trim(); const cleanValue = value.trim();
if (!cleanValue) { if (cleanValue && cleanValue !== defaultValue) {
field.current.ref.current.select();
return;
}
if (cleanValue !== defaultValue) {
onUpdate(cleanValue); onUpdate(cleanValue);
} }
@@ -60,10 +55,9 @@ const NameEdit = React.forwardRef(({ children, defaultValue, onUpdate }, ref) =>
[submit], [submit],
); );
const [handleFieldBlur, handleControlMouseOver, handleControlMouseOut] = useClosableForm( const handleFieldBlur = useCallback(() => {
close, submit();
isOpened, }, [submit]);
);
const handleSubmit = useCallback(() => { const handleSubmit = useCallback(() => {
submit(); submit();
@@ -93,13 +87,7 @@ const NameEdit = React.forwardRef(({ children, defaultValue, onUpdate }, ref) =>
onBlur={handleFieldBlur} onBlur={handleFieldBlur}
/> />
<div className={styles.controls}> <div className={styles.controls}>
{/* eslint-disable-next-line jsx-a11y/mouse-events-have-key-events */} <Button positive content={t('action.save')} />
<Button
positive
content={t('action.save')}
onMouseOver={handleControlMouseOver}
onMouseOut={handleControlMouseOut}
/>
</div> </div>
</Form> </Form>
); );
@@ -48,14 +48,21 @@ const Tasks = React.memo(({ items, canEdit, onCreate, onUpdate, onMove, onDelete
return ( return (
<> <>
{items.length > 0 && ( {items.length > 0 && (
<Progress <>
autoSuccess <span className={styles.progressWrapper}>
value={completedItems.length} <Progress
total={items.length} autoSuccess
color="blue" value={completedItems.length}
size="tiny" total={items.length}
className={styles.progress} color="blue"
/> size="tiny"
className={styles.progress}
/>
</span>
<span className={styles.count}>
{completedItems.length}/{items.length}
</span>
</>
)} )}
<DragDropContext onDragStart={handleDragStart} onDragEnd={handleDragEnd}> <DragDropContext onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
<Droppable droppableId="tasks" type={DroppableTypes.TASK}> <Droppable droppableId="tasks" type={DroppableTypes.TASK}>
@@ -3,6 +3,23 @@
margin: 0 0 16px; margin: 0 0 16px;
} }
.progressWrapper {
display: inline-block;
padding: 3px 0;
vertical-align: top;
width: calc(100% - 50px);
}
.count {
color: #8c8c8c;
display: inline-block;
font-size: 14px;
line-height: 14px;
text-align: right;
vertical-align: top;
width: 50px;
}
.taskButton { .taskButton {
background: transparent; background: transparent;
border: none; border: none;
+16 -2
View File
@@ -4,6 +4,8 @@ import PropTypes from 'prop-types';
import classNames from 'classnames'; import classNames from 'classnames';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import getDateFormat from '../../utils/get-date-format';
import styles from './DueDate.module.scss'; import styles from './DueDate.module.scss';
const SIZES = { const SIZES = {
@@ -12,15 +14,27 @@ const SIZES = {
MEDIUM: 'medium', MEDIUM: 'medium',
}; };
const FORMATS = { const LONG_DATE_FORMAT_BY_SIZE = {
tiny: 'longDate', tiny: 'longDate',
small: 'longDate', small: 'longDate',
medium: 'longDateTime', medium: 'longDateTime',
}; };
const FULL_DATE_FORMAT_BY_SIZE = {
tiny: 'fullDate',
small: 'fullDate',
medium: 'fullDateTime',
};
const DueDate = React.memo(({ value, size, isDisabled, onClick }) => { const DueDate = React.memo(({ value, size, isDisabled, onClick }) => {
const [t] = useTranslation(); const [t] = useTranslation();
const dateFormat = getDateFormat(
value,
LONG_DATE_FORMAT_BY_SIZE[size],
FULL_DATE_FORMAT_BY_SIZE[size],
);
const contentNode = ( const contentNode = (
<span <span
className={classNames( className={classNames(
@@ -29,7 +43,7 @@ const DueDate = React.memo(({ value, size, isDisabled, onClick }) => {
onClick && styles.wrapperHoverable, onClick && styles.wrapperHoverable,
)} )}
> >
{t(`format:${FORMATS[size]}`, { {t(`format:${dateFormat}`, {
value, value,
postProcess: 'formatDate', postProcess: 'formatDate',
})} })}
+13 -10
View File
@@ -2,11 +2,12 @@ import React, { useCallback } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import classNames from 'classnames'; import classNames from 'classnames';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { Icon, Menu } from 'semantic-ui-react'; import { Button, Icon, Menu } from 'semantic-ui-react';
import { usePopup } from '../../lib/popup'; import { usePopup } from '../../lib/popup';
import Paths from '../../constants/Paths'; import Paths from '../../constants/Paths';
import NotificationsStep from './NotificationsStep'; import NotificationsStep from './NotificationsStep';
import User from '../User';
import UserStep from '../UserStep'; import UserStep from '../UserStep';
import styles from './Header.module.scss'; import styles from './Header.module.scss';
@@ -55,15 +56,16 @@ const Header = React.memo(
> >
<Icon fitted name="arrow left" /> <Icon fitted name="arrow left" />
</Menu.Item> </Menu.Item>
<Menu.Item <Menu.Item className={classNames(styles.item, styles.title)}>
className={classNames(
styles.item,
canEditProject && styles.itemHoverable,
styles.title,
)}
onClick={handleProjectSettingsClick}
>
{project.name} {project.name}
{canEditProject && (
<Button
className={classNames(styles.editButton, styles.target)}
onClick={handleProjectSettingsClick}
>
<Icon fitted name="pencil" size="small" />
</Button>
)}
</Menu.Item> </Menu.Item>
</Menu.Menu> </Menu.Menu>
)} )}
@@ -90,7 +92,8 @@ const Header = React.memo(
onLogout={onLogout} onLogout={onLogout}
> >
<Menu.Item className={classNames(styles.item, styles.itemHoverable)}> <Menu.Item className={classNames(styles.item, styles.itemHoverable)}>
{user.name} <span className={styles.userName}>{user.name}</span>
<User name={user.name} avatarUrl={user.avatarUrl} size="small" />
</Menu.Item> </Menu.Item>
</UserPopup> </UserPopup>
</Menu.Menu> </Menu.Menu>
@@ -1,4 +1,20 @@
:global(#app) { :global(#app) {
.editButton {
background: transparent;
box-shadow: none;
color: #fff;
font-size: 15px;
line-height: 34px;
margin-left: 8px;
opacity: 0;
padding: 0;
width: 34px;
&:hover {
background: rgba(255, 255, 255, 0.08);
}
}
.item { .item {
cursor: auto; cursor: auto;
user-select: auto; user-select: auto;
@@ -11,6 +27,10 @@
&:hover { &:hover {
background: transparent; background: transparent;
color: rgba(255, 255, 255, 0.9); color: rgba(255, 255, 255, 0.9);
.target {
opacity: 1;
}
} }
} }
@@ -66,6 +86,10 @@
font-weight: bold; font-weight: bold;
} }
.userName {
margin-right: 10px;
}
.wrapper { .wrapper {
background: rgba(0, 0, 0, 0.24); background: rgba(0, 0, 0, 0.24);
display: flex; display: flex;
@@ -49,7 +49,6 @@
max-height: 60vh; max-height: 60vh;
overflow-x: hidden; overflow-x: hidden;
overflow-y: auto; overflow-y: auto;
scrollbar-width: thin;
&::-webkit-scrollbar { &::-webkit-scrollbar {
width: 5px; width: 5px;
@@ -25,7 +25,6 @@
max-height: 60vh; max-height: 60vh;
overflow-x: hidden; overflow-x: hidden;
overflow-y: auto; overflow-y: auto;
scrollbar-width: thin;
&::-webkit-scrollbar { &::-webkit-scrollbar {
width: 5px; width: 5px;
+68
View File
@@ -0,0 +1,68 @@
import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import LinkifyReact from 'linkify-react';
import history from '../history';
const Linkify = React.memo(({ children, linkStopPropagation, ...props }) => {
const handleLinkClick = useCallback(
(event) => {
if (linkStopPropagation) {
event.stopPropagation();
}
if (!event.target.getAttribute('target')) {
event.preventDefault();
history.push(event.target.href);
}
},
[linkStopPropagation],
);
const linkRenderer = useCallback(
({ attributes: { href, ...linkProps }, content }) => {
let url;
try {
url = new URL(href, window.location);
} catch (error) {} // eslint-disable-line no-empty
const isSameSite = !!url && url.origin === window.location.origin;
return (
<a
{...linkProps} // eslint-disable-line react/jsx-props-no-spreading
href={href}
target={isSameSite ? undefined : '_blank'}
rel={isSameSite ? undefined : 'noreferrer'}
onClick={handleLinkClick}
>
{isSameSite ? url.pathname : content}
</a>
);
},
[handleLinkClick],
);
return (
<LinkifyReact
{...props} // eslint-disable-line react/jsx-props-no-spreading
options={{
defaultProtocol: 'https',
render: linkRenderer,
}}
>
{children}
</LinkifyReact>
);
});
Linkify.propTypes = {
children: PropTypes.string.isRequired,
linkStopPropagation: PropTypes.bool,
};
Linkify.defaultProps = {
linkStopPropagation: false,
};
export default Linkify;
+40 -11
View File
@@ -5,15 +5,17 @@ import { Menu } from 'semantic-ui-react';
import { Popup } from '../../lib/custom-ui'; import { Popup } from '../../lib/custom-ui';
import { useSteps } from '../../hooks'; import { useSteps } from '../../hooks';
import ListSortStep from '../ListSortStep';
import DeleteStep from '../DeleteStep'; import DeleteStep from '../DeleteStep';
import styles from './ActionsStep.module.scss'; import styles from './ActionsStep.module.scss';
const StepTypes = { const StepTypes = {
DELETE: 'DELETE', DELETE: 'DELETE',
SORT: 'SORT',
}; };
const ActionsStep = React.memo(({ onNameEdit, onCardAdd, onDelete, onClose }) => { const ActionsStep = React.memo(({ onNameEdit, onCardAdd, onSort, onDelete, onClose }) => {
const [t] = useTranslation(); const [t] = useTranslation();
const [step, openStep, handleBack] = useSteps(); const [step, openStep, handleBack] = useSteps();
@@ -27,20 +29,41 @@ const ActionsStep = React.memo(({ onNameEdit, onCardAdd, onDelete, onClose }) =>
onClose(); onClose();
}, [onCardAdd, onClose]); }, [onCardAdd, onClose]);
const handleSortClick = useCallback(() => {
openStep(StepTypes.SORT);
}, [openStep]);
const handleDeleteClick = useCallback(() => { const handleDeleteClick = useCallback(() => {
openStep(StepTypes.DELETE); openStep(StepTypes.DELETE);
}, [openStep]); }, [openStep]);
if (step && step.type === StepTypes.DELETE) { const handleSortTypeSelect = useCallback(
return ( (type) => {
<DeleteStep onSort({
title="common.deleteList" type,
content="common.areYouSureYouWantToDeleteThisList" });
buttonContent="action.deleteList"
onConfirm={onDelete} onClose();
onBack={handleBack} },
/> [onSort, onClose],
); );
if (step && step.type) {
switch (step.type) {
case StepTypes.SORT:
return <ListSortStep onTypeSelect={handleSortTypeSelect} onBack={handleBack} />;
case StepTypes.DELETE:
return (
<DeleteStep
title="common.deleteList"
content="common.areYouSureYouWantToDeleteThisList"
buttonContent="action.deleteList"
onConfirm={onDelete}
onBack={handleBack}
/>
);
default:
}
} }
return ( return (
@@ -62,6 +85,11 @@ const ActionsStep = React.memo(({ onNameEdit, onCardAdd, onDelete, onClose }) =>
context: 'title', context: 'title',
})} })}
</Menu.Item> </Menu.Item>
<Menu.Item className={styles.menuItem} onClick={handleSortClick}>
{t('action.sortList', {
context: 'title',
})}
</Menu.Item>
<Menu.Item className={styles.menuItem} onClick={handleDeleteClick}> <Menu.Item className={styles.menuItem} onClick={handleDeleteClick}>
{t('action.deleteList', { {t('action.deleteList', {
context: 'title', context: 'title',
@@ -77,6 +105,7 @@ ActionsStep.propTypes = {
onNameEdit: PropTypes.func.isRequired, onNameEdit: PropTypes.func.isRequired,
onCardAdd: PropTypes.func.isRequired, onCardAdd: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired, onDelete: PropTypes.func.isRequired,
onSort: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired, onClose: PropTypes.func.isRequired,
}; };
+14 -1
View File
@@ -16,7 +16,18 @@ import { ReactComponent as PlusMathIcon } from '../../assets/images/plus-math-ic
import styles from './List.module.scss'; import styles from './List.module.scss';
const List = React.memo( const List = React.memo(
({ id, index, name, isPersisted, cardIds, canEdit, onUpdate, onDelete, onCardCreate }) => { ({
id,
index,
name,
isPersisted,
cardIds,
canEdit,
onUpdate,
onDelete,
onSort,
onCardCreate,
}) => {
const [t] = useTranslation(); const [t] = useTranslation();
const [isAddCardOpened, setIsAddCardOpened] = useState(false); const [isAddCardOpened, setIsAddCardOpened] = useState(false);
@@ -114,6 +125,7 @@ const List = React.memo(
onNameEdit={handleNameEdit} onNameEdit={handleNameEdit}
onCardAdd={handleCardAdd} onCardAdd={handleCardAdd}
onDelete={onDelete} onDelete={onDelete}
onSort={onSort}
> >
<Button className={classNames(styles.headerButton, styles.target)}> <Button className={classNames(styles.headerButton, styles.target)}>
<Icon fitted name="pencil" size="small" /> <Icon fitted name="pencil" size="small" />
@@ -159,6 +171,7 @@ List.propTypes = {
cardIds: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types cardIds: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types
canEdit: PropTypes.bool.isRequired, canEdit: PropTypes.bool.isRequired,
onUpdate: PropTypes.func.isRequired, onUpdate: PropTypes.func.isRequired,
onSort: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired, onDelete: PropTypes.func.isRequired,
onCardCreate: PropTypes.func.isRequired, onCardCreate: PropTypes.func.isRequired,
}; };
@@ -43,7 +43,6 @@
max-height: calc(100vh - 268px); max-height: calc(100vh - 268px);
overflow-x: hidden; overflow-x: hidden;
overflow-y: auto; overflow-y: auto;
scrollbar-width: thin;
width: 290px; width: 290px;
&:hover { &:hover {
@@ -0,0 +1,61 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { Menu } from 'semantic-ui-react';
import { Popup } from '../../lib/custom-ui';
import { ListSortTypes } from '../../constants/Enums';
import styles from './ListSortStep.module.scss';
const ListSortStep = React.memo(({ onTypeSelect, onBack }) => {
const [t] = useTranslation();
return (
<>
<Popup.Header onBack={onBack}>
{t('common.sortList', {
context: 'title',
})}
</Popup.Header>
<Popup.Content>
<Menu secondary vertical className={styles.menu}>
<Menu.Item
className={styles.menuItem}
onClick={() => onTypeSelect(ListSortTypes.NAME_ASC)}
>
{t('common.title')}
</Menu.Item>
<Menu.Item
className={styles.menuItem}
onClick={() => onTypeSelect(ListSortTypes.DUE_DATE_ASC)}
>
{t('common.dueDate')}
</Menu.Item>
<Menu.Item
className={styles.menuItem}
onClick={() => onTypeSelect(ListSortTypes.CREATED_AT_ASC)}
>
{t('common.oldestFirst')}
</Menu.Item>
<Menu.Item
className={styles.menuItem}
onClick={() => onTypeSelect(ListSortTypes.CREATED_AT_DESC)}
>
{t('common.newestFirst')}
</Menu.Item>
</Menu>
</Popup.Content>
</>
);
});
ListSortStep.propTypes = {
onTypeSelect: PropTypes.func.isRequired,
onBack: PropTypes.func,
};
ListSortStep.defaultProps = {
onBack: undefined,
};
export default ListSortStep;
@@ -0,0 +1,11 @@
:global(#app) {
.menu {
margin: -7px -12px -5px;
width: calc(100% + 24px);
}
.menuItem {
margin: 0;
padding-left: 14px;
}
}
@@ -0,0 +1,3 @@
import ListSortStep from './ListSortStep';
export default ListSortStep;
+88 -39
View File
@@ -3,7 +3,7 @@ import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import classNames from 'classnames'; import classNames from 'classnames';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Form, Grid, Header, Message } from 'semantic-ui-react'; import { Button, Form, Grid, Header, Message } from 'semantic-ui-react';
import { useDidUpdate, usePrevious, useToggle } from '../../lib/hooks'; import { useDidUpdate, usePrevious, useToggle } from '../../lib/hooks';
import { Input } from '../../lib/custom-ui'; import { Input } from '../../lib/custom-ui';
@@ -28,6 +28,21 @@ const createMessage = (error) => {
type: 'error', type: 'error',
content: 'common.invalidPassword', content: 'common.invalidPassword',
}; };
case 'Use single sign-on':
return {
type: 'error',
content: 'common.useSingleSignOn',
};
case 'Email already in use':
return {
type: 'error',
content: 'common.emailAlreadyInUse',
};
case 'Username already in use':
return {
type: 'error',
content: 'common.usernameAlreadyInUse',
};
case 'Failed to fetch': case 'Failed to fetch':
return { return {
type: 'warning', type: 'warning',
@@ -47,7 +62,17 @@ const createMessage = (error) => {
}; };
const Login = React.memo( const Login = React.memo(
({ defaultData, isSubmitting, error, onAuthenticate, onMessageDismiss }) => { ({
defaultData,
isSubmitting,
isSubmittingUsingOidc,
error,
withOidc,
isOidcEnforced,
onAuthenticate,
onAuthenticateUsingOidc,
onMessageDismiss,
}) => {
const [t] = useTranslation(); const [t] = useTranslation();
const wasSubmitting = usePrevious(isSubmitting); const wasSubmitting = usePrevious(isSubmitting);
@@ -83,8 +108,10 @@ const Login = React.memo(
}, [onAuthenticate, data]); }, [onAuthenticate, data]);
useEffect(() => { useEffect(() => {
emailOrUsernameField.current.focus(); if (!isOidcEnforced) {
}, []); emailOrUsernameField.current.focus();
}
}, [isOidcEnforced]);
useEffect(() => { useEffect(() => {
if (wasSubmitting && !isSubmitting && error) { if (wasSubmitting && !isSubmitting && error) {
@@ -135,42 +162,58 @@ const Login = React.memo(
onDismiss={onMessageDismiss} onDismiss={onMessageDismiss}
/> />
)} )}
<Form size="large" onSubmit={handleSubmit}> {!isOidcEnforced && (
<div className={styles.inputWrapper}> <Form size="large" onSubmit={handleSubmit}>
<div className={styles.inputLabel}>{t('common.emailOrUsername')}</div> <div className={styles.inputWrapper}>
<Input <div className={styles.inputLabel}>{t('common.emailOrUsername')}</div>
fluid <Input
ref={emailOrUsernameField} fluid
name="emailOrUsername" ref={emailOrUsernameField}
value={data.emailOrUsername} name="emailOrUsername"
readOnly={isSubmitting} value={data.emailOrUsername}
className={styles.input} readOnly={isSubmitting}
onChange={handleFieldChange} className={styles.input}
onChange={handleFieldChange}
/>
</div>
<div className={styles.inputWrapper}>
<div className={styles.inputLabel}>{t('common.password')}</div>
<Input.Password
fluid
ref={passwordField}
name="password"
value={data.password}
readOnly={isSubmitting}
className={styles.input}
onChange={handleFieldChange}
/>
</div>
<Form.Button
primary
size="large"
icon="right arrow"
labelPosition="right"
content={t('action.logIn')}
floated="right"
loading={isSubmitting}
disabled={isSubmitting || isSubmittingUsingOidc}
/> />
</div> </Form>
<div className={styles.inputWrapper}> )}
<div className={styles.inputLabel}>{t('common.password')}</div> {withOidc && (
<Input.Password <Button
fluid type="button"
ref={passwordField} fluid={isOidcEnforced}
name="password" primary={isOidcEnforced}
value={data.password} size={isOidcEnforced ? 'large' : undefined}
readOnly={isSubmitting} icon={isOidcEnforced ? 'right arrow' : undefined}
className={styles.input} labelPosition={isOidcEnforced ? 'right' : undefined}
onChange={handleFieldChange} content={t('action.logInWithSSO')}
/> loading={isSubmittingUsingOidc}
</div> disabled={isSubmitting || isSubmittingUsingOidc}
<Form.Button onClick={onAuthenticateUsingOidc}
primary
size="large"
icon="right arrow"
labelPosition="right"
content={t('action.logIn')}
floated="right"
loading={isSubmitting}
disabled={isSubmitting}
/> />
</Form> )}
</div> </div>
</div> </div>
</Grid.Column> </Grid.Column>
@@ -201,10 +244,16 @@ const Login = React.memo(
); );
Login.propTypes = { Login.propTypes = {
defaultData: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types /* eslint-disable react/forbid-prop-types */
defaultData: PropTypes.object.isRequired,
/* eslint-enable react/forbid-prop-types */
isSubmitting: PropTypes.bool.isRequired, isSubmitting: PropTypes.bool.isRequired,
isSubmittingUsingOidc: PropTypes.bool.isRequired,
error: PropTypes.object, // eslint-disable-line react/forbid-prop-types error: PropTypes.object, // eslint-disable-line react/forbid-prop-types
withOidc: PropTypes.bool.isRequired,
isOidcEnforced: PropTypes.bool.isRequired,
onAuthenticate: PropTypes.func.isRequired, onAuthenticate: PropTypes.func.isRequired,
onAuthenticateUsingOidc: PropTypes.func.isRequired,
onMessageDismiss: PropTypes.func.isRequired, onMessageDismiss: PropTypes.func.isRequired,
}; };
+19
View File
@@ -0,0 +1,19 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Loader } from 'semantic-ui-react';
import LoginContainer from '../containers/LoginContainer';
const LoginWrapper = React.memo(({ isInitializing }) => {
if (isInitializing) {
return <Loader active size="massive" />;
}
return <LoginContainer />;
});
LoginWrapper.propTypes = {
isInitializing: PropTypes.bool.isRequired,
};
export default LoginWrapper;
@@ -3,6 +3,7 @@ import React, { useCallback } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button } from 'semantic-ui-react'; import { Button } from 'semantic-ui-react';
import { Popup } from '../../lib/custom-ui';
import { useSteps } from '../../hooks'; import { useSteps } from '../../hooks';
import User from '../User'; import User from '../User';
@@ -19,6 +20,7 @@ const ActionsStep = React.memo(
({ ({
membership, membership,
permissionsSelectStep, permissionsSelectStep,
title,
leaveButtonContent, leaveButtonContent,
leaveConfirmationTitle, leaveConfirmationTitle,
leaveConfirmationContent, leaveConfirmationContent,
@@ -31,6 +33,7 @@ const ActionsStep = React.memo(
canLeave, canLeave,
onUpdate, onUpdate,
onDelete, onDelete,
onBack,
onClose, onClose,
}) => { }) => {
const [t] = useTranslation(); const [t] = useTranslation();
@@ -53,6 +56,11 @@ const ActionsStep = React.memo(
[onUpdate], [onUpdate],
); );
const handleDeleteConfirm = useCallback(() => {
onDelete();
onClose();
}, [onDelete, onClose]);
if (step) { if (step) {
switch (step.type) { switch (step.type) {
case StepTypes.EDIT_PERMISSIONS: { case StepTypes.EDIT_PERMISSIONS: {
@@ -81,7 +89,7 @@ const ActionsStep = React.memo(
? leaveConfirmationButtonContent ? leaveConfirmationButtonContent
: deleteConfirmationButtonContent : deleteConfirmationButtonContent
} }
onConfirm={onDelete} onConfirm={handleDeleteConfirm}
onBack={handleBack} onBack={handleBack}
/> />
); );
@@ -89,7 +97,7 @@ const ActionsStep = React.memo(
} }
} }
return ( const contentNode = (
<> <>
<span className={styles.user}> <span className={styles.user}>
<User name={membership.user.name} avatarUrl={membership.user.avatarUrl} size="large" /> <User name={membership.user.name} avatarUrl={membership.user.avatarUrl} size="large" />
@@ -125,12 +133,26 @@ const ActionsStep = React.memo(
)} )}
</> </>
); );
return onBack ? (
<>
<Popup.Header onBack={onBack}>
{t(title, {
context: 'title',
})}
</Popup.Header>
<Popup.Content>{contentNode}</Popup.Content>
</>
) : (
contentNode
);
}, },
); );
ActionsStep.propTypes = { ActionsStep.propTypes = {
membership: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types membership: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
permissionsSelectStep: PropTypes.elementType, permissionsSelectStep: PropTypes.elementType,
title: PropTypes.string,
leaveButtonContent: PropTypes.string, leaveButtonContent: PropTypes.string,
leaveConfirmationTitle: PropTypes.string, leaveConfirmationTitle: PropTypes.string,
leaveConfirmationContent: PropTypes.string, leaveConfirmationContent: PropTypes.string,
@@ -143,11 +165,13 @@ ActionsStep.propTypes = {
canLeave: PropTypes.bool.isRequired, canLeave: PropTypes.bool.isRequired,
onUpdate: PropTypes.func, onUpdate: PropTypes.func,
onDelete: PropTypes.func.isRequired, onDelete: PropTypes.func.isRequired,
onBack: PropTypes.func,
onClose: PropTypes.func.isRequired, onClose: PropTypes.func.isRequired,
}; };
ActionsStep.defaultProps = { ActionsStep.defaultProps = {
permissionsSelectStep: undefined, permissionsSelectStep: undefined,
title: 'common.memberActions',
leaveButtonContent: 'action.leaveBoard', leaveButtonContent: 'action.leaveBoard',
leaveConfirmationTitle: 'common.leaveBoard', leaveConfirmationTitle: 'common.leaveBoard',
leaveConfirmationContent: 'common.areYouSureYouWantToLeaveBoard', leaveConfirmationContent: 'common.areYouSureYouWantToLeaveBoard',
@@ -157,6 +181,7 @@ ActionsStep.defaultProps = {
deleteConfirmationContent: 'common.areYouSureYouWantToRemoveThisMemberFromBoard', deleteConfirmationContent: 'common.areYouSureYouWantToRemoveThisMemberFromBoard',
deleteConfirmationButtonContent: 'action.removeMember', deleteConfirmationButtonContent: 'action.removeMember',
onUpdate: undefined, onUpdate: undefined,
onBack: undefined,
}; };
export default ActionsStep; export default ActionsStep;
@@ -4,7 +4,6 @@
max-height: 60vh; max-height: 60vh;
overflow-x: hidden; overflow-x: hidden;
overflow-y: auto; overflow-y: auto;
scrollbar-width: thin;
&::-webkit-scrollbar { &::-webkit-scrollbar {
width: 5px; width: 5px;
@@ -5,16 +5,21 @@ import { usePopup } from '../../lib/popup';
import AddStep from './AddStep'; import AddStep from './AddStep';
import ActionsStep from './ActionsStep'; import ActionsStep from './ActionsStep';
import MembershipsStep from './MembershipsStep';
import User from '../User'; import User from '../User';
import styles from './Memberships.module.scss'; import styles from './Memberships.module.scss';
const MAX_MEMBERS = 6;
const Memberships = React.memo( const Memberships = React.memo(
({ ({
items, items,
allUsers, allUsers,
permissionsSelectStep, permissionsSelectStep,
title,
addTitle, addTitle,
actionsTitle,
leaveButtonContent, leaveButtonContent,
leaveConfirmationTitle, leaveConfirmationTitle,
leaveConfirmationContent, leaveConfirmationContent,
@@ -31,11 +36,14 @@ const Memberships = React.memo(
}) => { }) => {
const AddPopup = usePopup(AddStep); const AddPopup = usePopup(AddStep);
const ActionsPopup = usePopup(ActionsStep); const ActionsPopup = usePopup(ActionsStep);
const MembershipsPopup = usePopup(MembershipsStep);
const remainMembersCount = items.length - MAX_MEMBERS;
return ( return (
<> <>
<span className={styles.users}> <span className={styles.users}>
{items.map((item) => ( {items.slice(0, MAX_MEMBERS).map((item) => (
<span key={item.id} className={styles.user}> <span key={item.id} className={styles.user}>
<ActionsPopup <ActionsPopup
membership={item} membership={item}
@@ -63,6 +71,30 @@ const Memberships = React.memo(
</span> </span>
))} ))}
</span> </span>
{remainMembersCount > 0 && (
<MembershipsPopup
items={items}
permissionsSelectStep={permissionsSelectStep}
title={title}
actionsTitle={actionsTitle}
leaveButtonContent={leaveButtonContent}
leaveConfirmationTitle={leaveConfirmationTitle}
leaveConfirmationContent={leaveConfirmationContent}
leaveConfirmationButtonContent={leaveConfirmationButtonContent}
deleteButtonContent={deleteButtonContent}
deleteConfirmationTitle={deleteConfirmationTitle}
deleteConfirmationContent={deleteConfirmationContent}
deleteConfirmationButtonContent={deleteConfirmationButtonContent}
canEdit={canEdit}
canLeave={items.length > 1 || canLeaveIfLast}
onUpdate={onUpdate}
onDelete={onDelete}
>
<Button icon className={styles.addUser}>
+{remainMembersCount < 99 ? remainMembersCount : 99}
</Button>
</MembershipsPopup>
)}
{canEdit && ( {canEdit && (
<AddPopup <AddPopup
users={allUsers} users={allUsers}
@@ -85,7 +117,9 @@ Memberships.propTypes = {
allUsers: PropTypes.array.isRequired, allUsers: PropTypes.array.isRequired,
/* eslint-enable react/forbid-prop-types */ /* eslint-enable react/forbid-prop-types */
permissionsSelectStep: PropTypes.elementType, permissionsSelectStep: PropTypes.elementType,
title: PropTypes.string,
addTitle: PropTypes.string, addTitle: PropTypes.string,
actionsTitle: PropTypes.string,
leaveButtonContent: PropTypes.string, leaveButtonContent: PropTypes.string,
leaveConfirmationTitle: PropTypes.string, leaveConfirmationTitle: PropTypes.string,
leaveConfirmationContent: PropTypes.string, leaveConfirmationContent: PropTypes.string,
@@ -103,7 +137,9 @@ Memberships.propTypes = {
Memberships.defaultProps = { Memberships.defaultProps = {
permissionsSelectStep: undefined, permissionsSelectStep: undefined,
title: undefined,
addTitle: undefined, addTitle: undefined,
actionsTitle: undefined,
leaveButtonContent: undefined, leaveButtonContent: undefined,
leaveConfirmationTitle: undefined, leaveConfirmationTitle: undefined,
leaveConfirmationContent: undefined, leaveConfirmationContent: undefined,
@@ -0,0 +1,121 @@
import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import { useSteps } from '../../hooks';
import ActionsStep from './ActionsStep';
import BoardMembershipsStep from '../BoardMembershipsStep';
const StepTypes = {
EDIT: 'EDIT',
};
const MembershipsStep = React.memo(
({
items,
permissionsSelectStep,
title,
actionsTitle,
leaveButtonContent,
leaveConfirmationTitle,
leaveConfirmationContent,
leaveConfirmationButtonContent,
deleteButtonContent,
deleteConfirmationTitle,
deleteConfirmationContent,
deleteConfirmationButtonContent,
canEdit,
canLeave,
onUpdate,
onDelete,
onClose,
}) => {
const [step, openStep, handleBack] = useSteps();
const handleUserSelect = useCallback(
(userId) => {
openStep(StepTypes.EDIT, {
userId,
});
},
[openStep],
);
if (step && step.type === StepTypes.EDIT) {
const currentItem = items.find((item) => item.userId === step.params.userId);
if (currentItem) {
return (
<ActionsStep
membership={currentItem}
permissionsSelectStep={permissionsSelectStep}
title={actionsTitle}
leaveButtonContent={leaveButtonContent}
leaveConfirmationTitle={leaveConfirmationTitle}
leaveConfirmationContent={leaveConfirmationContent}
leaveConfirmationButtonContent={leaveConfirmationButtonContent}
deleteButtonContent={deleteButtonContent}
deleteConfirmationTitle={deleteConfirmationTitle}
deleteConfirmationContent={deleteConfirmationContent}
deleteConfirmationButtonContent={deleteConfirmationButtonContent}
canEdit={canEdit}
canLeave={canLeave}
onUpdate={(data) => onUpdate(currentItem.id, data)}
onDelete={() => onDelete(currentItem.id)}
onBack={handleBack}
onClose={onClose}
/>
);
}
openStep(null);
}
return (
// FIXME: hack
<BoardMembershipsStep
items={items}
currentUserIds={[]}
title={title}
onUserSelect={handleUserSelect}
onUserDeselect={() => {}}
/>
);
},
);
MembershipsStep.propTypes = {
items: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types
permissionsSelectStep: PropTypes.elementType,
title: PropTypes.string,
actionsTitle: PropTypes.string,
leaveButtonContent: PropTypes.string,
leaveConfirmationTitle: PropTypes.string,
leaveConfirmationContent: PropTypes.string,
leaveConfirmationButtonContent: PropTypes.string,
deleteButtonContent: PropTypes.string,
deleteConfirmationTitle: PropTypes.string,
deleteConfirmationContent: PropTypes.string,
deleteConfirmationButtonContent: PropTypes.string,
canEdit: PropTypes.bool.isRequired,
canLeave: PropTypes.bool.isRequired,
onUpdate: PropTypes.func,
onDelete: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
};
MembershipsStep.defaultProps = {
permissionsSelectStep: undefined,
title: undefined,
actionsTitle: undefined,
leaveButtonContent: undefined,
leaveConfirmationTitle: undefined,
leaveConfirmationContent: undefined,
leaveConfirmationButtonContent: undefined,
deleteButtonContent: undefined,
deleteConfirmationTitle: undefined,
deleteConfirmationContent: undefined,
deleteConfirmationButtonContent: undefined,
onUpdate: undefined,
};
export default MembershipsStep;
@@ -12,7 +12,9 @@ const ManagersPane = React.memo(({ items, allUsers, onCreate, onDelete }) => {
<Memberships <Memberships
items={items} items={items}
allUsers={allUsers} allUsers={allUsers}
title="common.managers"
addTitle="common.addManager" addTitle="common.addManager"
actionsTitle="common.managerActions"
leaveButtonContent="action.leaveProject" leaveButtonContent="action.leaveProject"
leaveConfirmationTitle="common.leaveProject" leaveConfirmationTitle="common.leaveProject"
leaveConfirmationContent="common.areYouSureYouWantToLeaveProject" leaveConfirmationContent="common.areYouSureYouWantToLeaveProject"
+4 -3
View File
@@ -5,7 +5,7 @@ import { Route, Routes } from 'react-router-dom';
import { ReduxRouter } from '../lib/redux-router'; import { ReduxRouter } from '../lib/redux-router';
import Paths from '../constants/Paths'; import Paths from '../constants/Paths';
import LoginContainer from '../containers/LoginContainer'; import LoginWrapperContainer from '../containers/LoginWrapperContainer';
import CoreContainer from '../containers/CoreContainer'; import CoreContainer from '../containers/CoreContainer';
import NotFound from './NotFound'; import NotFound from './NotFound';
@@ -13,7 +13,7 @@ import 'react-datepicker/dist/react-datepicker.css';
import 'photoswipe/dist/photoswipe.css'; import 'photoswipe/dist/photoswipe.css';
import 'easymde/dist/easymde.min.css'; import 'easymde/dist/easymde.min.css';
import '../lib/custom-ui/styles.css'; import '../lib/custom-ui/styles.css';
import '../assets/css/font-awesome.css';
import '../styles.module.scss'; import '../styles.module.scss';
function Root({ store, history }) { function Root({ store, history }) {
@@ -21,7 +21,8 @@ function Root({ store, history }) {
<Provider store={store}> <Provider store={store}>
<ReduxRouter history={history}> <ReduxRouter history={history}>
<Routes> <Routes>
<Route path={Paths.LOGIN} element={<LoginContainer />} /> <Route path={Paths.LOGIN} element={<LoginWrapperContainer />} />
<Route path={Paths.OIDC_CALLBACK} element={<LoginWrapperContainer />} />
<Route path={Paths.ROOT} element={<CoreContainer />} /> <Route path={Paths.ROOT} element={<CoreContainer />} />
<Route path={Paths.PROJECTS} element={<CoreContainer />} /> <Route path={Paths.PROJECTS} element={<CoreContainer />} />
<Route path={Paths.BOARDS} element={<CoreContainer />} /> <Route path={Paths.BOARDS} element={<CoreContainer />} />
@@ -4,9 +4,9 @@ import PropTypes from 'prop-types';
import classNames from 'classnames'; import classNames from 'classnames';
import { useForceUpdate, usePrevious } from '../../lib/hooks'; import { useForceUpdate, usePrevious } from '../../lib/hooks';
import { formatTimer } from '../../utils/timer'; import { formatStopwatch } from '../../utils/stopwatch';
import styles from './Timer.module.scss'; import styles from './Stopwatch.module.scss';
const SIZES = { const SIZES = {
TINY: 'tiny', TINY: 'tiny',
@@ -14,7 +14,7 @@ const SIZES = {
MEDIUM: 'medium', MEDIUM: 'medium',
}; };
const Timer = React.memo(({ as, startedAt, total, size, isDisabled, onClick }) => { const Stopwatch = React.memo(({ as, startedAt, total, size, isDisabled, onClick }) => {
const prevStartedAt = usePrevious(startedAt); const prevStartedAt = usePrevious(startedAt);
const forceUpdate = useForceUpdate(); const forceUpdate = useForceUpdate();
@@ -56,7 +56,7 @@ const Timer = React.memo(({ as, startedAt, total, size, isDisabled, onClick }) =
onClick && styles.wrapperHoverable, onClick && styles.wrapperHoverable,
)} )}
> >
{formatTimer({ startedAt, total })} {formatStopwatch({ startedAt, total })}
</span> </span>
); );
@@ -71,7 +71,7 @@ const Timer = React.memo(({ as, startedAt, total, size, isDisabled, onClick }) =
); );
}); });
Timer.propTypes = { Stopwatch.propTypes = {
as: PropTypes.elementType, as: PropTypes.elementType,
startedAt: PropTypes.instanceOf(Date), startedAt: PropTypes.instanceOf(Date),
total: PropTypes.number.isRequired, // eslint-disable-line react/no-unused-prop-types total: PropTypes.number.isRequired, // eslint-disable-line react/no-unused-prop-types
@@ -80,7 +80,7 @@ Timer.propTypes = {
onClick: PropTypes.func, onClick: PropTypes.func,
}; };
Timer.defaultProps = { Stopwatch.defaultProps = {
as: 'button', as: 'button',
startedAt: undefined, startedAt: undefined,
size: SIZES.MEDIUM, size: SIZES.MEDIUM,
@@ -88,4 +88,4 @@ Timer.defaultProps = {
onClick: undefined, onClick: undefined,
}; };
export default Timer; export default Stopwatch;
+3
View File
@@ -0,0 +1,3 @@
import Stopwatch from './Stopwatch';
export default Stopwatch;
@@ -7,12 +7,18 @@ import { useToggle } from '../../lib/hooks';
import { Input, Popup } from '../../lib/custom-ui'; import { Input, Popup } from '../../lib/custom-ui';
import { useForm } from '../../hooks'; import { useForm } from '../../hooks';
import { createTimer, getTimerParts, startTimer, stopTimer, updateTimer } from '../../utils/timer'; import {
createStopwatch,
getStopwatchParts,
startStopwatch,
stopStopwatch,
updateStopwatch,
} from '../../utils/stopwatch';
import styles from './TimerEditStep.module.scss'; import styles from './StopwatchEditStep.module.scss';
const createData = (timer) => { const createData = (stopwatch) => {
if (!timer) { if (!stopwatch) {
return { return {
hours: '0', hours: '0',
minutes: '0', minutes: '0',
@@ -20,7 +26,7 @@ const createData = (timer) => {
}; };
} }
const { hours, minutes, seconds } = getTimerParts(timer); const { hours, minutes, seconds } = getStopwatchParts(stopwatch);
return { return {
hours: `${hours}`, hours: `${hours}`,
@@ -29,7 +35,7 @@ const createData = (timer) => {
}; };
}; };
const TimerEditStep = React.memo(({ defaultValue, onUpdate, onBack, onClose }) => { const StopwatchEditStep = React.memo(({ defaultValue, onUpdate, onBack, onClose }) => {
const [t] = useTranslation(); const [t] = useTranslation();
const [data, handleFieldChange, setData] = useForm(() => createData(defaultValue)); const [data, handleFieldChange, setData] = useForm(() => createData(defaultValue));
const [isEditing, toggleEditing] = useToggle(); const [isEditing, toggleEditing] = useToggle();
@@ -39,12 +45,12 @@ const TimerEditStep = React.memo(({ defaultValue, onUpdate, onBack, onClose }) =
const secondsField = useRef(null); const secondsField = useRef(null);
const handleStartClick = useCallback(() => { const handleStartClick = useCallback(() => {
onUpdate(startTimer(defaultValue)); onUpdate(startStopwatch(defaultValue));
onClose(); onClose();
}, [defaultValue, onUpdate, onClose]); }, [defaultValue, onUpdate, onClose]);
const handleStopClick = useCallback(() => { const handleStopClick = useCallback(() => {
onUpdate(stopTimer(defaultValue)); onUpdate(stopStopwatch(defaultValue));
}, [defaultValue, onUpdate]); }, [defaultValue, onUpdate]);
const handleClearClick = useCallback(() => { const handleClearClick = useCallback(() => {
@@ -83,11 +89,11 @@ const TimerEditStep = React.memo(({ defaultValue, onUpdate, onBack, onClose }) =
} }
if (defaultValue) { if (defaultValue) {
if (!dequal(parts, getTimerParts(defaultValue))) { if (!dequal(parts, getStopwatchParts(defaultValue))) {
onUpdate(updateTimer(defaultValue, parts)); onUpdate(updateStopwatch(defaultValue, parts));
} }
} else { } else {
onUpdate(createTimer(parts)); onUpdate(createStopwatch(parts));
} }
onClose(); onClose();
@@ -102,7 +108,7 @@ const TimerEditStep = React.memo(({ defaultValue, onUpdate, onBack, onClose }) =
return ( return (
<> <>
<Popup.Header onBack={onBack}> <Popup.Header onBack={onBack}>
{t('common.editTimer', { {t('common.editStopwatch', {
context: 'title', context: 'title',
})} })}
</Popup.Header> </Popup.Header>
@@ -171,16 +177,16 @@ const TimerEditStep = React.memo(({ defaultValue, onUpdate, onBack, onClose }) =
); );
}); });
TimerEditStep.propTypes = { StopwatchEditStep.propTypes = {
defaultValue: PropTypes.object, // eslint-disable-line react/forbid-prop-types defaultValue: PropTypes.object, // eslint-disable-line react/forbid-prop-types
onUpdate: PropTypes.func.isRequired, onUpdate: PropTypes.func.isRequired,
onBack: PropTypes.func, onBack: PropTypes.func,
onClose: PropTypes.func.isRequired, onClose: PropTypes.func.isRequired,
}; };
TimerEditStep.defaultProps = { StopwatchEditStep.defaultProps = {
defaultValue: undefined, defaultValue: undefined,
onBack: undefined, onBack: undefined,
}; };
export default TimerEditStep; export default StopwatchEditStep;

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