mirror of
https://github.com/plankanban/planka.git
synced 2025-07-18 20:59:44 +02:00
Compare commits
84 commits
v2.0.0-rc.
...
master
Author | SHA1 | Date | |
---|---|---|---|
|
fdac299fc7 | ||
|
e659ed4a2d | ||
|
adf60c0c74 | ||
|
71b4fd32cc | ||
|
b3f226c10b | ||
|
b4be12eb28 | ||
|
3aba4d4a56 | ||
|
70cadcd974 | ||
|
230f50e3d9 | ||
|
49203e9d56 | ||
|
709a0d1758 | ||
|
69c75a03b1 | ||
|
4d40af9c8a | ||
|
b76ca9547f | ||
|
9d07178e57 | ||
|
4346b7040a | ||
|
b61b4e658f | ||
|
a8b9ac8165 | ||
|
a55fc08c43 | ||
|
76c7be5063 | ||
|
b22dba0d11 | ||
|
f0680831c2 | ||
|
e58a9f5d21 | ||
|
463b6284c7 | ||
|
f5653cde80 | ||
|
b37fc61b12 | ||
|
6988864808 | ||
|
c8cb1f4a20 | ||
|
774bdc2b64 | ||
|
b2e8c453b7 | ||
|
8df49a636a | ||
|
2d1a8658bb | ||
|
3126a40bba | ||
|
99a06ce1ae | ||
|
98a7f1c9a9 | ||
|
bdac15240f | ||
|
5a3a4240c6 | ||
|
144731ccb4 | ||
|
e24bb845c6 | ||
|
240b01e694 | ||
|
07483909b4 | ||
|
84dc0385af | ||
|
041199af8b | ||
|
f72ac437b9 | ||
|
128028c848 | ||
|
0f1e7ecc2c | ||
|
9ca49f1f51 | ||
|
8447f0c884 | ||
|
180984799c | ||
|
2b86484421 | ||
|
a6820162fb | ||
|
037a9101c2 | ||
|
754629e6a9 | ||
|
8bc1569242 | ||
|
1a8dcd9858 | ||
|
edda5dda79 | ||
|
6dbce8c790 | ||
|
b696fe0e4d | ||
|
7286ecaf35 | ||
|
2e6658221f | ||
|
63de346b0e | ||
|
585464eef3 | ||
|
c2bd31d523 | ||
|
21f90610bc | ||
|
608a7c983f | ||
|
dd5e0f448f | ||
|
c4a48d510b | ||
|
2f62d56242 | ||
|
4eb8b75c24 | ||
|
49770ea9ec | ||
|
40a84d0c8a | ||
|
9690f7b73f | ||
|
04b97b66cb | ||
|
46f4d5c1f8 | ||
|
fc7863aaaf | ||
|
6c2999044b | ||
|
a2495b664e | ||
|
e0374f30db | ||
|
665f9998dc | ||
|
fcf1fc4319 | ||
|
4e05b88ecf | ||
|
c0b0436851 | ||
|
eb2a3a2875 | ||
|
74274e511f |
354 changed files with 14233 additions and 2783 deletions
|
@ -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
|
||||||
|
|
2
.github/workflows/build-and-test.yml
vendored
2
.github/workflows/build-and-test.yml
vendored
|
@ -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
|
||||||
|
|
2
.github/workflows/lint.yml
vendored
2
.github/workflows/lint.yml
vendored
|
@ -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
|
||||||
|
|
|
@ -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 \
|
||||||
|
|
|
@ -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 \
|
||||||
|
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 }}
|
||||||
|
|
|
@ -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
3584
client/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
|
@ -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 |
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
|
|
104
client/src/actions/webhooks.js
Normal file
104
client/src/actions/webhooks.js
Normal 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,
|
||||||
|
};
|
|
@ -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
23
client/src/api/webhooks.js
Executable 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 |
|
@ -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 {
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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',
|
||||||
})}
|
})}
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -52,6 +52,10 @@
|
||||||
outline: none;
|
outline: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|
||||||
|
&:not(:last-child) {
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: #172b4d;
|
color: #172b4d;
|
||||||
}
|
}
|
||||||
|
|
26
client/src/components/attachments/Attachments/PdfViewer.jsx
Normal file
26
client/src/components/attachments/Attachments/PdfViewer.jsx
Normal 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;
|
|
@ -4,7 +4,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
:global(#app) {
|
:global(#app) {
|
||||||
.submitButton {
|
.wrapper {
|
||||||
margin-top: 12px;
|
border: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -23,6 +23,6 @@
|
||||||
|
|
||||||
.menuItemIcon {
|
.menuItemIcon {
|
||||||
float: left;
|
float: left;
|
||||||
margin-right: 0.5em;
|
margin: 0 0.5em 0 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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',
|
||||||
|
|
|
@ -13,4 +13,9 @@
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding-left: 14px;
|
padding-left: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.menuItemIcon {
|
||||||
|
float: left;
|
||||||
|
margin: 0 0.5em 0 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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 && (
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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 && (
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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}>
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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',
|
||||||
})}
|
})}
|
||||||
|
|
|
@ -13,4 +13,9 @@
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding-left: 14px;
|
padding-left: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.menuItemIcon {
|
||||||
|
float: left;
|
||||||
|
margin: 0 0.5em 0 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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(() => {
|
||||||
|
|
|
@ -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;
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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) {
|
||||||
|
|
|
@ -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;
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
8
client/src/components/common/ExpandableMarkdown/index.js
Normal file
8
client/src/components/common/ExpandableMarkdown/index.js
Normal 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;
|
|
@ -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;
|
|
81
client/src/components/common/Linkify/Link.jsx
Normal file
81
client/src/components/common/Linkify/Link.jsx
Normal 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;
|
43
client/src/components/common/Linkify/Linkify.jsx
Normal file
43
client/src/components/common/Linkify/Linkify.jsx
Normal 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;
|
8
client/src/components/common/Linkify/index.js
Normal file
8
client/src/components/common/Linkify/index.js
Normal 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;
|
|
@ -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
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
</>
|
</>
|
||||||
|
|
|
@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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',
|
||||||
})}
|
})}
|
||||||
|
|
|
@ -13,4 +13,9 @@
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding-left: 14px;
|
padding-left: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.menuItemIcon {
|
||||||
|
float: left;
|
||||||
|
margin: 0 0.5em 0 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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',
|
||||||
})}
|
})}
|
||||||
|
|
|
@ -13,4 +13,9 @@
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding-left: 14px;
|
padding-left: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.menuItemIcon {
|
||||||
|
float: left;
|
||||||
|
margin: 0 0.5em 0 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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" />
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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>
|
||||||
));
|
));
|
||||||
|
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue