1
0
Fork 0
mirror of https://github.com/plankanban/planka.git synced 2025-07-18 20:59:44 +02:00

Compare commits

...

84 commits

Author SHA1 Message Date
Maksim Eltyshev
fdac299fc7 feat: Open card actions on right-click 2025-07-17 17:43:01 +02:00
Maksim Eltyshev
e659ed4a2d fix: Add icons to popup menu items 2025-07-17 15:23:53 +02:00
Maksim Eltyshev
adf60c0c74 fix: Enable spellcheck on all textareas 2025-07-17 14:18:30 +02:00
uppertoe
71b4fd32cc
fix: Fix typo in English translations (#1259) 2025-07-16 12:18:56 +02:00
Maksim Eltyshev
b3f226c10b fix: Fix red curtain background gradient 2025-07-15 17:42:46 +02:00
Symon Baikov
b4be12eb28
fix: Update Greek translation (#1255) 2025-07-15 13:13:52 +02:00
Maksim Eltyshev
3aba4d4a56 ref: Little refactoring 2025-07-14 14:54:06 +02:00
Symon Baikov
70cadcd974
fix: Update Finnish translation (#1251) 2025-07-11 12:16:22 +02:00
Maksim Eltyshev
230f50e3d9 feat: Add ability to link tasks to cards 2025-07-11 01:04:02 +02:00
HanJiang-cn
49203e9d56
fix: Update Chinese translation (#1250) 2025-07-09 17:53:58 +02:00
Maksim Eltyshev
709a0d1758 feat: Persist closed state per card 2025-07-09 17:45:47 +02:00
Maksim Eltyshev
69c75a03b1 fix: Fix toolbar styles in markdown markup editor 2025-07-08 17:26:21 +02:00
Daniel Silvestre
4d40af9c8a
feat: Display card name when linking in task (#1234) 2025-07-08 17:15:48 +02:00
Maksim Eltyshev
b76ca9547f fix: Handle webhooks correctly during socket reconnection 2025-07-08 12:41:27 +02:00
Maksim Eltyshev
9d07178e57 fix: Move webhooks declaration to correct position 2025-07-08 00:22:24 +02:00
Maksim Eltyshev
4346b7040a feat: Add INTERNAL_ACCESS_TOKEN to support internal user configuration 2025-07-07 21:35:37 +02:00
Winston-1874
b61b4e658f
fix: Update French translation (#1242) 2025-07-07 13:01:25 +02:00
Symon Baikov
a8b9ac8165
fix: Update Turkish translation (#1239) 2025-07-07 12:03:14 +02:00
Daniel Silvestre
a55fc08c43
fix: Update French translation (#1238) 2025-07-07 12:00:36 +02:00
Symon Baikov
76c7be5063
fix: Update German translation (#1235) 2025-07-07 11:47:21 +02:00
Maksim Eltyshev
b22dba0d11 feat: Move webhooks configuration from environment variable to UI 2025-07-04 22:04:11 +02:00
Maksim Eltyshev
f0680831c2 fix: Improve PDF viewer cross-browser compatibility
Closes #1219
2025-07-02 18:26:03 +02:00
Maksim Eltyshev
e58a9f5d21 chore: Update dependencies 2025-06-30 12:43:06 +02:00
dependabot[bot]
463b6284c7
build(deps): Bump pbkdf2 from 3.1.2 to 3.1.3 in /client (#1223)
Bumps [pbkdf2](https://github.com/crypto-browserify/pbkdf2) from 3.1.2 to 3.1.3.
- [Changelog](https://github.com/browserify/pbkdf2/blob/master/CHANGELOG.md)
- [Commits](https://github.com/crypto-browserify/pbkdf2/compare/v3.1.2...v3.1.3)

---
updated-dependencies:
- dependency-name: pbkdf2
  dependency-version: 3.1.3
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-30 12:23:57 +02:00
Maksim Eltyshev
f5653cde80 chore: Add missing environment variables for OIDC roles 2025-06-30 12:07:44 +02:00
Roman T
b37fc61b12
docs: Add example SMTP config using extraEnv in Helm (#1217) 2025-06-18 15:11:03 +02:00
Maksim Eltyshev
6988864808 fix: Fix filtering by task assignees 2025-06-17 16:04:22 +02:00
millenium-codebug
c8cb1f4a20
feat: Include task assignees in member filter logic (#1214) 2025-06-16 23:16:57 +02:00
Maksim Eltyshev
774bdc2b64 fix: Display tasks with preserved newlines in card modal 2025-06-16 22:44:27 +02:00
Maksim Eltyshev
b2e8c453b7 fix: Use correct action to clear user creation error 2025-06-16 22:37:41 +02:00
Maksim Eltyshev
8df49a636a build: Use Node.js v22 2025-06-16 21:32:54 +02:00
Symon Baikov
2d1a8658bb
feat: Add Estonian translation (#1212) 2025-06-16 20:39:50 +02:00
Maksim Eltyshev
3126a40bba feat: Add copy-to-clipboard for custom fields 2025-06-11 00:14:42 +02:00
Maksim Eltyshev
99a06ce1ae feat: Make card descriptions expandable 2025-06-11 00:09:33 +02:00
Maksim Eltyshev
98a7f1c9a9 feat: Strikethrough cards in closed lists
Closes #1207
2025-06-10 23:43:26 +02:00
Symon Baikov
bdac15240f
fix: Update Serbian (Cyrillic) translation (#1206) 2025-06-10 18:54:23 +02:00
Maksim Eltyshev
5a3a4240c6 fix: Add cancel button when editing comment 2025-06-10 14:00:08 +02:00
Maksim Eltyshev
144731ccb4 fix: Increase max username length for identity provider users
Closes #1174
2025-06-10 13:49:23 +02:00
Symon Baikov
e24bb845c6
fix: Update Slovak translation (#1205) 2025-06-10 12:18:24 +02:00
Symon Baikov
240b01e694
fix: Update Romanian translation (#1203) 2025-06-09 23:42:44 +02:00
Symon Baikov
07483909b4
fix: Update Brazilian Portuguese translation (#1202) 2025-06-09 23:40:35 +02:00
Symon Baikov
84dc0385af
fix: Update Dutch translation (#1201) 2025-06-09 12:35:31 +02:00
Symon Baikov
041199af8b
fix: Update Korean translation (#1197) 2025-06-09 12:33:51 +02:00
Symon Baikov
f72ac437b9
fix: Update Japanese translation (#1196) 2025-06-09 12:32:17 +02:00
Symon Baikov
128028c848
fix: Update Italian translation (#1195) 2025-06-09 12:30:12 +02:00
Symon Baikov
0f1e7ecc2c
fix: Update Indonesian translation (#1194) 2025-06-08 13:11:03 +02:00
Symon Baikov
9ca49f1f51
fix: Update Hungarian translation (#1193) 2025-06-08 13:09:31 +02:00
Symon Baikov
8447f0c884
fix: Update French translation (#1192) 2025-06-08 13:08:17 +02:00
Symon Baikov
180984799c
fix: Update Spanish translation (#1189) 2025-06-08 13:06:40 +02:00
Maksim Eltyshev
2b86484421 fix: FIx deps in download attachment handler 2025-06-07 19:23:15 +02:00
Maksim Eltyshev
a6820162fb fix: Fix mentions input styles 2025-06-07 18:03:36 +02:00
Symon Baikov
037a9101c2
fix: Update Bulgarian translation (#1187) 2025-06-07 16:33:15 +02:00
Symon Baikov
754629e6a9
fix: Update Czech translation (#1185) 2025-06-07 13:52:33 +02:00
Symon Baikov
8bc1569242
fix: Responsive label color selector, improve action button styles (#1184) 2025-06-07 13:44:45 +02:00
Maksim Eltyshev
1a8dcd9858 fix: Update Chinese translation
Closes #1181
2025-06-06 18:20:40 +02:00
Maksim Eltyshev
edda5dda79 ref: Format translations 2025-06-06 18:17:25 +02:00
Symon Baikov
6dbce8c790
feat: Add Finnish translation (#1180) 2025-06-06 18:00:13 +02:00
Symon Baikov
b696fe0e4d
feat: Add Greek translation (#1176) 2025-06-06 14:14:00 +02:00
Maksim Eltyshev
7286ecaf35 fix: Align text to center in content error message 2025-06-06 13:54:01 +02:00
Maksim Eltyshev
2e6658221f fix: Handle escape actions properly in mentions input 2025-06-06 12:48:43 +02:00
Maksim Eltyshev
63de346b0e ref: Little refactoring 2025-06-06 12:34:09 +02:00
Christian T R
585464eef3
fix: Update Danish translation (#1177) 2025-06-06 12:07:02 +02:00
Maksim Eltyshev
c2bd31d523 fix: Use console as logger for YFM note
Closes #1178
2025-06-06 11:49:35 +02:00
Maksim Eltyshev
21f90610bc fix: Prevent auto-filling title in YFM note
Closes #1172
2025-06-05 15:10:46 +02:00
Christian T R
608a7c983f
fix: Update Danish translation (#1173) 2025-06-05 14:06:45 +02:00
Maksim Eltyshev
dd5e0f448f fix: Prevent text overflow in card name 2025-06-05 00:01:34 +02:00
Maksim Eltyshev
c4a48d510b feat: Add download button for file attachments 2025-06-05 00:00:36 +02:00
Mario Aparcero
2f62d56242
fix: Update Spanish translation (#1170) 2025-06-04 22:45:28 +02:00
Maksim Eltyshev
4eb8b75c24 license: Remove ambiguity in usage restrictions 2025-06-04 21:04:00 +02:00
Maksim Eltyshev
49770ea9ec ref: Move comment counter logic into Card model 2025-06-04 18:38:39 +02:00
Anthony
40a84d0c8a
feat: Allow to add custom annotations for deployment in Helm (#1168) 2025-06-04 12:25:43 +02:00
Maksim Eltyshev
9690f7b73f fix: Prevent text overflow in activities 2025-06-03 23:15:07 +02:00
Maksim Eltyshev
04b97b66cb chore: Update dependencies 2025-06-03 13:02:16 +02:00
Maksim Eltyshev
46f4d5c1f8 fix: Canonicalize locale codes 2025-06-03 12:46:06 +02:00
Maksim Eltyshev
fc7863aaaf fix: Add mentions support when editing comments 2025-06-02 21:35:51 +02:00
Maksim Eltyshev
6c2999044b chore: Update logo assets 2025-06-02 20:13:22 +02:00
Maksim Eltyshev
a2495b664e chore: Add missing things 2025-06-02 19:44:08 +02:00
Jason Jack
e0374f30db
build: Add tmpfs to compose file (#1165) 2025-06-02 19:39:10 +02:00
Maksim Eltyshev
665f9998dc fix: Prevent auto-updating translation files when key is missing 2025-06-01 21:44:14 +02:00
Maksim Eltyshev
fcf1fc4319 ref: Little refactoring 2025-06-01 20:53:35 +02:00
Maksim Eltyshev
4e05b88ecf chore: Add links to GitHub
Closes #1161
2025-05-31 00:10:00 +02:00
Roman Zavarnitsyn
c0b0436851
feat: Add ability to mention users in comments (#1162) 2025-05-30 22:01:29 +02:00
Maksim Eltyshev
eb2a3a2875 ref: Fix locale position 2025-05-30 13:44:58 +02:00
Niccolò Pedrini
74274e511f
fix: Update Italian translation (#1163) 2025-05-30 13:42:57 +02:00
354 changed files with 14233 additions and 2783 deletions

View file

@ -15,7 +15,7 @@ jobs:
- name: Set up Node.js - name: Set up Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: '18' node-version: '22'
cache: 'npm' cache: 'npm'
- name: Update npm - name: Update npm

View file

@ -24,7 +24,7 @@ jobs:
- name: Set up Node.js - name: Set up Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: '18' node-version: '22'
cache: 'npm' cache: 'npm'
- name: Set up PostgreSQL - name: Set up PostgreSQL

View file

@ -16,7 +16,7 @@ jobs:
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: '18' node-version: '22'
cache: 'npm' cache: 'npm'
- name: Cache Node.js modules - name: Cache Node.js modules

View file

@ -1,4 +1,4 @@
FROM node:18-alpine AS server-dependencies FROM node:22-alpine AS server-dependencies
RUN apk -U upgrade \ RUN apk -U upgrade \
&& apk add build-base python3 --no-cache && apk add build-base python3 --no-cache
@ -10,7 +10,7 @@ COPY server/package.json server/package-lock.json server/requirements.txt ./
RUN npm install npm --global \ RUN npm install npm --global \
&& npm install --omit=dev && npm install --omit=dev
FROM node:lts AS client FROM node:22 AS client
WORKDIR /app WORKDIR /app
@ -21,7 +21,7 @@ RUN npm install npm --global \
RUN DISABLE_ESLINT_PLUGIN=true npm run build RUN DISABLE_ESLINT_PLUGIN=true npm run build
FROM node:18-alpine FROM node:22-alpine
RUN apk -U upgrade \ RUN apk -U upgrade \
&& apk add bash python3 --no-cache \ && apk add bash python3 --no-cache \

View file

@ -1,4 +1,4 @@
FROM node:lts-alpine FROM node:22-alpine
RUN apk -U upgrade \ RUN apk -U upgrade \
&& apk add bash build-base python3 xdg-utils --no-cache \ && apk add bash build-base python3 xdg-utils --no-cache \

View file

@ -48,7 +48,7 @@ You may use or modify PLANKA (a) for personal, hobby, or educational purposes, (
### Restricted Use ### Restricted Use
Sharing accounts/credentials with third parties for business purposes or operating PLANKA as a hosted service for any commercial gain whatsoever is prohibited. Commercial gain includes any form of payment, advertising revenue, data monetization, or indirect commercial benefit or business advantage. Sharing accounts/credentials with third parties for business purposes or operating PLANKA as a hosted service for third parties for any commercial gain whatsoever is prohibited. Commercial gain includes any form of payment, advertising revenue, data monetization, or indirect commercial benefit or business advantage.
For all other PLANKA-based hosting services or shared use of PLANKA accounts across organizations, you need to buy a commercial PLANKA license. For all other PLANKA-based hosting services or shared use of PLANKA accounts across organizations, you need to buy a commercial PLANKA license.

View file

@ -48,7 +48,7 @@ Sie dürfen PLANKA nutzen oder modifizieren (a) für persönliche, Hobby- oder B
### Eingeschränkte Nutzung ### Eingeschränkte Nutzung
Das Teilen von Konten/Zugangsdaten mit Dritten für geschäftliche Zwecke oder der Betrieb von PLANKA als gehosteter Dienst für jeglichen kommerziellen Gewinn ist untersagt. Kommerzieller Gewinn umfasst jede Form von Zahlung, Werbeeinnahmen, Datenmonetarisierung oder indirekten kommerziellen Nutzen oder Geschäftsvorteil. Das Teilen von Konten/Zugangsdaten mit Dritten für geschäftliche Zwecke oder der Betrieb von PLANKA als gehosteter Dienst für Dritte zu jeglichen kommerziellen Gewinn ist untersagt. Kommerzieller Gewinn umfasst jede Form von Zahlung, Werbeeinnahmen, Datenmonetarisierung oder indirekten kommerziellen Nutzen oder Geschäftsvorteil.
Für alle anderen PLANKA-basierten Hosting-Dienste oder die gemeinsame Nutzung von PLANKA-Konten zwischen Organisationen müssen Sie eine kommerzielle PLANKA-Lizenz erwerben. Für alle anderen PLANKA-basierten Hosting-Dienste oder die gemeinsame Nutzung von PLANKA-Konten zwischen Organisationen müssen Sie eine kommerzielle PLANKA-Lizenz erwerben.

View file

@ -48,7 +48,7 @@ You may use or modify PLANKA (a) for personal, hobby, or educational purposes, (
### Restricted Use ### Restricted Use
Sharing accounts/credentials with third parties for business purposes or operating PLANKA as a hosted service for any commercial gain whatsoever is prohibited. Commercial gain includes any form of payment, advertising revenue, data monetization, or indirect commercial benefit or business advantage. Sharing accounts/credentials with third parties for business purposes or operating PLANKA as a hosted service for third parties for any commercial gain whatsoever is prohibited. Commercial gain includes any form of payment, advertising revenue, data monetization, or indirect commercial benefit or business advantage.
For all other PLANKA-based hosting services or shared use of PLANKA accounts across organizations, you need to buy a commercial PLANKA license. For all other PLANKA-based hosting services or shared use of PLANKA accounts across organizations, you need to buy a commercial PLANKA license.

View file

@ -52,7 +52,7 @@ Die Lizenz gewährt Ihnen das kostenlose Recht, die Software zu nutzen, zu modif
- Sie dürfen PLANKA nutzen oder modifizieren (a) für persönliche, Hobby- oder Bildungszwecke, (b) intern innerhalb Ihrer eigenen Organisation, (c) für privates Hosting für eine typische Anzahl von Freunden, Familie oder persönlichen Projekten, (d) um gemeinnützigen Organisationen (wie von den jeweiligen Steuerbehörden anerkannt) kostenlosen Zugang zu gewähren, oder wenn Sie selbst eine anerkannte gemeinnützige Organisation sind, die externe Nutzer in Ihre Mission einbezieht, und (e) öffentliche Bildungseinrichtungen ausschließlich für akademische/Forschungszwecke. - Sie dürfen PLANKA nutzen oder modifizieren (a) für persönliche, Hobby- oder Bildungszwecke, (b) intern innerhalb Ihrer eigenen Organisation, (c) für privates Hosting für eine typische Anzahl von Freunden, Familie oder persönlichen Projekten, (d) um gemeinnützigen Organisationen (wie von den jeweiligen Steuerbehörden anerkannt) kostenlosen Zugang zu gewähren, oder wenn Sie selbst eine anerkannte gemeinnützige Organisation sind, die externe Nutzer in Ihre Mission einbezieht, und (e) öffentliche Bildungseinrichtungen ausschließlich für akademische/Forschungszwecke.
- Das Teilen von Konten/Zugangsdaten mit Dritten für geschäftliche Zwecke oder der Betrieb von PLANKA als gehosteter Dienst für jeglichen kommerziellen Gewinn ist untersagt. Kommerzieller Gewinn umfasst jede Form von Zahlung, Werbeeinnahmen, Datenmonetarisierung oder indirekten kommerziellen Nutzen oder Geschäftsvorteil. - Das Teilen von Konten/Zugangsdaten mit Dritten für geschäftliche Zwecke oder der Betrieb von PLANKA als gehosteter Dienst für Dritte zu jeglichen kommerziellen Gewinn ist untersagt. Kommerzieller Gewinn umfasst jede Form von Zahlung, Werbeeinnahmen, Datenmonetarisierung oder indirekten kommerziellen Nutzen oder Geschäftsvorteil.
- Sie dürfen keine vom Lizenzgeber bereitgestellten Lizenz-, Urheber- oder anderen Hinweise in der Software verändern, entfernen oder verschleiern. Jede Nutzung der Marken des Lizenzgebers unterliegt dem geltenden Recht. - Sie dürfen keine vom Lizenzgeber bereitgestellten Lizenz-, Urheber- oder anderen Hinweise in der Software verändern, entfernen oder verschleiern. Jede Nutzung der Marken des Lizenzgebers unterliegt dem geltenden Recht.

View file

@ -52,7 +52,7 @@ The license allows you the free right to use, modify, create derivative works, a
- You may use or modify PLANKA (a) for personal, hobby, or educational purposes, (b) internally within your own organization, (c) for private hosting for a typical number of friends, family, or personal projects, (d) to provide free access to non-profit organizations (as recognized by applicable tax authorities), or if you are a recognized non-profit organization yourself involving external users into your mission, and (e) public educational institutions for academic/research purposes only. - You may use or modify PLANKA (a) for personal, hobby, or educational purposes, (b) internally within your own organization, (c) for private hosting for a typical number of friends, family, or personal projects, (d) to provide free access to non-profit organizations (as recognized by applicable tax authorities), or if you are a recognized non-profit organization yourself involving external users into your mission, and (e) public educational institutions for academic/research purposes only.
- Sharing accounts/credentials with third parties for business purposes or operating PLANKA as a hosted service for any commercial gain whatsoever is prohibited. Commercial gain includes any form of payment, advertising revenue, data monetization, or indirect commercial benefit or business advantage. - Sharing accounts/credentials with third parties for business purposes or operating PLANKA as a hosted service for third parties for any commercial gain whatsoever is prohibited. Commercial gain includes any form of payment, advertising revenue, data monetization, or indirect commercial benefit or business advantage.
- You may not alter, remove, or obscure any licensing, copyright, or other notices from the software provided by the licensor. Any use of the licensor's trademarks is subject to applicable law. - You may not alter, remove, or obscure any licensing, copyright, or other notices from the software provided by the licensor. Any use of the licensor's trademarks is subject to applicable law.

View file

@ -15,7 +15,7 @@ type: application
# This is the chart version. This version number should be incremented each time you make changes # 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. # to the chart and its templates, including the app version.
# Versions are expected to follow Semantic Versioning (https://semver.org/) # Versions are expected to follow Semantic Versioning (https://semver.org/)
version: 1.0.2 version: 1.0.3
# This is the version number of the application being deployed. This version number should be # 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 # incremented each time you make changes to the application. Versions are not expected to

View file

@ -4,6 +4,10 @@ metadata:
name: {{ include "planka.fullname" . }} name: {{ include "planka.fullname" . }}
labels: labels:
{{- include "planka.labels" . | nindent 4 }} {{- include "planka.labels" . | nindent 4 }}
{{- with .Values.deploymentAnnotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec: spec:
{{- if not .Values.autoscaling.enabled }} {{- if not .Values.autoscaling.enabled }}
replicas: {{ .Values.replicaCount }} replicas: {{ .Values.replicaCount }}

View file

@ -45,6 +45,9 @@ podAnnotations: {}
podSecurityContext: {} podSecurityContext: {}
# fsGroup: 2000 # fsGroup: 2000
# Annotations to add to the deployment
deploymentAnnotations: {}
securityContext: {} securityContext: {}
# capabilities: # capabilities:
# drop: # drop:
@ -210,3 +213,22 @@ oidc:
## key: key-inside-the-secret ## key: key-inside-the-secret
## ##
extraEnv: [] extraEnv: []
## Example extraEnv for configuring SMTP
## extraEnv:
## - name: SMTP_HOST
## value: "smtp.example.com"
## - name: SMTP_PORT
## value: "587"
## - name: SMTP_NAME
## value: "Your Name"
## - name: SMTP_SECURE
## value: "true"
## - name: SMTP_USER
## value: "your_email@example.com"
## - name: SMTP_PASSWORD
## value: "your_password"
## - name: SMTP_FROM
## value: "your_email@example.com"
## - name: SMTP_TLS_REJECT_UNAUTHORIZED
## value: "false"

3584
client/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -79,13 +79,13 @@
}, },
"dependencies": { "dependencies": {
"@ballerina/highlightjs-ballerina": "^1.0.1", "@ballerina/highlightjs-ballerina": "^1.0.1",
"@diplodoc/cut-extension": "^0.7.3", "@diplodoc/cut-extension": "^0.7.4",
"@diplodoc/transform": "^4.57.2", "@diplodoc/transform": "^4.57.7",
"@gravity-ui/markdown-editor": "^15.11.0", "@gravity-ui/components": "^4.4.0",
"@gravity-ui/uikit": "^7.11.0", "@gravity-ui/markdown-editor": "^15.14.1",
"@gravity-ui/uikit": "^7.16.2",
"@juggle/resize-observer": "^3.4.0", "@juggle/resize-observer": "^3.4.0",
"@types/papaparse": "^5.3.16", "@vitejs/plugin-react": "^4.6.0",
"@vitejs/plugin-react": "^4.4.1",
"browserslist-to-esbuild": "^2.1.1", "browserslist-to-esbuild": "^2.1.1",
"classnames": "^2.5.1", "classnames": "^2.5.1",
"date-fns": "^2.30.0", "date-fns": "^2.30.0",
@ -96,7 +96,7 @@
"highlightjs-apex": "^1.5.0", "highlightjs-apex": "^1.5.0",
"highlightjs-blade": "^0.1.0", "highlightjs-blade": "^0.1.0",
"highlightjs-cobol": "^0.3.3", "highlightjs-cobol": "^0.3.3",
"highlightjs-cshtml-razor": "^2.1.1", "highlightjs-cshtml-razor": "^2.2.0",
"highlightjs-gf": "^1.0.1", "highlightjs-gf": "^1.0.1",
"highlightjs-jolie": "^0.1.8", "highlightjs-jolie": "^0.1.8",
"highlightjs-lean": "^1.2.0", "highlightjs-lean": "^1.2.0",
@ -114,8 +114,8 @@
"highlightjs-zenscript": "^2.0.0", "highlightjs-zenscript": "^2.0.0",
"hightlightjs-papyrus": "^0.0.4", "hightlightjs-papyrus": "^0.0.4",
"history": "^5.3.0", "history": "^5.3.0",
"i18next": "23.15.2", "i18next": "^25.2.1",
"i18next-browser-languagedetector": "^8.1.0", "i18next-browser-languagedetector": "^8.2.0",
"initials": "^3.1.2", "initials": "^3.1.2",
"javascript-time-ago": "^2.5.11", "javascript-time-ago": "^2.5.11",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
@ -137,12 +137,13 @@
"react-dropzone": "^14.3.8", "react-dropzone": "^14.3.8",
"react-frame-component": "^5.2.7", "react-frame-component": "^5.2.7",
"react-hot-toast": "^2.5.2", "react-hot-toast": "^2.5.2",
"react-i18next": "^15.5.1", "react-i18next": "^15.5.3",
"react-input-mask": "^2.0.4", "react-input-mask": "^2.0.4",
"react-intersection-observer": "^9.16.0", "react-intersection-observer": "^9.16.0",
"react-mentions": "^4.4.10",
"react-photoswipe-gallery": "^2.2.7", "react-photoswipe-gallery": "^2.2.7",
"react-redux": "^8.1.3", "react-redux": "^8.1.3",
"react-router-dom": "^6.30.0", "react-router-dom": "^6.30.1",
"react-textarea-autosize": "^8.5.9", "react-textarea-autosize": "^8.5.9",
"react-time-ago": "^7.3.3", "react-time-ago": "^7.3.3",
"redux": "^4.2.1", "redux": "^4.2.1",
@ -151,10 +152,10 @@
"redux-saga": "^1.3.0", "redux-saga": "^1.3.0",
"reselect": "^4.1.8", "reselect": "^4.1.8",
"sails.io.js": "^1.2.1", "sails.io.js": "^1.2.1",
"sass-embedded": "^1.87.0", "sass-embedded": "^1.89.2",
"semantic-ui-react": "^2.1.5", "semantic-ui-react": "^2.1.5",
"socket.io-client": "^2.5.0", "socket.io-client": "^2.5.0",
"validator": "^13.15.0", "validator": "^13.15.15",
"vite": "^6.3.5", "vite": "^6.3.5",
"vite-plugin-commonjs": "^0.10.4", "vite-plugin-commonjs": "^0.10.4",
"vite-plugin-node-polyfills": "^0.23.0", "vite-plugin-node-polyfills": "^0.23.0",
@ -162,23 +163,23 @@
"zxcvbn": "^4.4.2" "zxcvbn": "^4.4.2"
}, },
"devDependencies": { "devDependencies": {
"@babel/eslint-parser": "^7.27.1", "@babel/eslint-parser": "^7.27.5",
"@babel/preset-env": "^7.27.2", "@babel/preset-env": "^7.27.2",
"@cucumber/cucumber": "^11.2.0", "@cucumber/cucumber": "^11.3.0",
"@cucumber/pretty-formatter": "^1.0.1", "@cucumber/pretty-formatter": "^1.0.1",
"@playwright/test": "^1.52.0", "@playwright/test": "^1.53.1",
"babel-jest": "^29.7.0", "babel-jest": "^29.7.0",
"babel-preset-airbnb": "^5.0.0", "babel-preset-airbnb": "^5.0.0",
"eslint": "^8.57.1", "eslint": "^8.57.1",
"eslint-config-airbnb": "^19.0.4", "eslint-config-airbnb": "^19.0.4",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^9.1.0",
"eslint-plugin-import": "^2.31.0", "eslint-plugin-import": "^2.32.0",
"eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-prettier": "^5.4.0", "eslint-plugin-prettier": "^5.5.1",
"eslint-plugin-react": "^7.37.5", "eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^4.6.2", "eslint-plugin-react-hooks": "^4.6.2",
"jest": "^29.7.0", "jest": "^29.7.0",
"playwright": "^1.52.0", "playwright": "^1.53.1",
"prettier": "3.3.3" "prettier": "3.3.3"
} }
} }

View file

@ -22,6 +22,19 @@ index 2152fd6..ceda0c1 100644
yfmHeading: { yfmHeading: {
h1Key: f.toPM(A.Heading1), h1Key: f.toPM(A.Heading1),
h2Key: f.toPM(A.Heading2), h2Key: f.toPM(A.Heading2),
diff --git a/node_modules/@gravity-ui/markdown-editor/build/esm/extensions/yfm/YfmNote/YfmNoteSpecs/index.js b/node_modules/@gravity-ui/markdown-editor/build/esm/extensions/yfm/YfmNote/YfmNoteSpecs/index.js
index bd3d4b9..5a5a5be 100644
--- a/node_modules/@gravity-ui/markdown-editor/build/esm/extensions/yfm/YfmNote/YfmNoteSpecs/index.js
+++ b/node_modules/@gravity-ui/markdown-editor/build/esm/extensions/yfm/YfmNote/YfmNoteSpecs/index.js
@@ -9,7 +9,7 @@ export { noteType, noteTitleType } from "./utils.js";
export const YfmNoteSpecs = (builder, opts) => {
const schemaSpecs = getSchemaSpecs(opts, builder.context.get('placeholder'));
builder
- .configureMd((md) => md.use(yfmPlugin, { log }))
+ .configureMd((md) => md.use(yfmPlugin, { log, notesAutotitle: false }))
.addNode(NoteNode.Note, () => ({
spec: schemaSpecs[NoteNode.Note],
toMd: serializerTokens[NoteNode.Note],
diff --git a/node_modules/@gravity-ui/markdown-editor/build/esm/presets/yfm.js b/node_modules/@gravity-ui/markdown-editor/build/esm/presets/yfm.js diff --git a/node_modules/@gravity-ui/markdown-editor/build/esm/presets/yfm.js b/node_modules/@gravity-ui/markdown-editor/build/esm/presets/yfm.js
index ed2a9db..f95b693 100644 index ed2a9db..f95b693 100644
--- a/node_modules/@gravity-ui/markdown-editor/build/esm/presets/yfm.js --- a/node_modules/@gravity-ui/markdown-editor/build/esm/presets/yfm.js

View file

@ -1,5 +1,5 @@
diff --git a/node_modules/semantic-ui-react/dist/es/lib/doesNodeContainClick.js b/node_modules/semantic-ui-react/dist/es/lib/doesNodeContainClick.js diff --git a/node_modules/semantic-ui-react/dist/es/lib/doesNodeContainClick.js b/node_modules/semantic-ui-react/dist/es/lib/doesNodeContainClick.js
index 6d06078..fb7534d 100644 index 6d06078..e22d4f0 100644
--- a/node_modules/semantic-ui-react/dist/es/lib/doesNodeContainClick.js --- a/node_modules/semantic-ui-react/dist/es/lib/doesNodeContainClick.js
+++ b/node_modules/semantic-ui-react/dist/es/lib/doesNodeContainClick.js +++ b/node_modules/semantic-ui-react/dist/es/lib/doesNodeContainClick.js
@@ -17,13 +17,7 @@ var doesNodeContainClick = function doesNodeContainClick(node, e) { @@ -17,13 +17,7 @@ var doesNodeContainClick = function doesNodeContainClick(node, e) {
@ -17,6 +17,46 @@ index 6d06078..fb7534d 100644
} // Below logic handles cases where the e.target is no longer in the document. } // Below logic handles cases where the e.target is no longer in the document.
// The result of the click likely has removed the e.target node. // The result of the click likely has removed the e.target node.
// Instead of node.contains(), we'll identify the click by X/Y position. // Instead of node.contains(), we'll identify the click by X/Y position.
diff --git a/node_modules/semantic-ui-react/dist/es/modules/Dropdown/Dropdown.js b/node_modules/semantic-ui-react/dist/es/modules/Dropdown/Dropdown.js
index 1cc1bab..7abb016 100644
--- a/node_modules/semantic-ui-react/dist/es/modules/Dropdown/Dropdown.js
+++ b/node_modules/semantic-ui-react/dist/es/modules/Dropdown/Dropdown.js
@@ -342,7 +342,7 @@ var Dropdown = /*#__PURE__*/function (_Component) {
return;
}
- if (searchQuery.length >= minCharacters || minCharacters === 1) {
+ if (searchQuery.length >= minCharacters || minCharacters === 0) {
_this.open(e);
return;
@@ -480,7 +480,7 @@ var Dropdown = /*#__PURE__*/function (_Component) {
} // close search dropdown if search query is too small
- if (open && minCharacters !== 1 && newQuery.length < minCharacters) _this.close();
+ if (open && minCharacters !== 0 && newQuery.length < minCharacters) _this.close();
};
_this.handleKeyDown = function (e) {
@@ -1048,7 +1048,7 @@ var Dropdown = /*#__PURE__*/function (_Component) {
if (!prevState.focus && this.state.focus) {
if (!this.isMouseDown) {
- var openable = !search || search && minCharacters === 1 && !this.state.open;
+ var openable = !search || search && minCharacters === 0 && !this.state.open;
if (openOnFocus && openable) this.open();
}
} else if (prevState.focus && !this.state.focus) {
@@ -1436,7 +1436,7 @@ Dropdown.defaultProps = {
closeOnEscape: true,
deburr: false,
icon: 'dropdown',
- minCharacters: 1,
+ minCharacters: 0,
noResultsMessage: 'No results found.',
openOnFocus: true,
renderLabel: renderItemContent,
diff --git a/node_modules/semantic-ui-react/src/lib/doesNodeContainClick.js b/node_modules/semantic-ui-react/src/lib/doesNodeContainClick.js diff --git a/node_modules/semantic-ui-react/src/lib/doesNodeContainClick.js b/node_modules/semantic-ui-react/src/lib/doesNodeContainClick.js
index d1ae271..43e1170 100644 index d1ae271..43e1170 100644
--- a/node_modules/semantic-ui-react/src/lib/doesNodeContainClick.js --- a/node_modules/semantic-ui-react/src/lib/doesNodeContainClick.js

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Before After
Before After

View file

@ -8,6 +8,7 @@ import ActionTypes from '../constants/ActionTypes';
const initializeCore = ( const initializeCore = (
user, user,
board, board,
webhooks,
users, users,
projects, projects,
projectManagers, projectManagers,
@ -33,6 +34,7 @@ const initializeCore = (
payload: { payload: {
user, user,
board, board,
webhooks,
users, users,
projects, projects,
projectManagers, projectManagers,

View file

@ -8,6 +8,7 @@ import socket from './socket';
import login from './login'; import login from './login';
import core from './core'; import core from './core';
import modals from './modals'; import modals from './modals';
import webhooks from './webhooks';
import users from './users'; import users from './users';
import projects from './projects'; import projects from './projects';
import projectManagers from './project-managers'; import projectManagers from './project-managers';
@ -35,6 +36,7 @@ export default {
...login, ...login,
...core, ...core,
...modals, ...modals,
...webhooks,
...users, ...users,
...projects, ...projects,
...projectManagers, ...projectManagers,

View file

@ -14,6 +14,7 @@ const handleSocketReconnect = (
config, config,
user, user,
board, board,
webhooks,
users, users,
projects, projects,
projectManagers, projectManagers,
@ -40,6 +41,7 @@ const handleSocketReconnect = (
config, config,
user, user,
board, board,
webhooks,
users, users,
projects, projects,
projectManagers, projectManagers,

View file

@ -67,6 +67,7 @@ const handleUserUpdate = (
boardIds, boardIds,
config, config,
board, board,
webhooks,
users, users,
projects, projects,
projectManagers, projectManagers,
@ -95,6 +96,7 @@ const handleUserUpdate = (
boardIds, boardIds,
config, config,
board, board,
webhooks,
users, users,
projects, projects,
projectManagers, projectManagers,

View file

@ -0,0 +1,104 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import ActionTypes from '../constants/ActionTypes';
const createWebhook = (webhook) => ({
type: ActionTypes.WEBHOOK_CREATE,
payload: {
webhook,
},
});
createWebhook.success = (localId, webhook) => ({
type: ActionTypes.WEBHOOK_CREATE__SUCCESS,
payload: {
localId,
webhook,
},
});
createWebhook.failure = (localId, error) => ({
type: ActionTypes.WEBHOOK_CREATE__FAILURE,
payload: {
localId,
error,
},
});
const handleWebhookCreate = (webhook) => ({
type: ActionTypes.WEBHOOK_CREATE_HANDLE,
payload: {
webhook,
},
});
const updateWebhook = (id, data) => ({
type: ActionTypes.WEBHOOK_UPDATE,
payload: {
id,
data,
},
});
updateWebhook.success = (webhook) => ({
type: ActionTypes.WEBHOOK_UPDATE__SUCCESS,
payload: {
webhook,
},
});
updateWebhook.failure = (id, error) => ({
type: ActionTypes.WEBHOOK_UPDATE__FAILURE,
payload: {
id,
error,
},
});
const handleWebhookUpdate = (webhook) => ({
type: ActionTypes.WEBHOOK_UPDATE_HANDLE,
payload: {
webhook,
},
});
const deleteWebhook = (id) => ({
type: ActionTypes.WEBHOOK_DELETE,
payload: {
id,
},
});
deleteWebhook.success = (webhook) => ({
type: ActionTypes.WEBHOOK_DELETE__SUCCESS,
payload: {
webhook,
},
});
deleteWebhook.failure = (id, error) => ({
type: ActionTypes.WEBHOOK_DELETE__FAILURE,
payload: {
id,
error,
},
});
const handleWebhookDelete = (webhook) => ({
type: ActionTypes.WEBHOOK_DELETE_HANDLE,
payload: {
webhook,
},
});
export default {
createWebhook,
handleWebhookCreate,
updateWebhook,
handleWebhookUpdate,
deleteWebhook,
handleWebhookDelete,
};

View file

@ -7,6 +7,7 @@ import http from './http';
import socket from './socket'; import socket from './socket';
import config from './config'; import config from './config';
import accessTokens from './access-tokens'; import accessTokens from './access-tokens';
import webhooks from './webhooks';
import users from './users'; import users from './users';
import projects from './projects'; import projects from './projects';
import projectManagers from './project-managers'; import projectManagers from './project-managers';
@ -35,6 +36,7 @@ export { http, socket };
export default { export default {
...config, ...config,
...accessTokens, ...accessTokens,
...webhooks,
...users, ...users,
...projects, ...projects,
...projectManagers, ...projectManagers,

23
client/src/api/webhooks.js Executable file
View file

@ -0,0 +1,23 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import socket from './socket';
/* Actions */
const getWebhooks = (headers) => socket.get('/webhooks', undefined, headers);
const createWebhook = (data, headers) => socket.post('/webhooks', data, headers);
const updateWebhook = (id, data, headers) => socket.patch(`/webhooks/${id}`, data, headers);
const deleteWebhook = (id, headers) => socket.delete(`/webhooks/${id}`, undefined, headers);
export default {
getWebhooks,
createWebhook,
updateWebhook,
deleteWebhook,
};

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Before After
Before After

View file

@ -16,6 +16,7 @@
padding-bottom: 14px; padding-bottom: 14px;
vertical-align: top; vertical-align: top;
width: calc(100% - 40px); width: calc(100% - 40px);
word-wrap: break-word;
} }
.date { .date {

View file

@ -16,6 +16,7 @@
padding-bottom: 14px; padding-bottom: 14px;
vertical-align: top; vertical-align: top;
width: calc(100% - 40px); width: calc(100% - 40px);
word-wrap: break-word;
} }
.date { .date {

View file

@ -7,7 +7,7 @@ import React, { useCallback } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Menu } from 'semantic-ui-react'; import { Icon, Menu } from 'semantic-ui-react';
import { FilePicker, Popup } from '../../../lib/custom-ui'; import { FilePicker, Popup } from '../../../lib/custom-ui';
import entryActions from '../../../entry-actions'; import entryActions from '../../../entry-actions';
@ -47,6 +47,7 @@ const AddAttachmentStep = React.memo(({ onClose }) => {
<Menu secondary vertical className={styles.menu}> <Menu secondary vertical className={styles.menu}>
<FilePicker multiple onSelect={handleFilesSelect}> <FilePicker multiple onSelect={handleFilesSelect}>
<Menu.Item className={styles.menuItem}> <Menu.Item className={styles.menuItem}>
<Icon name="computer" className={styles.menuItemIcon} />
{t('common.fromComputer', { {t('common.fromComputer', {
context: 'title', context: 'title',
})} })}

View file

@ -21,6 +21,11 @@
padding-left: 14px; padding-left: 14px;
} }
.menuItemIcon {
float: left;
margin: 0 0.5em 0 0;
}
.tip { .tip {
opacity: 0.5; opacity: 0.5;
} }

View file

@ -3,20 +3,19 @@
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md * Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/ */
import React, { useEffect, useState, useCallback, useMemo } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import classNames from 'classnames'; import classNames from 'classnames';
import { Pagination, Table } from 'semantic-ui-react';
import Papa from 'papaparse'; import Papa from 'papaparse';
import Frame from 'react-frame-component'; import Frame from 'react-frame-component';
import { Loader, Pagination, Table } from 'semantic-ui-react';
import styles from './CsvViewer.module.scss'; import styles from './CsvViewer.module.scss';
const ROWS_PER_PAGE = 50; const ROWS_PER_PAGE = 50;
/* eslint-disable react/no-array-index-key */
const CsvViewer = React.memo(({ src, className }) => { const CsvViewer = React.memo(({ src, className }) => {
const [csvData, setCsvData] = useState(null); const [rows, setRows] = useState(null);
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const frameStyles = useMemo( const frameStyles = useMemo(
@ -34,7 +33,7 @@ const CsvViewer = React.memo(({ src, className }) => {
[], [],
); );
const handlePageChange = useCallback((e, { activePage }) => { const handlePageChange = useCallback((_, { activePage }) => {
setCurrentPage(activePage); setCurrentPage(activePage);
}, []); }, []);
@ -49,47 +48,46 @@ const CsvViewer = React.memo(({ src, className }) => {
Papa.parse(text, { Papa.parse(text, {
skipEmptyLines: true, skipEmptyLines: true,
complete: (results) => { complete: ({ data }) => {
const rows = results.data; setRows(data);
setCsvData({
rows,
totalRows: rows.length,
});
}, },
}); });
} catch (err) { } catch {
setCsvData(null); /* empty */
} }
} }
fetchFile(); fetchFile();
}, [src]); }, [src]);
if (!csvData) { if (rows === null) {
return null; return <Loader active size="big" />;
} }
const startIdx = (currentPage - 1) * ROWS_PER_PAGE; const startIndex = (currentPage - 1) * ROWS_PER_PAGE;
const endIdx = startIdx + ROWS_PER_PAGE; const endIndex = startIndex + ROWS_PER_PAGE;
const currentRows = csvData.rows.slice(startIdx, endIdx); const currentRows = rows.slice(startIndex, endIndex);
const totalPages = Math.ceil(csvData.totalRows / ROWS_PER_PAGE); const totalPages = Math.ceil(rows.length / ROWS_PER_PAGE);
const content = ( return (
<div> <Frame
head={<style>{frameStyles.join('')}</style>}
className={classNames(styles.wrapper, className)}
>
<div> <div>
<Table celled compact> <Table celled compact>
<Table.Header> <Table.Header>
<Table.Row> <Table.Row>
{csvData.rows[0].map((header, index) => ( {rows[0].map((cell) => (
<Table.HeaderCell key={index}>{header}</Table.HeaderCell> <Table.HeaderCell key={cell}>{cell}</Table.HeaderCell>
))} ))}
</Table.Row> </Table.Row>
</Table.Header> </Table.Header>
<Table.Body> <Table.Body>
{currentRows.slice(1).map((row, rowIndex) => ( {currentRows.slice(1).map((row) => (
<Table.Row key={rowIndex}> <Table.Row key={row}>
{row.map((cell, cellIndex) => ( {row.map((cell) => (
<Table.Cell key={cellIndex}>{cell}</Table.Cell> <Table.Cell key={cell}>{cell}</Table.Cell>
))} ))}
</Table.Row> </Table.Row>
))} ))}
@ -98,30 +96,18 @@ const CsvViewer = React.memo(({ src, className }) => {
</div> </div>
{totalPages > 1 && ( {totalPages > 1 && (
<Pagination <Pagination
activePage={currentPage} secondary
pointing
totalPages={totalPages} totalPages={totalPages}
onPageChange={handlePageChange} activePage={currentPage}
firstItem={null} firstItem={null}
lastItem={null} lastItem={null}
pointing onPageChange={handlePageChange}
secondary
boundaryRange={1}
siblingRange={1}
/> />
)} )}
</div>
);
return (
<Frame
head={<style>{frameStyles.join('')}</style>}
className={classNames(styles.wrapper, className)}
>
{content}
</Frame> </Frame>
); );
}); });
/* eslint-enable react/no-array-index-key */
CsvViewer.propTypes = { CsvViewer.propTypes = {
src: PropTypes.string.isRequired, src: PropTypes.string.isRequired,

View file

@ -16,6 +16,7 @@ import Encodings from '../../../constants/Encodings';
import { AttachmentTypes } from '../../../constants/Enums'; import { AttachmentTypes } from '../../../constants/Enums';
import ItemContent from './ItemContent'; import ItemContent from './ItemContent';
import ContentViewer from './ContentViewer'; import ContentViewer from './ContentViewer';
import PdfViewer from './PdfViewer';
import CsvViewer from './CsvViewer'; import CsvViewer from './CsvViewer';
import styles from './Item.module.scss'; import styles from './Item.module.scss';
@ -40,10 +41,8 @@ const Item = React.memo(({ id, isVisible }) => {
switch (attachment.data.mimeType) { switch (attachment.data.mimeType) {
case 'application/pdf': case 'application/pdf':
content = ( content = (
// eslint-disable-next-line jsx-a11y/alt-text <PdfViewer
<object src={attachment.data.url}
data={attachment.data.url}
type={attachment.data.mimeType}
className={classNames(styles.content, styles.contentViewer)} className={classNames(styles.content, styles.contentViewer)}
/> />
); );
@ -60,15 +59,6 @@ const Item = React.memo(({ id, isVisible }) => {
<audio controls src={attachment.data.url} className={styles.content} /> <audio controls src={attachment.data.url} className={styles.content} />
); );
break;
case 'video/mp4':
case 'video/ogg':
case 'video/webm':
content = (
// eslint-disable-next-line jsx-a11y/media-has-caption
<video controls src={attachment.data.url} className={styles.content} />
);
break; break;
case 'text/csv': case 'text/csv':
content = ( content = (
@ -78,6 +68,15 @@ const Item = React.memo(({ id, isVisible }) => {
/> />
); );
break;
case 'video/mp4':
case 'video/ogg':
case 'video/webm':
content = (
// eslint-disable-next-line jsx-a11y/media-has-caption
<video controls src={attachment.data.url} className={styles.content} />
);
break; break;
default: default:
if (attachment.data.encoding === Encodings.UTF8) { if (attachment.data.encoding === Encodings.UTF8) {

View file

@ -23,6 +23,7 @@
font-size: 20px; font-size: 20px;
font-weight: bold; font-weight: bold;
height: 20px; height: 20px;
text-align: center;
width: 470px; width: 470px;
} }
} }

View file

@ -54,6 +54,19 @@ const ItemContent = React.forwardRef(({ id, onOpen }, ref) => {
} }
}, [onOpen, attachment.data]); }, [onOpen, attachment.data]);
const handleDownloadClick = useCallback(
(event) => {
event.stopPropagation();
const linkElement = document.createElement('a');
linkElement.href = attachment.data.url;
linkElement.download = attachment.data.filename;
linkElement.target = '_blank';
linkElement.click();
},
[attachment.data],
);
const handleToggleCoverClick = useCallback( const handleToggleCoverClick = useCallback(
(event) => { (event) => {
event.stopPropagation(); event.stopPropagation();
@ -114,25 +127,31 @@ const ItemContent = React.forwardRef(({ id, onOpen }, ref) => {
<span className={styles.information}> <span className={styles.information}>
<TimeAgo date={attachment.createdAt} /> <TimeAgo date={attachment.createdAt} />
</span> </span>
{attachment.type === AttachmentTypes.FILE && attachment.data.image && canEdit && ( {attachment.type === AttachmentTypes.FILE && (
<span className={styles.options}> <span className={styles.options}>
<button type="button" className={styles.option} onClick={handleToggleCoverClick}> <button type="button" className={styles.option} onClick={handleDownloadClick}>
<Icon <Icon name="download" size="small" className={styles.optionIcon} />
name="window maximize outline" <span className={styles.optionText}>Download</span>
flipped="vertically"
size="small"
className={styles.optionIcon}
/>
<span className={styles.optionText}>
{isCover
? t('action.removeCover', {
context: 'title',
})
: t('action.makeCover', {
context: 'title',
})}
</span>
</button> </button>
{attachment.data.image && canEdit && (
<button type="button" className={styles.option} onClick={handleToggleCoverClick}>
<Icon
name="window maximize outline"
flipped="vertically"
size="small"
className={styles.optionIcon}
/>
<span className={styles.optionText}>
{isCover
? t('action.removeCover', {
context: 'title',
})
: t('action.makeCover', {
context: 'title',
})}
</span>
</button>
)}
</span> </span>
)} )}
</div> </div>

View file

@ -52,6 +52,10 @@
outline: none; outline: none;
padding: 0; padding: 0;
&:not(:last-child) {
margin-right: 10px;
}
&:hover { &:hover {
color: #172b4d; color: #172b4d;
} }

View file

@ -0,0 +1,26 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import styles from './PdfViewer.module.scss';
const PdfViewer = React.memo(({ src, className }) => (
// eslint-disable-next-line jsx-a11y/iframe-has-title
<iframe src={src} type="application/pdf" className={classNames(styles.wrapper, className)} />
));
PdfViewer.propTypes = {
src: PropTypes.string.isRequired,
className: PropTypes.string,
};
PdfViewer.defaultProps = {
className: undefined,
};
export default PdfViewer;

View file

@ -4,7 +4,7 @@
*/ */
:global(#app) { :global(#app) {
.submitButton { .wrapper {
margin-top: 12px; border: 0;
} }
} }

View file

@ -67,5 +67,6 @@
font-size: 14px; font-size: 14px;
line-height: 1.2; line-height: 1.2;
padding: 2px 0 2px 2px; padding: 2px 0 2px 2px;
word-wrap: break-word;
} }
} }

View file

@ -141,7 +141,7 @@ const ActionsStep = React.memo(({ onClose }) => {
</Menu.Item> </Menu.Item>
{withTrashEmptier && ( {withTrashEmptier && (
<> <>
{(withSubscribe || withCustomFieldGroups) && <hr className={styles.divider} />} <hr className={styles.divider} />
<Menu.Item className={styles.menuItem} onClick={handleEmptyTrashClick}> <Menu.Item className={styles.menuItem} onClick={handleEmptyTrashClick}>
<Icon name="trash alternate outline" className={styles.menuItemIcon} /> <Icon name="trash alternate outline" className={styles.menuItemIcon} />
{t('action.emptyTrash', { {t('action.emptyTrash', {
@ -151,7 +151,7 @@ const ActionsStep = React.memo(({ onClose }) => {
</> </>
)} )}
<> <>
{(withSubscribe || withTrashEmptier) && <hr className={styles.divider} />} <hr className={styles.divider} />
{[BoardContexts.BOARD, BoardContexts.ARCHIVE, BoardContexts.TRASH].map((context) => ( {[BoardContexts.BOARD, BoardContexts.ARCHIVE, BoardContexts.TRASH].map((context) => (
<Menu.Item <Menu.Item
key={context} key={context}

View file

@ -23,6 +23,6 @@
.menuItemIcon { .menuItemIcon {
float: left; float: left;
margin-right: 0.5em; margin: 0 0.5em 0 0;
} }
} }

View file

@ -166,7 +166,6 @@ const AddCard = React.memo(({ isOpened, className, onCreate, onClose }) => {
placeholder={t('common.enterCardTitle')} placeholder={t('common.enterCardTitle')}
maxLength={1024} maxLength={1024}
minRows={3} minRows={3}
spellCheck={false}
className={styles.field} className={styles.field}
onKeyDown={handleFieldKeyDown} onKeyDown={handleFieldKeyDown}
onChange={handleFieldChange} onChange={handleFieldChange}

View file

@ -7,7 +7,7 @@ import React, { useCallback, useMemo } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { shallowEqual, useDispatch, useSelector } from 'react-redux'; import { shallowEqual, useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Menu } from 'semantic-ui-react'; import { Icon, Menu } from 'semantic-ui-react';
import { Popup } from '../../../lib/custom-ui'; import { Popup } from '../../../lib/custom-ui';
import selectors from '../../../selectors'; import selectors from '../../../selectors';
@ -290,6 +290,7 @@ const ActionsStep = React.memo(({ cardId, onNameEdit, onClose }) => {
<Menu secondary vertical className={styles.menu}> <Menu secondary vertical className={styles.menu}>
{canEditName && ( {canEditName && (
<Menu.Item className={styles.menuItem} onClick={handleEditNameClick}> <Menu.Item className={styles.menuItem} onClick={handleEditNameClick}>
<Icon name="edit outline" className={styles.menuItemIcon} />
{t('action.editTitle', { {t('action.editTitle', {
context: 'title', context: 'title',
})} })}
@ -297,6 +298,7 @@ const ActionsStep = React.memo(({ cardId, onNameEdit, onClose }) => {
)} )}
{!board.limitCardTypesToDefaultOne && canEditType && ( {!board.limitCardTypesToDefaultOne && canEditType && (
<Menu.Item className={styles.menuItem} onClick={handleEditTypeClick}> <Menu.Item className={styles.menuItem} onClick={handleEditTypeClick}>
<Icon name="map outline" className={styles.menuItemIcon} />
{t('action.editType', { {t('action.editType', {
context: 'title', context: 'title',
})} })}
@ -304,6 +306,7 @@ const ActionsStep = React.memo(({ cardId, onNameEdit, onClose }) => {
)} )}
{card.type === CardTypes.PROJECT && canUseMembers && ( {card.type === CardTypes.PROJECT && canUseMembers && (
<Menu.Item className={styles.menuItem} onClick={handleUsersClick}> <Menu.Item className={styles.menuItem} onClick={handleUsersClick}>
<Icon name="user outline" className={styles.menuItemIcon} />
{t('common.members', { {t('common.members', {
context: 'title', context: 'title',
})} })}
@ -311,6 +314,7 @@ const ActionsStep = React.memo(({ cardId, onNameEdit, onClose }) => {
)} )}
{canUseLabels && ( {canUseLabels && (
<Menu.Item className={styles.menuItem} onClick={handleLabelsClick}> <Menu.Item className={styles.menuItem} onClick={handleLabelsClick}>
<Icon name="bookmark outline" className={styles.menuItemIcon} />
{t('common.labels', { {t('common.labels', {
context: 'title', context: 'title',
})} })}
@ -318,6 +322,7 @@ const ActionsStep = React.memo(({ cardId, onNameEdit, onClose }) => {
)} )}
{card.type === CardTypes.STORY && canUseMembers && ( {card.type === CardTypes.STORY && canUseMembers && (
<Menu.Item className={styles.menuItem} onClick={handleUsersClick}> <Menu.Item className={styles.menuItem} onClick={handleUsersClick}>
<Icon name="user outline" className={styles.menuItemIcon} />
{t('common.members', { {t('common.members', {
context: 'title', context: 'title',
})} })}
@ -325,6 +330,7 @@ const ActionsStep = React.memo(({ cardId, onNameEdit, onClose }) => {
)} )}
{card.type === CardTypes.PROJECT && canEditDueDate && ( {card.type === CardTypes.PROJECT && canEditDueDate && (
<Menu.Item className={styles.menuItem} onClick={handleEditDueDateClick}> <Menu.Item className={styles.menuItem} onClick={handleEditDueDateClick}>
<Icon name="calendar check outline" className={styles.menuItemIcon} />
{t('action.editDueDate', { {t('action.editDueDate', {
context: 'title', context: 'title',
})} })}
@ -332,6 +338,7 @@ const ActionsStep = React.memo(({ cardId, onNameEdit, onClose }) => {
)} )}
{card.type === CardTypes.PROJECT && canEditStopwatch && ( {card.type === CardTypes.PROJECT && canEditStopwatch && (
<Menu.Item className={styles.menuItem} onClick={handleEditStopwatchClick}> <Menu.Item className={styles.menuItem} onClick={handleEditStopwatchClick}>
<Icon name="clock outline" className={styles.menuItemIcon} />
{t('action.editStopwatch', { {t('action.editStopwatch', {
context: 'title', context: 'title',
})} })}
@ -339,6 +346,7 @@ const ActionsStep = React.memo(({ cardId, onNameEdit, onClose }) => {
)} )}
{canDuplicate && ( {canDuplicate && (
<Menu.Item className={styles.menuItem} onClick={handleDuplicateClick}> <Menu.Item className={styles.menuItem} onClick={handleDuplicateClick}>
<Icon name="copy outline" className={styles.menuItemIcon} />
{t('action.duplicateCard', { {t('action.duplicateCard', {
context: 'title', context: 'title',
})} })}
@ -346,6 +354,7 @@ const ActionsStep = React.memo(({ cardId, onNameEdit, onClose }) => {
)} )}
{canMove && ( {canMove && (
<Menu.Item className={styles.menuItem} onClick={handleMoveClick}> <Menu.Item className={styles.menuItem} onClick={handleMoveClick}>
<Icon name="share square outline" className={styles.menuItemIcon} />
{t('action.moveCard', { {t('action.moveCard', {
context: 'title', context: 'title',
})} })}
@ -353,6 +362,7 @@ const ActionsStep = React.memo(({ cardId, onNameEdit, onClose }) => {
)} )}
{prevList && canRestore && ( {prevList && canRestore && (
<Menu.Item className={styles.menuItem} onClick={handleRestoreClick}> <Menu.Item className={styles.menuItem} onClick={handleRestoreClick}>
<Icon name="undo alternate" className={styles.menuItemIcon} />
{t('action.restoreToList', { {t('action.restoreToList', {
context: 'title', context: 'title',
list: prevList.name || t(`common.${prevList.type}`), list: prevList.name || t(`common.${prevList.type}`),
@ -361,6 +371,7 @@ const ActionsStep = React.memo(({ cardId, onNameEdit, onClose }) => {
)} )}
{list.type !== ListTypes.ARCHIVE && canArchive && ( {list.type !== ListTypes.ARCHIVE && canArchive && (
<Menu.Item className={styles.menuItem} onClick={handleArchiveClick}> <Menu.Item className={styles.menuItem} onClick={handleArchiveClick}>
<Icon name="folder open outline" className={styles.menuItemIcon} />
{t('action.archiveCard', { {t('action.archiveCard', {
context: 'title', context: 'title',
})} })}
@ -368,6 +379,7 @@ const ActionsStep = React.memo(({ cardId, onNameEdit, onClose }) => {
)} )}
{canDelete && ( {canDelete && (
<Menu.Item className={styles.menuItem} onClick={handleDeleteClick}> <Menu.Item className={styles.menuItem} onClick={handleDeleteClick}>
<Icon name="trash alternate outline" className={styles.menuItemIcon} />
{isInTrashList {isInTrashList
? t('action.deleteForever', { ? t('action.deleteForever', {
context: 'title', context: 'title',

View file

@ -13,4 +13,9 @@
margin: 0; margin: 0;
padding-left: 14px; padding-left: 14px;
} }
.menuItemIcon {
float: left;
margin: 0 0.5em 0 0;
}
} }

View file

@ -5,17 +5,17 @@
import upperFirst from 'lodash/upperFirst'; import upperFirst from 'lodash/upperFirst';
import camelCase from 'lodash/camelCase'; import camelCase from 'lodash/camelCase';
import React, { useCallback, useMemo, useState } from 'react'; import React, { useCallback, useMemo, useRef, useState } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import classNames from 'classnames'; import classNames from 'classnames';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { Button, Icon } from 'semantic-ui-react'; import { Button, Icon } from 'semantic-ui-react';
import { push } from '../../../lib/redux-router'; import { push } from '../../../lib/redux-router';
import { usePopup } from '../../../lib/popup'; import { closePopup, usePopup } from '../../../lib/popup';
import selectors from '../../../selectors'; import selectors from '../../../selectors';
import Paths from '../../../constants/Paths'; import Paths from '../../../constants/Paths';
import { BoardMembershipRoles, CardTypes, ListTypes } from '../../../constants/Enums'; import { BoardMembershipRoles, CardTypes } from '../../../constants/Enums';
import ProjectContent from './ProjectContent'; import ProjectContent from './ProjectContent';
import StoryContent from './StoryContent'; import StoryContent from './StoryContent';
import InlineContent from './InlineContent'; import InlineContent from './InlineContent';
@ -51,6 +51,8 @@ const Card = React.memo(({ id, isInline }) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const [isEditNameOpened, setIsEditNameOpened] = useState(false); const [isEditNameOpened, setIsEditNameOpened] = useState(false);
const actionsPopupRef = useRef(null);
const handleClick = useCallback(() => { const handleClick = useCallback(() => {
if (document.activeElement) { if (document.activeElement) {
document.activeElement.blur(); document.activeElement.blur();
@ -59,6 +61,17 @@ const Card = React.memo(({ id, isInline }) => {
dispatch(push(Paths.CARDS.replace(':id', id))); dispatch(push(Paths.CARDS.replace(':id', id)));
}, [id, dispatch]); }, [id, dispatch]);
const handleContextMenu = useCallback((event) => {
if (!actionsPopupRef.current) {
return;
}
event.preventDefault();
closePopup();
actionsPopupRef.current.open();
}, []);
const handleNameEdit = useCallback(() => { const handleNameEdit = useCallback(() => {
setIsEditNameOpened(true); setIsEditNameOpened(true);
}, []); }, []);
@ -108,17 +121,15 @@ const Card = React.memo(({ id, isInline }) => {
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,
jsx-a11y/no-static-element-interactions */} jsx-a11y/no-static-element-interactions */}
<div <div
className={classNames( className={classNames(styles.content, card.isClosed && styles.contentDisabled)}
styles.content,
list.type === ListTypes.CLOSED && styles.contentDisabled,
)}
onClick={handleClick} onClick={handleClick}
onContextMenu={handleContextMenu}
> >
<Content cardId={id} /> <Content cardId={id} />
{colorLineNode} {colorLineNode}
</div> </div>
{canUseActions && ( {canUseActions && (
<ActionsPopup cardId={id} onNameEdit={handleNameEdit}> <ActionsPopup ref={actionsPopupRef} cardId={id} onNameEdit={handleNameEdit}>
<Button className={styles.actionsButton}> <Button className={styles.actionsButton}>
<Icon fitted name="pencil" size="small" /> <Icon fitted name="pencil" size="small" />
</Button> </Button>
@ -126,12 +137,7 @@ const Card = React.memo(({ id, isInline }) => {
)} )}
</> </>
) : ( ) : (
<span <span className={classNames(styles.content, card.isClosed && styles.contentDisabled)}>
className={classNames(
styles.content,
list.type === ListTypes.CLOSED && styles.contentDisabled,
)}
>
<Content cardId={id} /> <Content cardId={id} />
{colorLineNode} {colorLineNode}
</span> </span>

View file

@ -96,7 +96,6 @@ const EditName = React.memo(({ cardId, onClose }) => {
maxLength={1024} maxLength={1024}
minRows={3} minRows={3}
maxRows={8} maxRows={8}
spellCheck={false}
className={styles.field} className={styles.field}
onKeyDown={handleFieldKeyDown} onKeyDown={handleFieldKeyDown}
onChange={handleFieldChange} onChange={handleFieldChange}

View file

@ -28,6 +28,7 @@ const InlineContent = React.memo(({ cardId }) => {
); );
const card = useSelector((state) => selectCardById(state, cardId)); const card = useSelector((state) => selectCardById(state, cardId));
const list = useSelector((state) => selectListById(state, card.listId));
const labelIds = useSelector((state) => selectLabelIdsByCardId(state, cardId)); const labelIds = useSelector((state) => selectLabelIdsByCardId(state, cardId));
const notificationsTotal = useSelector((state) => const notificationsTotal = useSelector((state) =>
@ -35,8 +36,6 @@ const InlineContent = React.memo(({ cardId }) => {
); );
const listName = useSelector((state) => { const listName = useSelector((state) => {
const list = selectListById(state, card.listId);
if (!list.name) { if (!list.name) {
return null; return null;
} }
@ -88,7 +87,9 @@ const InlineContent = React.memo(({ cardId }) => {
))} ))}
</span> </span>
)} )}
<span className={classNames(styles.attachments, styles.name)}> <span
className={classNames(styles.attachments, styles.name, card.isClosed && styles.nameClosed)}
>
<div className={styles.hidable}>{card.name}</div> <div className={styles.hidable}>{card.name}</div>
</span> </span>
{descriptionText && ( {descriptionText && (

View file

@ -50,6 +50,10 @@
max-width: 30%; max-width: 30%;
} }
.nameClosed {
text-decoration: line-through;
}
.notification { .notification {
background: #eb5a46; background: #eb5a46;
color: #fff; color: #fff;

View file

@ -13,7 +13,7 @@ import selectors from '../../../selectors';
import entryActions from '../../../entry-actions'; import entryActions from '../../../entry-actions';
import { startStopwatch, stopStopwatch } from '../../../utils/stopwatch'; import { startStopwatch, stopStopwatch } from '../../../utils/stopwatch';
import { isListArchiveOrTrash } from '../../../utils/record-helpers'; import { isListArchiveOrTrash } from '../../../utils/record-helpers';
import { BoardMembershipRoles, BoardViews, ListTypes } from '../../../constants/Enums'; import { BoardMembershipRoles, BoardViews } from '../../../constants/Enums';
import TaskList from './TaskList'; import TaskList from './TaskList';
import DueDateChip from '../DueDateChip'; import DueDateChip from '../DueDateChip';
import StopwatchChip from '../StopwatchChip'; import StopwatchChip from '../StopwatchChip';
@ -145,7 +145,7 @@ const ProjectContent = React.memo(({ cardId }) => {
return ( return (
<div className={styles.wrapper}> <div className={styles.wrapper}>
<div className={styles.name}>{card.name}</div> <div className={classNames(styles.name, card.isClosed && styles.nameClosed)}>{card.name}</div>
{coverUrl && ( {coverUrl && (
<div className={styles.coverWrapper}> <div className={styles.coverWrapper}>
<img src={coverUrl} alt="" className={styles.cover} /> <img src={coverUrl} alt="" className={styles.cover} />
@ -187,11 +187,7 @@ const ProjectContent = React.memo(({ cardId }) => {
)} )}
{card.dueDate && ( {card.dueDate && (
<span className={classNames(styles.attachment, styles.attachmentLeft)}> <span className={classNames(styles.attachment, styles.attachmentLeft)}>
<DueDateChip <DueDateChip value={card.dueDate} size="tiny" withStatus={!card.isClosed} />
value={card.dueDate}
size="tiny"
withStatus={list.type !== ListTypes.CLOSED && !isListArchiveOrTrash(list)}
/>
</span> </span>
)} )}
{card.stopwatch && ( {card.stopwatch && (

View file

@ -84,6 +84,10 @@
word-wrap: break-word; word-wrap: break-word;
} }
.nameClosed {
text-decoration: line-through;
}
.notification { .notification {
background: #eb5a46; background: #eb5a46;
color: #fff; color: #fff;

View file

@ -40,6 +40,7 @@ const StoryContent = React.memo(({ cardId }) => {
const selectAttachmentById = useMemo(() => selectors.makeSelectAttachmentById(), []); const selectAttachmentById = useMemo(() => selectors.makeSelectAttachmentById(), []);
const card = useSelector((state) => selectCardById(state, cardId)); const card = useSelector((state) => selectCardById(state, cardId));
const list = useSelector((state) => selectListById(state, card.listId));
const labelIds = useSelector((state) => selectLabelIdsByCardId(state, cardId)); const labelIds = useSelector((state) => selectLabelIdsByCardId(state, cardId));
const attachmentsTotal = useSelector((state) => selectAttachmentsTotalByCardId(state, cardId)); const attachmentsTotal = useSelector((state) => selectAttachmentsTotalByCardId(state, cardId));
@ -52,8 +53,6 @@ const StoryContent = React.memo(({ cardId }) => {
); );
const listName = useSelector((state) => { const listName = useSelector((state) => {
const list = selectListById(state, card.listId);
if (!list.name) { if (!list.name) {
return null; return null;
} }
@ -106,7 +105,9 @@ const StoryContent = React.memo(({ cardId }) => {
))} ))}
</span> </span>
)} )}
<div className={styles.name}>{card.name}</div> <div className={classNames(styles.name, card.isClosed && styles.nameClosed)}>
{card.name}
</div>
{card.description && <div className={styles.descriptionText}>{descriptionText}</div>} {card.description && <div className={styles.descriptionText}>{descriptionText}</div>}
{(attachmentsTotal > 0 || notificationsTotal > 0 || listName) && ( {(attachmentsTotal > 0 || notificationsTotal > 0 || listName) && (
<span className={styles.attachments}> <span className={styles.attachments}>

View file

@ -74,6 +74,10 @@
word-wrap: break-word; word-wrap: break-word;
} }
.nameClosed {
text-decoration: line-through;
}
.notification { .notification {
background: #eb5a46; background: #eb5a46;
color: #fff; color: #fff;

View file

@ -3,24 +3,49 @@
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md * Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/ */
import React, { useMemo } from 'react'; import React, { useCallback, useMemo } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import classNames from 'classnames'; import classNames from 'classnames';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { Link } from 'react-router-dom';
import { Icon } from 'semantic-ui-react';
import selectors from '../../../../selectors'; import selectors from '../../../../selectors';
import Paths from '../../../../constants/Paths';
import Linkify from '../../../common/Linkify'; import Linkify from '../../../common/Linkify';
import styles from './Task.module.scss'; import styles from './Task.module.scss';
const Task = React.memo(({ id }) => { const Task = React.memo(({ id }) => {
const selectTaskById = useMemo(() => selectors.makeSelectTaskById(), []); const selectTaskById = useMemo(() => selectors.makeSelectTaskById(), []);
const selectLinkedCardById = useMemo(() => selectors.makeSelectCardById(), []);
const task = useSelector((state) => selectTaskById(state, id)); const task = useSelector((state) => selectTaskById(state, id));
const linkedCard = useSelector(
(state) => task.linkedCardId && selectLinkedCardById(state, task.linkedCardId),
);
const handleLinkClick = useCallback((event) => {
event.stopPropagation();
}, []);
return ( return (
<li className={classNames(styles.wrapper, task.isCompleted && styles.wrapperCompleted)}> <li className={styles.wrapper}>
<Linkify linkStopPropagation>{task.name}</Linkify> {task.linkedCardId ? (
<>
<Icon name="exchange" size="small" className={styles.icon} />
<span className={classNames(styles.name, task.isCompleted && styles.nameCompleted)}>
<Link to={Paths.CARDS.replace(':id', task.linkedCardId)} onClick={handleLinkClick}>
{linkedCard ? linkedCard.name : task.name}
</Link>
</span>
</>
) : (
<span className={classNames(styles.name, task.isCompleted && styles.nameCompleted)}>
<Linkify linkStopPropagation>{task.name}</Linkify>
</span>
)}
</li> </li>
); );
}); });

View file

@ -4,6 +4,21 @@
*/ */
:global(#app) { :global(#app) {
.icon {
color: rgba(9, 30, 66, 0.24);
}
.name {
a:hover {
text-decoration: underline;
}
}
.nameCompleted {
color: #aaa;
text-decoration: line-through;
}
.wrapper { .wrapper {
display: block; display: block;
font-size: 12px; font-size: 12px;
@ -18,9 +33,4 @@
left: 10px; left: 10px;
} }
} }
.wrapperCompleted {
color: #aaa;
text-decoration: line-through;
}
} }

View file

@ -43,5 +43,6 @@
font-size: 14px; font-size: 14px;
line-height: 1.2; line-height: 1.2;
padding: 2px 0 2px 2px; padding: 2px 0 2px 2px;
word-wrap: break-word;
} }
} }

View file

@ -82,7 +82,6 @@ const NameField = React.memo(({ defaultValue, size, onUpdate }) => {
as={TextareaAutosize} as={TextareaAutosize}
value={value} value={value}
maxLength={1024} maxLength={1024}
spellCheck={false}
className={classNames(styles.field, styles[`field${upperFirst(size)}`])} className={classNames(styles.field, styles[`field${upperFirst(size)}`])}
onFocus={handleFocus} onFocus={handleFocus}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}

View file

@ -30,7 +30,7 @@ import SelectCardTypeStep from '../SelectCardTypeStep';
import EditDueDateStep from '../EditDueDateStep'; import EditDueDateStep from '../EditDueDateStep';
import EditStopwatchStep from '../EditStopwatchStep'; import EditStopwatchStep from '../EditStopwatchStep';
import MoveCardStep from '../MoveCardStep'; import MoveCardStep from '../MoveCardStep';
import Markdown from '../../common/Markdown'; import ExpandableMarkdown from '../../common/ExpandableMarkdown';
import EditMarkdown from '../../common/EditMarkdown'; import EditMarkdown from '../../common/EditMarkdown';
import ConfirmationStep from '../../common/ConfirmationStep'; import ConfirmationStep from '../../common/ConfirmationStep';
import UserAvatar from '../../users/UserAvatar'; import UserAvatar from '../../users/UserAvatar';
@ -442,18 +442,14 @@ const ProjectContent = React.memo(({ onClose }) => {
<DueDateChip <DueDateChip
withStatusIcon withStatusIcon
value={card.dueDate} value={card.dueDate}
withStatus={ withStatus={!card.isClosed}
list.type !== ListTypes.CLOSED && !isInArchiveList && !isInTrashList
}
/> />
</EditDueDatePopup> </EditDueDatePopup>
) : ( ) : (
<DueDateChip <DueDateChip
withStatusIcon withStatusIcon
value={card.dueDate} value={card.dueDate}
withStatus={ withStatus={!card.isClosed}
list.type !== ListTypes.CLOSED && !isInArchiveList && !isInTrashList
}
/> />
)} )}
</span> </span>
@ -521,7 +517,7 @@ const ProjectContent = React.memo(({ onClose }) => {
<Button className={styles.editButton}> <Button className={styles.editButton}>
<Icon fitted name="pencil" size="small" /> <Icon fitted name="pencil" size="small" />
</Button> </Button>
<Markdown>{card.description}</Markdown> <ExpandableMarkdown>{card.description}</ExpandableMarkdown>
</div> </div>
) : ( ) : (
<button <button
@ -536,7 +532,7 @@ const ProjectContent = React.memo(({ onClose }) => {
))} ))}
</> </>
)} )}
{!canEditDescription && <Markdown>{card.description}</Markdown>} {!canEditDescription && <ExpandableMarkdown>{card.description}</ExpandableMarkdown>}
</div> </div>
</div> </div>
)} )}

View file

@ -195,6 +195,7 @@
font-size: 28px; font-size: 28px;
font-weight: bold; font-weight: bold;
line-height: 34px; line-height: 34px;
word-wrap: break-word;
} }
.headerTitleWrapper { .headerTitleWrapper {

View file

@ -3,16 +3,18 @@
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md * Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/ */
import React, { useCallback, useState } from 'react'; import React, { useCallback, useState, useRef } from 'react';
import { useDispatch } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import TextareaAutosize from 'react-textarea-autosize'; import { Mention, MentionsInput } from 'react-mentions';
import { Button, Form, TextArea } from 'semantic-ui-react'; import { Button, Form } from 'semantic-ui-react';
import { useClickAwayListener, useDidUpdate, useToggle } from '../../../lib/hooks'; import { useClickAwayListener, useDidUpdate, useToggle } from '../../../lib/hooks';
import selectors from '../../../selectors';
import entryActions from '../../../entry-actions'; import entryActions from '../../../entry-actions';
import { useEscapeInterceptor, useForm, useNestedRef } from '../../../hooks'; import { useEscapeInterceptor, useForm, useNestedRef } from '../../../hooks';
import { isModifierKeyPressed } from '../../../utils/event-helpers'; import { isModifierKeyPressed } from '../../../utils/event-helpers';
import UserAvatar from '../../users/UserAvatar';
import styles from './Add.module.scss'; import styles from './Add.module.scss';
@ -21,13 +23,17 @@ const DEFAULT_DATA = {
}; };
const Add = React.memo(() => { const Add = React.memo(() => {
const boardMemberships = useSelector(selectors.selectMembershipsForCurrentBoard);
const dispatch = useDispatch(); const dispatch = useDispatch();
const [t] = useTranslation(); const [t] = useTranslation();
const [data, , setData] = useForm(DEFAULT_DATA);
const [isOpened, setIsOpened] = useState(false); const [isOpened, setIsOpened] = useState(false);
const [data, handleFieldChange, setData] = useForm(DEFAULT_DATA);
const [selectTextFieldState, selectTextField] = useToggle(); const [selectTextFieldState, selectTextField] = useToggle();
const [textFieldRef, handleTextFieldRef] = useNestedRef(); const textFieldRef = useRef(null);
const textMentionsRef = useRef(null);
const textInputRef = useRef(null);
const [buttonRef, handleButtonRef] = useNestedRef(); const [buttonRef, handleButtonRef] = useNestedRef();
const submit = useCallback(() => { const submit = useCallback(() => {
@ -37,19 +43,24 @@ const Add = React.memo(() => {
}; };
if (!cleanData.text) { if (!cleanData.text) {
textFieldRef.current.select(); textInputRef.current.select();
return; return;
} }
dispatch(entryActions.createCommentInCurrentCard(cleanData)); dispatch(entryActions.createCommentInCurrentCard(cleanData));
setData(DEFAULT_DATA); setData(DEFAULT_DATA);
selectTextField(); selectTextField();
}, [dispatch, data, setData, selectTextField, textFieldRef]); }, [dispatch, data, setData, selectTextField]);
const handleEscape = useCallback(() => { const handleEscape = useCallback(() => {
if (textMentionsRef.current.isOpened()) {
textMentionsRef.current.clearSuggestions();
return;
}
setIsOpened(false); setIsOpened(false);
textFieldRef.current.blur(); textInputRef.current.blur();
}, [textFieldRef]); }, []);
const [activateEscapeInterceptor, deactivateEscapeInterceptor] = const [activateEscapeInterceptor, deactivateEscapeInterceptor] =
useEscapeInterceptor(handleEscape); useEscapeInterceptor(handleEscape);
@ -62,6 +73,15 @@ const Add = React.memo(() => {
setIsOpened(true); setIsOpened(true);
}, []); }, []);
const handleFieldChange = useCallback(
(_, text) => {
setData({
text,
});
},
[setData],
);
const handleFieldKeyDown = useCallback( const handleFieldKeyDown = useCallback(
(event) => { (event) => {
if (isModifierKeyPressed(event) && event.key === 'Enter') { if (isModifierKeyPressed(event) && event.key === 'Enter') {
@ -76,8 +96,8 @@ const Add = React.memo(() => {
}, []); }, []);
const handleClickAwayCancel = useCallback(() => { const handleClickAwayCancel = useCallback(() => {
textFieldRef.current.focus(); textInputRef.current.focus();
}, [textFieldRef]); }, []);
const clickAwayProps = useClickAwayListener( const clickAwayProps = useClickAwayListener(
[textFieldRef, buttonRef], [textFieldRef, buttonRef],
@ -85,6 +105,16 @@ const Add = React.memo(() => {
handleClickAwayCancel, handleClickAwayCancel,
); );
const suggestionRenderer = useCallback(
(entry, _, highlightedDisplay) => (
<div className={styles.suggestion}>
<UserAvatar id={entry.id} size="tiny" />
{highlightedDisplay}
</div>
),
[],
);
useDidUpdate(() => { useDidUpdate(() => {
if (isOpened) { if (isOpened) {
activateEscapeInterceptor(); activateEscapeInterceptor();
@ -94,26 +124,44 @@ const Add = React.memo(() => {
}, [isOpened]); }, [isOpened]);
useDidUpdate(() => { useDidUpdate(() => {
textFieldRef.current.focus(); textInputRef.current.focus();
}, [selectTextFieldState]); }, [selectTextFieldState]);
return ( return (
<Form onSubmit={handleSubmit}> <Form onSubmit={handleSubmit}>
<TextArea <div ref={textFieldRef} className={styles.field}>
{...clickAwayProps} // eslint-disable-line react/jsx-props-no-spreading <MentionsInput
ref={handleTextFieldRef} {...clickAwayProps} // eslint-disable-line react/jsx-props-no-spreading
as={TextareaAutosize} allowSpaceInQuery
name="text" allowSuggestionsAboveCursor
value={data.text} ref={textMentionsRef}
placeholder={t('common.writeComment')} inputRef={textInputRef}
maxLength={1048576} value={data.text}
minRows={isOpened ? 3 : 1} placeholder={t('common.writeComment')}
spellCheck={false} maxLength={1048576}
className={styles.field} rows={isOpened ? 3 : 1}
onFocus={handleFieldFocus} className="mentions-input"
onKeyDown={handleFieldKeyDown} style={{
onChange={handleFieldChange} control: {
/> minHeight: isOpened ? '79px' : '37px',
},
}}
onFocus={handleFieldFocus}
onChange={handleFieldChange}
onKeyDown={handleFieldKeyDown}
>
<Mention
appendSpaceOnAdd
data={boardMemberships.map(({ user }) => ({
id: user.id,
display: user.username || user.name,
}))}
displayTransform={(_, display) => `@${display}`}
renderSuggestion={suggestionRenderer}
className={styles.mention}
/>
</MentionsInput>
</div>
{isOpened && ( {isOpened && (
<div className={styles.controls}> <div className={styles.controls}>
<Button <Button

View file

@ -16,20 +16,34 @@
} }
.field { .field {
background: #fff; textarea {
border: 0; background: #fff !important;
box-sizing: border-box; border: 1px solid rgba(9, 30, 66, 0.13);
color: #333; border-radius: 3px;
display: block; box-sizing: border-box;
line-height: 1.5; color: #333;
font-size: 14px; display: block;
overflow: hidden; font-size: 14px;
padding: 8px 12px; line-height: 1.4;
resize: none; margin: 0 !important;
width: 100%; overflow: hidden;
padding: 8px 12px;
resize: none;
&:focus { &:focus {
outline: none; outline: none;
}
} }
} }
.mention {
background-color: #f1f8ff;
border-radius: 3px;
}
.suggestion {
align-items: center;
display: flex;
gap: 8px;
}
} }

View file

@ -4,12 +4,12 @@
*/ */
import { dequal } from 'dequal'; import { dequal } from 'dequal';
import React, { useCallback, useEffect, useMemo } from 'react'; import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import TextareaAutosize from 'react-textarea-autosize'; import { Mention, MentionsInput } from 'react-mentions';
import { Button, Form, TextArea } from 'semantic-ui-react'; import { Button, Form } from 'semantic-ui-react';
import { useClickAwayListener } from '../../../lib/hooks'; import { useClickAwayListener } from '../../../lib/hooks';
import selectors from '../../../selectors'; import selectors from '../../../selectors';
@ -17,6 +17,7 @@ import entryActions from '../../../entry-actions';
import { useForm, useNestedRef } from '../../../hooks'; import { useForm, useNestedRef } from '../../../hooks';
import { focusEnd } from '../../../utils/element-helpers'; import { focusEnd } from '../../../utils/element-helpers';
import { isModifierKeyPressed } from '../../../utils/event-helpers'; import { isModifierKeyPressed } from '../../../utils/event-helpers';
import UserAvatar from '../../users/UserAvatar';
import styles from './Edit.module.scss'; import styles from './Edit.module.scss';
@ -24,6 +25,7 @@ const Edit = React.memo(({ commentId, onClose }) => {
const selectCommentById = useMemo(() => selectors.makeSelectCommentById(), []); const selectCommentById = useMemo(() => selectors.makeSelectCommentById(), []);
const comment = useSelector((state) => selectCommentById(state, commentId)); const comment = useSelector((state) => selectCommentById(state, commentId));
const boardMemberships = useSelector(selectors.selectMembershipsForCurrentBoard);
const dispatch = useDispatch(); const dispatch = useDispatch();
const [t] = useTranslation(); const [t] = useTranslation();
@ -35,13 +37,16 @@ const Edit = React.memo(({ commentId, onClose }) => {
[comment.text], [comment.text],
); );
const [data, handleFieldChange] = useForm(() => ({ const [data, , setData] = useForm(() => ({
text: '', text: '',
...defaultData, ...defaultData,
})); }));
const [textFieldRef, handleTextFieldRef] = useNestedRef(); const textFieldRef = useRef(null);
const [buttonRef, handleButtonRef] = useNestedRef(); const textMentionsRef = useRef(null);
const textInputRef = useRef(null);
const [submitButtonRef, handleSubmitButtonRef] = useNestedRef();
const [cancelButtonRef, handleCancelButtonRef] = useNestedRef();
const submit = useCallback(() => { const submit = useCallback(() => {
const cleanData = { const cleanData = {
@ -60,6 +65,15 @@ const Edit = React.memo(({ commentId, onClose }) => {
submit(); submit();
}, [submit]); }, [submit]);
const handleFieldChange = useCallback(
(_, text) => {
setData({
text,
});
},
[setData],
);
const handleFieldKeyDown = useCallback( const handleFieldKeyDown = useCallback(
(event) => { (event) => {
if (event.key === 'Enter') { if (event.key === 'Enter') {
@ -67,48 +81,92 @@ const Edit = React.memo(({ commentId, onClose }) => {
submit(); submit();
} }
} else if (event.key === 'Escape') { } else if (event.key === 'Escape') {
if (textMentionsRef.current.isOpened()) {
textMentionsRef.current.clearSuggestions();
return;
}
onClose(); onClose();
} }
}, },
[onClose, submit], [onClose, submit],
); );
const handleCancelClick = useCallback(() => {
onClose();
}, [onClose]);
const handleClickAwayCancel = useCallback(() => { const handleClickAwayCancel = useCallback(() => {
textFieldRef.current.focus(); textInputRef.current.focus();
}, [textFieldRef]); }, []);
const clickAwayProps = useClickAwayListener( const clickAwayProps = useClickAwayListener(
[textFieldRef, buttonRef], [textFieldRef, submitButtonRef, cancelButtonRef],
submit, submit,
handleClickAwayCancel, handleClickAwayCancel,
); );
const suggestionRenderer = useCallback(
(entry, _, highlightedDisplay) => (
<div className={styles.suggestion}>
<UserAvatar id={entry.id} size="tiny" />
{highlightedDisplay}
</div>
),
[],
);
useEffect(() => { useEffect(() => {
focusEnd(textFieldRef.current); focusEnd(textInputRef.current);
}, [textFieldRef]); }, []);
return ( return (
<Form onSubmit={handleSubmit}> <Form onSubmit={handleSubmit}>
<TextArea <div ref={textFieldRef} className={styles.field}>
{...clickAwayProps} // eslint-disable-line react/jsx-props-no-spreading <MentionsInput
ref={handleTextFieldRef} {...clickAwayProps} // eslint-disable-line react/jsx-props-no-spreading
as={TextareaAutosize} allowSpaceInQuery
name="text" allowSuggestionsAboveCursor
value={data.text} ref={textMentionsRef}
maxLength={1048576} inputRef={textInputRef}
minRows={3} value={data.text}
spellCheck={false} maxLength={1048576}
className={styles.field} rows={3}
onKeyDown={handleFieldKeyDown} className="mentions-input"
onChange={handleFieldChange} style={{
/> control: {
minHeight: '79px',
},
}}
onChange={handleFieldChange}
onKeyDown={handleFieldKeyDown}
>
<Mention
appendSpaceOnAdd
data={boardMemberships.map(({ user }) => ({
id: user.id,
display: user.username || user.name,
}))}
displayTransform={(_, display) => `@${display}`}
renderSuggestion={suggestionRenderer}
className={styles.mention}
/>
</MentionsInput>
</div>
<div className={styles.controls}> <div className={styles.controls}>
<Button <Button
{...clickAwayProps} // eslint-disable-line react/jsx-props-no-spreading {...clickAwayProps} // eslint-disable-line react/jsx-props-no-spreading
positive positive
ref={handleButtonRef} ref={handleSubmitButtonRef}
content={t('action.save')} content={t('action.save')}
/> />
<Button
{...clickAwayProps} // eslint-disable-line react/jsx-props-no-spreading
ref={handleCancelButtonRef}
type="button"
content={t('action.cancel')}
onClick={handleCancelClick}
/>
</div> </div>
</Form> </Form>
); );

View file

@ -10,22 +10,34 @@
} }
.field { .field {
background: #fff; textarea {
border: 1px solid rgba(9, 30, 66, 0.13); background: #fff !important;
border-radius: 3px; border: 1px solid rgba(9, 30, 66, 0.13);
box-sizing: border-box; border-radius: 3px;
color: #333; box-sizing: border-box;
display: block; color: #333;
line-height: 1.4; display: block;
font-size: 14px; font-size: 14px;
margin-bottom: 4px; line-height: 1.4;
overflow: hidden; margin: 0 !important;
padding: 8px 12px; overflow: hidden;
resize: none; padding: 8px 12px;
width: 100%; resize: none;
&:focus { &:focus {
outline: none; outline: none;
}
} }
} }
.mention {
background-color: #f1f8ff;
border-radius: 3px;
}
.suggestion {
align-items: center;
display: flex;
gap: 8px;
}
} }

View file

@ -12,6 +12,7 @@ import { Modal, Tab } from 'semantic-ui-react';
import entryActions from '../../../entry-actions'; import entryActions from '../../../entry-actions';
import { useClosableModal } from '../../../hooks'; import { useClosableModal } from '../../../hooks';
import UsersPane from './UsersPane'; import UsersPane from './UsersPane';
import WebhooksPane from './WebhooksPane';
import styles from './AdministrationModal.module.scss'; import styles from './AdministrationModal.module.scss';
@ -37,6 +38,12 @@ const AdministrationModal = React.memo(() => {
}), }),
render: () => <UsersPane />, render: () => <UsersPane />,
}, },
{
menuItem: t('common.webhooks', {
context: 'title',
}),
render: () => <WebhooksPane />,
},
]; ];
const isUsersPaneActive = activeTabIndex === 0; const isUsersPaneActive = activeTabIndex === 0;

View file

@ -7,7 +7,7 @@ import React, { useCallback, useMemo } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Menu } from 'semantic-ui-react'; import { Icon, Menu } from 'semantic-ui-react';
import { Popup } from '../../../../lib/custom-ui'; import { Popup } from '../../../../lib/custom-ui';
import selectors from '../../../../selectors'; import selectors from '../../../../selectors';
@ -180,12 +180,14 @@ const ActionsStep = React.memo(({ userId, onClose }) => {
<Popup.Content> <Popup.Content>
<Menu secondary vertical className={styles.menu}> <Menu secondary vertical className={styles.menu}>
<Menu.Item className={styles.menuItem} onClick={handleEditInformationClick}> <Menu.Item className={styles.menuItem} onClick={handleEditInformationClick}>
<Icon name="info" className={styles.menuItemIcon} />
{t('action.editInformation', { {t('action.editInformation', {
context: 'title', context: 'title',
})} })}
</Menu.Item> </Menu.Item>
{!user.lockedFieldNames.includes('username') && ( {!user.lockedFieldNames.includes('username') && (
<Menu.Item className={styles.menuItem} onClick={handleEditUsernameClick}> <Menu.Item className={styles.menuItem} onClick={handleEditUsernameClick}>
<Icon name="at" className={styles.menuItemIcon} />
{t('action.editUsername', { {t('action.editUsername', {
context: 'title', context: 'title',
})} })}
@ -193,6 +195,7 @@ const ActionsStep = React.memo(({ userId, onClose }) => {
)} )}
{!user.lockedFieldNames.includes('email') && ( {!user.lockedFieldNames.includes('email') && (
<Menu.Item className={styles.menuItem} onClick={handleEditEmailClick}> <Menu.Item className={styles.menuItem} onClick={handleEditEmailClick}>
<Icon name="mail outline" className={styles.menuItemIcon} />
{t('action.editEmail', { {t('action.editEmail', {
context: 'title', context: 'title',
})} })}
@ -200,6 +203,7 @@ const ActionsStep = React.memo(({ userId, onClose }) => {
)} )}
{!user.lockedFieldNames.includes('password') && ( {!user.lockedFieldNames.includes('password') && (
<Menu.Item className={styles.menuItem} onClick={handleEditPasswordClick}> <Menu.Item className={styles.menuItem} onClick={handleEditPasswordClick}>
<Icon name="keyboard outline" className={styles.menuItemIcon} />
{t('action.editPassword', { {t('action.editPassword', {
context: 'title', context: 'title',
})} })}
@ -207,6 +211,7 @@ const ActionsStep = React.memo(({ userId, onClose }) => {
)} )}
{!user.lockedFieldNames.includes('role') && ( {!user.lockedFieldNames.includes('role') && (
<Menu.Item className={styles.menuItem} onClick={handleEditRoleClick}> <Menu.Item className={styles.menuItem} onClick={handleEditRoleClick}>
<Icon name="sun outline" className={styles.menuItemIcon} />
{t('action.editRole', { {t('action.editRole', {
context: 'title', context: 'title',
})} })}
@ -221,6 +226,7 @@ const ActionsStep = React.memo(({ userId, onClose }) => {
className={styles.menuItem} className={styles.menuItem}
onClick={user.isDeactivated ? handleActivateClick : handleDeactivateClick} onClick={user.isDeactivated ? handleActivateClick : handleDeactivateClick}
> >
<Icon name={user.isDeactivated ? 'plus' : 'close'} className={styles.menuItemIcon} />
{user.isDeactivated {user.isDeactivated
? t('action.activateUser', { ? t('action.activateUser', {
context: 'title', context: 'title',
@ -231,6 +237,7 @@ const ActionsStep = React.memo(({ userId, onClose }) => {
</Menu.Item> </Menu.Item>
{user.isDeactivated && !user.isDefaultAdmin && ( {user.isDeactivated && !user.isDefaultAdmin && (
<Menu.Item className={styles.menuItem} onClick={handleDeleteClick}> <Menu.Item className={styles.menuItem} onClick={handleDeleteClick}>
<Icon name="trash alternate outline" className={styles.menuItemIcon} />
{t('action.deleteUser', { {t('action.deleteUser', {
context: 'title', context: 'title',
})} })}

View file

@ -13,4 +13,9 @@
margin: 0; margin: 0;
padding-left: 14px; padding-left: 14px;
} }
.menuItemIcon {
float: left;
margin: 0 0.5em 0 0;
}
} }

View file

@ -117,7 +117,7 @@ const AddStep = React.memo(({ onClose }) => {
); );
const handleMessageDismiss = useCallback(() => { const handleMessageDismiss = useCallback(() => {
dispatch(entryActions.clearUserPasswordUpdateError()); dispatch(entryActions.clearUserCreateError());
}, [dispatch]); }, [dispatch]);
const handleSelectRoleClick = useCallback(() => { const handleSelectRoleClick = useCallback(() => {

View file

@ -0,0 +1,35 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import React, { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { Tab } from 'semantic-ui-react';
import selectors from '../../../selectors';
import entryActions from '../../../entry-actions';
import Webhooks from '../../webhooks/Webhooks';
import styles from './WebhooksPane.module.scss';
const WebhooksPane = React.memo(() => {
const webhookIds = useSelector(selectors.selectWebhookIds);
const dispatch = useDispatch();
const handleCreate = useCallback(
(data) => {
dispatch(entryActions.createWebhook(data));
},
[dispatch],
);
return (
<Tab.Pane attached={false} className={styles.wrapper}>
<Webhooks ids={webhookIds} onCreate={handleCreate} />
</Tab.Pane>
);
});
export default WebhooksPane;

View file

@ -0,0 +1,11 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.wrapper {
border: none;
box-shadow: none;
}
}

View file

@ -28,21 +28,26 @@ const ConfirmationStep = React.memo(
const [nameFieldRef, handleNameFieldRef] = useNestedRef('inputRef'); const [nameFieldRef, handleNameFieldRef] = useNestedRef('inputRef');
const handleSubmit = useCallback(() => { const handleSubmit = useCallback(
if (typeValue) { (event) => {
const cleanData = { event.stopPropagation();
...data,
typeValue: data.typeValue.trim(),
};
if (cleanData.typeValue.toLowerCase() !== typeValue.toLowerCase()) { if (typeValue) {
nameFieldRef.current.select(); const cleanData = {
return; ...data,
typeValue: data.typeValue.trim(),
};
if (cleanData.typeValue.toLowerCase() !== typeValue.toLowerCase()) {
nameFieldRef.current.select();
return;
}
} }
}
onConfirm(); onConfirm();
}, [typeValue, onConfirm, data, nameFieldRef]); },
[typeValue, onConfirm, data, nameFieldRef],
);
useEffect(() => { useEffect(() => {
if (typeValue) { if (typeValue) {

View file

@ -0,0 +1,78 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import React, { useCallback, useEffect, useRef, useState } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useTranslation } from 'react-i18next';
import { Button, Icon } from 'semantic-ui-react';
import { useToggle } from '../../../lib/hooks';
import Markdown from '../Markdown';
import styles from './ExpandableMarkdown.module.scss';
const MAX_VISIBLE_PART_HEIGHT = 800;
const ExpandableMarkdown = React.memo(({ children }) => {
const [t] = useTranslation();
const [isOverflowing, setIsOverflowing] = useState(false);
const [isExpanded, toggleExpanded] = useToggle();
const contentRef = useRef(null);
const handleToggleExpandClick = useCallback(
(event) => {
event.stopPropagation();
event.target.blur();
toggleExpanded();
},
[toggleExpanded],
);
useEffect(() => {
const resizeObserver = new ResizeObserver(() => {
setIsOverflowing(contentRef.current.scrollHeight > MAX_VISIBLE_PART_HEIGHT);
});
resizeObserver.observe(contentRef.current);
return () => {
resizeObserver.disconnect();
};
}, []);
const isPartHidden = isOverflowing && !isExpanded;
return (
<>
<div
className={classNames(
styles.wrapper,
isExpanded && styles.wrapperExpanded,
isPartHidden && styles.wrapperPartHidden,
)}
style={{ maxHeight: `${MAX_VISIBLE_PART_HEIGHT}px` }}
>
<div ref={contentRef}>
<Markdown>{children}</Markdown>
</div>
</div>
{isOverflowing && (
<Button fluid className={styles.toggleButton} onClick={handleToggleExpandClick}>
<Icon name={isExpanded ? 'angle up' : 'angle down'} />
{isExpanded ? t('action.showLess') : t('action.showMore')}
</Button>
)}
</>
);
});
ExpandableMarkdown.propTypes = {
children: PropTypes.string.isRequired,
};
export default ExpandableMarkdown;

View file

@ -0,0 +1,28 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.toggleButton {
box-shadow: none;
font-weight: normal;
margin-top: 8px;
padding: 6px 11px;
text-align: center;
}
.wrapper {
overflow: hidden;
}
.wrapperExpanded {
max-height: none !important;
overflow: visible;
}
.wrapperPartHidden {
mask-image: linear-gradient(180deg, #000 80%, transparent);
-webkit-mask-image: linear-gradient(180deg, #000 80%, transparent);
}
}

View file

@ -0,0 +1,8 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import ExpandableMarkdown from './ExpandableMarkdown';
export default ExpandableMarkdown;

View file

@ -1,75 +0,0 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
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 {
/* 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;

View file

@ -0,0 +1,81 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import React, { useCallback, useMemo } from 'react';
import PropTypes from 'prop-types';
import { useSelector } from 'react-redux';
import history from '../../../history';
import selectors from '../../../selectors';
import matchPaths from '../../../utils/match-paths';
import Paths from '../../../constants/Paths';
const Linkify = React.memo(({ href, content, stopPropagation, ...props }) => {
const selectCardById = useMemo(() => selectors.makeSelectCardById(), []);
const url = useMemo(() => {
try {
return new URL(href, window.location);
} catch {
return null;
}
}, [href]);
const isSameSite = !!url && url.origin === window.location.origin;
const cardsPathMatch = useMemo(() => {
if (!isSameSite) {
return null;
}
return matchPaths(url.pathname, [Paths.CARDS]);
}, [url.pathname, isSameSite]);
const card = useSelector((state) => {
if (!cardsPathMatch) {
return null;
}
return selectCardById(state, cardsPathMatch.params.id);
});
const handleClick = useCallback(
(event) => {
if (stopPropagation) {
event.stopPropagation();
}
if (isSameSite) {
event.preventDefault();
history.push(event.target.href);
}
},
[stopPropagation, isSameSite],
);
return (
<a
{...props} // eslint-disable-line react/jsx-props-no-spreading
href={href}
target={isSameSite ? undefined : '_blank'}
rel={isSameSite ? undefined : 'noreferrer'}
onClick={handleClick}
>
{card ? card.name : content}
</a>
);
});
Linkify.propTypes = {
href: PropTypes.string.isRequired,
content: PropTypes.string.isRequired,
stopPropagation: PropTypes.bool,
};
Linkify.defaultProps = {
stopPropagation: false,
};
export default Linkify;

View file

@ -0,0 +1,43 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import LinkifyReact from 'linkify-react';
import Link from './Link';
const Linkify = React.memo(({ children, linkStopPropagation, ...props }) => {
const linkRenderer = useCallback(
({ attributes: { href, ...linkProps }, content }) => (
// eslint-disable-next-line react/jsx-props-no-spreading
<Link {...linkProps} href={href} content={content} stopPropagation={linkStopPropagation} />
),
[linkStopPropagation],
);
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: undefined,
};
export default Linkify;

View file

@ -0,0 +1,8 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import Linkify from './Linkify';
export default Linkify;

View file

@ -7,7 +7,7 @@ import isEmail from 'validator/lib/isEmail';
import React, { useCallback, useEffect, useMemo } from 'react'; import React, { useCallback, useEffect, useMemo } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next'; import { useTranslation, Trans } from 'react-i18next';
import { Button, Divider, Form, Grid, Header, Message } from 'semantic-ui-react'; import { Button, Divider, 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';
@ -247,7 +247,14 @@ const Content = React.memo(() => {
/> />
)} )}
</div> </div>
<p className={styles.formFooter}>{t('common.poweredByPlanka')}</p> <p className={styles.formFooter}>
<Trans i18nKey="common.poweredByPlanka">
{'Powered by '}
<a href="https://github.com/plankanban/planka" target="_blank" rel="noreferrer">
PLANKA
</a>
</Trans>
</p>
</div> </div>
</Grid.Column> </Grid.Column>
<Grid.Column <Grid.Column

View file

@ -14,6 +14,7 @@
background: #fff; background: #fff;
} }
.g-md-markup-editor__toolbar,
.g-md-wysiwyg-editor__toolbar { .g-md-wysiwyg-editor__toolbar {
background: #f5f6f7; background: #f5f6f7;
z-index: 2000; z-index: 2000;

View file

@ -3,9 +3,10 @@
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md * Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/ */
import React, { useCallback, useMemo } from 'react'; import React, { useCallback, useMemo, useState } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { Button, Icon } from 'semantic-ui-react';
import selectors from '../../../selectors'; import selectors from '../../../selectors';
import entryActions from '../../../entry-actions'; import entryActions from '../../../entry-actions';
@ -48,6 +49,7 @@ const CustomField = React.memo(({ id, customFieldGroupId }) => {
}); });
const dispatch = useDispatch(); const dispatch = useDispatch();
const [isCopied, setIsCopied] = useState(false);
const handleValueUpdate = useCallback( const handleValueUpdate = useCallback(
(content) => { (content) => {
@ -64,18 +66,40 @@ const CustomField = React.memo(({ id, customFieldGroupId }) => {
[id, customFieldGroupId, cardId, dispatch], [id, customFieldGroupId, cardId, dispatch],
); );
const handleCopyClick = useCallback(() => {
if (isCopied) {
return;
}
navigator.clipboard.writeText(customFieldValue.content);
setIsCopied(true);
setTimeout(() => {
setIsCopied(false);
}, 1000);
}, [customFieldValue, isCopied]);
return ( return (
<div> <div>
<div className={styles.name}>{customField.name}</div> <div className={styles.name}>{customField.name}</div>
{canEdit ? ( <div className={styles.valueWrapper}>
<ValueField {canEdit ? (
defaultValue={customFieldValue && customFieldValue.content} <ValueField
disabled={!customField.isPersisted} defaultValue={customFieldValue && customFieldValue.content}
onUpdate={handleValueUpdate} disabled={!customField.isPersisted}
/> onUpdate={handleValueUpdate}
) : ( />
<div className={styles.value}>{customFieldValue ? customFieldValue.content : '\u00A0'}</div> ) : (
)} <div className={styles.value}>
{customFieldValue ? customFieldValue.content : '\u00A0'}
</div>
)}
{customFieldValue && customFieldValue.content && (
<Button className={styles.copyButton} onClick={handleCopyClick}>
<Icon fitted name={isCopied ? 'check' : 'copy'} />
</Button>
)}
</div>
</div> </div>
); );
}); });

View file

@ -4,6 +4,30 @@
*/ */
:global(#app) { :global(#app) {
.copyButton {
background: #ebeef0;
box-shadow: none;
border-radius: 3px;
box-sizing: content-box;
color: #516b7a;
display: none;
height: 30px;
margin: 0;
min-height: auto;
outline: none;
padding: 4px;
position: absolute;
right: 0;
top: 0;
transition: background 85ms ease;
width: 20px;
&:hover {
background: #dfe3e6;
color: #4c4c4c;
}
}
.name { .name {
color: #6b808c; color: #6b808c;
font-size: 13px; font-size: 13px;
@ -25,4 +49,14 @@
padding: 8px 12px; padding: 8px 12px;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.valueWrapper {
position: relative;
&:hover:not(:has(input:focus)) {
.copyButton {
display: block;
}
}
}
} }

View file

@ -15,8 +15,6 @@ import { useForm } from '../../../hooks';
import LABEL_COLORS from '../../../constants/LabelColors'; import LABEL_COLORS from '../../../constants/LabelColors';
import Editor from './Editor'; import Editor from './Editor';
import styles from './AddStep.module.scss';
const AddStep = React.memo(({ cardId, defaultData, onBack }) => { const AddStep = React.memo(({ cardId, defaultData, onBack }) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const [t] = useTranslation(); const [t] = useTranslation();
@ -52,7 +50,7 @@ const AddStep = React.memo(({ cardId, defaultData, onBack }) => {
<Popup.Content> <Popup.Content>
<Form onSubmit={handleSubmit}> <Form onSubmit={handleSubmit}>
<Editor data={data} onFieldChange={handleFieldChange} /> <Editor data={data} onFieldChange={handleFieldChange} />
<Button positive content={t('action.createLabel')} className={styles.submitButton} /> <Button positive content={t('action.createLabel')} />
</Form> </Form>
</Popup.Content> </Popup.Content>
</> </>

View file

@ -91,13 +91,16 @@ const EditStep = React.memo(({ labelId, onBack }) => {
<Popup.Content> <Popup.Content>
<Form onSubmit={handleSubmit}> <Form onSubmit={handleSubmit}>
<Editor data={data} onFieldChange={handleFieldChange} /> <Editor data={data} onFieldChange={handleFieldChange} />
<Button positive content={t('action.save')} className={styles.submitButton} /> <div className={styles.actions}>
<Button positive content={t('action.save')} />
<Button
type="button"
content={t('action.delete')}
className={styles.deleteButton}
onClick={handleDeleteClick}
/>
</div>
</Form> </Form>
<Button
content={t('action.delete')}
className={styles.deleteButton}
onClick={handleDeleteClick}
/>
</Popup.Content> </Popup.Content>
</> </>
); );

View file

@ -4,14 +4,14 @@
*/ */
:global(#app) { :global(#app) {
.deleteButton { .actions {
bottom: 12px; align-items: center;
box-shadow: 0 1px 0 #cbcccc; display: flex;
position: absolute; gap: 12px;
right: 9px; justify-content: space-between;
} }
.submitButton { .deleteButton {
margin-top: 12px; box-shadow: 0 1px 0 #cbcccc;
} }
} }

View file

@ -5,12 +5,8 @@
:global(#app) { :global(#app) {
.colorButton { .colorButton {
float: left; margin: 0;
height: 30px;
margin: 3px;
padding: 0;
position: relative; position: relative;
width: 41.6px;
&:hover { &:hover {
opacity: 0.9; opacity: 0.9;
@ -18,28 +14,43 @@
} }
.colorButtonActive:before { .colorButtonActive:before {
bottom: 1px;
color: #ffffff; color: #ffffff;
content: "Г"; content: "Г";
font-size: 16px; font-size: 16px;
line-height: 32px; line-height: 32px;
position: absolute; position: absolute;
right: 4px; right: calc(50% - 5px);
text-align: center; text-align: center;
text-shadow: 1px 1px 0 rgba(0, 0, 0, 0.2); text-shadow: 1px 1px 0 rgba(0, 0, 0, 0.2);
top: 0; top: calc(50% - 17px);
transform: rotate(-135deg); transform: rotate(-135deg);
width: 32px;
} }
.colorButtons { .colorButtons {
margin: -3px; display: grid;
padding-bottom: 16px; gap: 6px;
grid-template-columns: repeat(auto-fill, minmax(40px, 1fr));
margin-bottom: 12px;
max-height: 60vh;
overflow-y: auto;
&:after { @supports (-moz-appearance: none) {
clear: both; scrollbar-color: rgba(0, 0, 0, 0.32) transparent;
content: ""; scrollbar-width: thin;
display: table; }
&::-webkit-scrollbar {
width: 9px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background-clip: padding-box;
border-left: 0.25em transparent solid;
border-radius: 3px;
} }
} }

View file

@ -7,7 +7,7 @@ import React, { useCallback, useMemo } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Menu } from 'semantic-ui-react'; import { Icon, Menu } from 'semantic-ui-react';
import { Popup } from '../../../lib/custom-ui'; import { Popup } from '../../../lib/custom-ui';
import selectors from '../../../selectors'; import selectors from '../../../selectors';
@ -128,38 +128,45 @@ const ActionsStep = React.memo(({ listId, onNameEdit, onCardAdd, onClose }) => {
<Popup.Content> <Popup.Content>
<Menu secondary vertical className={styles.menu}> <Menu secondary vertical className={styles.menu}>
<Menu.Item className={styles.menuItem} onClick={handleEditNameClick}> <Menu.Item className={styles.menuItem} onClick={handleEditNameClick}>
<Icon name="edit outline" className={styles.menuItemIcon} />
{t('action.editTitle', { {t('action.editTitle', {
context: 'title', context: 'title',
})} })}
</Menu.Item> </Menu.Item>
<Menu.Item className={styles.menuItem} onClick={handleEditTypeClick}> <Menu.Item className={styles.menuItem} onClick={handleEditTypeClick}>
<Icon name="map outline" className={styles.menuItemIcon} />
{t('action.editType', { {t('action.editType', {
context: 'title', context: 'title',
})} })}
</Menu.Item> </Menu.Item>
<Menu.Item className={styles.menuItem} onClick={handleEditColorClick}> <Menu.Item className={styles.menuItem} onClick={handleEditColorClick}>
<Icon name="dot circle outline" className={styles.menuItemIcon} />
{t('action.editColor', { {t('action.editColor', {
context: 'title', context: 'title',
})} })}
</Menu.Item> </Menu.Item>
<Menu.Item className={styles.menuItem} onClick={handleAddCardClick}> <Menu.Item className={styles.menuItem} onClick={handleAddCardClick}>
<Icon name="list alternate outline" className={styles.menuItemIcon} />
{t('action.addCard', { {t('action.addCard', {
context: 'title', context: 'title',
})} })}
</Menu.Item> </Menu.Item>
<Menu.Item className={styles.menuItem} onClick={handleSortClick}> <Menu.Item className={styles.menuItem} onClick={handleSortClick}>
<Icon name="sort amount down" className={styles.menuItemIcon} />
{t('action.sortList', { {t('action.sortList', {
context: 'title', context: 'title',
})} })}
</Menu.Item> </Menu.Item>
{list.type === ListTypes.CLOSED && ( {list.type === ListTypes.CLOSED && (
<Menu.Item className={styles.menuItem} onClick={handleArchiveCardsClick}> <Menu.Item className={styles.menuItem} onClick={handleArchiveCardsClick}>
<Icon name="folder open outline" className={styles.menuItemIcon} />
{t('action.archiveCards', { {t('action.archiveCards', {
context: 'title', context: 'title',
})} })}
</Menu.Item> </Menu.Item>
)} )}
<Menu.Item className={styles.menuItem} onClick={handleDeleteClick}> <Menu.Item className={styles.menuItem} onClick={handleDeleteClick}>
<Icon name="trash alternate outline" className={styles.menuItemIcon} />
{t('action.deleteList', { {t('action.deleteList', {
context: 'title', context: 'title',
})} })}

View file

@ -13,4 +13,9 @@
margin: 0; margin: 0;
padding-left: 14px; padding-left: 14px;
} }
.menuItemIcon {
float: left;
margin: 0 0.5em 0 0;
}
} }

View file

@ -76,7 +76,6 @@ const EditName = React.memo(({ listId, onClose }) => {
as={TextareaAutosize} as={TextareaAutosize}
value={value} value={value}
maxLength={128} maxLength={128}
spellCheck={false}
className={styles.field} className={styles.field}
onClick={handleFieldClick} onClick={handleFieldClick}
onKeyDown={handleFieldKeyDown} onKeyDown={handleFieldKeyDown}

View file

@ -125,7 +125,7 @@ const Item = React.memo(({ id }) => {
dispatch(entryActions.deleteNotificationService(id)); dispatch(entryActions.deleteNotificationService(id));
}, [id, dispatch]); }, [id, dispatch]);
const handleUpdateSubmit = useCallback(() => { const handleSubmit = useCallback(() => {
urlFieldRef.current.blur(); urlFieldRef.current.blur();
}, [urlFieldRef]); }, [urlFieldRef]);
@ -153,7 +153,7 @@ const Item = React.memo(({ id }) => {
const ConfirmationPopup = usePopupInClosableContext(ConfirmationStep); const ConfirmationPopup = usePopupInClosableContext(ConfirmationStep);
return ( return (
<Form className={styles.wrapper} onSubmit={handleUpdateSubmit}> <Form className={styles.wrapper} onSubmit={handleSubmit}>
<Input <Input
ref={handleUrlFieldRef} ref={handleUrlFieldRef}
name="url" name="url"

View file

@ -13,6 +13,7 @@ import { Button } from 'semantic-ui-react';
import selectors from '../../../selectors'; import selectors from '../../../selectors';
import entryActions from '../../../entry-actions'; import entryActions from '../../../entry-actions';
import { formatTextWithMentions } from '../../../utils/mentions';
import Paths from '../../../constants/Paths'; import Paths from '../../../constants/Paths';
import { StaticUserIds } from '../../../constants/StaticUsers'; import { StaticUserIds } from '../../../constants/StaticUsers';
import { NotificationTypes } from '../../../constants/Enums'; import { NotificationTypes } from '../../../constants/Enums';
@ -83,7 +84,7 @@ const Item = React.memo(({ id, onClose }) => {
break; break;
} }
case NotificationTypes.COMMENT_CARD: { case NotificationTypes.COMMENT_CARD: {
const commentText = truncate(notification.data.text); const commentText = truncate(formatTextWithMentions(notification.data.text));
contentNode = ( contentNode = (
<Trans <Trans
@ -122,6 +123,28 @@ const Item = React.memo(({ id, onClose }) => {
); );
break; break;
case NotificationTypes.MENTION_IN_COMMENT: {
const commentText = truncate(formatTextWithMentions(notification.data.text));
contentNode = (
<Trans
i18nKey="common.userMentionedYouInCommentOnCard"
values={{
user: creatorUserName,
comment: commentText,
card: cardName,
}}
>
<span className={styles.author}>{creatorUserName}</span>
{` mentioned you in «${commentText}» on `}
<Link to={Paths.CARDS.replace(':id', notification.cardId)} onClick={onClose}>
{cardName}
</Link>
</Trans>
);
break;
}
default: default:
contentNode = null; contentNode = null;
} }

View file

@ -46,5 +46,6 @@
font-size: 14px; font-size: 14px;
line-height: 1.2; line-height: 1.2;
padding: 2px 0 2px 2px; padding: 2px 0 2px 2px;
word-wrap: break-word;
} }
} }

View file

@ -129,7 +129,6 @@ const AddProjectModal = React.memo(() => {
value={data.description} value={data.description}
maxLength={1024} maxLength={1024}
minRows={2} minRows={2}
spellCheck={false}
className={styles.field} className={styles.field}
onKeyDown={handleDescriptionKeyDown} onKeyDown={handleDescriptionKeyDown}
onChange={handleFieldChange} onChange={handleFieldChange}

View file

@ -89,7 +89,6 @@ const EditInformation = React.memo(() => {
value={data.description} value={data.description}
maxLength={1024} maxLength={1024}
minRows={2} minRows={2}
spellCheck={false}
className={styles.field} className={styles.field}
onKeyDown={handleDescriptionKeyDown} onKeyDown={handleDescriptionKeyDown}
onChange={handleFieldChange} onChange={handleFieldChange}

View file

@ -5,12 +5,13 @@
import React, { useCallback, useEffect } from 'react'; import React, { useCallback, useEffect } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { useDispatch } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next'; 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, Dropdown, Form, Icon, TextArea } from 'semantic-ui-react';
import { useClickAwayListener, useDidUpdate, useToggle } from '../../../lib/hooks'; import { useClickAwayListener, useDidUpdate, useToggle } from '../../../lib/hooks';
import selectors from '../../../selectors';
import entryActions from '../../../entry-actions'; import entryActions from '../../../entry-actions';
import { useForm, useNestedRef } from '../../../hooks'; import { useForm, useNestedRef } from '../../../hooks';
import { focusEnd } from '../../../utils/element-helpers'; import { focusEnd } from '../../../utils/element-helpers';
@ -20,18 +21,23 @@ import styles from './AddTask.module.scss';
const DEFAULT_DATA = { const DEFAULT_DATA = {
name: '', name: '',
linkedCardId: null,
}; };
const MULTIPLE_REGEX = /\s*\r?\n\s*/; const MULTIPLE_REGEX = /\s*\r?\n\s*/;
const AddTask = React.memo(({ children, taskListId, isOpened, onClose }) => { const AddTask = React.memo(({ children, taskListId, isOpened, onClose }) => {
const cards = useSelector(selectors.selectCardsExceptCurrentForCurrentBoard);
const dispatch = useDispatch(); const dispatch = useDispatch();
const [t] = useTranslation(); const [t] = useTranslation();
const [data, handleFieldChange, setData] = useForm(DEFAULT_DATA); const [data, handleFieldChange, setData] = useForm(DEFAULT_DATA);
const [focusNameFieldState, focusNameField] = useToggle(); const [isLinkingToCard, toggleLinkingToCard] = useToggle();
const [focusFieldState, focusField] = useToggle();
const [nameFieldRef, handleNameFieldRef] = useNestedRef(); const [fieldRef, handleFieldRef] = useNestedRef();
const [buttonRef, handleButtonRef] = useNestedRef(); const [submitButtonRef, handleSubmitButtonRef] = useNestedRef();
const [toggleLinkingButtonRef, handleToggleLinkingButtonRef] = useNestedRef();
const submit = useCallback( const submit = useCallback(
(isMultiple = false) => { (isMultiple = false) => {
@ -40,12 +46,23 @@ const AddTask = React.memo(({ children, taskListId, isOpened, onClose }) => {
name: data.name.trim(), name: data.name.trim(),
}; };
if (!cleanData.name) { if (isLinkingToCard) {
nameFieldRef.current.select(); if (!cleanData.linkedCardId) {
return; fieldRef.current.querySelector('.search').focus();
return;
}
delete cleanData.name;
} else {
if (!cleanData.name) {
fieldRef.current.select();
return;
}
delete cleanData.linkedCardId;
} }
if (isMultiple) { if (!isLinkingToCard && isMultiple) {
cleanData.name.split(MULTIPLE_REGEX).forEach((name) => { cleanData.name.split(MULTIPLE_REGEX).forEach((name) => {
dispatch( dispatch(
entryActions.createTask(taskListId, { entryActions.createTask(taskListId, {
@ -59,9 +76,9 @@ const AddTask = React.memo(({ children, taskListId, isOpened, onClose }) => {
} }
setData(DEFAULT_DATA); setData(DEFAULT_DATA);
focusNameField(); focusField();
}, },
[taskListId, dispatch, data, setData, focusNameField, nameFieldRef], [taskListId, dispatch, data, setData, isLinkingToCard, focusField, fieldRef],
); );
const handleSubmit = useCallback(() => { const handleSubmit = useCallback(() => {
@ -71,34 +88,48 @@ const AddTask = React.memo(({ children, taskListId, isOpened, onClose }) => {
const handleFieldKeyDown = useCallback( const handleFieldKeyDown = useCallback(
(event) => { (event) => {
if (event.key === 'Enter') { if (event.key === 'Enter') {
event.preventDefault(); if (!isLinkingToCard) {
submit(isModifierKeyPressed(event)); event.preventDefault();
submit(isModifierKeyPressed(event));
}
} else if (event.key === 'Escape') { } else if (event.key === 'Escape') {
onClose(); onClose();
} }
}, },
[onClose, submit], [onClose, isLinkingToCard, submit],
); );
const handleToggleLinkingClick = useCallback(() => {
toggleLinkingToCard();
}, [toggleLinkingToCard]);
const handleClickAwayCancel = useCallback(() => { const handleClickAwayCancel = useCallback(() => {
nameFieldRef.current.focus(); if (isLinkingToCard) {
}, [nameFieldRef]); fieldRef.current.querySelector('.search').focus();
} else {
focusEnd(fieldRef.current);
}
}, [isLinkingToCard, fieldRef]);
const clickAwayProps = useClickAwayListener( const clickAwayProps = useClickAwayListener(
[nameFieldRef, buttonRef], [fieldRef, submitButtonRef, toggleLinkingButtonRef],
onClose, onClose,
handleClickAwayCancel, handleClickAwayCancel,
); );
useEffect(() => { useEffect(() => {
if (isOpened) { if (isOpened) {
focusEnd(nameFieldRef.current); if (isLinkingToCard) {
fieldRef.current.querySelector('.search').focus();
} else {
focusEnd(fieldRef.current);
}
} }
}, [isOpened, nameFieldRef]); }, [isOpened, isLinkingToCard, fieldRef]);
useDidUpdate(() => { useDidUpdate(() => {
nameFieldRef.current.focus(); fieldRef.current.focus();
}, [focusNameFieldState]); }, [focusFieldState]);
if (!isOpened) { if (!isOpened) {
return children; return children;
@ -106,27 +137,62 @@ const AddTask = React.memo(({ children, taskListId, isOpened, onClose }) => {
return ( return (
<Form className={styles.wrapper} onSubmit={handleSubmit}> <Form className={styles.wrapper} onSubmit={handleSubmit}>
<TextArea {isLinkingToCard ? (
{...clickAwayProps} // eslint-disable-line react/jsx-props-no-spreading <Dropdown
ref={handleNameFieldRef} fluid
as={TextareaAutosize} selection
name="name" search
value={data.name} ref={handleFieldRef}
placeholder={t('common.enterTaskDescription')} name="linkedCardId"
maxLength={1024} options={cards.map((card) => ({
minRows={2} text: card.name,
spellCheck={false} value: card.id,
className={styles.field} }))}
onKeyDown={handleFieldKeyDown} value={data.linkedCardId}
onChange={handleFieldChange} placeholder={t('common.searchCards')}
/> minCharacters={1}
closeOnBlur={false}
closeOnEscape={false}
noResultsMessage={t('common.noCardsFound')}
className={styles.field}
onKeyDown={handleFieldKeyDown}
onChange={handleFieldChange}
/>
) : (
<TextArea
{...clickAwayProps} // eslint-disable-line react/jsx-props-no-spreading
ref={handleFieldRef}
as={TextareaAutosize}
name="name"
value={data.name}
placeholder={t('common.enterTaskDescription')}
maxLength={1024}
minRows={2}
className={styles.field}
onKeyDown={handleFieldKeyDown}
onChange={handleFieldChange}
/>
)}
<div className={styles.controls}> <div className={styles.controls}>
<Button <Button
{...clickAwayProps} // eslint-disable-line react/jsx-props-no-spreading {...clickAwayProps} // eslint-disable-line react/jsx-props-no-spreading
positive positive
ref={handleButtonRef} ref={handleSubmitButtonRef}
content={t('action.addTask')} content={t('action.addTask')}
/> />
<Button
{...clickAwayProps} // eslint-disable-line react/jsx-props-no-spreading
ref={handleToggleLinkingButtonRef}
type="button"
className={styles.toggleLinkingButton}
onClick={handleToggleLinkingClick}
>
<Icon
name={isLinkingToCard ? 'align left' : 'exchange'}
className={styles.toggleLinkingButtonIcon}
/>
{isLinkingToCard ? t('common.description') : t('common.linkToCard')}
</Button>
</div> </div>
</Form> </Form>
); );

View file

@ -5,7 +5,7 @@
:global(#app) { :global(#app) {
.controls { .controls {
clear: both; display: flex;
margin-top: 6px; margin-top: 6px;
} }
@ -18,9 +18,41 @@
line-height: 1.5; line-height: 1.5;
font-size: 14px; font-size: 14px;
margin-bottom: 4px; margin-bottom: 4px;
overflow: hidden;
padding: 8px 12px; padding: 8px 12px;
resize: none; resize: none;
:global {
.menu {
border: 1px solid rgba(9, 30, 66, 0.08);
}
.search {
left: 0;
}
}
}
.toggleLinkingButton {
background: transparent;
box-shadow: none;
color: #6b808c;
font-weight: normal;
margin-left: auto;
margin-right: 0;
overflow: hidden;
text-align: left;
text-decoration: underline;
text-overflow: ellipsis;
transition: none;
&:hover {
background: rgba(9, 30, 66, 0.08);
color: #092d42;
}
}
.toggleLinkingButtonIcon {
text-decoration: none;
} }
.wrapper { .wrapper {

View file

@ -3,13 +3,14 @@
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md * Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/ */
import React, { useCallback } from 'react'; import React, { useCallback, useMemo } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { useDispatch } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Menu } from 'semantic-ui-react'; import { Icon, Menu } from 'semantic-ui-react';
import { Popup } from '../../../../lib/custom-ui'; import { Popup } from '../../../../lib/custom-ui';
import selectors from '../../../../selectors';
import entryActions from '../../../../entry-actions'; import entryActions from '../../../../entry-actions';
import { useSteps } from '../../../../hooks'; import { useSteps } from '../../../../hooks';
import ConfirmationStep from '../../../common/ConfirmationStep'; import ConfirmationStep from '../../../common/ConfirmationStep';
@ -21,6 +22,10 @@ const StepTypes = {
}; };
const ActionsStep = React.memo(({ taskId, onNameEdit, onClose }) => { const ActionsStep = React.memo(({ taskId, onNameEdit, onClose }) => {
const selectTaskById = useMemo(() => selectors.makeSelectTaskById(), []);
const task = useSelector((state) => selectTaskById(state, taskId));
const dispatch = useDispatch(); const dispatch = useDispatch();
const [t] = useTranslation(); const [t] = useTranslation();
const [step, openStep, handleBack] = useSteps(); const [step, openStep, handleBack] = useSteps();
@ -59,12 +64,16 @@ const ActionsStep = React.memo(({ taskId, onNameEdit, onClose }) => {
</Popup.Header> </Popup.Header>
<Popup.Content> <Popup.Content>
<Menu secondary vertical className={styles.menu}> <Menu secondary vertical className={styles.menu}>
<Menu.Item className={styles.menuItem} onClick={handleEditNameClick}> {!task.linkedCardId && (
{t('action.editDescription', { <Menu.Item className={styles.menuItem} onClick={handleEditNameClick}>
context: 'title', <Icon name="align left" className={styles.menuItemIcon} />
})} {t('action.editDescription', {
</Menu.Item> context: 'title',
})}
</Menu.Item>
)}
<Menu.Item className={styles.menuItem} onClick={handleDeleteClick}> <Menu.Item className={styles.menuItem} onClick={handleDeleteClick}>
<Icon name="trash alternate outline" className={styles.menuItemIcon} />
{t('action.deleteTask', { {t('action.deleteTask', {
context: 'title', context: 'title',
})} })}

View file

@ -13,4 +13,9 @@
margin: 0; margin: 0;
padding-left: 14px; padding-left: 14px;
} }
.menuItemIcon {
float: left;
margin: 0 0.5em 0 0;
}
} }

View file

@ -79,7 +79,6 @@ const EditName = React.memo(({ taskId, onClose }) => {
value={value} value={value}
maxLength={1024} maxLength={1024}
minRows={2} minRows={2}
spellCheck={false}
className={styles.field} className={styles.field}
onKeyDown={handleFieldKeyDown} onKeyDown={handleFieldKeyDown}
onChange={handleFieldChange} onChange={handleFieldChange}

View file

@ -8,6 +8,7 @@ import ReactDOM from 'react-dom';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import classNames from 'classnames'; import classNames from 'classnames';
import { shallowEqual, useDispatch, useSelector } from 'react-redux'; import { shallowEqual, useDispatch, useSelector } from 'react-redux';
import { Link } from 'react-router-dom';
import { Draggable } from 'react-beautiful-dnd'; import { Draggable } from 'react-beautiful-dnd';
import { Button, Checkbox, Icon } from 'semantic-ui-react'; import { Button, Checkbox, Icon } from 'semantic-ui-react';
import { useDidUpdate } from '../../../../lib/hooks'; import { useDidUpdate } from '../../../../lib/hooks';
@ -18,6 +19,7 @@ import { usePopupInClosableContext } from '../../../../hooks';
import { isListArchiveOrTrash } from '../../../../utils/record-helpers'; import { isListArchiveOrTrash } from '../../../../utils/record-helpers';
import { BoardMembershipRoles } from '../../../../constants/Enums'; import { BoardMembershipRoles } from '../../../../constants/Enums';
import { ClosableContext } from '../../../../contexts'; import { ClosableContext } from '../../../../contexts';
import Paths from '../../../../constants/Paths';
import EditName from './EditName'; import EditName from './EditName';
import SelectAssigneeStep from './SelectAssigneeStep'; import SelectAssigneeStep from './SelectAssigneeStep';
import ActionsStep from './ActionsStep'; import ActionsStep from './ActionsStep';
@ -28,10 +30,15 @@ import styles from './Task.module.scss';
const Task = React.memo(({ id, index }) => { const Task = React.memo(({ id, index }) => {
const selectTaskById = useMemo(() => selectors.makeSelectTaskById(), []); const selectTaskById = useMemo(() => selectors.makeSelectTaskById(), []);
const selectLinkedCardById = useMemo(() => selectors.makeSelectCardById(), []);
const selectListById = useMemo(() => selectors.makeSelectListById(), []); const selectListById = useMemo(() => selectors.makeSelectListById(), []);
const task = useSelector((state) => selectTaskById(state, id)); const task = useSelector((state) => selectTaskById(state, id));
const linkedCard = useSelector(
(state) => task.linkedCardId && selectLinkedCardById(state, task.linkedCardId),
);
const { canEdit, canToggle } = useSelector((state) => { const { canEdit, canToggle } = useSelector((state) => {
const { listId } = selectors.selectCurrentCard(state); const { listId } = selectors.selectCurrentCard(state);
const list = selectListById(state, listId); const list = selectListById(state, listId);
@ -86,10 +93,10 @@ const Task = React.memo(({ id, index }) => {
const isEditable = task.isPersisted && canEdit; const isEditable = task.isPersisted && canEdit;
const handleClick = useCallback(() => { const handleClick = useCallback(() => {
if (isEditable) { if (!task.linkedCardId && isEditable) {
setIsEditNameOpened(true); setIsEditNameOpened(true);
} }
}, [isEditable]); }, [task.linkedCardId, isEditable]);
const handleNameEdit = useCallback(() => { const handleNameEdit = useCallback(() => {
setIsEditNameOpened(true); setIsEditNameOpened(true);
@ -99,6 +106,10 @@ const Task = React.memo(({ id, index }) => {
setIsEditNameOpened(false); setIsEditNameOpened(false);
}, []); }, []);
const handleLinkClick = useCallback((event) => {
event.stopPropagation();
}, []);
useDidUpdate(() => { useDidUpdate(() => {
setIsClosableActive(isEditNameOpened); setIsClosableActive(isEditNameOpened);
}, [isEditNameOpened]); }, [isEditNameOpened]);
@ -123,7 +134,7 @@ const Task = React.memo(({ id, index }) => {
<span className={styles.checkboxWrapper}> <span className={styles.checkboxWrapper}>
<Checkbox <Checkbox
checked={task.isCompleted} checked={task.isCompleted}
disabled={!task.isPersisted || !canToggle} disabled={!!task.linkedCardId || !task.isPersisted || !canToggle}
className={styles.checkbox} className={styles.checkbox}
onChange={handleToggleChange} onChange={handleToggleChange}
/> />
@ -135,36 +146,69 @@ const Task = React.memo(({ id, index }) => {
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,
jsx-a11y/no-static-element-interactions */} jsx-a11y/no-static-element-interactions */}
<span <span
className={classNames(styles.text, canEdit && styles.textEditable)} className={classNames(
styles.text,
task.linkedCardId && styles.textLinked,
canEdit && styles.textEditable,
canEdit && !task.linkedCardId && styles.textPointable,
)}
onClick={handleClick} onClick={handleClick}
> >
<span <span
className={classNames(styles.task, task.isCompleted && styles.taskCompleted)} className={classNames(styles.task, task.isCompleted && styles.taskCompleted)}
> >
<Linkify linkStopPropagation>{task.name}</Linkify> {task.linkedCardId ? (
<>
<Icon name="exchange" size="small" className={styles.icon} />
<span
className={classNames(
styles.name,
task.isCompleted && styles.nameCompleted,
)}
>
<Link
to={Paths.CARDS.replace(':id', task.linkedCardId)}
onClick={handleLinkClick}
>
{linkedCard ? linkedCard.name : task.name}
</Link>
</span>
</>
) : (
<span
className={classNames(
styles.name,
task.isCompleted && styles.nameCompleted,
)}
>
<Linkify linkStopPropagation>{task.name}</Linkify>
</span>
)}
</span> </span>
</span> </span>
{(task.assigneeUserId || isEditable) && ( {(task.assigneeUserId || isEditable) && (
<div className={classNames(styles.actions, isEditable && styles.actionsEditable)}> <div className={classNames(styles.actions, isEditable && styles.actionsEditable)}>
{isEditable ? ( {isEditable ? (
<> <>
<SelectAssigneePopup {!task.linkedCardId && (
currentUserId={task.assigneeUserId} <SelectAssigneePopup
onUserSelect={handleUserSelect} currentUserId={task.assigneeUserId}
onUserDeselect={handleUserDeselect} onUserSelect={handleUserSelect}
> onUserDeselect={handleUserDeselect}
{task.assigneeUserId ? ( >
<UserAvatar {task.assigneeUserId ? (
id={task.assigneeUserId} <UserAvatar
size="tiny" id={task.assigneeUserId}
className={styles.assigneeUserAvatar} size="tiny"
/> className={styles.assigneeUserAvatar}
) : ( />
<Button className={styles.button}> ) : (
<Icon fitted name="add user" size="small" /> <Button className={styles.button}>
</Button> <Icon fitted name="add user" size="small" />
)} </Button>
</SelectAssigneePopup> )}
</SelectAssigneePopup>
)}
<ActionsPopup taskId={id} onNameEdit={handleNameEdit}> <ActionsPopup taskId={id} onNameEdit={handleNameEdit}>
<Button className={styles.button}> <Button className={styles.button}>
<Icon fitted name="pencil" size="small" /> <Icon fitted name="pencil" size="small" />

View file

@ -56,20 +56,32 @@
} }
} }
.icon {
color: rgba(9, 30, 66, 0.24);
margin-right: 8px;
}
.name {
a:hover {
text-decoration: underline;
}
}
.nameCompleted {
color: #aaa;
text-decoration: line-through;
}
.task { .task {
display: inline-block; display: inline-block;
overflow: hidden; overflow: hidden;
overflow-wrap: break-word; overflow-wrap: break-word;
padding: 8px 0; padding: 8px 0;
vertical-align: top; vertical-align: top;
white-space: pre-wrap;
width: 100%; width: 100%;
} }
.taskCompleted {
color: #aaa;
text-decoration: line-through;
}
.text { .text {
background: transparent; background: transparent;
border-radius: 3px; border-radius: 3px;
@ -80,11 +92,22 @@
min-height: 32px; min-height: 32px;
padding: 0 34px 0 40px; padding: 0 34px 0 40px;
width: 100%; width: 100%;
&.textLinked {
padding-right: 0px;
}
} }
.textEditable { .textEditable {
cursor: pointer;
padding-right: 68px; padding-right: 68px;
&.textLinked {
padding-right: 34px;
}
}
.textPointable {
cursor: pointer;
} }
.wrapper { .wrapper {

View file

@ -14,7 +14,9 @@ import styles from './AboutPane.module.scss';
const AboutPane = React.memo(() => ( const AboutPane = React.memo(() => (
<Tab.Pane attached={false} className={styles.wrapper}> <Tab.Pane attached={false} className={styles.wrapper}>
<Image centered src={logo} size="large" /> <a href="https://github.com/plankanban/planka" target="_blank" rel="noreferrer">
<Image centered src={logo} size="large" />
</a>
<div className={styles.version}>{version}</div> <div className={styles.version}>{version}</div>
</Tab.Pane> </Tab.Pane>
)); ));

View file

@ -7,7 +7,7 @@ import React, { useCallback } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button, Menu } from 'semantic-ui-react'; import { Button, Icon, Menu } from 'semantic-ui-react';
import { Popup } from '../../../lib/custom-ui'; import { Popup } from '../../../lib/custom-ui';
import selectors from '../../../selectors'; import selectors from '../../../selectors';
@ -31,15 +31,15 @@ const UserStep = React.memo(({ onClose }) => {
onClose(); onClose();
}, [onClose, dispatch]); }, [onClose, dispatch]);
const handleLogoutClick = useCallback(() => {
dispatch(entryActions.logout());
}, [dispatch]);
const handleAdministrationClick = useCallback(() => { const handleAdministrationClick = useCallback(() => {
dispatch(entryActions.openAdministrationModal()); dispatch(entryActions.openAdministrationModal());
onClose(); onClose();
}, [onClose, dispatch]); }, [onClose, dispatch]);
const handleLogoutClick = useCallback(() => {
dispatch(entryActions.logout());
}, [dispatch]);
let logoutMenuItemProps; let logoutMenuItemProps;
if (isLogouting) { if (isLogouting) {
logoutMenuItemProps = { logoutMenuItemProps = {
@ -61,29 +61,30 @@ const UserStep = React.memo(({ onClose }) => {
<Popup.Content> <Popup.Content>
<Menu secondary vertical className={styles.menu}> <Menu secondary vertical className={styles.menu}>
<Menu.Item className={styles.menuItem} onClick={handleSettingsClick}> <Menu.Item className={styles.menuItem} onClick={handleSettingsClick}>
<Icon name="user" className={styles.menuItemIcon} />
{t('common.settings', { {t('common.settings', {
context: 'title', context: 'title',
})} })}
</Menu.Item> </Menu.Item>
{withAdministration && (
<Menu.Item className={styles.menuItem} onClick={handleAdministrationClick}>
<Icon name="setting" className={styles.menuItemIcon} />
{t('common.administration', {
context: 'title',
})}
</Menu.Item>
)}
<hr className={styles.divider} />
<Menu.Item <Menu.Item
{...logoutMenuItemProps} // eslint-disable-line react/jsx-props-no-spreading {...logoutMenuItemProps} // eslint-disable-line react/jsx-props-no-spreading
className={styles.menuItem} className={styles.menuItem}
onClick={handleLogoutClick} onClick={handleLogoutClick}
> >
<Icon name="log out" className={styles.menuItemIcon} />
{t('action.logOut', { {t('action.logOut', {
context: 'title', context: 'title',
})} })}
</Menu.Item> </Menu.Item>
{withAdministration && (
<>
<hr className={styles.divider} />
<Menu.Item className={styles.menuItem} onClick={handleAdministrationClick}>
{t('common.administration', {
context: 'title',
})}
</Menu.Item>
</>
)}
</Menu> </Menu>
</Popup.Content> </Popup.Content>
</> </>

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