mirror of
https://github.com/plankanban/planka.git
synced 2025-07-18 20:59:44 +02:00
Compare commits
84 commits
planka-1.0
...
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
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18'
|
||||
node-version: '22'
|
||||
cache: '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
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18'
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
|
||||
- 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
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18'
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
|
||||
- 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 \
|
||||
&& 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 \
|
||||
&& npm install --omit=dev
|
||||
|
||||
FROM node:lts AS client
|
||||
FROM node:22 AS client
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
@ -21,7 +21,7 @@ RUN npm install npm --global \
|
|||
|
||||
RUN DISABLE_ESLINT_PLUGIN=true npm run build
|
||||
|
||||
FROM node:18-alpine
|
||||
FROM node:22-alpine
|
||||
|
||||
RUN apk -U upgrade \
|
||||
&& apk add bash python3 --no-cache \
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
FROM node:lts-alpine
|
||||
FROM node:22-alpine
|
||||
|
||||
RUN apk -U upgrade \
|
||||
&& 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
|
||||
|
||||
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.
|
||||
|
||||
|
|
|
@ -48,7 +48,7 @@ Sie dürfen PLANKA nutzen oder modifizieren (a) für persönliche, Hobby- oder B
|
|||
|
||||
### 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.
|
||||
|
||||
|
|
|
@ -48,7 +48,7 @@ You may use or modify PLANKA (a) for personal, hobby, or educational purposes, (
|
|||
|
||||
### 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.
|
||||
|
||||
|
|
|
@ -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.
|
||||
|
||||
- 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.
|
||||
|
||||
|
|
|
@ -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.
|
||||
|
||||
- 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.
|
||||
|
||||
|
|
|
@ -15,7 +15,7 @@ type: application
|
|||
# This is the chart version. This version number should be incremented each time you make changes
|
||||
# to the chart and its templates, including the app version.
|
||||
# Versions are expected to follow Semantic Versioning (https://semver.org/)
|
||||
version: 1.0.2
|
||||
version: 1.0.3
|
||||
|
||||
# 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
|
||||
|
|
|
@ -4,6 +4,10 @@ metadata:
|
|||
name: {{ include "planka.fullname" . }}
|
||||
labels:
|
||||
{{- include "planka.labels" . | nindent 4 }}
|
||||
{{- with .Values.deploymentAnnotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- if not .Values.autoscaling.enabled }}
|
||||
replicas: {{ .Values.replicaCount }}
|
||||
|
|
|
@ -45,6 +45,9 @@ podAnnotations: {}
|
|||
podSecurityContext: {}
|
||||
# fsGroup: 2000
|
||||
|
||||
# Annotations to add to the deployment
|
||||
deploymentAnnotations: {}
|
||||
|
||||
securityContext: {}
|
||||
# capabilities:
|
||||
# drop:
|
||||
|
@ -210,3 +213,22 @@ oidc:
|
|||
## key: key-inside-the-secret
|
||||
##
|
||||
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": {
|
||||
"@ballerina/highlightjs-ballerina": "^1.0.1",
|
||||
"@diplodoc/cut-extension": "^0.7.3",
|
||||
"@diplodoc/transform": "^4.57.2",
|
||||
"@gravity-ui/markdown-editor": "^15.11.0",
|
||||
"@gravity-ui/uikit": "^7.11.0",
|
||||
"@diplodoc/cut-extension": "^0.7.4",
|
||||
"@diplodoc/transform": "^4.57.7",
|
||||
"@gravity-ui/components": "^4.4.0",
|
||||
"@gravity-ui/markdown-editor": "^15.14.1",
|
||||
"@gravity-ui/uikit": "^7.16.2",
|
||||
"@juggle/resize-observer": "^3.4.0",
|
||||
"@types/papaparse": "^5.3.16",
|
||||
"@vitejs/plugin-react": "^4.4.1",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"browserslist-to-esbuild": "^2.1.1",
|
||||
"classnames": "^2.5.1",
|
||||
"date-fns": "^2.30.0",
|
||||
|
@ -96,7 +96,7 @@
|
|||
"highlightjs-apex": "^1.5.0",
|
||||
"highlightjs-blade": "^0.1.0",
|
||||
"highlightjs-cobol": "^0.3.3",
|
||||
"highlightjs-cshtml-razor": "^2.1.1",
|
||||
"highlightjs-cshtml-razor": "^2.2.0",
|
||||
"highlightjs-gf": "^1.0.1",
|
||||
"highlightjs-jolie": "^0.1.8",
|
||||
"highlightjs-lean": "^1.2.0",
|
||||
|
@ -114,8 +114,8 @@
|
|||
"highlightjs-zenscript": "^2.0.0",
|
||||
"hightlightjs-papyrus": "^0.0.4",
|
||||
"history": "^5.3.0",
|
||||
"i18next": "23.15.2",
|
||||
"i18next-browser-languagedetector": "^8.1.0",
|
||||
"i18next": "^25.2.1",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"initials": "^3.1.2",
|
||||
"javascript-time-ago": "^2.5.11",
|
||||
"js-cookie": "^3.0.5",
|
||||
|
@ -137,12 +137,13 @@
|
|||
"react-dropzone": "^14.3.8",
|
||||
"react-frame-component": "^5.2.7",
|
||||
"react-hot-toast": "^2.5.2",
|
||||
"react-i18next": "^15.5.1",
|
||||
"react-i18next": "^15.5.3",
|
||||
"react-input-mask": "^2.0.4",
|
||||
"react-intersection-observer": "^9.16.0",
|
||||
"react-mentions": "^4.4.10",
|
||||
"react-photoswipe-gallery": "^2.2.7",
|
||||
"react-redux": "^8.1.3",
|
||||
"react-router-dom": "^6.30.0",
|
||||
"react-router-dom": "^6.30.1",
|
||||
"react-textarea-autosize": "^8.5.9",
|
||||
"react-time-ago": "^7.3.3",
|
||||
"redux": "^4.2.1",
|
||||
|
@ -151,10 +152,10 @@
|
|||
"redux-saga": "^1.3.0",
|
||||
"reselect": "^4.1.8",
|
||||
"sails.io.js": "^1.2.1",
|
||||
"sass-embedded": "^1.87.0",
|
||||
"sass-embedded": "^1.89.2",
|
||||
"semantic-ui-react": "^2.1.5",
|
||||
"socket.io-client": "^2.5.0",
|
||||
"validator": "^13.15.0",
|
||||
"validator": "^13.15.15",
|
||||
"vite": "^6.3.5",
|
||||
"vite-plugin-commonjs": "^0.10.4",
|
||||
"vite-plugin-node-polyfills": "^0.23.0",
|
||||
|
@ -162,23 +163,23 @@
|
|||
"zxcvbn": "^4.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/eslint-parser": "^7.27.1",
|
||||
"@babel/eslint-parser": "^7.27.5",
|
||||
"@babel/preset-env": "^7.27.2",
|
||||
"@cucumber/cucumber": "^11.2.0",
|
||||
"@cucumber/cucumber": "^11.3.0",
|
||||
"@cucumber/pretty-formatter": "^1.0.1",
|
||||
"@playwright/test": "^1.52.0",
|
||||
"@playwright/test": "^1.53.1",
|
||||
"babel-jest": "^29.7.0",
|
||||
"babel-preset-airbnb": "^5.0.0",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-config-airbnb": "^19.0.4",
|
||||
"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-prettier": "^5.4.0",
|
||||
"eslint-plugin-prettier": "^5.5.1",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"jest": "^29.7.0",
|
||||
"playwright": "^1.52.0",
|
||||
"playwright": "^1.53.1",
|
||||
"prettier": "3.3.3"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -22,6 +22,19 @@ index 2152fd6..ceda0c1 100644
|
|||
yfmHeading: {
|
||||
h1Key: f.toPM(A.Heading1),
|
||||
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
|
||||
index ed2a9db..f95b693 100644
|
||||
--- 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
|
||||
index 6d06078..fb7534d 100644
|
||||
index 6d06078..e22d4f0 100644
|
||||
--- a/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,6 +17,46 @@ index 6d06078..fb7534d 100644
|
|||
} // 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.
|
||||
// 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
|
||||
index d1ae271..43e1170 100644
|
||||
--- 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 = (
|
||||
user,
|
||||
board,
|
||||
webhooks,
|
||||
users,
|
||||
projects,
|
||||
projectManagers,
|
||||
|
@ -33,6 +34,7 @@ const initializeCore = (
|
|||
payload: {
|
||||
user,
|
||||
board,
|
||||
webhooks,
|
||||
users,
|
||||
projects,
|
||||
projectManagers,
|
||||
|
|
|
@ -8,6 +8,7 @@ import socket from './socket';
|
|||
import login from './login';
|
||||
import core from './core';
|
||||
import modals from './modals';
|
||||
import webhooks from './webhooks';
|
||||
import users from './users';
|
||||
import projects from './projects';
|
||||
import projectManagers from './project-managers';
|
||||
|
@ -35,6 +36,7 @@ export default {
|
|||
...login,
|
||||
...core,
|
||||
...modals,
|
||||
...webhooks,
|
||||
...users,
|
||||
...projects,
|
||||
...projectManagers,
|
||||
|
|
|
@ -14,6 +14,7 @@ const handleSocketReconnect = (
|
|||
config,
|
||||
user,
|
||||
board,
|
||||
webhooks,
|
||||
users,
|
||||
projects,
|
||||
projectManagers,
|
||||
|
@ -40,6 +41,7 @@ const handleSocketReconnect = (
|
|||
config,
|
||||
user,
|
||||
board,
|
||||
webhooks,
|
||||
users,
|
||||
projects,
|
||||
projectManagers,
|
||||
|
|
|
@ -67,6 +67,7 @@ const handleUserUpdate = (
|
|||
boardIds,
|
||||
config,
|
||||
board,
|
||||
webhooks,
|
||||
users,
|
||||
projects,
|
||||
projectManagers,
|
||||
|
@ -95,6 +96,7 @@ const handleUserUpdate = (
|
|||
boardIds,
|
||||
config,
|
||||
board,
|
||||
webhooks,
|
||||
users,
|
||||
projects,
|
||||
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 config from './config';
|
||||
import accessTokens from './access-tokens';
|
||||
import webhooks from './webhooks';
|
||||
import users from './users';
|
||||
import projects from './projects';
|
||||
import projectManagers from './project-managers';
|
||||
|
@ -35,6 +36,7 @@ export { http, socket };
|
|||
export default {
|
||||
...config,
|
||||
...accessTokens,
|
||||
...webhooks,
|
||||
...users,
|
||||
...projects,
|
||||
...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;
|
||||
vertical-align: top;
|
||||
width: calc(100% - 40px);
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.date {
|
||||
|
|
|
@ -16,6 +16,7 @@
|
|||
padding-bottom: 14px;
|
||||
vertical-align: top;
|
||||
width: calc(100% - 40px);
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.date {
|
||||
|
|
|
@ -7,7 +7,7 @@ import React, { useCallback } from 'react';
|
|||
import PropTypes from 'prop-types';
|
||||
import { useDispatch } from 'react-redux';
|
||||
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 entryActions from '../../../entry-actions';
|
||||
|
@ -47,6 +47,7 @@ const AddAttachmentStep = React.memo(({ onClose }) => {
|
|||
<Menu secondary vertical className={styles.menu}>
|
||||
<FilePicker multiple onSelect={handleFilesSelect}>
|
||||
<Menu.Item className={styles.menuItem}>
|
||||
<Icon name="computer" className={styles.menuItemIcon} />
|
||||
{t('common.fromComputer', {
|
||||
context: 'title',
|
||||
})}
|
||||
|
|
|
@ -21,6 +21,11 @@
|
|||
padding-left: 14px;
|
||||
}
|
||||
|
||||
.menuItemIcon {
|
||||
float: left;
|
||||
margin: 0 0.5em 0 0;
|
||||
}
|
||||
|
||||
.tip {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
|
|
@ -3,20 +3,19 @@
|
|||
* 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 classNames from 'classnames';
|
||||
import { Pagination, Table } from 'semantic-ui-react';
|
||||
import Papa from 'papaparse';
|
||||
import Frame from 'react-frame-component';
|
||||
import { Loader, Pagination, Table } from 'semantic-ui-react';
|
||||
|
||||
import styles from './CsvViewer.module.scss';
|
||||
|
||||
const ROWS_PER_PAGE = 50;
|
||||
|
||||
/* eslint-disable react/no-array-index-key */
|
||||
const CsvViewer = React.memo(({ src, className }) => {
|
||||
const [csvData, setCsvData] = useState(null);
|
||||
const [rows, setRows] = useState(null);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
||||
const frameStyles = useMemo(
|
||||
|
@ -34,7 +33,7 @@ const CsvViewer = React.memo(({ src, className }) => {
|
|||
[],
|
||||
);
|
||||
|
||||
const handlePageChange = useCallback((e, { activePage }) => {
|
||||
const handlePageChange = useCallback((_, { activePage }) => {
|
||||
setCurrentPage(activePage);
|
||||
}, []);
|
||||
|
||||
|
@ -49,47 +48,46 @@ const CsvViewer = React.memo(({ src, className }) => {
|
|||
|
||||
Papa.parse(text, {
|
||||
skipEmptyLines: true,
|
||||
complete: (results) => {
|
||||
const rows = results.data;
|
||||
setCsvData({
|
||||
rows,
|
||||
totalRows: rows.length,
|
||||
});
|
||||
complete: ({ data }) => {
|
||||
setRows(data);
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
setCsvData(null);
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
}
|
||||
|
||||
fetchFile();
|
||||
}, [src]);
|
||||
|
||||
if (!csvData) {
|
||||
return null;
|
||||
if (rows === null) {
|
||||
return <Loader active size="big" />;
|
||||
}
|
||||
|
||||
const startIdx = (currentPage - 1) * ROWS_PER_PAGE;
|
||||
const endIdx = startIdx + ROWS_PER_PAGE;
|
||||
const currentRows = csvData.rows.slice(startIdx, endIdx);
|
||||
const totalPages = Math.ceil(csvData.totalRows / ROWS_PER_PAGE);
|
||||
const startIndex = (currentPage - 1) * ROWS_PER_PAGE;
|
||||
const endIndex = startIndex + ROWS_PER_PAGE;
|
||||
const currentRows = rows.slice(startIndex, endIndex);
|
||||
const totalPages = Math.ceil(rows.length / ROWS_PER_PAGE);
|
||||
|
||||
const content = (
|
||||
<div>
|
||||
return (
|
||||
<Frame
|
||||
head={<style>{frameStyles.join('')}</style>}
|
||||
className={classNames(styles.wrapper, className)}
|
||||
>
|
||||
<div>
|
||||
<Table celled compact>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
{csvData.rows[0].map((header, index) => (
|
||||
<Table.HeaderCell key={index}>{header}</Table.HeaderCell>
|
||||
{rows[0].map((cell) => (
|
||||
<Table.HeaderCell key={cell}>{cell}</Table.HeaderCell>
|
||||
))}
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{currentRows.slice(1).map((row, rowIndex) => (
|
||||
<Table.Row key={rowIndex}>
|
||||
{row.map((cell, cellIndex) => (
|
||||
<Table.Cell key={cellIndex}>{cell}</Table.Cell>
|
||||
{currentRows.slice(1).map((row) => (
|
||||
<Table.Row key={row}>
|
||||
{row.map((cell) => (
|
||||
<Table.Cell key={cell}>{cell}</Table.Cell>
|
||||
))}
|
||||
</Table.Row>
|
||||
))}
|
||||
|
@ -98,30 +96,18 @@ const CsvViewer = React.memo(({ src, className }) => {
|
|||
</div>
|
||||
{totalPages > 1 && (
|
||||
<Pagination
|
||||
activePage={currentPage}
|
||||
secondary
|
||||
pointing
|
||||
totalPages={totalPages}
|
||||
onPageChange={handlePageChange}
|
||||
activePage={currentPage}
|
||||
firstItem={null}
|
||||
lastItem={null}
|
||||
pointing
|
||||
secondary
|
||||
boundaryRange={1}
|
||||
siblingRange={1}
|
||||
onPageChange={handlePageChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Frame
|
||||
head={<style>{frameStyles.join('')}</style>}
|
||||
className={classNames(styles.wrapper, className)}
|
||||
>
|
||||
{content}
|
||||
</Frame>
|
||||
);
|
||||
});
|
||||
/* eslint-enable react/no-array-index-key */
|
||||
|
||||
CsvViewer.propTypes = {
|
||||
src: PropTypes.string.isRequired,
|
||||
|
|
|
@ -16,6 +16,7 @@ import Encodings from '../../../constants/Encodings';
|
|||
import { AttachmentTypes } from '../../../constants/Enums';
|
||||
import ItemContent from './ItemContent';
|
||||
import ContentViewer from './ContentViewer';
|
||||
import PdfViewer from './PdfViewer';
|
||||
import CsvViewer from './CsvViewer';
|
||||
|
||||
import styles from './Item.module.scss';
|
||||
|
@ -40,10 +41,8 @@ const Item = React.memo(({ id, isVisible }) => {
|
|||
switch (attachment.data.mimeType) {
|
||||
case 'application/pdf':
|
||||
content = (
|
||||
// eslint-disable-next-line jsx-a11y/alt-text
|
||||
<object
|
||||
data={attachment.data.url}
|
||||
type={attachment.data.mimeType}
|
||||
<PdfViewer
|
||||
src={attachment.data.url}
|
||||
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} />
|
||||
);
|
||||
|
||||
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;
|
||||
case 'text/csv':
|
||||
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;
|
||||
default:
|
||||
if (attachment.data.encoding === Encodings.UTF8) {
|
||||
|
|
|
@ -23,6 +23,7 @@
|
|||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
height: 20px;
|
||||
text-align: center;
|
||||
width: 470px;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -54,6 +54,19 @@ const ItemContent = React.forwardRef(({ id, onOpen }, ref) => {
|
|||
}
|
||||
}, [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(
|
||||
(event) => {
|
||||
event.stopPropagation();
|
||||
|
@ -114,25 +127,31 @@ const ItemContent = React.forwardRef(({ id, onOpen }, ref) => {
|
|||
<span className={styles.information}>
|
||||
<TimeAgo date={attachment.createdAt} />
|
||||
</span>
|
||||
{attachment.type === AttachmentTypes.FILE && attachment.data.image && canEdit && (
|
||||
{attachment.type === AttachmentTypes.FILE && (
|
||||
<span className={styles.options}>
|
||||
<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 type="button" className={styles.option} onClick={handleDownloadClick}>
|
||||
<Icon name="download" size="small" className={styles.optionIcon} />
|
||||
<span className={styles.optionText}>Download</span>
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
@ -52,6 +52,10 @@
|
|||
outline: none;
|
||||
padding: 0;
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
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) {
|
||||
.submitButton {
|
||||
margin-top: 12px;
|
||||
.wrapper {
|
||||
border: 0;
|
||||
}
|
||||
}
|
|
@ -67,5 +67,6 @@
|
|||
font-size: 14px;
|
||||
line-height: 1.2;
|
||||
padding: 2px 0 2px 2px;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -141,7 +141,7 @@ const ActionsStep = React.memo(({ onClose }) => {
|
|||
</Menu.Item>
|
||||
{withTrashEmptier && (
|
||||
<>
|
||||
{(withSubscribe || withCustomFieldGroups) && <hr className={styles.divider} />}
|
||||
<hr className={styles.divider} />
|
||||
<Menu.Item className={styles.menuItem} onClick={handleEmptyTrashClick}>
|
||||
<Icon name="trash alternate outline" className={styles.menuItemIcon} />
|
||||
{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) => (
|
||||
<Menu.Item
|
||||
key={context}
|
||||
|
|
|
@ -23,6 +23,6 @@
|
|||
|
||||
.menuItemIcon {
|
||||
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')}
|
||||
maxLength={1024}
|
||||
minRows={3}
|
||||
spellCheck={false}
|
||||
className={styles.field}
|
||||
onKeyDown={handleFieldKeyDown}
|
||||
onChange={handleFieldChange}
|
||||
|
|
|
@ -7,7 +7,7 @@ import React, { useCallback, useMemo } from 'react';
|
|||
import PropTypes from 'prop-types';
|
||||
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
|
||||
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 selectors from '../../../selectors';
|
||||
|
@ -290,6 +290,7 @@ const ActionsStep = React.memo(({ cardId, onNameEdit, onClose }) => {
|
|||
<Menu secondary vertical className={styles.menu}>
|
||||
{canEditName && (
|
||||
<Menu.Item className={styles.menuItem} onClick={handleEditNameClick}>
|
||||
<Icon name="edit outline" className={styles.menuItemIcon} />
|
||||
{t('action.editTitle', {
|
||||
context: 'title',
|
||||
})}
|
||||
|
@ -297,6 +298,7 @@ const ActionsStep = React.memo(({ cardId, onNameEdit, onClose }) => {
|
|||
)}
|
||||
{!board.limitCardTypesToDefaultOne && canEditType && (
|
||||
<Menu.Item className={styles.menuItem} onClick={handleEditTypeClick}>
|
||||
<Icon name="map outline" className={styles.menuItemIcon} />
|
||||
{t('action.editType', {
|
||||
context: 'title',
|
||||
})}
|
||||
|
@ -304,6 +306,7 @@ const ActionsStep = React.memo(({ cardId, onNameEdit, onClose }) => {
|
|||
)}
|
||||
{card.type === CardTypes.PROJECT && canUseMembers && (
|
||||
<Menu.Item className={styles.menuItem} onClick={handleUsersClick}>
|
||||
<Icon name="user outline" className={styles.menuItemIcon} />
|
||||
{t('common.members', {
|
||||
context: 'title',
|
||||
})}
|
||||
|
@ -311,6 +314,7 @@ const ActionsStep = React.memo(({ cardId, onNameEdit, onClose }) => {
|
|||
)}
|
||||
{canUseLabels && (
|
||||
<Menu.Item className={styles.menuItem} onClick={handleLabelsClick}>
|
||||
<Icon name="bookmark outline" className={styles.menuItemIcon} />
|
||||
{t('common.labels', {
|
||||
context: 'title',
|
||||
})}
|
||||
|
@ -318,6 +322,7 @@ const ActionsStep = React.memo(({ cardId, onNameEdit, onClose }) => {
|
|||
)}
|
||||
{card.type === CardTypes.STORY && canUseMembers && (
|
||||
<Menu.Item className={styles.menuItem} onClick={handleUsersClick}>
|
||||
<Icon name="user outline" className={styles.menuItemIcon} />
|
||||
{t('common.members', {
|
||||
context: 'title',
|
||||
})}
|
||||
|
@ -325,6 +330,7 @@ const ActionsStep = React.memo(({ cardId, onNameEdit, onClose }) => {
|
|||
)}
|
||||
{card.type === CardTypes.PROJECT && canEditDueDate && (
|
||||
<Menu.Item className={styles.menuItem} onClick={handleEditDueDateClick}>
|
||||
<Icon name="calendar check outline" className={styles.menuItemIcon} />
|
||||
{t('action.editDueDate', {
|
||||
context: 'title',
|
||||
})}
|
||||
|
@ -332,6 +338,7 @@ const ActionsStep = React.memo(({ cardId, onNameEdit, onClose }) => {
|
|||
)}
|
||||
{card.type === CardTypes.PROJECT && canEditStopwatch && (
|
||||
<Menu.Item className={styles.menuItem} onClick={handleEditStopwatchClick}>
|
||||
<Icon name="clock outline" className={styles.menuItemIcon} />
|
||||
{t('action.editStopwatch', {
|
||||
context: 'title',
|
||||
})}
|
||||
|
@ -339,6 +346,7 @@ const ActionsStep = React.memo(({ cardId, onNameEdit, onClose }) => {
|
|||
)}
|
||||
{canDuplicate && (
|
||||
<Menu.Item className={styles.menuItem} onClick={handleDuplicateClick}>
|
||||
<Icon name="copy outline" className={styles.menuItemIcon} />
|
||||
{t('action.duplicateCard', {
|
||||
context: 'title',
|
||||
})}
|
||||
|
@ -346,6 +354,7 @@ const ActionsStep = React.memo(({ cardId, onNameEdit, onClose }) => {
|
|||
)}
|
||||
{canMove && (
|
||||
<Menu.Item className={styles.menuItem} onClick={handleMoveClick}>
|
||||
<Icon name="share square outline" className={styles.menuItemIcon} />
|
||||
{t('action.moveCard', {
|
||||
context: 'title',
|
||||
})}
|
||||
|
@ -353,6 +362,7 @@ const ActionsStep = React.memo(({ cardId, onNameEdit, onClose }) => {
|
|||
)}
|
||||
{prevList && canRestore && (
|
||||
<Menu.Item className={styles.menuItem} onClick={handleRestoreClick}>
|
||||
<Icon name="undo alternate" className={styles.menuItemIcon} />
|
||||
{t('action.restoreToList', {
|
||||
context: 'title',
|
||||
list: prevList.name || t(`common.${prevList.type}`),
|
||||
|
@ -361,6 +371,7 @@ const ActionsStep = React.memo(({ cardId, onNameEdit, onClose }) => {
|
|||
)}
|
||||
{list.type !== ListTypes.ARCHIVE && canArchive && (
|
||||
<Menu.Item className={styles.menuItem} onClick={handleArchiveClick}>
|
||||
<Icon name="folder open outline" className={styles.menuItemIcon} />
|
||||
{t('action.archiveCard', {
|
||||
context: 'title',
|
||||
})}
|
||||
|
@ -368,6 +379,7 @@ const ActionsStep = React.memo(({ cardId, onNameEdit, onClose }) => {
|
|||
)}
|
||||
{canDelete && (
|
||||
<Menu.Item className={styles.menuItem} onClick={handleDeleteClick}>
|
||||
<Icon name="trash alternate outline" className={styles.menuItemIcon} />
|
||||
{isInTrashList
|
||||
? t('action.deleteForever', {
|
||||
context: 'title',
|
||||
|
|
|
@ -13,4 +13,9 @@
|
|||
margin: 0;
|
||||
padding-left: 14px;
|
||||
}
|
||||
|
||||
.menuItemIcon {
|
||||
float: left;
|
||||
margin: 0 0.5em 0 0;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,17 +5,17 @@
|
|||
|
||||
import upperFirst from 'lodash/upperFirst';
|
||||
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 classNames from 'classnames';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { Button, Icon } from 'semantic-ui-react';
|
||||
import { push } from '../../../lib/redux-router';
|
||||
import { usePopup } from '../../../lib/popup';
|
||||
import { closePopup, usePopup } from '../../../lib/popup';
|
||||
|
||||
import selectors from '../../../selectors';
|
||||
import Paths from '../../../constants/Paths';
|
||||
import { BoardMembershipRoles, CardTypes, ListTypes } from '../../../constants/Enums';
|
||||
import { BoardMembershipRoles, CardTypes } from '../../../constants/Enums';
|
||||
import ProjectContent from './ProjectContent';
|
||||
import StoryContent from './StoryContent';
|
||||
import InlineContent from './InlineContent';
|
||||
|
@ -51,6 +51,8 @@ const Card = React.memo(({ id, isInline }) => {
|
|||
const dispatch = useDispatch();
|
||||
const [isEditNameOpened, setIsEditNameOpened] = useState(false);
|
||||
|
||||
const actionsPopupRef = useRef(null);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (document.activeElement) {
|
||||
document.activeElement.blur();
|
||||
|
@ -59,6 +61,17 @@ const Card = React.memo(({ id, isInline }) => {
|
|||
dispatch(push(Paths.CARDS.replace(':id', id)));
|
||||
}, [id, dispatch]);
|
||||
|
||||
const handleContextMenu = useCallback((event) => {
|
||||
if (!actionsPopupRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
closePopup();
|
||||
actionsPopupRef.current.open();
|
||||
}, []);
|
||||
|
||||
const handleNameEdit = useCallback(() => {
|
||||
setIsEditNameOpened(true);
|
||||
}, []);
|
||||
|
@ -108,17 +121,15 @@ const Card = React.memo(({ id, isInline }) => {
|
|||
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,
|
||||
jsx-a11y/no-static-element-interactions */}
|
||||
<div
|
||||
className={classNames(
|
||||
styles.content,
|
||||
list.type === ListTypes.CLOSED && styles.contentDisabled,
|
||||
)}
|
||||
className={classNames(styles.content, card.isClosed && styles.contentDisabled)}
|
||||
onClick={handleClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
>
|
||||
<Content cardId={id} />
|
||||
{colorLineNode}
|
||||
</div>
|
||||
{canUseActions && (
|
||||
<ActionsPopup cardId={id} onNameEdit={handleNameEdit}>
|
||||
<ActionsPopup ref={actionsPopupRef} cardId={id} onNameEdit={handleNameEdit}>
|
||||
<Button className={styles.actionsButton}>
|
||||
<Icon fitted name="pencil" size="small" />
|
||||
</Button>
|
||||
|
@ -126,12 +137,7 @@ const Card = React.memo(({ id, isInline }) => {
|
|||
)}
|
||||
</>
|
||||
) : (
|
||||
<span
|
||||
className={classNames(
|
||||
styles.content,
|
||||
list.type === ListTypes.CLOSED && styles.contentDisabled,
|
||||
)}
|
||||
>
|
||||
<span className={classNames(styles.content, card.isClosed && styles.contentDisabled)}>
|
||||
<Content cardId={id} />
|
||||
{colorLineNode}
|
||||
</span>
|
||||
|
|
|
@ -96,7 +96,6 @@ const EditName = React.memo(({ cardId, onClose }) => {
|
|||
maxLength={1024}
|
||||
minRows={3}
|
||||
maxRows={8}
|
||||
spellCheck={false}
|
||||
className={styles.field}
|
||||
onKeyDown={handleFieldKeyDown}
|
||||
onChange={handleFieldChange}
|
||||
|
|
|
@ -28,6 +28,7 @@ const InlineContent = React.memo(({ cardId }) => {
|
|||
);
|
||||
|
||||
const card = useSelector((state) => selectCardById(state, cardId));
|
||||
const list = useSelector((state) => selectListById(state, card.listId));
|
||||
const labelIds = useSelector((state) => selectLabelIdsByCardId(state, cardId));
|
||||
|
||||
const notificationsTotal = useSelector((state) =>
|
||||
|
@ -35,8 +36,6 @@ const InlineContent = React.memo(({ cardId }) => {
|
|||
);
|
||||
|
||||
const listName = useSelector((state) => {
|
||||
const list = selectListById(state, card.listId);
|
||||
|
||||
if (!list.name) {
|
||||
return null;
|
||||
}
|
||||
|
@ -88,7 +87,9 @@ const InlineContent = React.memo(({ cardId }) => {
|
|||
))}
|
||||
</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>
|
||||
</span>
|
||||
{descriptionText && (
|
||||
|
|
|
@ -50,6 +50,10 @@
|
|||
max-width: 30%;
|
||||
}
|
||||
|
||||
.nameClosed {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.notification {
|
||||
background: #eb5a46;
|
||||
color: #fff;
|
||||
|
|
|
@ -13,7 +13,7 @@ import selectors from '../../../selectors';
|
|||
import entryActions from '../../../entry-actions';
|
||||
import { startStopwatch, stopStopwatch } from '../../../utils/stopwatch';
|
||||
import { isListArchiveOrTrash } from '../../../utils/record-helpers';
|
||||
import { BoardMembershipRoles, BoardViews, ListTypes } from '../../../constants/Enums';
|
||||
import { BoardMembershipRoles, BoardViews } from '../../../constants/Enums';
|
||||
import TaskList from './TaskList';
|
||||
import DueDateChip from '../DueDateChip';
|
||||
import StopwatchChip from '../StopwatchChip';
|
||||
|
@ -145,7 +145,7 @@ const ProjectContent = React.memo(({ cardId }) => {
|
|||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<div className={styles.name}>{card.name}</div>
|
||||
<div className={classNames(styles.name, card.isClosed && styles.nameClosed)}>{card.name}</div>
|
||||
{coverUrl && (
|
||||
<div className={styles.coverWrapper}>
|
||||
<img src={coverUrl} alt="" className={styles.cover} />
|
||||
|
@ -187,11 +187,7 @@ const ProjectContent = React.memo(({ cardId }) => {
|
|||
)}
|
||||
{card.dueDate && (
|
||||
<span className={classNames(styles.attachment, styles.attachmentLeft)}>
|
||||
<DueDateChip
|
||||
value={card.dueDate}
|
||||
size="tiny"
|
||||
withStatus={list.type !== ListTypes.CLOSED && !isListArchiveOrTrash(list)}
|
||||
/>
|
||||
<DueDateChip value={card.dueDate} size="tiny" withStatus={!card.isClosed} />
|
||||
</span>
|
||||
)}
|
||||
{card.stopwatch && (
|
||||
|
|
|
@ -84,6 +84,10 @@
|
|||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.nameClosed {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.notification {
|
||||
background: #eb5a46;
|
||||
color: #fff;
|
||||
|
|
|
@ -40,6 +40,7 @@ const StoryContent = React.memo(({ cardId }) => {
|
|||
const selectAttachmentById = useMemo(() => selectors.makeSelectAttachmentById(), []);
|
||||
|
||||
const card = useSelector((state) => selectCardById(state, cardId));
|
||||
const list = useSelector((state) => selectListById(state, card.listId));
|
||||
const labelIds = useSelector((state) => selectLabelIdsByCardId(state, cardId));
|
||||
const attachmentsTotal = useSelector((state) => selectAttachmentsTotalByCardId(state, cardId));
|
||||
|
||||
|
@ -52,8 +53,6 @@ const StoryContent = React.memo(({ cardId }) => {
|
|||
);
|
||||
|
||||
const listName = useSelector((state) => {
|
||||
const list = selectListById(state, card.listId);
|
||||
|
||||
if (!list.name) {
|
||||
return null;
|
||||
}
|
||||
|
@ -106,7 +105,9 @@ const StoryContent = React.memo(({ cardId }) => {
|
|||
))}
|
||||
</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>}
|
||||
{(attachmentsTotal > 0 || notificationsTotal > 0 || listName) && (
|
||||
<span className={styles.attachments}>
|
||||
|
|
|
@ -74,6 +74,10 @@
|
|||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.nameClosed {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.notification {
|
||||
background: #eb5a46;
|
||||
color: #fff;
|
||||
|
|
|
@ -3,24 +3,49 @@
|
|||
* 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 classNames from 'classnames';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Icon } from 'semantic-ui-react';
|
||||
|
||||
import selectors from '../../../../selectors';
|
||||
import Paths from '../../../../constants/Paths';
|
||||
import Linkify from '../../../common/Linkify';
|
||||
|
||||
import styles from './Task.module.scss';
|
||||
|
||||
const Task = React.memo(({ id }) => {
|
||||
const selectTaskById = useMemo(() => selectors.makeSelectTaskById(), []);
|
||||
const selectLinkedCardById = useMemo(() => selectors.makeSelectCardById(), []);
|
||||
|
||||
const task = useSelector((state) => selectTaskById(state, id));
|
||||
|
||||
const linkedCard = useSelector(
|
||||
(state) => task.linkedCardId && selectLinkedCardById(state, task.linkedCardId),
|
||||
);
|
||||
|
||||
const handleLinkClick = useCallback((event) => {
|
||||
event.stopPropagation();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<li className={classNames(styles.wrapper, task.isCompleted && styles.wrapperCompleted)}>
|
||||
<Linkify linkStopPropagation>{task.name}</Linkify>
|
||||
<li className={styles.wrapper}>
|
||||
{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>
|
||||
);
|
||||
});
|
||||
|
|
|
@ -4,6 +4,21 @@
|
|||
*/
|
||||
|
||||
:global(#app) {
|
||||
.icon {
|
||||
color: rgba(9, 30, 66, 0.24);
|
||||
}
|
||||
|
||||
.name {
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.nameCompleted {
|
||||
color: #aaa;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
|
@ -18,9 +33,4 @@
|
|||
left: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.wrapperCompleted {
|
||||
color: #aaa;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -43,5 +43,6 @@
|
|||
font-size: 14px;
|
||||
line-height: 1.2;
|
||||
padding: 2px 0 2px 2px;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -82,7 +82,6 @@ const NameField = React.memo(({ defaultValue, size, onUpdate }) => {
|
|||
as={TextareaAutosize}
|
||||
value={value}
|
||||
maxLength={1024}
|
||||
spellCheck={false}
|
||||
className={classNames(styles.field, styles[`field${upperFirst(size)}`])}
|
||||
onFocus={handleFocus}
|
||||
onKeyDown={handleKeyDown}
|
||||
|
|
|
@ -30,7 +30,7 @@ import SelectCardTypeStep from '../SelectCardTypeStep';
|
|||
import EditDueDateStep from '../EditDueDateStep';
|
||||
import EditStopwatchStep from '../EditStopwatchStep';
|
||||
import MoveCardStep from '../MoveCardStep';
|
||||
import Markdown from '../../common/Markdown';
|
||||
import ExpandableMarkdown from '../../common/ExpandableMarkdown';
|
||||
import EditMarkdown from '../../common/EditMarkdown';
|
||||
import ConfirmationStep from '../../common/ConfirmationStep';
|
||||
import UserAvatar from '../../users/UserAvatar';
|
||||
|
@ -442,18 +442,14 @@ const ProjectContent = React.memo(({ onClose }) => {
|
|||
<DueDateChip
|
||||
withStatusIcon
|
||||
value={card.dueDate}
|
||||
withStatus={
|
||||
list.type !== ListTypes.CLOSED && !isInArchiveList && !isInTrashList
|
||||
}
|
||||
withStatus={!card.isClosed}
|
||||
/>
|
||||
</EditDueDatePopup>
|
||||
) : (
|
||||
<DueDateChip
|
||||
withStatusIcon
|
||||
value={card.dueDate}
|
||||
withStatus={
|
||||
list.type !== ListTypes.CLOSED && !isInArchiveList && !isInTrashList
|
||||
}
|
||||
withStatus={!card.isClosed}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
|
@ -521,7 +517,7 @@ const ProjectContent = React.memo(({ onClose }) => {
|
|||
<Button className={styles.editButton}>
|
||||
<Icon fitted name="pencil" size="small" />
|
||||
</Button>
|
||||
<Markdown>{card.description}</Markdown>
|
||||
<ExpandableMarkdown>{card.description}</ExpandableMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
|
@ -536,7 +532,7 @@ const ProjectContent = React.memo(({ onClose }) => {
|
|||
))}
|
||||
</>
|
||||
)}
|
||||
{!canEditDescription && <Markdown>{card.description}</Markdown>}
|
||||
{!canEditDescription && <ExpandableMarkdown>{card.description}</ExpandableMarkdown>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
@ -195,6 +195,7 @@
|
|||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
line-height: 34px;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.headerTitleWrapper {
|
||||
|
|
|
@ -3,16 +3,18 @@
|
|||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import React, { useCallback, useState, useRef } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import TextareaAutosize from 'react-textarea-autosize';
|
||||
import { Button, Form, TextArea } from 'semantic-ui-react';
|
||||
import { Mention, MentionsInput } from 'react-mentions';
|
||||
import { Button, Form } from 'semantic-ui-react';
|
||||
import { useClickAwayListener, useDidUpdate, useToggle } from '../../../lib/hooks';
|
||||
|
||||
import selectors from '../../../selectors';
|
||||
import entryActions from '../../../entry-actions';
|
||||
import { useEscapeInterceptor, useForm, useNestedRef } from '../../../hooks';
|
||||
import { isModifierKeyPressed } from '../../../utils/event-helpers';
|
||||
import UserAvatar from '../../users/UserAvatar';
|
||||
|
||||
import styles from './Add.module.scss';
|
||||
|
||||
|
@ -21,13 +23,17 @@ const DEFAULT_DATA = {
|
|||
};
|
||||
|
||||
const Add = React.memo(() => {
|
||||
const boardMemberships = useSelector(selectors.selectMembershipsForCurrentBoard);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const [t] = useTranslation();
|
||||
const [data, , setData] = useForm(DEFAULT_DATA);
|
||||
const [isOpened, setIsOpened] = useState(false);
|
||||
const [data, handleFieldChange, setData] = useForm(DEFAULT_DATA);
|
||||
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 submit = useCallback(() => {
|
||||
|
@ -37,19 +43,24 @@ const Add = React.memo(() => {
|
|||
};
|
||||
|
||||
if (!cleanData.text) {
|
||||
textFieldRef.current.select();
|
||||
textInputRef.current.select();
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(entryActions.createCommentInCurrentCard(cleanData));
|
||||
setData(DEFAULT_DATA);
|
||||
selectTextField();
|
||||
}, [dispatch, data, setData, selectTextField, textFieldRef]);
|
||||
}, [dispatch, data, setData, selectTextField]);
|
||||
|
||||
const handleEscape = useCallback(() => {
|
||||
if (textMentionsRef.current.isOpened()) {
|
||||
textMentionsRef.current.clearSuggestions();
|
||||
return;
|
||||
}
|
||||
|
||||
setIsOpened(false);
|
||||
textFieldRef.current.blur();
|
||||
}, [textFieldRef]);
|
||||
textInputRef.current.blur();
|
||||
}, []);
|
||||
|
||||
const [activateEscapeInterceptor, deactivateEscapeInterceptor] =
|
||||
useEscapeInterceptor(handleEscape);
|
||||
|
@ -62,6 +73,15 @@ const Add = React.memo(() => {
|
|||
setIsOpened(true);
|
||||
}, []);
|
||||
|
||||
const handleFieldChange = useCallback(
|
||||
(_, text) => {
|
||||
setData({
|
||||
text,
|
||||
});
|
||||
},
|
||||
[setData],
|
||||
);
|
||||
|
||||
const handleFieldKeyDown = useCallback(
|
||||
(event) => {
|
||||
if (isModifierKeyPressed(event) && event.key === 'Enter') {
|
||||
|
@ -76,8 +96,8 @@ const Add = React.memo(() => {
|
|||
}, []);
|
||||
|
||||
const handleClickAwayCancel = useCallback(() => {
|
||||
textFieldRef.current.focus();
|
||||
}, [textFieldRef]);
|
||||
textInputRef.current.focus();
|
||||
}, []);
|
||||
|
||||
const clickAwayProps = useClickAwayListener(
|
||||
[textFieldRef, buttonRef],
|
||||
|
@ -85,6 +105,16 @@ const Add = React.memo(() => {
|
|||
handleClickAwayCancel,
|
||||
);
|
||||
|
||||
const suggestionRenderer = useCallback(
|
||||
(entry, _, highlightedDisplay) => (
|
||||
<div className={styles.suggestion}>
|
||||
<UserAvatar id={entry.id} size="tiny" />
|
||||
{highlightedDisplay}
|
||||
</div>
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
useDidUpdate(() => {
|
||||
if (isOpened) {
|
||||
activateEscapeInterceptor();
|
||||
|
@ -94,26 +124,44 @@ const Add = React.memo(() => {
|
|||
}, [isOpened]);
|
||||
|
||||
useDidUpdate(() => {
|
||||
textFieldRef.current.focus();
|
||||
textInputRef.current.focus();
|
||||
}, [selectTextFieldState]);
|
||||
|
||||
return (
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<TextArea
|
||||
{...clickAwayProps} // eslint-disable-line react/jsx-props-no-spreading
|
||||
ref={handleTextFieldRef}
|
||||
as={TextareaAutosize}
|
||||
name="text"
|
||||
value={data.text}
|
||||
placeholder={t('common.writeComment')}
|
||||
maxLength={1048576}
|
||||
minRows={isOpened ? 3 : 1}
|
||||
spellCheck={false}
|
||||
className={styles.field}
|
||||
onFocus={handleFieldFocus}
|
||||
onKeyDown={handleFieldKeyDown}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
<div ref={textFieldRef} className={styles.field}>
|
||||
<MentionsInput
|
||||
{...clickAwayProps} // eslint-disable-line react/jsx-props-no-spreading
|
||||
allowSpaceInQuery
|
||||
allowSuggestionsAboveCursor
|
||||
ref={textMentionsRef}
|
||||
inputRef={textInputRef}
|
||||
value={data.text}
|
||||
placeholder={t('common.writeComment')}
|
||||
maxLength={1048576}
|
||||
rows={isOpened ? 3 : 1}
|
||||
className="mentions-input"
|
||||
style={{
|
||||
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 && (
|
||||
<div className={styles.controls}>
|
||||
<Button
|
||||
|
|
|
@ -16,20 +16,34 @@
|
|||
}
|
||||
|
||||
.field {
|
||||
background: #fff;
|
||||
border: 0;
|
||||
box-sizing: border-box;
|
||||
color: #333;
|
||||
display: block;
|
||||
line-height: 1.5;
|
||||
font-size: 14px;
|
||||
overflow: hidden;
|
||||
padding: 8px 12px;
|
||||
resize: none;
|
||||
width: 100%;
|
||||
textarea {
|
||||
background: #fff !important;
|
||||
border: 1px solid rgba(9, 30, 66, 0.13);
|
||||
border-radius: 3px;
|
||||
box-sizing: border-box;
|
||||
color: #333;
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
margin: 0 !important;
|
||||
overflow: hidden;
|
||||
padding: 8px 12px;
|
||||
resize: none;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
&:focus {
|
||||
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 React, { useCallback, useEffect, useMemo } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import TextareaAutosize from 'react-textarea-autosize';
|
||||
import { Button, Form, TextArea } from 'semantic-ui-react';
|
||||
import { Mention, MentionsInput } from 'react-mentions';
|
||||
import { Button, Form } from 'semantic-ui-react';
|
||||
import { useClickAwayListener } from '../../../lib/hooks';
|
||||
|
||||
import selectors from '../../../selectors';
|
||||
|
@ -17,6 +17,7 @@ import entryActions from '../../../entry-actions';
|
|||
import { useForm, useNestedRef } from '../../../hooks';
|
||||
import { focusEnd } from '../../../utils/element-helpers';
|
||||
import { isModifierKeyPressed } from '../../../utils/event-helpers';
|
||||
import UserAvatar from '../../users/UserAvatar';
|
||||
|
||||
import styles from './Edit.module.scss';
|
||||
|
||||
|
@ -24,6 +25,7 @@ const Edit = React.memo(({ commentId, onClose }) => {
|
|||
const selectCommentById = useMemo(() => selectors.makeSelectCommentById(), []);
|
||||
|
||||
const comment = useSelector((state) => selectCommentById(state, commentId));
|
||||
const boardMemberships = useSelector(selectors.selectMembershipsForCurrentBoard);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const [t] = useTranslation();
|
||||
|
@ -35,13 +37,16 @@ const Edit = React.memo(({ commentId, onClose }) => {
|
|||
[comment.text],
|
||||
);
|
||||
|
||||
const [data, handleFieldChange] = useForm(() => ({
|
||||
const [data, , setData] = useForm(() => ({
|
||||
text: '',
|
||||
...defaultData,
|
||||
}));
|
||||
|
||||
const [textFieldRef, handleTextFieldRef] = useNestedRef();
|
||||
const [buttonRef, handleButtonRef] = useNestedRef();
|
||||
const textFieldRef = useRef(null);
|
||||
const textMentionsRef = useRef(null);
|
||||
const textInputRef = useRef(null);
|
||||
const [submitButtonRef, handleSubmitButtonRef] = useNestedRef();
|
||||
const [cancelButtonRef, handleCancelButtonRef] = useNestedRef();
|
||||
|
||||
const submit = useCallback(() => {
|
||||
const cleanData = {
|
||||
|
@ -60,6 +65,15 @@ const Edit = React.memo(({ commentId, onClose }) => {
|
|||
submit();
|
||||
}, [submit]);
|
||||
|
||||
const handleFieldChange = useCallback(
|
||||
(_, text) => {
|
||||
setData({
|
||||
text,
|
||||
});
|
||||
},
|
||||
[setData],
|
||||
);
|
||||
|
||||
const handleFieldKeyDown = useCallback(
|
||||
(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
|
@ -67,48 +81,92 @@ const Edit = React.memo(({ commentId, onClose }) => {
|
|||
submit();
|
||||
}
|
||||
} else if (event.key === 'Escape') {
|
||||
if (textMentionsRef.current.isOpened()) {
|
||||
textMentionsRef.current.clearSuggestions();
|
||||
return;
|
||||
}
|
||||
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[onClose, submit],
|
||||
);
|
||||
|
||||
const handleCancelClick = useCallback(() => {
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
const handleClickAwayCancel = useCallback(() => {
|
||||
textFieldRef.current.focus();
|
||||
}, [textFieldRef]);
|
||||
textInputRef.current.focus();
|
||||
}, []);
|
||||
|
||||
const clickAwayProps = useClickAwayListener(
|
||||
[textFieldRef, buttonRef],
|
||||
[textFieldRef, submitButtonRef, cancelButtonRef],
|
||||
submit,
|
||||
handleClickAwayCancel,
|
||||
);
|
||||
|
||||
const suggestionRenderer = useCallback(
|
||||
(entry, _, highlightedDisplay) => (
|
||||
<div className={styles.suggestion}>
|
||||
<UserAvatar id={entry.id} size="tiny" />
|
||||
{highlightedDisplay}
|
||||
</div>
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
focusEnd(textFieldRef.current);
|
||||
}, [textFieldRef]);
|
||||
focusEnd(textInputRef.current);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<TextArea
|
||||
{...clickAwayProps} // eslint-disable-line react/jsx-props-no-spreading
|
||||
ref={handleTextFieldRef}
|
||||
as={TextareaAutosize}
|
||||
name="text"
|
||||
value={data.text}
|
||||
maxLength={1048576}
|
||||
minRows={3}
|
||||
spellCheck={false}
|
||||
className={styles.field}
|
||||
onKeyDown={handleFieldKeyDown}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
<div ref={textFieldRef} className={styles.field}>
|
||||
<MentionsInput
|
||||
{...clickAwayProps} // eslint-disable-line react/jsx-props-no-spreading
|
||||
allowSpaceInQuery
|
||||
allowSuggestionsAboveCursor
|
||||
ref={textMentionsRef}
|
||||
inputRef={textInputRef}
|
||||
value={data.text}
|
||||
maxLength={1048576}
|
||||
rows={3}
|
||||
className="mentions-input"
|
||||
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}>
|
||||
<Button
|
||||
{...clickAwayProps} // eslint-disable-line react/jsx-props-no-spreading
|
||||
positive
|
||||
ref={handleButtonRef}
|
||||
ref={handleSubmitButtonRef}
|
||||
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>
|
||||
</Form>
|
||||
);
|
||||
|
|
|
@ -10,22 +10,34 @@
|
|||
}
|
||||
|
||||
.field {
|
||||
background: #fff;
|
||||
border: 1px solid rgba(9, 30, 66, 0.13);
|
||||
border-radius: 3px;
|
||||
box-sizing: border-box;
|
||||
color: #333;
|
||||
display: block;
|
||||
line-height: 1.4;
|
||||
font-size: 14px;
|
||||
margin-bottom: 4px;
|
||||
overflow: hidden;
|
||||
padding: 8px 12px;
|
||||
resize: none;
|
||||
width: 100%;
|
||||
textarea {
|
||||
background: #fff !important;
|
||||
border: 1px solid rgba(9, 30, 66, 0.13);
|
||||
border-radius: 3px;
|
||||
box-sizing: border-box;
|
||||
color: #333;
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
margin: 0 !important;
|
||||
overflow: hidden;
|
||||
padding: 8px 12px;
|
||||
resize: none;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
&:focus {
|
||||
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 { useClosableModal } from '../../../hooks';
|
||||
import UsersPane from './UsersPane';
|
||||
import WebhooksPane from './WebhooksPane';
|
||||
|
||||
import styles from './AdministrationModal.module.scss';
|
||||
|
||||
|
@ -37,6 +38,12 @@ const AdministrationModal = React.memo(() => {
|
|||
}),
|
||||
render: () => <UsersPane />,
|
||||
},
|
||||
{
|
||||
menuItem: t('common.webhooks', {
|
||||
context: 'title',
|
||||
}),
|
||||
render: () => <WebhooksPane />,
|
||||
},
|
||||
];
|
||||
|
||||
const isUsersPaneActive = activeTabIndex === 0;
|
||||
|
|
|
@ -7,7 +7,7 @@ import React, { useCallback, useMemo } from 'react';
|
|||
import PropTypes from 'prop-types';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
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 selectors from '../../../../selectors';
|
||||
|
@ -180,12 +180,14 @@ const ActionsStep = React.memo(({ userId, onClose }) => {
|
|||
<Popup.Content>
|
||||
<Menu secondary vertical className={styles.menu}>
|
||||
<Menu.Item className={styles.menuItem} onClick={handleEditInformationClick}>
|
||||
<Icon name="info" className={styles.menuItemIcon} />
|
||||
{t('action.editInformation', {
|
||||
context: 'title',
|
||||
})}
|
||||
</Menu.Item>
|
||||
{!user.lockedFieldNames.includes('username') && (
|
||||
<Menu.Item className={styles.menuItem} onClick={handleEditUsernameClick}>
|
||||
<Icon name="at" className={styles.menuItemIcon} />
|
||||
{t('action.editUsername', {
|
||||
context: 'title',
|
||||
})}
|
||||
|
@ -193,6 +195,7 @@ const ActionsStep = React.memo(({ userId, onClose }) => {
|
|||
)}
|
||||
{!user.lockedFieldNames.includes('email') && (
|
||||
<Menu.Item className={styles.menuItem} onClick={handleEditEmailClick}>
|
||||
<Icon name="mail outline" className={styles.menuItemIcon} />
|
||||
{t('action.editEmail', {
|
||||
context: 'title',
|
||||
})}
|
||||
|
@ -200,6 +203,7 @@ const ActionsStep = React.memo(({ userId, onClose }) => {
|
|||
)}
|
||||
{!user.lockedFieldNames.includes('password') && (
|
||||
<Menu.Item className={styles.menuItem} onClick={handleEditPasswordClick}>
|
||||
<Icon name="keyboard outline" className={styles.menuItemIcon} />
|
||||
{t('action.editPassword', {
|
||||
context: 'title',
|
||||
})}
|
||||
|
@ -207,6 +211,7 @@ const ActionsStep = React.memo(({ userId, onClose }) => {
|
|||
)}
|
||||
{!user.lockedFieldNames.includes('role') && (
|
||||
<Menu.Item className={styles.menuItem} onClick={handleEditRoleClick}>
|
||||
<Icon name="sun outline" className={styles.menuItemIcon} />
|
||||
{t('action.editRole', {
|
||||
context: 'title',
|
||||
})}
|
||||
|
@ -221,6 +226,7 @@ const ActionsStep = React.memo(({ userId, onClose }) => {
|
|||
className={styles.menuItem}
|
||||
onClick={user.isDeactivated ? handleActivateClick : handleDeactivateClick}
|
||||
>
|
||||
<Icon name={user.isDeactivated ? 'plus' : 'close'} className={styles.menuItemIcon} />
|
||||
{user.isDeactivated
|
||||
? t('action.activateUser', {
|
||||
context: 'title',
|
||||
|
@ -231,6 +237,7 @@ const ActionsStep = React.memo(({ userId, onClose }) => {
|
|||
</Menu.Item>
|
||||
{user.isDeactivated && !user.isDefaultAdmin && (
|
||||
<Menu.Item className={styles.menuItem} onClick={handleDeleteClick}>
|
||||
<Icon name="trash alternate outline" className={styles.menuItemIcon} />
|
||||
{t('action.deleteUser', {
|
||||
context: 'title',
|
||||
})}
|
||||
|
|
|
@ -13,4 +13,9 @@
|
|||
margin: 0;
|
||||
padding-left: 14px;
|
||||
}
|
||||
|
||||
.menuItemIcon {
|
||||
float: left;
|
||||
margin: 0 0.5em 0 0;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -117,7 +117,7 @@ const AddStep = React.memo(({ onClose }) => {
|
|||
);
|
||||
|
||||
const handleMessageDismiss = useCallback(() => {
|
||||
dispatch(entryActions.clearUserPasswordUpdateError());
|
||||
dispatch(entryActions.clearUserCreateError());
|
||||
}, [dispatch]);
|
||||
|
||||
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 handleSubmit = useCallback(() => {
|
||||
if (typeValue) {
|
||||
const cleanData = {
|
||||
...data,
|
||||
typeValue: data.typeValue.trim(),
|
||||
};
|
||||
const handleSubmit = useCallback(
|
||||
(event) => {
|
||||
event.stopPropagation();
|
||||
|
||||
if (cleanData.typeValue.toLowerCase() !== typeValue.toLowerCase()) {
|
||||
nameFieldRef.current.select();
|
||||
return;
|
||||
if (typeValue) {
|
||||
const cleanData = {
|
||||
...data,
|
||||
typeValue: data.typeValue.trim(),
|
||||
};
|
||||
|
||||
if (cleanData.typeValue.toLowerCase() !== typeValue.toLowerCase()) {
|
||||
nameFieldRef.current.select();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onConfirm();
|
||||
}, [typeValue, onConfirm, data, nameFieldRef]);
|
||||
onConfirm();
|
||||
},
|
||||
[typeValue, onConfirm, data, nameFieldRef],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
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 classNames from 'classnames';
|
||||
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 { useDidUpdate, usePrevious, useToggle } from '../../../lib/hooks';
|
||||
import { Input } from '../../../lib/custom-ui';
|
||||
|
@ -247,7 +247,14 @@ const Content = React.memo(() => {
|
|||
/>
|
||||
)}
|
||||
</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>
|
||||
</Grid.Column>
|
||||
<Grid.Column
|
||||
|
|
|
@ -14,6 +14,7 @@
|
|||
background: #fff;
|
||||
}
|
||||
|
||||
.g-md-markup-editor__toolbar,
|
||||
.g-md-wysiwyg-editor__toolbar {
|
||||
background: #f5f6f7;
|
||||
z-index: 2000;
|
||||
|
|
|
@ -3,9 +3,10 @@
|
|||
* 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 { useDispatch, useSelector } from 'react-redux';
|
||||
import { Button, Icon } from 'semantic-ui-react';
|
||||
|
||||
import selectors from '../../../selectors';
|
||||
import entryActions from '../../../entry-actions';
|
||||
|
@ -48,6 +49,7 @@ const CustomField = React.memo(({ id, customFieldGroupId }) => {
|
|||
});
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
|
||||
const handleValueUpdate = useCallback(
|
||||
(content) => {
|
||||
|
@ -64,18 +66,40 @@ const CustomField = React.memo(({ id, customFieldGroupId }) => {
|
|||
[id, customFieldGroupId, cardId, dispatch],
|
||||
);
|
||||
|
||||
const handleCopyClick = useCallback(() => {
|
||||
if (isCopied) {
|
||||
return;
|
||||
}
|
||||
|
||||
navigator.clipboard.writeText(customFieldValue.content);
|
||||
|
||||
setIsCopied(true);
|
||||
setTimeout(() => {
|
||||
setIsCopied(false);
|
||||
}, 1000);
|
||||
}, [customFieldValue, isCopied]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.name}>{customField.name}</div>
|
||||
{canEdit ? (
|
||||
<ValueField
|
||||
defaultValue={customFieldValue && customFieldValue.content}
|
||||
disabled={!customField.isPersisted}
|
||||
onUpdate={handleValueUpdate}
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.value}>{customFieldValue ? customFieldValue.content : '\u00A0'}</div>
|
||||
)}
|
||||
<div className={styles.valueWrapper}>
|
||||
{canEdit ? (
|
||||
<ValueField
|
||||
defaultValue={customFieldValue && customFieldValue.content}
|
||||
disabled={!customField.isPersisted}
|
||||
onUpdate={handleValueUpdate}
|
||||
/>
|
||||
) : (
|
||||
<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>
|
||||
);
|
||||
});
|
||||
|
|
|
@ -4,6 +4,30 @@
|
|||
*/
|
||||
|
||||
: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 {
|
||||
color: #6b808c;
|
||||
font-size: 13px;
|
||||
|
@ -25,4 +49,14 @@
|
|||
padding: 8px 12px;
|
||||
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 Editor from './Editor';
|
||||
|
||||
import styles from './AddStep.module.scss';
|
||||
|
||||
const AddStep = React.memo(({ cardId, defaultData, onBack }) => {
|
||||
const dispatch = useDispatch();
|
||||
const [t] = useTranslation();
|
||||
|
@ -52,7 +50,7 @@ const AddStep = React.memo(({ cardId, defaultData, onBack }) => {
|
|||
<Popup.Content>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<Editor data={data} onFieldChange={handleFieldChange} />
|
||||
<Button positive content={t('action.createLabel')} className={styles.submitButton} />
|
||||
<Button positive content={t('action.createLabel')} />
|
||||
</Form>
|
||||
</Popup.Content>
|
||||
</>
|
||||
|
|
|
@ -91,13 +91,16 @@ const EditStep = React.memo(({ labelId, onBack }) => {
|
|||
<Popup.Content>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<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>
|
||||
<Button
|
||||
content={t('action.delete')}
|
||||
className={styles.deleteButton}
|
||||
onClick={handleDeleteClick}
|
||||
/>
|
||||
</Popup.Content>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -4,14 +4,14 @@
|
|||
*/
|
||||
|
||||
:global(#app) {
|
||||
.deleteButton {
|
||||
bottom: 12px;
|
||||
box-shadow: 0 1px 0 #cbcccc;
|
||||
position: absolute;
|
||||
right: 9px;
|
||||
.actions {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.submitButton {
|
||||
margin-top: 12px;
|
||||
.deleteButton {
|
||||
box-shadow: 0 1px 0 #cbcccc;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,12 +5,8 @@
|
|||
|
||||
:global(#app) {
|
||||
.colorButton {
|
||||
float: left;
|
||||
height: 30px;
|
||||
margin: 3px;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
position: relative;
|
||||
width: 41.6px;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.9;
|
||||
|
@ -18,28 +14,43 @@
|
|||
}
|
||||
|
||||
.colorButtonActive:before {
|
||||
bottom: 1px;
|
||||
color: #ffffff;
|
||||
content: "Г";
|
||||
font-size: 16px;
|
||||
line-height: 32px;
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
right: calc(50% - 5px);
|
||||
text-align: center;
|
||||
text-shadow: 1px 1px 0 rgba(0, 0, 0, 0.2);
|
||||
top: 0;
|
||||
top: calc(50% - 17px);
|
||||
transform: rotate(-135deg);
|
||||
width: 32px;
|
||||
}
|
||||
|
||||
.colorButtons {
|
||||
margin: -3px;
|
||||
padding-bottom: 16px;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(40px, 1fr));
|
||||
margin-bottom: 12px;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
|
||||
&:after {
|
||||
clear: both;
|
||||
content: "";
|
||||
display: table;
|
||||
@supports (-moz-appearance: none) {
|
||||
scrollbar-color: rgba(0, 0, 0, 0.32) transparent;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
&::-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 { useDispatch, useSelector } from 'react-redux';
|
||||
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 selectors from '../../../selectors';
|
||||
|
@ -128,38 +128,45 @@ const ActionsStep = React.memo(({ listId, onNameEdit, onCardAdd, onClose }) => {
|
|||
<Popup.Content>
|
||||
<Menu secondary vertical className={styles.menu}>
|
||||
<Menu.Item className={styles.menuItem} onClick={handleEditNameClick}>
|
||||
<Icon name="edit outline" className={styles.menuItemIcon} />
|
||||
{t('action.editTitle', {
|
||||
context: 'title',
|
||||
})}
|
||||
</Menu.Item>
|
||||
<Menu.Item className={styles.menuItem} onClick={handleEditTypeClick}>
|
||||
<Icon name="map outline" className={styles.menuItemIcon} />
|
||||
{t('action.editType', {
|
||||
context: 'title',
|
||||
})}
|
||||
</Menu.Item>
|
||||
<Menu.Item className={styles.menuItem} onClick={handleEditColorClick}>
|
||||
<Icon name="dot circle outline" className={styles.menuItemIcon} />
|
||||
{t('action.editColor', {
|
||||
context: 'title',
|
||||
})}
|
||||
</Menu.Item>
|
||||
<Menu.Item className={styles.menuItem} onClick={handleAddCardClick}>
|
||||
<Icon name="list alternate outline" className={styles.menuItemIcon} />
|
||||
{t('action.addCard', {
|
||||
context: 'title',
|
||||
})}
|
||||
</Menu.Item>
|
||||
<Menu.Item className={styles.menuItem} onClick={handleSortClick}>
|
||||
<Icon name="sort amount down" className={styles.menuItemIcon} />
|
||||
{t('action.sortList', {
|
||||
context: 'title',
|
||||
})}
|
||||
</Menu.Item>
|
||||
{list.type === ListTypes.CLOSED && (
|
||||
<Menu.Item className={styles.menuItem} onClick={handleArchiveCardsClick}>
|
||||
<Icon name="folder open outline" className={styles.menuItemIcon} />
|
||||
{t('action.archiveCards', {
|
||||
context: 'title',
|
||||
})}
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Item className={styles.menuItem} onClick={handleDeleteClick}>
|
||||
<Icon name="trash alternate outline" className={styles.menuItemIcon} />
|
||||
{t('action.deleteList', {
|
||||
context: 'title',
|
||||
})}
|
||||
|
|
|
@ -13,4 +13,9 @@
|
|||
margin: 0;
|
||||
padding-left: 14px;
|
||||
}
|
||||
|
||||
.menuItemIcon {
|
||||
float: left;
|
||||
margin: 0 0.5em 0 0;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -76,7 +76,6 @@ const EditName = React.memo(({ listId, onClose }) => {
|
|||
as={TextareaAutosize}
|
||||
value={value}
|
||||
maxLength={128}
|
||||
spellCheck={false}
|
||||
className={styles.field}
|
||||
onClick={handleFieldClick}
|
||||
onKeyDown={handleFieldKeyDown}
|
||||
|
|
|
@ -125,7 +125,7 @@ const Item = React.memo(({ id }) => {
|
|||
dispatch(entryActions.deleteNotificationService(id));
|
||||
}, [id, dispatch]);
|
||||
|
||||
const handleUpdateSubmit = useCallback(() => {
|
||||
const handleSubmit = useCallback(() => {
|
||||
urlFieldRef.current.blur();
|
||||
}, [urlFieldRef]);
|
||||
|
||||
|
@ -153,7 +153,7 @@ const Item = React.memo(({ id }) => {
|
|||
const ConfirmationPopup = usePopupInClosableContext(ConfirmationStep);
|
||||
|
||||
return (
|
||||
<Form className={styles.wrapper} onSubmit={handleUpdateSubmit}>
|
||||
<Form className={styles.wrapper} onSubmit={handleSubmit}>
|
||||
<Input
|
||||
ref={handleUrlFieldRef}
|
||||
name="url"
|
||||
|
|
|
@ -13,6 +13,7 @@ import { Button } from 'semantic-ui-react';
|
|||
|
||||
import selectors from '../../../selectors';
|
||||
import entryActions from '../../../entry-actions';
|
||||
import { formatTextWithMentions } from '../../../utils/mentions';
|
||||
import Paths from '../../../constants/Paths';
|
||||
import { StaticUserIds } from '../../../constants/StaticUsers';
|
||||
import { NotificationTypes } from '../../../constants/Enums';
|
||||
|
@ -83,7 +84,7 @@ const Item = React.memo(({ id, onClose }) => {
|
|||
break;
|
||||
}
|
||||
case NotificationTypes.COMMENT_CARD: {
|
||||
const commentText = truncate(notification.data.text);
|
||||
const commentText = truncate(formatTextWithMentions(notification.data.text));
|
||||
|
||||
contentNode = (
|
||||
<Trans
|
||||
|
@ -122,6 +123,28 @@ const Item = React.memo(({ id, onClose }) => {
|
|||
);
|
||||
|
||||
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:
|
||||
contentNode = null;
|
||||
}
|
||||
|
|
|
@ -46,5 +46,6 @@
|
|||
font-size: 14px;
|
||||
line-height: 1.2;
|
||||
padding: 2px 0 2px 2px;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -129,7 +129,6 @@ const AddProjectModal = React.memo(() => {
|
|||
value={data.description}
|
||||
maxLength={1024}
|
||||
minRows={2}
|
||||
spellCheck={false}
|
||||
className={styles.field}
|
||||
onKeyDown={handleDescriptionKeyDown}
|
||||
onChange={handleFieldChange}
|
||||
|
|
|
@ -89,7 +89,6 @@ const EditInformation = React.memo(() => {
|
|||
value={data.description}
|
||||
maxLength={1024}
|
||||
minRows={2}
|
||||
spellCheck={false}
|
||||
className={styles.field}
|
||||
onKeyDown={handleDescriptionKeyDown}
|
||||
onChange={handleFieldChange}
|
||||
|
|
|
@ -5,12 +5,13 @@
|
|||
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
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 selectors from '../../../selectors';
|
||||
import entryActions from '../../../entry-actions';
|
||||
import { useForm, useNestedRef } from '../../../hooks';
|
||||
import { focusEnd } from '../../../utils/element-helpers';
|
||||
|
@ -20,18 +21,23 @@ import styles from './AddTask.module.scss';
|
|||
|
||||
const DEFAULT_DATA = {
|
||||
name: '',
|
||||
linkedCardId: null,
|
||||
};
|
||||
|
||||
const MULTIPLE_REGEX = /\s*\r?\n\s*/;
|
||||
|
||||
const AddTask = React.memo(({ children, taskListId, isOpened, onClose }) => {
|
||||
const cards = useSelector(selectors.selectCardsExceptCurrentForCurrentBoard);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const [t] = useTranslation();
|
||||
const [data, handleFieldChange, setData] = useForm(DEFAULT_DATA);
|
||||
const [focusNameFieldState, focusNameField] = useToggle();
|
||||
const [isLinkingToCard, toggleLinkingToCard] = useToggle();
|
||||
const [focusFieldState, focusField] = useToggle();
|
||||
|
||||
const [nameFieldRef, handleNameFieldRef] = useNestedRef();
|
||||
const [buttonRef, handleButtonRef] = useNestedRef();
|
||||
const [fieldRef, handleFieldRef] = useNestedRef();
|
||||
const [submitButtonRef, handleSubmitButtonRef] = useNestedRef();
|
||||
const [toggleLinkingButtonRef, handleToggleLinkingButtonRef] = useNestedRef();
|
||||
|
||||
const submit = useCallback(
|
||||
(isMultiple = false) => {
|
||||
|
@ -40,12 +46,23 @@ const AddTask = React.memo(({ children, taskListId, isOpened, onClose }) => {
|
|||
name: data.name.trim(),
|
||||
};
|
||||
|
||||
if (!cleanData.name) {
|
||||
nameFieldRef.current.select();
|
||||
return;
|
||||
if (isLinkingToCard) {
|
||||
if (!cleanData.linkedCardId) {
|
||||
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) => {
|
||||
dispatch(
|
||||
entryActions.createTask(taskListId, {
|
||||
|
@ -59,9 +76,9 @@ const AddTask = React.memo(({ children, taskListId, isOpened, onClose }) => {
|
|||
}
|
||||
|
||||
setData(DEFAULT_DATA);
|
||||
focusNameField();
|
||||
focusField();
|
||||
},
|
||||
[taskListId, dispatch, data, setData, focusNameField, nameFieldRef],
|
||||
[taskListId, dispatch, data, setData, isLinkingToCard, focusField, fieldRef],
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
|
@ -71,34 +88,48 @@ const AddTask = React.memo(({ children, taskListId, isOpened, onClose }) => {
|
|||
const handleFieldKeyDown = useCallback(
|
||||
(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
submit(isModifierKeyPressed(event));
|
||||
if (!isLinkingToCard) {
|
||||
event.preventDefault();
|
||||
submit(isModifierKeyPressed(event));
|
||||
}
|
||||
} else if (event.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[onClose, submit],
|
||||
[onClose, isLinkingToCard, submit],
|
||||
);
|
||||
|
||||
const handleToggleLinkingClick = useCallback(() => {
|
||||
toggleLinkingToCard();
|
||||
}, [toggleLinkingToCard]);
|
||||
|
||||
const handleClickAwayCancel = useCallback(() => {
|
||||
nameFieldRef.current.focus();
|
||||
}, [nameFieldRef]);
|
||||
if (isLinkingToCard) {
|
||||
fieldRef.current.querySelector('.search').focus();
|
||||
} else {
|
||||
focusEnd(fieldRef.current);
|
||||
}
|
||||
}, [isLinkingToCard, fieldRef]);
|
||||
|
||||
const clickAwayProps = useClickAwayListener(
|
||||
[nameFieldRef, buttonRef],
|
||||
[fieldRef, submitButtonRef, toggleLinkingButtonRef],
|
||||
onClose,
|
||||
handleClickAwayCancel,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpened) {
|
||||
focusEnd(nameFieldRef.current);
|
||||
if (isLinkingToCard) {
|
||||
fieldRef.current.querySelector('.search').focus();
|
||||
} else {
|
||||
focusEnd(fieldRef.current);
|
||||
}
|
||||
}
|
||||
}, [isOpened, nameFieldRef]);
|
||||
}, [isOpened, isLinkingToCard, fieldRef]);
|
||||
|
||||
useDidUpdate(() => {
|
||||
nameFieldRef.current.focus();
|
||||
}, [focusNameFieldState]);
|
||||
fieldRef.current.focus();
|
||||
}, [focusFieldState]);
|
||||
|
||||
if (!isOpened) {
|
||||
return children;
|
||||
|
@ -106,27 +137,62 @@ const AddTask = React.memo(({ children, taskListId, isOpened, onClose }) => {
|
|||
|
||||
return (
|
||||
<Form className={styles.wrapper} onSubmit={handleSubmit}>
|
||||
<TextArea
|
||||
{...clickAwayProps} // eslint-disable-line react/jsx-props-no-spreading
|
||||
ref={handleNameFieldRef}
|
||||
as={TextareaAutosize}
|
||||
name="name"
|
||||
value={data.name}
|
||||
placeholder={t('common.enterTaskDescription')}
|
||||
maxLength={1024}
|
||||
minRows={2}
|
||||
spellCheck={false}
|
||||
className={styles.field}
|
||||
onKeyDown={handleFieldKeyDown}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
{isLinkingToCard ? (
|
||||
<Dropdown
|
||||
fluid
|
||||
selection
|
||||
search
|
||||
ref={handleFieldRef}
|
||||
name="linkedCardId"
|
||||
options={cards.map((card) => ({
|
||||
text: card.name,
|
||||
value: card.id,
|
||||
}))}
|
||||
value={data.linkedCardId}
|
||||
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}>
|
||||
<Button
|
||||
{...clickAwayProps} // eslint-disable-line react/jsx-props-no-spreading
|
||||
positive
|
||||
ref={handleButtonRef}
|
||||
ref={handleSubmitButtonRef}
|
||||
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>
|
||||
</Form>
|
||||
);
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
|
||||
:global(#app) {
|
||||
.controls {
|
||||
clear: both;
|
||||
display: flex;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
|
@ -18,9 +18,41 @@
|
|||
line-height: 1.5;
|
||||
font-size: 14px;
|
||||
margin-bottom: 4px;
|
||||
overflow: hidden;
|
||||
padding: 8px 12px;
|
||||
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 {
|
||||
|
|
|
@ -3,13 +3,14 @@
|
|||
* 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 { useDispatch } from 'react-redux';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
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 selectors from '../../../../selectors';
|
||||
import entryActions from '../../../../entry-actions';
|
||||
import { useSteps } from '../../../../hooks';
|
||||
import ConfirmationStep from '../../../common/ConfirmationStep';
|
||||
|
@ -21,6 +22,10 @@ const StepTypes = {
|
|||
};
|
||||
|
||||
const ActionsStep = React.memo(({ taskId, onNameEdit, onClose }) => {
|
||||
const selectTaskById = useMemo(() => selectors.makeSelectTaskById(), []);
|
||||
|
||||
const task = useSelector((state) => selectTaskById(state, taskId));
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const [t] = useTranslation();
|
||||
const [step, openStep, handleBack] = useSteps();
|
||||
|
@ -59,12 +64,16 @@ const ActionsStep = React.memo(({ taskId, onNameEdit, onClose }) => {
|
|||
</Popup.Header>
|
||||
<Popup.Content>
|
||||
<Menu secondary vertical className={styles.menu}>
|
||||
<Menu.Item className={styles.menuItem} onClick={handleEditNameClick}>
|
||||
{t('action.editDescription', {
|
||||
context: 'title',
|
||||
})}
|
||||
</Menu.Item>
|
||||
{!task.linkedCardId && (
|
||||
<Menu.Item className={styles.menuItem} onClick={handleEditNameClick}>
|
||||
<Icon name="align left" className={styles.menuItemIcon} />
|
||||
{t('action.editDescription', {
|
||||
context: 'title',
|
||||
})}
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Item className={styles.menuItem} onClick={handleDeleteClick}>
|
||||
<Icon name="trash alternate outline" className={styles.menuItemIcon} />
|
||||
{t('action.deleteTask', {
|
||||
context: 'title',
|
||||
})}
|
||||
|
|
|
@ -13,4 +13,9 @@
|
|||
margin: 0;
|
||||
padding-left: 14px;
|
||||
}
|
||||
|
||||
.menuItemIcon {
|
||||
float: left;
|
||||
margin: 0 0.5em 0 0;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -79,7 +79,6 @@ const EditName = React.memo(({ taskId, onClose }) => {
|
|||
value={value}
|
||||
maxLength={1024}
|
||||
minRows={2}
|
||||
spellCheck={false}
|
||||
className={styles.field}
|
||||
onKeyDown={handleFieldKeyDown}
|
||||
onChange={handleFieldChange}
|
||||
|
|
|
@ -8,6 +8,7 @@ import ReactDOM from 'react-dom';
|
|||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Draggable } from 'react-beautiful-dnd';
|
||||
import { Button, Checkbox, Icon } from 'semantic-ui-react';
|
||||
import { useDidUpdate } from '../../../../lib/hooks';
|
||||
|
@ -18,6 +19,7 @@ import { usePopupInClosableContext } from '../../../../hooks';
|
|||
import { isListArchiveOrTrash } from '../../../../utils/record-helpers';
|
||||
import { BoardMembershipRoles } from '../../../../constants/Enums';
|
||||
import { ClosableContext } from '../../../../contexts';
|
||||
import Paths from '../../../../constants/Paths';
|
||||
import EditName from './EditName';
|
||||
import SelectAssigneeStep from './SelectAssigneeStep';
|
||||
import ActionsStep from './ActionsStep';
|
||||
|
@ -28,10 +30,15 @@ import styles from './Task.module.scss';
|
|||
|
||||
const Task = React.memo(({ id, index }) => {
|
||||
const selectTaskById = useMemo(() => selectors.makeSelectTaskById(), []);
|
||||
const selectLinkedCardById = useMemo(() => selectors.makeSelectCardById(), []);
|
||||
const selectListById = useMemo(() => selectors.makeSelectListById(), []);
|
||||
|
||||
const task = useSelector((state) => selectTaskById(state, id));
|
||||
|
||||
const linkedCard = useSelector(
|
||||
(state) => task.linkedCardId && selectLinkedCardById(state, task.linkedCardId),
|
||||
);
|
||||
|
||||
const { canEdit, canToggle } = useSelector((state) => {
|
||||
const { listId } = selectors.selectCurrentCard(state);
|
||||
const list = selectListById(state, listId);
|
||||
|
@ -86,10 +93,10 @@ const Task = React.memo(({ id, index }) => {
|
|||
const isEditable = task.isPersisted && canEdit;
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (isEditable) {
|
||||
if (!task.linkedCardId && isEditable) {
|
||||
setIsEditNameOpened(true);
|
||||
}
|
||||
}, [isEditable]);
|
||||
}, [task.linkedCardId, isEditable]);
|
||||
|
||||
const handleNameEdit = useCallback(() => {
|
||||
setIsEditNameOpened(true);
|
||||
|
@ -99,6 +106,10 @@ const Task = React.memo(({ id, index }) => {
|
|||
setIsEditNameOpened(false);
|
||||
}, []);
|
||||
|
||||
const handleLinkClick = useCallback((event) => {
|
||||
event.stopPropagation();
|
||||
}, []);
|
||||
|
||||
useDidUpdate(() => {
|
||||
setIsClosableActive(isEditNameOpened);
|
||||
}, [isEditNameOpened]);
|
||||
|
@ -123,7 +134,7 @@ const Task = React.memo(({ id, index }) => {
|
|||
<span className={styles.checkboxWrapper}>
|
||||
<Checkbox
|
||||
checked={task.isCompleted}
|
||||
disabled={!task.isPersisted || !canToggle}
|
||||
disabled={!!task.linkedCardId || !task.isPersisted || !canToggle}
|
||||
className={styles.checkbox}
|
||||
onChange={handleToggleChange}
|
||||
/>
|
||||
|
@ -135,36 +146,69 @@ const Task = React.memo(({ id, index }) => {
|
|||
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,
|
||||
jsx-a11y/no-static-element-interactions */}
|
||||
<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}
|
||||
>
|
||||
<span
|
||||
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>
|
||||
{(task.assigneeUserId || isEditable) && (
|
||||
<div className={classNames(styles.actions, isEditable && styles.actionsEditable)}>
|
||||
{isEditable ? (
|
||||
<>
|
||||
<SelectAssigneePopup
|
||||
currentUserId={task.assigneeUserId}
|
||||
onUserSelect={handleUserSelect}
|
||||
onUserDeselect={handleUserDeselect}
|
||||
>
|
||||
{task.assigneeUserId ? (
|
||||
<UserAvatar
|
||||
id={task.assigneeUserId}
|
||||
size="tiny"
|
||||
className={styles.assigneeUserAvatar}
|
||||
/>
|
||||
) : (
|
||||
<Button className={styles.button}>
|
||||
<Icon fitted name="add user" size="small" />
|
||||
</Button>
|
||||
)}
|
||||
</SelectAssigneePopup>
|
||||
{!task.linkedCardId && (
|
||||
<SelectAssigneePopup
|
||||
currentUserId={task.assigneeUserId}
|
||||
onUserSelect={handleUserSelect}
|
||||
onUserDeselect={handleUserDeselect}
|
||||
>
|
||||
{task.assigneeUserId ? (
|
||||
<UserAvatar
|
||||
id={task.assigneeUserId}
|
||||
size="tiny"
|
||||
className={styles.assigneeUserAvatar}
|
||||
/>
|
||||
) : (
|
||||
<Button className={styles.button}>
|
||||
<Icon fitted name="add user" size="small" />
|
||||
</Button>
|
||||
)}
|
||||
</SelectAssigneePopup>
|
||||
)}
|
||||
<ActionsPopup taskId={id} onNameEdit={handleNameEdit}>
|
||||
<Button className={styles.button}>
|
||||
<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 {
|
||||
display: inline-block;
|
||||
overflow: hidden;
|
||||
overflow-wrap: break-word;
|
||||
padding: 8px 0;
|
||||
vertical-align: top;
|
||||
white-space: pre-wrap;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.taskCompleted {
|
||||
color: #aaa;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.text {
|
||||
background: transparent;
|
||||
border-radius: 3px;
|
||||
|
@ -80,11 +92,22 @@
|
|||
min-height: 32px;
|
||||
padding: 0 34px 0 40px;
|
||||
width: 100%;
|
||||
|
||||
&.textLinked {
|
||||
padding-right: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.textEditable {
|
||||
cursor: pointer;
|
||||
padding-right: 68px;
|
||||
|
||||
&.textLinked {
|
||||
padding-right: 34px;
|
||||
}
|
||||
}
|
||||
|
||||
.textPointable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
|
|
|
@ -14,7 +14,9 @@ import styles from './AboutPane.module.scss';
|
|||
|
||||
const AboutPane = React.memo(() => (
|
||||
<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>
|
||||
</Tab.Pane>
|
||||
));
|
||||
|
|
|
@ -7,7 +7,7 @@ import React, { useCallback } from 'react';
|
|||
import PropTypes from 'prop-types';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
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 selectors from '../../../selectors';
|
||||
|
@ -31,15 +31,15 @@ const UserStep = React.memo(({ onClose }) => {
|
|||
onClose();
|
||||
}, [onClose, dispatch]);
|
||||
|
||||
const handleLogoutClick = useCallback(() => {
|
||||
dispatch(entryActions.logout());
|
||||
}, [dispatch]);
|
||||
|
||||
const handleAdministrationClick = useCallback(() => {
|
||||
dispatch(entryActions.openAdministrationModal());
|
||||
onClose();
|
||||
}, [onClose, dispatch]);
|
||||
|
||||
const handleLogoutClick = useCallback(() => {
|
||||
dispatch(entryActions.logout());
|
||||
}, [dispatch]);
|
||||
|
||||
let logoutMenuItemProps;
|
||||
if (isLogouting) {
|
||||
logoutMenuItemProps = {
|
||||
|
@ -61,29 +61,30 @@ const UserStep = React.memo(({ onClose }) => {
|
|||
<Popup.Content>
|
||||
<Menu secondary vertical className={styles.menu}>
|
||||
<Menu.Item className={styles.menuItem} onClick={handleSettingsClick}>
|
||||
<Icon name="user" className={styles.menuItemIcon} />
|
||||
{t('common.settings', {
|
||||
context: 'title',
|
||||
})}
|
||||
</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
|
||||
{...logoutMenuItemProps} // eslint-disable-line react/jsx-props-no-spreading
|
||||
className={styles.menuItem}
|
||||
onClick={handleLogoutClick}
|
||||
>
|
||||
<Icon name="log out" className={styles.menuItemIcon} />
|
||||
{t('action.logOut', {
|
||||
context: 'title',
|
||||
})}
|
||||
</Menu.Item>
|
||||
{withAdministration && (
|
||||
<>
|
||||
<hr className={styles.divider} />
|
||||
<Menu.Item className={styles.menuItem} onClick={handleAdministrationClick}>
|
||||
{t('common.administration', {
|
||||
context: 'title',
|
||||
})}
|
||||
</Menu.Item>
|
||||
</>
|
||||
)}
|
||||
</Menu>
|
||||
</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