mirror of
https://github.com/plankanban/planka.git
synced 2025-12-10 17:23:16 +03:00
Compare commits
208 Commits
v2.0.0-rc.
...
95ae3affd9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95ae3affd9 | ||
|
|
19c201cb1b | ||
|
|
d472da7523 | ||
|
|
f030b78f82 | ||
|
|
b94759d399 | ||
|
|
fedee157c7 | ||
|
|
68e5c33418 | ||
|
|
f7a09b2eca | ||
|
|
c058df8fc4 | ||
|
|
0023c63be8 | ||
|
|
26b3cffdab | ||
|
|
bf2ab4649e | ||
|
|
54e230d4c1 | ||
|
|
7be2343076 | ||
|
|
c646f0f5b3 | ||
|
|
8a288d1816 | ||
|
|
197ebc16db | ||
|
|
5c787d65a9 | ||
|
|
7e41a0167d | ||
|
|
c68ab99bfe | ||
|
|
561d2c77d4 | ||
|
|
6e44b2ad19 | ||
|
|
cd5685049e | ||
|
|
015562d8da | ||
|
|
7da8d8219b | ||
|
|
4ed0f02657 | ||
|
|
0736b8fea0 | ||
|
|
7a05bf2a30 | ||
|
|
d7cb2087e5 | ||
|
|
22baf5ab12 | ||
|
|
33a7a6ccb1 | ||
|
|
b4cbd32bf2 | ||
|
|
5a2564f575 | ||
|
|
3edcc5ae61 | ||
|
|
d99e949d07 | ||
|
|
f5000e8c40 | ||
|
|
d385a1c238 | ||
|
|
0d8edbbbc6 | ||
|
|
04e5124dfd | ||
|
|
3a84fb2d52 | ||
|
|
232a760a3d | ||
|
|
2f873dc82f | ||
|
|
c35e720265 | ||
|
|
fee300908c | ||
|
|
20d8f8ae53 | ||
|
|
39f970689a | ||
|
|
d8d4558b8e | ||
|
|
e080d30b32 | ||
|
|
1df346d590 | ||
|
|
35f33a38ff | ||
|
|
f968e1976b | ||
|
|
ab0dbc4b7f | ||
|
|
a52b41207a | ||
|
|
18104a486f | ||
|
|
213f6cddc9 | ||
|
|
393fdbc6d1 | ||
|
|
c6f4dcdb70 | ||
|
|
3a12bb7457 | ||
|
|
4985643e68 | ||
|
|
709dd91e82 | ||
|
|
97ddefc236 | ||
|
|
33eef6e068 | ||
|
|
230b31914c | ||
|
|
98f537c2bc | ||
|
|
c4e2221444 | ||
|
|
b4979d214f | ||
|
|
494800d131 | ||
|
|
dca4b65921 | ||
|
|
9a81c83f03 | ||
|
|
2c716a53c9 | ||
|
|
5636cf27c0 | ||
|
|
68549d0da8 | ||
|
|
4910600e6c | ||
|
|
7fea8f5362 | ||
|
|
5ad3134519 | ||
|
|
e6b4c33542 | ||
|
|
984b789e2e | ||
|
|
b47a337a4f | ||
|
|
5dc783c5b5 | ||
|
|
37bd4d1349 | ||
|
|
7da241c5a3 | ||
|
|
203fbd152d | ||
|
|
eb259a1482 | ||
|
|
9683227fbc | ||
|
|
5f34a737bb | ||
|
|
069813e49d | ||
|
|
fc8f434843 | ||
|
|
2b5068de21 | ||
|
|
d792e4064b | ||
|
|
6863f557a3 | ||
|
|
21033ad8bc | ||
|
|
4c5c7799f2 | ||
|
|
60a94f33fc | ||
|
|
4d77a1f596 | ||
|
|
2f4bcb0583 | ||
|
|
bb40a22563 | ||
|
|
22cde183ff | ||
|
|
168e556f06 | ||
|
|
2fc7d672f6 | ||
|
|
3dab4b8bea | ||
|
|
d3cf23d3aa | ||
|
|
a22afd0bed | ||
|
|
12c40d8ebe | ||
|
|
4d85b46e8f | ||
|
|
6515877eb6 | ||
|
|
cbb00d1d59 | ||
|
|
b4f6e24442 | ||
|
|
48e261ffc4 | ||
|
|
0049ba2145 | ||
|
|
a37a6b45b1 | ||
|
|
c272a65015 | ||
|
|
fb8a4a1c45 | ||
|
|
e2a9b30f46 | ||
|
|
4effc0ce23 | ||
|
|
001621eec7 | ||
|
|
5beafca07e | ||
|
|
d40b4d3224 | ||
|
|
ef35ec279e | ||
|
|
6dfb5ba024 | ||
|
|
df023439c1 | ||
|
|
e742cb4737 | ||
|
|
d8fbf2f909 | ||
|
|
fc9c94b3b6 | ||
|
|
2e0483d9e9 | ||
|
|
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 |
@@ -1,33 +1,34 @@
|
||||
*/README.md
|
||||
*/.gitignore
|
||||
*/node_modules
|
||||
|
||||
**/.DS_Store
|
||||
|
||||
server/**/.gitkeep
|
||||
*/node_modules
|
||||
|
||||
server/swagger.json
|
||||
server/.env
|
||||
server/.editorconfig
|
||||
server/.eslintignore
|
||||
server/.npmrc
|
||||
server/test
|
||||
|
||||
server/dist
|
||||
server/logs
|
||||
server/.venv
|
||||
server/test
|
||||
server/.tmp
|
||||
server/.venv
|
||||
|
||||
server/views/*
|
||||
!server/views/.gitkeep
|
||||
|
||||
server/public/*
|
||||
!server/public/preloaded-favicons
|
||||
!server/public/favicons
|
||||
server/public/favicons/*
|
||||
!server/public/favicons/.gitkeep
|
||||
!server/public/user-avatars
|
||||
server/public/user-avatars/*
|
||||
!server/public/user-avatars/.gitkeep
|
||||
!server/public/background-images
|
||||
server/public/background-images/*
|
||||
!server/public/background-images/.gitkeep
|
||||
|
||||
server/private/*
|
||||
!server/private/attachments
|
||||
server/private/attachments/*
|
||||
!server/private/attachments/.gitkeep
|
||||
|
||||
client/dist
|
||||
|
||||
3
.github/ISSUE_TEMPLATE/2-feature-request.yml
vendored
3
.github/ISSUE_TEMPLATE/2-feature-request.yml
vendored
@@ -5,11 +5,12 @@ body:
|
||||
- type: dropdown
|
||||
id: idea-type
|
||||
attributes:
|
||||
label: Is this a feature for the backend or frontend?
|
||||
label: Which part of the project does this feature apply to?
|
||||
multiple: true
|
||||
options:
|
||||
- Backend
|
||||
- Frontend
|
||||
- Chart
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
|
||||
@@ -15,12 +15,20 @@ jobs:
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18'
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Update npm
|
||||
run: npm install npm --global
|
||||
|
||||
- name: Install server dependencies
|
||||
run: npm install --omit=prod --ignore-scripts
|
||||
working-directory: ./server
|
||||
|
||||
- name: Build server
|
||||
run: npm run build
|
||||
working-directory: ./server
|
||||
|
||||
- name: Install client dependencies
|
||||
run: npm install --omit=dev
|
||||
working-directory: ./client
|
||||
@@ -29,24 +37,25 @@ jobs:
|
||||
run: DISABLE_ESLINT_PLUGIN=true npm run build
|
||||
working-directory: ./client
|
||||
|
||||
- name: Include server into dist
|
||||
run: mv server dist
|
||||
- name: Include licenses into dist
|
||||
run: |
|
||||
mv LICENSE.md server/dist
|
||||
mv "LICENSES/PLANKA Community License DE.md" server/dist/LICENSE_DE.md
|
||||
|
||||
- name: Include built client into dist
|
||||
run: |
|
||||
mv dist/* ../dist/public
|
||||
cp ../dist/public/index.html ../dist/views
|
||||
working-directory: ./client
|
||||
|
||||
- name: Include LICENSE.md, README.md, SECURITY.md into dist
|
||||
run: mv LICENSE.md README.md SECURITY.md dist
|
||||
mv ../../client/dist/* public
|
||||
cp public/index.html views
|
||||
working-directory: ./server/dist
|
||||
|
||||
- name: Create release package
|
||||
run: |
|
||||
mv dist planka
|
||||
zip -r planka-prebuild.zip planka
|
||||
working-directory: ./server
|
||||
|
||||
- name: Publish release package
|
||||
run: gh release upload ${{ github.event.release.tag_name }} planka-prebuild.zip
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: gh release upload ${{ github.event.release.tag_name }} planka-prebuild.zip
|
||||
working-directory: ./server
|
||||
|
||||
20
.github/workflows/build-and-test.yml
vendored
20
.github/workflows/build-and-test.yml
vendored
@@ -13,9 +13,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
env:
|
||||
POSTGRES_DB: planka
|
||||
POSTGRES_USER: user
|
||||
POSTGRES_PASSWORD: password
|
||||
POSTGRES_USERNAME: planka
|
||||
POSTGRES_PASSWORD: planka
|
||||
POSTGRES_DATABASE: planka
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -24,15 +24,15 @@ jobs:
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18'
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Set up PostgreSQL
|
||||
uses: ikalnytskyi/action-setup-postgres@v5
|
||||
with:
|
||||
database: ${{ env.POSTGRES_DB }}
|
||||
username: ${{ env.POSTGRES_USER }}
|
||||
username: ${{ env.POSTGRES_USERNAME }}
|
||||
password: ${{ env.POSTGRES_PASSWORD }}
|
||||
database: ${{ env.POSTGRES_DATABASE }}
|
||||
|
||||
- name: Cache Node.js modules
|
||||
uses: actions/cache@v3
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
client/tests/setup-symlinks.sh
|
||||
cd server
|
||||
cp .env.sample .env
|
||||
sed -i "s|^DATABASE_URL=.*|DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost/${POSTGRES_DB}|" .env
|
||||
sed -i "s|^DATABASE_URL=.*|DATABASE_URL=postgresql://${POSTGRES_USERNAME}:${POSTGRES_PASSWORD}@localhost/${POSTGRES_DATABASE}|" .env
|
||||
npm run db:init
|
||||
npm start --prod &
|
||||
|
||||
@@ -67,6 +67,12 @@ jobs:
|
||||
sudo apt-get install wait-for-it -y
|
||||
wait-for-it -h localhost -p 1337 -t 10
|
||||
|
||||
- name: Seed database with terms signature
|
||||
run: |
|
||||
TERMS_SIGNATURE=$(sha256sum terms/en-US/extended.md | awk '{print $1}')
|
||||
PGPASSWORD=$POSTGRES_PASSWORD psql -h localhost -U $POSTGRES_USERNAME -d $POSTGRES_DATABASE -c "UPDATE user_account SET terms_signature = '$TERMS_SIGNATURE';"
|
||||
working-directory: ./server
|
||||
|
||||
- name: Run UI tests
|
||||
run: |
|
||||
npx playwright install chromium
|
||||
|
||||
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
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,9 +1,9 @@
|
||||
node_modules
|
||||
docker-compose.override.yml
|
||||
|
||||
.idea
|
||||
.DS_Store
|
||||
|
||||
node_modules
|
||||
|
||||
# Prevent another lockfile than package-lock.json (npm) from being created
|
||||
# If some case you are using pnpm or yarn, don't forget to generate npm lockfile
|
||||
# before commiting your code by running:
|
||||
|
||||
34
Dockerfile
34
Dockerfile
@@ -1,27 +1,31 @@
|
||||
FROM node:18-alpine AS server-dependencies
|
||||
# Stage 1: Server build
|
||||
FROM node:22-alpine AS server
|
||||
|
||||
RUN apk -U upgrade \
|
||||
&& apk add build-base python3 --no-cache
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY server/package.json server/package-lock.json server/requirements.txt ./
|
||||
COPY server .
|
||||
|
||||
RUN npm install npm --global \
|
||||
&& npm install --omit=dev
|
||||
&& npm install \
|
||||
&& npm run build \
|
||||
&& npm prune --production
|
||||
|
||||
FROM node:lts AS client
|
||||
# Stage 2: Client build
|
||||
FROM node:22 AS client
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY client .
|
||||
|
||||
RUN npm install npm --global \
|
||||
&& npm install --omit=dev
|
||||
&& npm install --omit=dev \
|
||||
&& DISABLE_ESLINT_PLUGIN=true npm run build
|
||||
|
||||
RUN DISABLE_ESLINT_PLUGIN=true npm run build
|
||||
|
||||
FROM node:18-alpine
|
||||
# Stage 3: Final image
|
||||
FROM node:22-alpine
|
||||
|
||||
RUN apk -U upgrade \
|
||||
&& apk add bash python3 --no-cache \
|
||||
@@ -30,18 +34,20 @@ RUN apk -U upgrade \
|
||||
USER node
|
||||
WORKDIR /app
|
||||
|
||||
COPY --chown=node:node server .
|
||||
COPY --chown=node:node LICENSE.md .
|
||||
COPY --chown=node:node ["LICENSES/PLANKA Community License DE.md", "LICENSE_DE.md"]
|
||||
|
||||
COPY --from=server --chown=node:node /app/node_modules node_modules
|
||||
COPY --from=server --chown=node:node /app/dist .
|
||||
|
||||
COPY --from=client --chown=node:node /app/dist public
|
||||
COPY --from=client --chown=node:node /app/dist/index.html views
|
||||
|
||||
RUN python3 -m venv .venv \
|
||||
&& .venv/bin/pip3 install -r requirements.txt --no-cache-dir \
|
||||
&& mv .env.sample .env \
|
||||
&& npm config set update-notifier false
|
||||
|
||||
COPY --from=server-dependencies --chown=node:node /app/node_modules node_modules
|
||||
|
||||
COPY --from=client --chown=node:node /app/dist public
|
||||
COPY --from=client --chown=node:node /app/dist/index.html views
|
||||
|
||||
VOLUME /app/public/favicons
|
||||
VOLUME /app/public/user-avatars
|
||||
VOLUME /app/public/background-images
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
**PLANKA Commercial License**
|
||||
|
||||
Version 1.1 - Zuletzt aktualisiert: 20. Mai 2025
|
||||
Version 1.2 - Zuletzt aktualisiert: 28. Nov 2025
|
||||
|
||||
Zugehörige Dateien in Englisch:
|
||||
|
||||
@@ -20,7 +20,7 @@ Zugehörige Dateien in Deutsch:
|
||||
|
||||
Copyright (c) 2025 bis heute von PLANKA Software GmbH.
|
||||
|
||||
Unsere Software und zugehörige Dokumentationsdateien (die "Software") dürfen nur dann produktiv genutzt werden, wenn Sie (und jede juristische Person, die Sie vertreten) eine gültige "PLANKA Pro/Enterprise-Lizenz" besitzen, die Ihrer Nutzung entspricht. Vorbehaltlich des vorstehenden Satzes steht es Ihnen frei, unsere Software zu modifizieren und Patches dafür zu veröffentlichen. Sie stimmen zu, dass die PLANKA Software GmbH und/oder ihre Lizenzgeber (falls zutreffend) alle Rechte, Titel und Ansprüche an und auf alle solche Modifikationen und/oder Patches behalten, und alle solche Modifikationen und/oder Patches dürfen nur mit einer gültigen "PLANKA Pro/Enterprise-Lizenz" für die entsprechende Nutzung verwendet, kopiert, modifiziert, angezeigt, verteilt oder anderweitig genutzt werden. Ungeachtet des Vorstehenden dürfen Sie die Software für Entwicklungs- und Testzwecke ohne Abonnement kopieren und modifizieren. Sie stimmen zu, dass PLANKA Software GmbH und/oder ihre Lizenzgeber (falls zutreffend) alle Rechte, Titel und Ansprüche an und auf alle solche Modifikationen behalten. Es werden Ihnen keine anderen Rechte gewährt als die, die hier ausdrücklich genannt sind. Vorbehaltlich des Vorstehenden ist es verboten, die Software zu kopieren, zusammenzuführen, zu veröffentlichen, zu verteilen, zu unterlizenzieren und/oder zu verkaufen.
|
||||
Unsere Software und zugehörige Dokumentationsdateien (die "Software") dürfen nur dann produktiv genutzt werden, wenn Sie (und jede juristische Person, die Sie vertreten) eine gültige "PLANKA Pro/Enterprise-Lizenz" besitzen, die Ihrer Nutzung entspricht. Sie stimmen zu, dass die PLANKA Software GmbH und/oder ihre Lizenzgeber (falls zutreffend) alle Rechte, Titel und Ansprüche an und auf alle solche Modifikationen und/oder Patches behalten, und alle solche Modifikationen und/oder Patches dürfen nur mit einer gültigen "PLANKA Pro/Enterprise-Lizenz" für die entsprechende Nutzung verwendet, kopiert, modifiziert, angezeigt, verteilt oder anderweitig genutzt werden. Ungeachtet des Vorstehenden dürfen Sie die Software für Entwicklungs- und Testzwecke ohne Abonnement kopieren und modifizieren. Sie stimmen zu, dass PLANKA Software GmbH und/oder ihre Lizenzgeber (falls zutreffend) alle Rechte, Titel und Ansprüche an und auf alle solche Modifikationen behalten. Es werden Ihnen keine anderen Rechte gewährt als die, die hier ausdrücklich genannt sind. Vorbehaltlich des Vorstehenden ist es verboten, die Software zu kopieren, zusammenzuführen, zu veröffentlichen, zu verteilen, zu unterlizenzieren und/oder zu verkaufen.
|
||||
|
||||
#### Komponenten von Drittanbietern
|
||||
|
||||
@@ -28,7 +28,11 @@ Für alle Komponenten von Drittanbietern, die in unsere Software integriert sind
|
||||
|
||||
## PLANKA Pro/Enterprise Repositories
|
||||
|
||||
Nach dem Kauf unserer "PLANKA Pro/Enterprise-Lizenz" erhalten Sie Zugang zu unseren "PLANKA Pro/Enterprise"-Repositories. Hier finden Sie unsere neuesten stabilen Builds, die umfangreiche, eingehende Tests bestanden haben und als kampferprobt gelten. Unter keinen Umständen dürfen Sie Dateien, Quellcode oder Teile aus unseren "PLANKA Pro/Enterprise"-Repositories ohne vorherige Genehmigung der PLANKA Software GmbH an Personen weitergeben, die nicht zugangsberechtigt sind.
|
||||
Nach dem Erwerb einer "PLANKA Pro/Enterprise-Lizenz" erhalten Sie Zugang zu unseren "PLANKA Pro/Enterprise"-Repositories. Dort finden Sie unsere neuesten stabilen Builds, die umfangreiche Tests durchlaufen haben und als produktionsreif gelten.
|
||||
|
||||
Wichtiger Hinweis zum Zugriffsumfang: Der Standardzugang umfasst ausschließlich die vorkompilierten Versionen unserer Software. Zugang zum Quellcode wird nur in Ausnahmefällen und nach gesonderter Vereinbarung mit PLANKA Software GmbH gewährt.
|
||||
|
||||
Unabhängig vom Zugriffsumfang gilt: Die Weitergabe von Dateien, Quellcode oder Teilen davon aus unseren "PLANKA Pro/Enterprise"-Repositories an Dritte, die nicht zugriffsberechtigt sind, ist ohne vorherige schriftliche Genehmigung von PLANKA Software GmbH untersagt.
|
||||
|
||||
## Eingeschränkte Garantie
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
**PLANKA Commercial License**
|
||||
|
||||
Version 1.1 - Last updated: May 20, 2025
|
||||
Version 1.2 - Last updated: Nov 28, 2025
|
||||
|
||||
Related files in English:
|
||||
|
||||
@@ -20,7 +20,7 @@ Related files in German:
|
||||
|
||||
Copyright (c) 2025 to present by PLANKA Software GmbH.
|
||||
|
||||
Our software and associated documentation files (the "Software") may only be used in production if you (and any entity that you represent) hold a valid "PLANKA Pro/Enterprise License" corresponding to your usage. Subject to the foregoing sentence, you are free to modify our Software and publish patches for it. You agree that PLANKA Software GmbH and/or its licensors (as applicable) retain all right, title, and interest in and to all such modifications and/or patches, and all such modifications and/or patches may only be used, copied, modified, displayed, distributed, or otherwise exploited with a valid "PLANKA Pro/Enterprise License" for the corresponding usage. Notwithstanding the foregoing, you may copy and modify the Software for development and testing purposes without requiring a subscription. You agree that PLANKA Software GmbH and/or its licensors (as applicable) retain all right, title, and interest in and to all such modifications. You are not granted any other rights beyond what is expressly stated herein. Subject to the foregoing, it is forbidden to copy, merge, publish, distribute, sublicense, and/or sell the Software.
|
||||
Our software and associated documentation files (the "Software") may only be used in production if you (and any entity that you represent) hold a valid "PLANKA Pro/Enterprise License" corresponding to your usage. You agree that PLANKA Software GmbH and/or its licensors (as applicable) retain all right, title, and interest in and to all such modifications and/or patches, and all such modifications and/or patches may only be used, copied, modified, displayed, distributed, or otherwise exploited with a valid "PLANKA Pro/Enterprise License" for the corresponding usage. Notwithstanding the foregoing, you may copy and modify the Software for development and testing purposes without requiring a subscription. You agree that PLANKA Software GmbH and/or its licensors (as applicable) retain all right, title, and interest in and to all such modifications. You are not granted any other rights beyond what is expressly stated herein. Subject to the foregoing, it is forbidden to copy, merge, publish, distribute, sublicense, and/or sell the Software.
|
||||
|
||||
#### Third-Party Components
|
||||
|
||||
@@ -28,7 +28,11 @@ For all third-party components incorporated into our Software, those components
|
||||
|
||||
## PLANKA Pro/Enterprise Repositories
|
||||
|
||||
After purchasing our "PLANKA Pro/Enterprise License", you get access to our "PLANKA Pro/Enterprise" repositories. Here you find our latest stable builds, which have passed extensive in-depth tests and are considered battle-proof. Under no circumstances are you allowed to pass files, source code, or any part of it from our "PLANKA Pro/Enterprise" repositories to anyone not eligible for access without prior permission from PLANKA Software GmbH.
|
||||
After purchasing a "PLANKA Pro/Enterprise License", you will receive access to our "PLANKA Pro/Enterprise" repositories. There you will find our latest stable builds, which have undergone extensive testing and are considered production-ready.
|
||||
|
||||
Important note on access scope: Standard access includes only the precompiled versions of our software. Access to the source code is granted only in exceptional cases and requires a separate agreement with PLANKA Software GmbH.
|
||||
|
||||
Regardless of access scope: The distribution of files, source code, or any parts thereof from our "PLANKA Pro/Enterprise" repositories to third parties who are not authorized for access is prohibited without prior written permission from PLANKA Software GmbH.
|
||||
|
||||
## Limited Warranty
|
||||
|
||||
|
||||
@@ -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,13 +15,13 @@ 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.1.1
|
||||
|
||||
# This is the version number of the application being deployed. This version number should be
|
||||
# incremented each time you make changes to the application. Versions are not expected to
|
||||
# follow Semantic Versioning. They should reflect the version the application is using.
|
||||
# It is recommended to use it with quotes.
|
||||
appVersion: "2.0.0-rc.3"
|
||||
appVersion: "2.0.0-rc.4"
|
||||
|
||||
dependencies:
|
||||
- alias: postgresql
|
||||
|
||||
@@ -109,3 +109,122 @@ If you want to host PLANKA for more than just playing around with, you might wan
|
||||
- Specify a password for `postgresql.auth.password` as there have been issues with the postgresql chart generating new passwords locking you out of the data you've already stored. (see [this issue](https://github.com/bitnami/charts/issues/2061))
|
||||
|
||||
Any questions or concerns, [raise an issue](https://github.com/Chris-Greaves/planka-helm-chart/issues/new).
|
||||
|
||||
## Advanced Configuration
|
||||
|
||||
### Extra Volume Mounts
|
||||
|
||||
The Helm chart supports mounting arbitrary ConfigMaps, Secrets, and Volumes to the PLANKA deployment using the `extraMounts` configuration. This is especially useful for scenarios like:
|
||||
|
||||
- Mounting custom CA certificates for OIDC with self-hosted identity providers
|
||||
- Adding custom configuration files
|
||||
- Mounting TLS certificates from existing secrets
|
||||
- Adding temporary or persistent storage volumes
|
||||
|
||||
**Note**: ConfigMaps and Secrets must be created separately before referencing them in `extraMounts`.
|
||||
|
||||
#### Basic Usage
|
||||
|
||||
Use the `extraMounts` section to mount any type of volume:
|
||||
|
||||
```yaml
|
||||
extraMounts:
|
||||
# Mount CA certificate from existing ConfigMap
|
||||
- name: ca-certs
|
||||
mountPath: /etc/ssl/certs/custom-ca.crt
|
||||
subPath: ca.crt
|
||||
readOnly: true
|
||||
configMap:
|
||||
name: ca-certificates # Must exist
|
||||
|
||||
# Mount TLS certificates from existing Secret
|
||||
- name: tls-certs
|
||||
mountPath: /etc/ssl/private
|
||||
readOnly: true
|
||||
secret:
|
||||
secretName: planka-tls-secret # Must exist
|
||||
items:
|
||||
- key: tls.crt
|
||||
path: server.crt
|
||||
- key: tls.key
|
||||
path: server.key
|
||||
|
||||
# Temporary storage
|
||||
- name: temp-storage
|
||||
mountPath: /tmp/planka-temp
|
||||
readOnly: false
|
||||
emptyDir:
|
||||
sizeLimit: 1Gi
|
||||
|
||||
# Host path mount
|
||||
- name: backup-storage
|
||||
mountPath: /var/lib/planka-backups
|
||||
readOnly: false
|
||||
hostPath:
|
||||
path: /var/lib/planka-backups
|
||||
type: DirectoryOrCreate
|
||||
|
||||
# NFS mount
|
||||
- name: nfs-storage
|
||||
mountPath: /shared/data
|
||||
readOnly: false
|
||||
nfs:
|
||||
server: nfs.example.com
|
||||
path: /exports/planka
|
||||
```
|
||||
|
||||
### OIDC with Self-Hosted Keycloak
|
||||
|
||||
A common use case is configuring OIDC with a self-hosted Keycloak instance that uses custom CA certificates.
|
||||
|
||||
First, create the CA certificate ConfigMap:
|
||||
```bash
|
||||
kubectl create configmap ca-certificates --from-file=ca.crt=/path/to/your/ca.crt
|
||||
```
|
||||
|
||||
Then configure the chart:
|
||||
```yaml
|
||||
# Mount custom CA certificate from existing ConfigMap
|
||||
extraMounts:
|
||||
- name: keycloak-ca
|
||||
mountPath: /etc/ssl/certs/keycloak-ca.crt
|
||||
subPath: ca.crt
|
||||
readOnly: true
|
||||
configMap:
|
||||
name: ca-certificates
|
||||
|
||||
# Configure Node.js to trust the custom CA
|
||||
extraEnv:
|
||||
- name: NODE_EXTRA_CA_CERTS
|
||||
value: "/etc/ssl/certs/keycloak-ca.crt"
|
||||
|
||||
# Enable OIDC
|
||||
oidc:
|
||||
enabled: true
|
||||
clientId: "planka-client"
|
||||
clientSecret: "your-client-secret"
|
||||
issuerUrl: "https://keycloak.example.com/realms/master"
|
||||
admin:
|
||||
roles:
|
||||
- "planka-admin"
|
||||
```
|
||||
|
||||
### Environment Variables from Secrets
|
||||
|
||||
You can reference values from existing secrets in environment variables:
|
||||
|
||||
```yaml
|
||||
extraEnv:
|
||||
- name: SMTP_PASSWORD
|
||||
valueFrom:
|
||||
secretName: smtp-credentials
|
||||
key: password
|
||||
- name: CUSTOM_API_KEY
|
||||
valueFrom:
|
||||
secretName: api-credentials
|
||||
key: api-key
|
||||
```
|
||||
|
||||
### Complete Example
|
||||
|
||||
See `values-example.yaml` for a comprehensive example that demonstrates all the advanced features including OIDC configuration with custom CA certificates.
|
||||
|
||||
@@ -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 }}
|
||||
@@ -67,6 +71,19 @@ spec:
|
||||
subPath: app-logs
|
||||
name: emptydir
|
||||
{{- end }}
|
||||
{{- /* Extra volume mounts */}}
|
||||
{{- range .Values.extraMounts }}
|
||||
- name: {{ .name }}
|
||||
mountPath: {{ .mountPath }}
|
||||
{{- if .subPath }}
|
||||
subPath: {{ .subPath }}
|
||||
{{- end }}
|
||||
{{- if hasKey . "readOnly" }}
|
||||
readOnly: {{ .readOnly }}
|
||||
{{- else }}
|
||||
readOnly: true
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
resources:
|
||||
{{- toYaml .Values.resources | nindent 12 }}
|
||||
env:
|
||||
@@ -196,4 +213,31 @@ spec:
|
||||
{{- if .Values.securityContext.readOnlyRootFilesystem }}
|
||||
- name: emptydir
|
||||
emptyDir: {}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- /* Extra volumes */}}
|
||||
{{- range .Values.extraMounts }}
|
||||
- name: {{ .name }}
|
||||
{{- if .configMap }}
|
||||
configMap:
|
||||
{{- toYaml .configMap | nindent 12 }}
|
||||
{{- else if .secret }}
|
||||
secret:
|
||||
{{- toYaml .secret | nindent 12 }}
|
||||
{{- else if .emptyDir }}
|
||||
emptyDir:
|
||||
{{- toYaml .emptyDir | nindent 12 }}
|
||||
{{- else if .hostPath }}
|
||||
hostPath:
|
||||
{{- toYaml .hostPath | nindent 12 }}
|
||||
{{- else if .persistentVolumeClaim }}
|
||||
persistentVolumeClaim:
|
||||
{{- toYaml .persistentVolumeClaim | nindent 12 }}
|
||||
{{- else if .nfs }}
|
||||
nfs:
|
||||
{{- toYaml .nfs | nindent 12 }}
|
||||
{{- else }}
|
||||
{{- /* Support any other volume type by removing known mount-specific keys */}}
|
||||
{{- $volume := omit . "name" "mountPath" "subPath" "readOnly" }}
|
||||
{{- toYaml $volume | nindent 10 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
@@ -18,6 +18,9 @@ metadata:
|
||||
name: {{ $fullName }}
|
||||
labels:
|
||||
{{- include "planka.labels" . | nindent 4 }}
|
||||
{{- with .Values.ingress.labels }}
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- with .Values.ingress.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
|
||||
@@ -27,6 +27,11 @@ existingSecretkeySecret: ""
|
||||
## NOTE: When it's set, the `admin_username` and `admin_password` parameters are ignored
|
||||
existingAdminCredsSecret: ""
|
||||
|
||||
admin_email: ""
|
||||
admin_password: ""
|
||||
admin_name: ""
|
||||
admin_username: ""
|
||||
|
||||
# Base url for PLANKA. Will override `ingress.hosts[0].host`
|
||||
# Defaults to `http://localhost:3000` if ingress is disabled.
|
||||
baseUrl: ""
|
||||
@@ -45,6 +50,9 @@ podAnnotations: {}
|
||||
podSecurityContext: {}
|
||||
# fsGroup: 2000
|
||||
|
||||
# Annotations to add to the deployment
|
||||
deploymentAnnotations: {}
|
||||
|
||||
securityContext: {}
|
||||
# capabilities:
|
||||
# drop:
|
||||
@@ -65,6 +73,7 @@ service:
|
||||
ingress:
|
||||
enabled: false
|
||||
className: ""
|
||||
labels: {}
|
||||
annotations: {}
|
||||
# kubernetes.io/ingress.class: nginx
|
||||
# kubernetes.io/tls-acme: "true"
|
||||
@@ -105,6 +114,11 @@ tolerations: []
|
||||
affinity: {}
|
||||
|
||||
postgresql:
|
||||
global:
|
||||
security:
|
||||
allowInsecureImages: true
|
||||
image:
|
||||
repository: bitnamilegacy/postgresql
|
||||
enabled: true
|
||||
auth:
|
||||
database: planka
|
||||
@@ -210,3 +224,65 @@ 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_TLS_REJECT_UNAUTHORIZED
|
||||
## value: "false"
|
||||
## - name: SMTP_USER
|
||||
## value: "your_email@example.com"
|
||||
## - name: SMTP_PASSWORD
|
||||
## value: "your_password"
|
||||
## - name: SMTP_FROM
|
||||
## value: "your_email@example.com"
|
||||
|
||||
## Extra volume mounts configuration
|
||||
## Mount ConfigMaps, Secrets, and arbitrary volumes to the PLANKA container
|
||||
## This allows mounting any pre-existing ConfigMaps, Secrets, or other volume types
|
||||
##
|
||||
extraMounts: []
|
||||
## Example extraMounts:
|
||||
## extraMounts:
|
||||
## - name: ca-certs
|
||||
## mountPath: /etc/ssl/certs/ca-certificates.crt
|
||||
## subPath: ca-bundle.crt
|
||||
## readOnly: true
|
||||
## configMap:
|
||||
## name: ca-certificates
|
||||
## - name: tls-certs
|
||||
## mountPath: /etc/ssl/private
|
||||
## readOnly: true
|
||||
## secret:
|
||||
## secretName: planka-tls
|
||||
## items:
|
||||
## - key: tls.crt
|
||||
## path: server.crt
|
||||
## - key: tls.key
|
||||
## path: server.key
|
||||
## - name: temp-storage
|
||||
## mountPath: /tmp/planka-temp
|
||||
## readOnly: false
|
||||
## emptyDir:
|
||||
## sizeLimit: 1Gi
|
||||
|
||||
## Example configuration for OIDC with self-hosted Keycloak using custom CA
|
||||
## (Requires pre-existing ConfigMap "ca-certificates")
|
||||
## extraMounts:
|
||||
## - name: keycloak-ca
|
||||
## mountPath: /etc/ssl/certs/keycloak-ca.crt
|
||||
## subPath: ca.crt
|
||||
## readOnly: true
|
||||
## configMap:
|
||||
## name: ca-certificates
|
||||
##
|
||||
## extraEnv:
|
||||
## - name: NODE_EXTRA_CA_CERTS
|
||||
## value: "/etc/ssl/certs/keycloak-ca.crt"
|
||||
|
||||
5272
client/package-lock.json
generated
5272
client/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
63
client/package.json
Executable file → Normal file
63
client/package.json
Executable file → Normal file
@@ -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.64.1",
|
||||
"@gravity-ui/components": "^4.16.0",
|
||||
"@gravity-ui/markdown-editor": "^15.25.0",
|
||||
"@gravity-ui/uikit": "^7.26.1",
|
||||
"@juggle/resize-observer": "^3.4.0",
|
||||
"@types/papaparse": "^5.3.16",
|
||||
"@vitejs/plugin-react": "^4.4.1",
|
||||
"@vitejs/plugin-react": "^4.7.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,20 +114,20 @@
|
||||
"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.6.3",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"initials": "^3.1.2",
|
||||
"javascript-time-ago": "^2.5.11",
|
||||
"javascript-time-ago": "^2.5.12",
|
||||
"js-cookie": "^3.0.5",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"linkify-react": "^4.3.1",
|
||||
"linkifyjs": "^4.3.1",
|
||||
"linkify-react": "^4.3.2",
|
||||
"linkifyjs": "^4.3.2",
|
||||
"lodash": "^4.17.21",
|
||||
"lowlight": "^3.3.0",
|
||||
"markdown-it": "^13.0.2",
|
||||
"nanoid": "^5.1.5",
|
||||
"nanoid": "^5.1.6",
|
||||
"papaparse": "^5.5.3",
|
||||
"patch-package": "^8.0.0",
|
||||
"patch-package": "^8.0.1",
|
||||
"photoswipe": "^5.4.4",
|
||||
"prop-types": "^15.8.1",
|
||||
"react": "18.2.0",
|
||||
@@ -136,49 +136,50 @@
|
||||
"react-dom": "18.2.0",
|
||||
"react-dropzone": "^14.3.8",
|
||||
"react-frame-component": "^5.2.7",
|
||||
"react-hot-toast": "^2.5.2",
|
||||
"react-i18next": "^15.5.1",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-i18next": "^15.7.4",
|
||||
"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.2",
|
||||
"react-textarea-autosize": "^8.5.9",
|
||||
"react-time-ago": "^7.3.3",
|
||||
"react-time-ago": "^7.3.5",
|
||||
"redux": "^4.2.1",
|
||||
"redux-logger": "^3.0.6",
|
||||
"redux-orm": "^0.16.2",
|
||||
"redux-saga": "^1.3.0",
|
||||
"redux-saga": "^1.4.2",
|
||||
"reselect": "^4.1.8",
|
||||
"sails.io.js": "^1.2.1",
|
||||
"sass-embedded": "^1.87.0",
|
||||
"sass-embedded": "^1.93.3",
|
||||
"semantic-ui-react": "^2.1.5",
|
||||
"socket.io-client": "^2.5.0",
|
||||
"validator": "^13.15.0",
|
||||
"vite": "^6.3.5",
|
||||
"validator": "^13.15.23",
|
||||
"vite": "^6.4.1",
|
||||
"vite-plugin-commonjs": "^0.10.4",
|
||||
"vite-plugin-node-polyfills": "^0.23.0",
|
||||
"vite-plugin-svgr": "^4.3.0",
|
||||
"vite-plugin-svgr": "^4.5.0",
|
||||
"zxcvbn": "^4.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/eslint-parser": "^7.27.1",
|
||||
"@babel/preset-env": "^7.27.2",
|
||||
"@cucumber/cucumber": "^11.2.0",
|
||||
"@babel/eslint-parser": "^7.28.5",
|
||||
"@babel/preset-env": "^7.28.5",
|
||||
"@cucumber/cucumber": "^11.3.0",
|
||||
"@cucumber/pretty-formatter": "^1.0.1",
|
||||
"@playwright/test": "^1.52.0",
|
||||
"@playwright/test": "^1.56.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-config-prettier": "^9.1.2",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-prettier": "^5.4.0",
|
||||
"eslint-plugin-prettier": "^5.5.4",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"jest": "^29.7.0",
|
||||
"playwright": "^1.52.0",
|
||||
"playwright": "^1.56.1",
|
||||
"prettier": "3.3.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
diff --git a/node_modules/@gravity-ui/markdown-editor/build/esm/bundle/wysiwyg-preset.js b/node_modules/@gravity-ui/markdown-editor/build/esm/bundle/wysiwyg-preset.js
|
||||
index 2152fd6..ceda0c1 100644
|
||||
index 84c7781..3b2f51c 100644
|
||||
--- a/node_modules/@gravity-ui/markdown-editor/build/esm/bundle/wysiwyg-preset.js
|
||||
+++ b/node_modules/@gravity-ui/markdown-editor/build/esm/bundle/wysiwyg-preset.js
|
||||
@@ -101,7 +101,6 @@ export const BundlePreset = (builder, opts) => {
|
||||
@@ -10,10 +10,10 @@ index 2152fd6..ceda0c1 100644
|
||||
deflist: {
|
||||
deflistTermPlaceholder: () => i18nPlaceholder('deflist_term'),
|
||||
deflistDescPlaceholder: () => i18nPlaceholder('deflist_desc'),
|
||||
@@ -118,11 +117,6 @@ export const BundlePreset = (builder, opts) => {
|
||||
...opts.yfmNote,
|
||||
@@ -122,11 +121,6 @@ export const BundlePreset = (builder, opts) => {
|
||||
...opts.yfmTable,
|
||||
controls: opts.mobile ? false : opts.yfmTable?.controls,
|
||||
},
|
||||
yfmTable: { yfmTableCellPlaceholder: () => i18nPlaceholder('table_cell'), ...opts.yfmTable },
|
||||
- yfmFile: {
|
||||
- fileUploadHandler: opts.fileUploadHandler,
|
||||
- needToSetDimensionsForUploadedImages: opts.needToSetDimensionsForUploadedImages,
|
||||
@@ -22,14 +22,27 @@ 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 212c583..b709383 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
|
||||
@@ -10,7 +10,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, lang: getConfig().lang || 'en' }))
|
||||
+ .configureMd((md) => md.use(yfmPlugin, { log, lang: getConfig().lang || 'en', 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
|
||||
index ed2a9db..77f6d08 100644
|
||||
--- a/node_modules/@gravity-ui/markdown-editor/build/esm/presets/yfm.js
|
||||
+++ b/node_modules/@gravity-ui/markdown-editor/build/esm/presets/yfm.js
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Deflist, Subscript, Superscript, Underline, } from "../extensions/markdown/index.js";
|
||||
-import { Checkbox, ImgSize, Monospace, Video, YfmConfigs, YfmCut, YfmFile, YfmHeading, YfmNote, YfmTable, YfmTabs, } from "../extensions/yfm/index.js";
|
||||
+import { ImgSize, Monospace, Video, YfmConfigs, YfmCut, YfmHeading, YfmNote, YfmTable } from "../extensions/yfm/index.js";
|
||||
+import { ImgSize, Monospace, Video, YfmConfigs, YfmCut, YfmHeading, YfmNote, YfmTable, } from "../extensions/yfm/index.js";
|
||||
import { DefaultPreset } from "./default.js";
|
||||
export const YfmPreset = (builder, opts) => {
|
||||
builder.use(DefaultPreset, { ...opts, image: false, heading: false });
|
||||
24
client/patches/react-mentions+4.4.10.patch
Normal file
24
client/patches/react-mentions+4.4.10.patch
Normal file
@@ -0,0 +1,24 @@
|
||||
diff --git a/node_modules/react-mentions/dist/react-mentions.esm.js b/node_modules/react-mentions/dist/react-mentions.esm.js
|
||||
index 2efebba..b244446 100644
|
||||
--- a/node_modules/react-mentions/dist/react-mentions.esm.js
|
||||
+++ b/node_modules/react-mentions/dist/react-mentions.esm.js
|
||||
@@ -1426,7 +1426,7 @@ var MentionsInput = /*#__PURE__*/function (_React$Component) {
|
||||
|
||||
var mentions = getMentions(newValue, config);
|
||||
|
||||
- if (ev.nativeEvent.isComposing && selectionStart === selectionEnd) {
|
||||
+ if ((ev.nativeEvent.isComposing || newValue.length < value.length) && selectionStart === selectionEnd) {
|
||||
_this.updateMentionsQueries(_this.inputElement.value, selectionStart);
|
||||
} // Propagate change
|
||||
// let handleChange = this.getOnChange(this.props) || emptyFunction;
|
||||
@@ -1454,7 +1454,9 @@ var MentionsInput = /*#__PURE__*/function (_React$Component) {
|
||||
var el = _this.inputElement;
|
||||
|
||||
if (ev.target.selectionStart === ev.target.selectionEnd) {
|
||||
- _this.updateMentionsQueries(el.value, ev.target.selectionStart);
|
||||
+ requestAnimationFrame(function () {
|
||||
+ _this.updateMentionsQueries(el.value, ev.target.selectionStart);
|
||||
+ });
|
||||
} else {
|
||||
_this.clearSuggestions();
|
||||
} // sync highlighters scroll position
|
||||
13
client/patches/react-photoswipe-gallery+2.2.7.patch
Normal file
13
client/patches/react-photoswipe-gallery+2.2.7.patch
Normal file
@@ -0,0 +1,13 @@
|
||||
diff --git a/node_modules/react-photoswipe-gallery/dist/gallery.js b/node_modules/react-photoswipe-gallery/dist/gallery.js
|
||||
index 53cc02c..f4baccb 100644
|
||||
--- a/node_modules/react-photoswipe-gallery/dist/gallery.js
|
||||
+++ b/node_modules/react-photoswipe-gallery/dist/gallery.js
|
||||
@@ -181,7 +181,7 @@ export const Gallery = ({
|
||||
alt
|
||||
} = pswpInstance.currSlide.data;
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
- el.innerHTML = caption || alt || '';
|
||||
+ el.textContent = caption || alt || '';
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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,113 @@ 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..0e178ee 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/dist/es/modules/Modal/Modal.js b/node_modules/semantic-ui-react/dist/es/modules/Modal/Modal.js
|
||||
index a39f694..c9c616a 100644
|
||||
--- a/node_modules/semantic-ui-react/dist/es/modules/Modal/Modal.js
|
||||
+++ b/node_modules/semantic-ui-react/dist/es/modules/Modal/Modal.js
|
||||
@@ -20,6 +20,7 @@ import ModalDescription from './ModalDescription';
|
||||
import ModalDimmer from './ModalDimmer';
|
||||
import ModalHeader from './ModalHeader';
|
||||
import { canFit, getLegacyStyles, isLegacy } from './utils';
|
||||
+var IS_WINDOWS = navigator.platform.startsWith('Win');
|
||||
|
||||
/**
|
||||
* A modal displays content that temporarily blocks interactions with the main view of a site.
|
||||
@@ -41,6 +42,7 @@ var Modal = /*#__PURE__*/function (_Component) {
|
||||
_this.ref = /*#__PURE__*/createRef();
|
||||
_this.dimmerRef = /*#__PURE__*/createRef();
|
||||
_this.latestDocumentMouseDownEvent = null;
|
||||
+ _this.latestWindowFocusTimeOnWindows = null;
|
||||
|
||||
_this.getMountNode = function () {
|
||||
return isBrowser() ? _this.props.mountNode || document.body : null;
|
||||
@@ -68,11 +70,17 @@ var Modal = /*#__PURE__*/function (_Component) {
|
||||
}));
|
||||
};
|
||||
|
||||
+ _this.handleWindowFocusOnWindows = function () {
|
||||
+ _this.latestWindowFocusTimeOnWindows = Date.now();
|
||||
+ };
|
||||
+
|
||||
_this.handleDocumentMouseDown = function (e) {
|
||||
+ if (_this.latestWindowFocusTimeOnWindows && Date.now() - _this.latestWindowFocusTimeOnWindows < 50) return;
|
||||
_this.latestDocumentMouseDownEvent = e;
|
||||
};
|
||||
|
||||
_this.handleDocumentClick = function (e) {
|
||||
+ if (!_this.latestDocumentMouseDownEvent) return;
|
||||
var closeOnDimmerClick = _this.props.closeOnDimmerClick;
|
||||
var currentDocumentMouseDownEvent = _this.latestDocumentMouseDownEvent;
|
||||
_this.latestDocumentMouseDownEvent = null;
|
||||
@@ -116,6 +124,13 @@ var Modal = /*#__PURE__*/function (_Component) {
|
||||
|
||||
_this.setPositionAndClassNames();
|
||||
|
||||
+ if (IS_WINDOWS) {
|
||||
+ eventStack.sub('focus', _this.handleWindowFocusOnWindows, {
|
||||
+ pool: eventPool,
|
||||
+ target: window
|
||||
+ });
|
||||
+ }
|
||||
+
|
||||
eventStack.sub('mousedown', _this.handleDocumentMouseDown, {
|
||||
pool: eventPool,
|
||||
target: _this.dimmerRef.current
|
||||
@@ -131,6 +146,14 @@ var Modal = /*#__PURE__*/function (_Component) {
|
||||
_this.handlePortalUnmount = function (e) {
|
||||
var eventPool = _this.props.eventPool;
|
||||
cancelAnimationFrame(_this.animationRequestId);
|
||||
+
|
||||
+ if (IS_WINDOWS) {
|
||||
+ eventStack.unsub('focus', _this.handleWindowFocusOnWindows, {
|
||||
+ pool: eventPool,
|
||||
+ target: window
|
||||
+ });
|
||||
+ }
|
||||
+
|
||||
eventStack.unsub('mousedown', _this.handleDocumentMouseDown, {
|
||||
pool: eventPool,
|
||||
target: _this.dimmerRef.current
|
||||
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 |
59
client/src/actions/config.js
Normal file
59
client/src/actions/config.js
Normal file
@@ -0,0 +1,59 @@
|
||||
/*!
|
||||
* 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 updateConfig = (data) => ({
|
||||
type: ActionTypes.CONFIG_UPDATE,
|
||||
payload: {
|
||||
data,
|
||||
},
|
||||
});
|
||||
|
||||
updateConfig.success = (config) => ({
|
||||
type: ActionTypes.CONFIG_UPDATE__SUCCESS,
|
||||
payload: {
|
||||
config,
|
||||
},
|
||||
});
|
||||
|
||||
updateConfig.failure = (error) => ({
|
||||
type: ActionTypes.CONFIG_UPDATE__FAILURE,
|
||||
payload: {
|
||||
error,
|
||||
},
|
||||
});
|
||||
|
||||
const handleConfigUpdate = (config) => ({
|
||||
type: ActionTypes.CONFIG_UPDATE_HANDLE,
|
||||
payload: {
|
||||
config,
|
||||
},
|
||||
});
|
||||
|
||||
const testSmtpConfig = () => ({
|
||||
type: ActionTypes.SMTP_CONFIG_TEST,
|
||||
payload: {},
|
||||
});
|
||||
|
||||
testSmtpConfig.success = (logs) => ({
|
||||
type: ActionTypes.SMTP_CONFIG_TEST__SUCCESS,
|
||||
payload: {
|
||||
logs,
|
||||
},
|
||||
});
|
||||
|
||||
testSmtpConfig.failure = (error) => ({
|
||||
type: ActionTypes.SMTP_CONFIG_TEST__FAILURE,
|
||||
payload: {
|
||||
error,
|
||||
},
|
||||
});
|
||||
|
||||
export default {
|
||||
updateConfig,
|
||||
handleConfigUpdate,
|
||||
testSmtpConfig,
|
||||
};
|
||||
@@ -6,8 +6,10 @@
|
||||
import ActionTypes from '../constants/ActionTypes';
|
||||
|
||||
const initializeCore = (
|
||||
config,
|
||||
user,
|
||||
board,
|
||||
webhooks,
|
||||
users,
|
||||
projects,
|
||||
projectManagers,
|
||||
@@ -31,8 +33,10 @@ const initializeCore = (
|
||||
) => ({
|
||||
type: ActionTypes.CORE_INITIALIZE,
|
||||
payload: {
|
||||
config,
|
||||
user,
|
||||
board,
|
||||
webhooks,
|
||||
users,
|
||||
projects,
|
||||
projectManagers,
|
||||
@@ -56,10 +60,10 @@ const initializeCore = (
|
||||
},
|
||||
});
|
||||
|
||||
initializeCore.fetchConfig = (config) => ({
|
||||
type: ActionTypes.CORE_INITIALIZE__CONFIG_FETCH,
|
||||
initializeCore.fetchBootstrap = (bootstrap) => ({
|
||||
type: ActionTypes.CORE_INITIALIZE__BOOTSTRAP_FETCH,
|
||||
payload: {
|
||||
config,
|
||||
bootstrap,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -89,8 +93,8 @@ const logout = () => ({
|
||||
payload: {},
|
||||
});
|
||||
|
||||
logout.invalidateAccessToken = () => ({
|
||||
type: ActionTypes.LOGOUT__ACCESS_TOKEN_INVALIDATE,
|
||||
logout.revokeAccessToken = () => ({
|
||||
type: ActionTypes.LOGOUT__ACCESS_TOKEN_REVOKE,
|
||||
payload: {},
|
||||
});
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ import socket from './socket';
|
||||
import login from './login';
|
||||
import core from './core';
|
||||
import modals from './modals';
|
||||
import config from './config';
|
||||
import webhooks from './webhooks';
|
||||
import users from './users';
|
||||
import projects from './projects';
|
||||
import projectManagers from './project-managers';
|
||||
@@ -35,6 +37,8 @@ export default {
|
||||
...login,
|
||||
...core,
|
||||
...modals,
|
||||
...config,
|
||||
...webhooks,
|
||||
...users,
|
||||
...projects,
|
||||
...projectManagers,
|
||||
|
||||
@@ -58,10 +58,34 @@ updateList.failure = (id, error) => ({
|
||||
},
|
||||
});
|
||||
|
||||
const handleListUpdate = (list) => ({
|
||||
const handleListUpdate = (
|
||||
list,
|
||||
isFetched,
|
||||
users,
|
||||
cards,
|
||||
cardMemberships,
|
||||
cardLabels,
|
||||
taskLists,
|
||||
tasks,
|
||||
attachments,
|
||||
customFieldGroups,
|
||||
customFields,
|
||||
customFieldValues,
|
||||
) => ({
|
||||
type: ActionTypes.LIST_UPDATE_HANDLE,
|
||||
payload: {
|
||||
list,
|
||||
isFetched,
|
||||
users,
|
||||
cards,
|
||||
cardMemberships,
|
||||
cardLabels,
|
||||
taskLists,
|
||||
tasks,
|
||||
attachments,
|
||||
customFieldGroups,
|
||||
customFields,
|
||||
customFieldValues,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
|
||||
import ActionTypes from '../constants/ActionTypes';
|
||||
|
||||
const initializeLogin = (config) => ({
|
||||
const initializeLogin = (bootstrap) => ({
|
||||
type: ActionTypes.LOGIN_INITIALIZE,
|
||||
payload: {
|
||||
config,
|
||||
bootstrap,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -26,10 +26,11 @@ authenticate.success = (accessToken) => ({
|
||||
},
|
||||
});
|
||||
|
||||
authenticate.failure = (error) => ({
|
||||
authenticate.failure = (error, terms) => ({
|
||||
type: ActionTypes.AUTHENTICATE__FAILURE,
|
||||
payload: {
|
||||
error,
|
||||
terms,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -45,10 +46,11 @@ authenticateWithOidc.success = (accessToken) => ({
|
||||
},
|
||||
});
|
||||
|
||||
authenticateWithOidc.failure = (error) => ({
|
||||
authenticateWithOidc.failure = (error, terms) => ({
|
||||
type: ActionTypes.WITH_OIDC_AUTHENTICATE__FAILURE,
|
||||
payload: {
|
||||
error,
|
||||
terms,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -57,9 +59,71 @@ const clearAuthenticateError = () => ({
|
||||
payload: {},
|
||||
});
|
||||
|
||||
const acceptTerms = (signature) => ({
|
||||
type: ActionTypes.TERMS_ACCEPT,
|
||||
payload: {
|
||||
signature,
|
||||
},
|
||||
});
|
||||
|
||||
acceptTerms.success = (accessToken) => ({
|
||||
type: ActionTypes.TERMS_ACCEPT__SUCCESS,
|
||||
payload: {
|
||||
accessToken,
|
||||
},
|
||||
});
|
||||
|
||||
acceptTerms.failure = (error) => ({
|
||||
type: ActionTypes.TERMS_ACCEPT__FAILURE,
|
||||
payload: {
|
||||
error,
|
||||
},
|
||||
});
|
||||
|
||||
const cancelTerms = () => ({
|
||||
type: ActionTypes.TERMS_CANCEL,
|
||||
payload: {},
|
||||
});
|
||||
|
||||
cancelTerms.success = () => ({
|
||||
type: ActionTypes.TERMS_CANCEL__SUCCESS,
|
||||
payload: {},
|
||||
});
|
||||
|
||||
cancelTerms.failure = (error) => ({
|
||||
type: ActionTypes.TERMS_CANCEL__FAILURE,
|
||||
payload: {
|
||||
error,
|
||||
},
|
||||
});
|
||||
|
||||
const updateTermsLanguage = (value) => ({
|
||||
type: ActionTypes.TERMS_LANGUAGE_UPDATE,
|
||||
payload: {
|
||||
value,
|
||||
},
|
||||
});
|
||||
|
||||
updateTermsLanguage.success = (terms) => ({
|
||||
type: ActionTypes.TERMS_LANGUAGE_UPDATE__SUCCESS,
|
||||
payload: {
|
||||
terms,
|
||||
},
|
||||
});
|
||||
|
||||
updateTermsLanguage.failure = (error) => ({
|
||||
type: ActionTypes.TERMS_LANGUAGE_UPDATE__FAILURE,
|
||||
payload: {
|
||||
error,
|
||||
},
|
||||
});
|
||||
|
||||
export default {
|
||||
initializeLogin,
|
||||
authenticate,
|
||||
authenticateWithOidc,
|
||||
clearAuthenticateError,
|
||||
acceptTerms,
|
||||
cancelTerms,
|
||||
updateTermsLanguage,
|
||||
};
|
||||
|
||||
@@ -11,9 +11,11 @@ const handleSocketDisconnect = () => ({
|
||||
});
|
||||
|
||||
const handleSocketReconnect = (
|
||||
bootstrap,
|
||||
config,
|
||||
user,
|
||||
board,
|
||||
webhooks,
|
||||
users,
|
||||
projects,
|
||||
projectManagers,
|
||||
@@ -37,9 +39,11 @@ const handleSocketReconnect = (
|
||||
) => ({
|
||||
type: ActionTypes.SOCKET_RECONNECT_HANDLE,
|
||||
payload: {
|
||||
bootstrap,
|
||||
config,
|
||||
user,
|
||||
board,
|
||||
webhooks,
|
||||
users,
|
||||
projects,
|
||||
projectManagers,
|
||||
|
||||
@@ -65,8 +65,10 @@ const handleUserUpdate = (
|
||||
user,
|
||||
projectIds,
|
||||
boardIds,
|
||||
bootstrap,
|
||||
config,
|
||||
board,
|
||||
webhooks,
|
||||
users,
|
||||
projects,
|
||||
projectManagers,
|
||||
@@ -93,8 +95,10 @@ const handleUserUpdate = (
|
||||
user,
|
||||
projectIds,
|
||||
boardIds,
|
||||
bootstrap,
|
||||
config,
|
||||
board,
|
||||
webhooks,
|
||||
users,
|
||||
projects,
|
||||
projectManagers,
|
||||
@@ -231,6 +235,58 @@ updateUserAvatar.failure = (id, error) => ({
|
||||
},
|
||||
});
|
||||
|
||||
const createUserApiKey = (id) => ({
|
||||
type: ActionTypes.USER_API_KEY_CREATE,
|
||||
payload: {
|
||||
id,
|
||||
},
|
||||
});
|
||||
|
||||
createUserApiKey.success = (user, apiKey) => ({
|
||||
type: ActionTypes.USER_API_KEY_CREATE__SUCCESS,
|
||||
payload: {
|
||||
user,
|
||||
apiKey,
|
||||
},
|
||||
});
|
||||
|
||||
createUserApiKey.failure = (id, error) => ({
|
||||
type: ActionTypes.USER_API_KEY_CREATE__FAILURE,
|
||||
payload: {
|
||||
id,
|
||||
error,
|
||||
},
|
||||
});
|
||||
|
||||
const deleteUserApiKey = (id) => ({
|
||||
type: ActionTypes.USER_API_KEY_DELETE,
|
||||
payload: {
|
||||
id,
|
||||
},
|
||||
});
|
||||
|
||||
deleteUserApiKey.success = (user) => ({
|
||||
type: ActionTypes.USER_API_KEY_DELETE__SUCCESS,
|
||||
payload: {
|
||||
user,
|
||||
},
|
||||
});
|
||||
|
||||
deleteUserApiKey.failure = (id, error) => ({
|
||||
type: ActionTypes.USER_API_KEY_DELETE__FAILURE,
|
||||
payload: {
|
||||
id,
|
||||
error,
|
||||
},
|
||||
});
|
||||
|
||||
const clearUserApiKeyValue = (id) => ({
|
||||
type: ActionTypes.USER_API_KEY_VALUE_CLEAR,
|
||||
payload: {
|
||||
id,
|
||||
},
|
||||
});
|
||||
|
||||
const deleteUser = (id) => ({
|
||||
type: ActionTypes.USER_DELETE,
|
||||
payload: {
|
||||
@@ -355,6 +411,9 @@ export default {
|
||||
updateUserUsername,
|
||||
clearUserUsernameUpdateError,
|
||||
updateUserAvatar,
|
||||
createUserApiKey,
|
||||
deleteUserApiKey,
|
||||
clearUserApiKeyValue,
|
||||
deleteUser,
|
||||
handleUserDelete,
|
||||
addUserToCard,
|
||||
|
||||
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,
|
||||
};
|
||||
@@ -13,10 +13,18 @@ const createAccessToken = (data, headers) =>
|
||||
const exchangeForAccessTokenWithOidc = (data, headers) =>
|
||||
http.post('/access-tokens/exchange-with-oidc?withHttpOnlyToken=true', data, headers);
|
||||
|
||||
// TODO: rename?
|
||||
const acceptTerms = (data, headers) => http.post('/access-tokens/accept-terms', data, headers);
|
||||
|
||||
const revokePendingToken = (data, headers) =>
|
||||
http.post('/access-tokens/revoke-pending-token', data, headers);
|
||||
|
||||
const deleteCurrentAccessToken = (headers) => http.delete('/access-tokens/me', undefined, headers);
|
||||
|
||||
export default {
|
||||
createAccessToken,
|
||||
exchangeForAccessTokenWithOidc,
|
||||
acceptTerms,
|
||||
revokePendingToken,
|
||||
deleteCurrentAccessToken,
|
||||
};
|
||||
|
||||
@@ -16,13 +16,13 @@ export const transformActivity = (activity) => ({
|
||||
|
||||
/* Actions */
|
||||
|
||||
const getActivitiesInBoard = (boardId, data, headers) =>
|
||||
const getBoardActivities = (boardId, data, headers) =>
|
||||
socket.get(`/boards/${boardId}/actions`, data, headers).then((body) => ({
|
||||
...body,
|
||||
items: body.items.map(transformActivity),
|
||||
}));
|
||||
|
||||
const getActivitiesInCard = (cardId, data, headers) =>
|
||||
const getCardActivities = (cardId, data, headers) =>
|
||||
socket.get(`/cards/${cardId}/actions`, data, headers).then((body) => ({
|
||||
...body,
|
||||
items: body.items.map(transformActivity),
|
||||
@@ -38,7 +38,7 @@ const makeHandleActivityCreate = (next) => (body) => {
|
||||
};
|
||||
|
||||
export default {
|
||||
getActivitiesInBoard,
|
||||
getActivitiesInCard,
|
||||
getBoardActivities,
|
||||
getCardActivities,
|
||||
makeHandleActivityCreate,
|
||||
};
|
||||
|
||||
14
client/src/api/bootstrap.js
vendored
Normal file
14
client/src/api/bootstrap.js
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import http from './http';
|
||||
|
||||
/* Actions */
|
||||
|
||||
const getBootstrap = (headers) => http.get('/bootstrap', undefined, headers);
|
||||
|
||||
export default {
|
||||
getBootstrap,
|
||||
};
|
||||
@@ -3,12 +3,18 @@
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import http from './http';
|
||||
import socket from './socket';
|
||||
|
||||
/* Actions */
|
||||
|
||||
const getConfig = (headers) => http.get('/config', undefined, headers);
|
||||
const getConfig = (headers) => socket.get('/config', undefined, headers);
|
||||
|
||||
const updateConfig = (data, headers) => socket.patch('/config', data, headers);
|
||||
|
||||
const testSmtpConfig = (headers) => socket.post('/config/test-smtp', undefined, headers);
|
||||
|
||||
export default {
|
||||
getConfig,
|
||||
updateConfig,
|
||||
testSmtpConfig,
|
||||
};
|
||||
|
||||
@@ -7,10 +7,10 @@ import socket from './socket';
|
||||
|
||||
/* Actions */
|
||||
|
||||
const createCustomFieldGroupInBoard = (cardId, data, headers) =>
|
||||
const createBoardCustomFieldGroup = (cardId, data, headers) =>
|
||||
socket.post(`/boards/${cardId}/custom-field-groups`, data, headers);
|
||||
|
||||
const createCustomFieldGroupInCard = (cardId, data, headers) =>
|
||||
const createCardCustomFieldGroup = (cardId, data, headers) =>
|
||||
socket.post(`/cards/${cardId}/custom-field-groups`, data, headers);
|
||||
|
||||
const getCustomFieldGroup = (id, headers) =>
|
||||
@@ -23,8 +23,8 @@ const deleteCustomFieldGroup = (id, headers) =>
|
||||
socket.delete(`/custom-field-groups/${id}`, undefined, headers);
|
||||
|
||||
export default {
|
||||
createCustomFieldGroupInBoard,
|
||||
createCustomFieldGroupInCard,
|
||||
createBoardCustomFieldGroup,
|
||||
createCardCustomFieldGroup,
|
||||
getCustomFieldGroup,
|
||||
updateCustomFieldGroup,
|
||||
deleteCustomFieldGroup,
|
||||
|
||||
@@ -5,8 +5,11 @@
|
||||
|
||||
import http from './http';
|
||||
import socket from './socket';
|
||||
import config from './config';
|
||||
import bootstrap from './bootstrap';
|
||||
import terms from './terms';
|
||||
import accessTokens from './access-tokens';
|
||||
import config from './config';
|
||||
import webhooks from './webhooks';
|
||||
import users from './users';
|
||||
import projects from './projects';
|
||||
import projectManagers from './project-managers';
|
||||
@@ -33,8 +36,11 @@ import notificationServices from './notification-services';
|
||||
export { http, socket };
|
||||
|
||||
export default {
|
||||
...config,
|
||||
...bootstrap,
|
||||
...terms,
|
||||
...accessTokens,
|
||||
...config,
|
||||
...webhooks,
|
||||
...users,
|
||||
...projects,
|
||||
...projectManagers,
|
||||
|
||||
@@ -7,10 +7,10 @@ import socket from './socket';
|
||||
|
||||
/* Actions */
|
||||
|
||||
const createNotificationServiceInUser = (userId, data, headers) =>
|
||||
const createUserNotificationService = (userId, data, headers) =>
|
||||
socket.post(`/users/${userId}/notification-services`, data, headers);
|
||||
|
||||
const createNotificationServiceInBoard = (boardId, data, headers) =>
|
||||
const createBoardNotificationService = (boardId, data, headers) =>
|
||||
socket.post(`/boards/${boardId}/notification-services`, data, headers);
|
||||
|
||||
const updateNotificationService = (id, data, headers) =>
|
||||
@@ -23,8 +23,8 @@ const deleteNotificationService = (id, headers) =>
|
||||
socket.delete(`/notification-services/${id}`, undefined, headers);
|
||||
|
||||
export default {
|
||||
createNotificationServiceInUser,
|
||||
createNotificationServiceInBoard,
|
||||
createUserNotificationService,
|
||||
createBoardNotificationService,
|
||||
updateNotificationService,
|
||||
testNotificationService,
|
||||
deleteNotificationService,
|
||||
|
||||
15
client/src/api/terms.js
Normal file
15
client/src/api/terms.js
Normal file
@@ -0,0 +1,15 @@
|
||||
/*!
|
||||
* Copyright (c) 2024 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import http from './http';
|
||||
|
||||
/* Actions */
|
||||
|
||||
const getTerms = (type, language, headers) =>
|
||||
http.get(`/terms/${type}${language ? `?language=${language}` : ''}`, undefined, headers);
|
||||
|
||||
export default {
|
||||
getTerms,
|
||||
};
|
||||
@@ -33,6 +33,9 @@ const updateUserUsername = (id, data, headers) =>
|
||||
|
||||
const updateUserAvatar = (id, data, headers) => http.post(`/users/${id}/avatar`, data, headers);
|
||||
|
||||
const createUserApiKey = (userId, headers) =>
|
||||
socket.post(`/users/${userId}/api-key`, undefined, headers);
|
||||
|
||||
const deleteUser = (id, headers) => socket.delete(`/users/${id}`, undefined, headers);
|
||||
|
||||
export default {
|
||||
@@ -45,5 +48,6 @@ export default {
|
||||
updateUserPassword,
|
||||
updateUserUsername,
|
||||
updateUserAvatar,
|
||||
createUserApiKey,
|
||||
deleteUser,
|
||||
};
|
||||
|
||||
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 |
@@ -11,8 +11,8 @@ import { Link } from 'react-router-dom';
|
||||
import { Comment } from 'semantic-ui-react';
|
||||
|
||||
import selectors from '../../../selectors';
|
||||
import { isUserStatic } from '../../../utils/record-helpers';
|
||||
import Paths from '../../../constants/Paths';
|
||||
import { StaticUserIds } from '../../../constants/StaticUsers';
|
||||
import { ActivityTypes } from '../../../constants/Enums';
|
||||
import TimeAgo from '../../common/TimeAgo';
|
||||
import UserAvatar from '../../users/UserAvatar';
|
||||
@@ -30,12 +30,11 @@ const Item = React.memo(({ id }) => {
|
||||
|
||||
const [t] = useTranslation();
|
||||
|
||||
const userName =
|
||||
user.id === StaticUserIds.DELETED
|
||||
? t(`common.${user.name}`, {
|
||||
context: 'title',
|
||||
})
|
||||
: user.name;
|
||||
const userName = isUserStatic(user)
|
||||
? t(`common.${user.name}`, {
|
||||
context: 'title',
|
||||
})
|
||||
: user.name;
|
||||
|
||||
const cardName = card ? card.name : activity.data.card.name;
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
padding-bottom: 14px;
|
||||
vertical-align: top;
|
||||
width: calc(100% - 40px);
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.date {
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useTranslation, Trans } from 'react-i18next';
|
||||
import { Comment } from 'semantic-ui-react';
|
||||
|
||||
import selectors from '../../../selectors';
|
||||
import { StaticUserIds } from '../../../constants/StaticUsers';
|
||||
import { isUserStatic } from '../../../utils/record-helpers';
|
||||
import { ActivityTypes } from '../../../constants/Enums';
|
||||
import TimeAgo from '../../common/TimeAgo';
|
||||
import UserAvatar from '../../users/UserAvatar';
|
||||
@@ -26,12 +26,11 @@ const Item = React.memo(({ id }) => {
|
||||
|
||||
const [t] = useTranslation();
|
||||
|
||||
const userName =
|
||||
user.id === StaticUserIds.DELETED
|
||||
? t(`common.${user.name}`, {
|
||||
context: 'title',
|
||||
})
|
||||
: user.name;
|
||||
const userName = isUserStatic(user)
|
||||
? t(`common.${user.name}`, {
|
||||
context: 'title',
|
||||
})
|
||||
: user.name;
|
||||
|
||||
let contentNode;
|
||||
switch (activity.type) {
|
||||
|
||||
@@ -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',
|
||||
})}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
}
|
||||
|
||||
.menu {
|
||||
margin: -7px -12px -5px;
|
||||
margin: 0 -12px -5px;
|
||||
width: calc(100% + 24px);
|
||||
}
|
||||
|
||||
@@ -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,10 +68,19 @@ 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) {
|
||||
if (attachment.data.sizeInBytes <= Config.MAX_SIZE_IN_BYTES_TO_DISPLAY_CONTENT) {
|
||||
if (attachment.data.size <= Config.MAX_SIZE_TO_DISPLAY_CONTENT) {
|
||||
content = (
|
||||
<ContentViewer
|
||||
src={attachment.data.url}
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
height: 20px;
|
||||
text-align: center;
|
||||
width: 470px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import { usePopupInClosableContext } from '../../../hooks';
|
||||
import { isListArchiveOrTrash } from '../../../utils/record-helpers';
|
||||
import { AttachmentTypes, BoardMembershipRoles } from '../../../constants/Enums';
|
||||
import EditStep from './EditStep';
|
||||
import Favicon from './Favicon';
|
||||
import Favicon from '../../common/Favicon';
|
||||
import TimeAgo from '../../common/TimeAgo';
|
||||
|
||||
import styles from './ItemContent.module.scss';
|
||||
@@ -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,35 @@ 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}
|
||||
/>
|
||||
<button type="button" className={styles.option} onClick={handleDownloadClick}>
|
||||
<Icon name="download" size="small" className={styles.optionIcon} />
|
||||
<span className={styles.optionText}>
|
||||
{isCover
|
||||
? t('action.removeCover', {
|
||||
context: 'title',
|
||||
})
|
||||
: t('action.makeCover', {
|
||||
context: 'title',
|
||||
})}
|
||||
{t('action.download', {
|
||||
context: 'title',
|
||||
})}
|
||||
</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;
|
||||
}
|
||||
}
|
||||
@@ -121,7 +121,7 @@ const ActionsStep = React.memo(({ boardMembershipId, title, onBack, onClose }) =
|
||||
)}
|
||||
{user.organization && (
|
||||
<div className={styles.information}>
|
||||
<Icon name="briefcase" className={styles.informationIcon} />
|
||||
<Icon name="building" className={styles.informationIcon} />
|
||||
{user.organization}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -67,5 +67,6 @@
|
||||
font-size: 14px;
|
||||
line-height: 1.2;
|
||||
padding: 2px 0 2px 2px;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import orderBy from 'lodash/orderBy';
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -31,10 +32,13 @@ const PureBoardMembershipsStep = React.memo(
|
||||
|
||||
const filteredItems = useMemo(
|
||||
() =>
|
||||
items.filter(
|
||||
({ user }) =>
|
||||
user.name.toLowerCase().includes(cleanSearch) ||
|
||||
(user.username && user.username.includes(cleanSearch)),
|
||||
orderBy(
|
||||
items.filter(
|
||||
({ user }) =>
|
||||
user.name.toLowerCase().includes(cleanSearch) ||
|
||||
(user.username && user.username.includes(cleanSearch)),
|
||||
),
|
||||
({ user }) => user.name.toLowerCase(),
|
||||
),
|
||||
[items, cleanSearch],
|
||||
);
|
||||
|
||||
@@ -9,20 +9,20 @@ import classNames from 'classnames';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Form, Icon } from 'semantic-ui-react';
|
||||
import { useDidUpdate, useToggle } from '../../../../lib/hooks';
|
||||
import { Input, Popup } from '../../../../lib/custom-ui';
|
||||
import { useDidUpdate, useToggle } from '../../../lib/hooks';
|
||||
import { Input, Popup } from '../../../lib/custom-ui';
|
||||
|
||||
import entryActions from '../../../../entry-actions';
|
||||
import { useForm, useNestedRef, useSteps } from '../../../../hooks';
|
||||
import entryActions from '../../../entry-actions';
|
||||
import { useForm, useNestedRef, useSteps } from '../../../hooks';
|
||||
import ImportStep from './ImportStep';
|
||||
|
||||
import styles from './AddStep.module.scss';
|
||||
import styles from './AddBoardStep.module.scss';
|
||||
|
||||
const StepTypes = {
|
||||
IMPORT: 'IMPORT',
|
||||
};
|
||||
|
||||
const AddStep = React.memo(({ onClose }) => {
|
||||
const AddBoardStep = React.memo(({ onClose }) => {
|
||||
const dispatch = useDispatch();
|
||||
const [t] = useTranslation();
|
||||
|
||||
@@ -123,8 +123,8 @@ const AddStep = React.memo(({ onClose }) => {
|
||||
);
|
||||
});
|
||||
|
||||
AddStep.propTypes = {
|
||||
AddBoardStep.propTypes = {
|
||||
onClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default AddStep;
|
||||
export default AddBoardStep;
|
||||
@@ -7,7 +7,7 @@ import React, { useCallback } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from 'semantic-ui-react';
|
||||
import { FilePicker, Popup } from '../../../../lib/custom-ui';
|
||||
import { FilePicker, Popup } from '../../../lib/custom-ui';
|
||||
|
||||
import styles from './ImportStep.module.scss';
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
import StoryContent from './StoryContent';
|
||||
import AddBoardStep from './AddBoardStep';
|
||||
|
||||
export default StoryContent;
|
||||
export default AddBoardStep;
|
||||
@@ -12,6 +12,7 @@ import { BoardContexts, BoardViews } from '../../../constants/Enums';
|
||||
import KanbanContent from './KanbanContent';
|
||||
import FiniteContent from './FiniteContent';
|
||||
import EndlessContent from './EndlessContent';
|
||||
import ShortcutsProvider from './ShortcutsProvider';
|
||||
import CardModal from '../../cards/CardModal';
|
||||
import BoardActivitiesModal from '../../activities/BoardActivitiesModal';
|
||||
|
||||
@@ -53,7 +54,9 @@ const Board = React.memo(() => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Content />
|
||||
<ShortcutsProvider>
|
||||
<Content />
|
||||
</ShortcutsProvider>
|
||||
{modalNode}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -15,13 +15,13 @@ import ListView from './ListView';
|
||||
const FiniteContent = React.memo(() => {
|
||||
const board = useSelector(selectors.selectCurrentBoard);
|
||||
const cardIds = useSelector(selectors.selectFilteredCardIdsForCurrentBoard);
|
||||
const hasAnyFiniteList = useSelector((state) => !!selectors.selectFirstFiniteListId(state));
|
||||
const canAddCard = useSelector((state) => !!selectors.selectFirstKanbanListId(state));
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const handleCardCreate = useCallback(
|
||||
(data, autoOpen) => {
|
||||
dispatch(entryActions.createCardInFirstFiniteList(data, autoOpen));
|
||||
dispatch(entryActions.createCardInCurrentContext(data, undefined, autoOpen));
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
@@ -39,7 +39,7 @@ const FiniteContent = React.memo(() => {
|
||||
default:
|
||||
}
|
||||
|
||||
return <View cardIds={cardIds} onCardCreate={hasAnyFiniteList ? handleCardCreate : undefined} />;
|
||||
return <View cardIds={cardIds} onCardCreate={canAddCard ? handleCardCreate : undefined} />;
|
||||
});
|
||||
|
||||
export default FiniteContent;
|
||||
|
||||
@@ -23,7 +23,7 @@ import styles from './KanbanContent.module.scss';
|
||||
import globalStyles from '../../../../styles.module.scss';
|
||||
|
||||
const KanbanContent = React.memo(() => {
|
||||
const listIds = useSelector(selectors.selectFiniteListIdsForCurrentBoard);
|
||||
const listIds = useSelector(selectors.selectKanbanListIdsForCurrentBoard);
|
||||
|
||||
const canAddList = useSelector((state) => {
|
||||
const isEditModeEnabled = selectors.selectIsEditModeEnabled(state); // TODO: move out?
|
||||
|
||||
295
client/src/components/boards/Board/ShortcutsProvider.jsx
Normal file
295
client/src/components/boards/Board/ShortcutsProvider.jsx
Normal file
@@ -0,0 +1,295 @@
|
||||
/*!
|
||||
* 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, useMemo, useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { push } from '../../../lib/redux-router';
|
||||
import { useDidUpdate } from '../../../lib/hooks';
|
||||
import { closePopup } from '../../../lib/popup';
|
||||
|
||||
import store from '../../../store';
|
||||
import selectors from '../../../selectors';
|
||||
import entryActions from '../../../entry-actions';
|
||||
import { isListArchiveOrTrash } from '../../../utils/record-helpers';
|
||||
import { isActiveTextElement } from '../../../utils/element-helpers';
|
||||
import { isModifierKeyPressed } from '../../../utils/event-helpers';
|
||||
import { BoardShortcutsContext } from '../../../contexts';
|
||||
import Paths from '../../../constants/Paths';
|
||||
import { BoardMembershipRoles, ListTypes } from '../../../constants/Enums';
|
||||
import CardActionsStep from '../../cards/CardActionsStep';
|
||||
|
||||
const canEditCardName = (boardMembership, list) => {
|
||||
if (isListArchiveOrTrash(list)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return boardMembership && boardMembership.role === BoardMembershipRoles.EDITOR;
|
||||
};
|
||||
|
||||
const canArchiveCard = (boardMembership, list) => {
|
||||
if (list.type === ListTypes.ARCHIVE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return boardMembership && boardMembership.role === BoardMembershipRoles.EDITOR;
|
||||
};
|
||||
|
||||
const canUseCardMembers = (boardMembership, list) => {
|
||||
if (isListArchiveOrTrash(list)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return boardMembership && boardMembership.role === BoardMembershipRoles.EDITOR;
|
||||
};
|
||||
|
||||
const canUseCardLabels = (boardMembership, list) => {
|
||||
if (isListArchiveOrTrash(list)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return boardMembership && boardMembership.role === BoardMembershipRoles.EDITOR;
|
||||
};
|
||||
|
||||
const ShortcutsProvider = React.memo(({ children }) => {
|
||||
const { cardId, boardId } = useSelector(selectors.selectPath);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const selectedCardRef = useRef(null);
|
||||
|
||||
const handleCardMouseEnter = useCallback((id, editName, openActions) => {
|
||||
selectedCardRef.current = {
|
||||
id,
|
||||
editName,
|
||||
openActions,
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleCardMouseLeave = useCallback(() => {
|
||||
selectedCardRef.current = null;
|
||||
}, []);
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => [handleCardMouseEnter, handleCardMouseLeave],
|
||||
[handleCardMouseEnter, handleCardMouseLeave],
|
||||
);
|
||||
|
||||
useDidUpdate(() => {
|
||||
selectedCardRef.current = null;
|
||||
}, [cardId, boardId]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleCardOpen = (event) => {
|
||||
if (!selectedCardRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const state = store.getState();
|
||||
const card = selectors.selectCardById(state, selectedCardRef.current.id);
|
||||
|
||||
if (!card || !card.isPersisted) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
closePopup();
|
||||
dispatch(push(Paths.CARDS.replace(':id', card.id)));
|
||||
};
|
||||
|
||||
const handleCardNameEdit = (event) => {
|
||||
if (!selectedCardRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const state = store.getState();
|
||||
const card = selectors.selectCardById(state, selectedCardRef.current.id);
|
||||
|
||||
if (!card || !card.isPersisted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const boardMembership = selectors.selectCurrentUserMembershipForCurrentBoard(state);
|
||||
const list = selectors.selectListById(state, card.listId);
|
||||
|
||||
if (!canEditCardName(boardMembership, list)) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
selectedCardRef.current.editName();
|
||||
};
|
||||
|
||||
const handleCardArchive = (event) => {
|
||||
if (!selectedCardRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const state = store.getState();
|
||||
const card = selectors.selectCardById(state, selectedCardRef.current.id);
|
||||
|
||||
if (!card || !card.isPersisted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const boardMembership = selectors.selectCurrentUserMembershipForCurrentBoard(state);
|
||||
const list = selectors.selectListById(state, card.listId);
|
||||
|
||||
if (!canArchiveCard(boardMembership, list)) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
selectedCardRef.current.openActions(CardActionsStep.StepTypes.ARCHIVE);
|
||||
};
|
||||
|
||||
const handleCardMembers = (event) => {
|
||||
if (!selectedCardRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const state = store.getState();
|
||||
const card = selectors.selectCardById(state, selectedCardRef.current.id);
|
||||
|
||||
if (!card || !card.isPersisted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const boardMembership = selectors.selectCurrentUserMembershipForCurrentBoard(state);
|
||||
const list = selectors.selectListById(state, card.listId);
|
||||
|
||||
if (!canUseCardMembers(boardMembership, list)) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
selectedCardRef.current.openActions(CardActionsStep.StepTypes.MEMBERS);
|
||||
};
|
||||
|
||||
const handleCardLabels = (event) => {
|
||||
if (!selectedCardRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const state = store.getState();
|
||||
const card = selectors.selectCardById(state, selectedCardRef.current.id);
|
||||
|
||||
if (!card || !card.isPersisted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const boardMembership = selectors.selectCurrentUserMembershipForCurrentBoard(state);
|
||||
const list = selectors.selectListById(state, card.listId);
|
||||
|
||||
if (!canUseCardLabels(boardMembership, list)) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
selectedCardRef.current.openActions(CardActionsStep.StepTypes.LABELS);
|
||||
};
|
||||
|
||||
const handleLabelToCardAdd = (event) => {
|
||||
if (!selectedCardRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const state = store.getState();
|
||||
const card = selectors.selectCardById(state, selectedCardRef.current.id);
|
||||
|
||||
if (!card || !card.isPersisted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const boardMembership = selectors.selectCurrentUserMembershipForCurrentBoard(state);
|
||||
const list = selectors.selectListById(state, card.listId);
|
||||
|
||||
if (!canUseCardLabels(boardMembership, list)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const index = event.code === 'Digit0' ? 10 : parseInt(event.code.slice(-1), 10) - 1;
|
||||
const label = selectors.selectLabelsForCurrentBoard(state)[index];
|
||||
|
||||
if (!label) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
const labelIds = selectors.selectLabelIdsByCardId(state, card.id);
|
||||
|
||||
if (labelIds.includes(label.id)) {
|
||||
dispatch(entryActions.removeLabelFromCard(label.id, card.id));
|
||||
} else {
|
||||
dispatch(entryActions.addLabelToCard(label.id, card.id));
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (event) => {
|
||||
if (isActiveTextElement(event.target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isModifierKeyPressed(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.code) {
|
||||
case 'KeyE':
|
||||
case 'Enter':
|
||||
handleCardOpen(event);
|
||||
|
||||
break;
|
||||
case 'KeyL':
|
||||
handleCardLabels(event);
|
||||
|
||||
break;
|
||||
case 'KeyM':
|
||||
handleCardMembers(event);
|
||||
|
||||
break;
|
||||
case 'KeyT':
|
||||
handleCardNameEdit(event);
|
||||
|
||||
break;
|
||||
case 'KeyV':
|
||||
handleCardArchive(event);
|
||||
|
||||
break;
|
||||
case 'Digit1':
|
||||
case 'Digit2':
|
||||
case 'Digit3':
|
||||
case 'Digit4':
|
||||
case 'Digit5':
|
||||
case 'Digit6':
|
||||
case 'Digit7':
|
||||
case 'Digit8':
|
||||
case 'Digit9':
|
||||
case 'Digit0':
|
||||
handleLabelToCardAdd(event);
|
||||
|
||||
break;
|
||||
default:
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [dispatch]);
|
||||
|
||||
return (
|
||||
<BoardShortcutsContext.Provider value={contextValue}>{children}</BoardShortcutsContext.Provider>
|
||||
);
|
||||
});
|
||||
|
||||
ShortcutsProvider.propTypes = {
|
||||
children: PropTypes.element.isRequired,
|
||||
};
|
||||
|
||||
export default ShortcutsProvider;
|
||||
@@ -6,8 +6,12 @@
|
||||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Icon } from 'semantic-ui-react';
|
||||
|
||||
import selectors from '../../../selectors';
|
||||
import { BoardContexts } from '../../../constants/Enums';
|
||||
import { BoardContextIcons } from '../../../constants/Icons';
|
||||
import Filters from './Filters';
|
||||
import RightSide from './RightSide';
|
||||
import BoardMemberships from '../../board-memberships/BoardMemberships';
|
||||
@@ -15,7 +19,15 @@ import BoardMemberships from '../../board-memberships/BoardMemberships';
|
||||
import styles from './BoardActions.module.scss';
|
||||
|
||||
const BoardActions = React.memo(() => {
|
||||
const boardContext = useSelector((state) => selectors.selectCurrentBoard(state).context);
|
||||
|
||||
const withContextTitle = boardContext !== BoardContexts.BOARD;
|
||||
|
||||
const withMemberships = useSelector((state) => {
|
||||
if (withContextTitle) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const boardMemberships = selectors.selectMembershipsForCurrentBoard(state);
|
||||
|
||||
if (boardMemberships.length > 0) {
|
||||
@@ -25,9 +37,19 @@ const BoardActions = React.memo(() => {
|
||||
return selectors.selectIsCurrentUserManagerForCurrentProject(state);
|
||||
});
|
||||
|
||||
const [t] = useTranslation();
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<div className={styles.actions}>
|
||||
{withContextTitle && (
|
||||
<div className={styles.action}>
|
||||
<div className={styles.contextTitle}>
|
||||
<Icon name={BoardContextIcons[boardContext]} className={styles.contextTitleIcon} />
|
||||
{t(`common.${boardContext}`)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{withMemberships && (
|
||||
<div className={styles.action}>
|
||||
<BoardMemberships />
|
||||
|
||||
@@ -31,6 +31,18 @@
|
||||
height: 46px;
|
||||
}
|
||||
|
||||
.contextTitle {
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
border-radius: 3px;
|
||||
color: #fff;
|
||||
line-height: 34px;
|
||||
padding: 2px 14px;
|
||||
}
|
||||
|
||||
.contextTitleIcon {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
}
|
||||
|
||||
.menu {
|
||||
margin: -7px -12px -5px;
|
||||
margin: 0 -12px -5px;
|
||||
width: calc(100% + 24px);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,6 @@
|
||||
|
||||
.menuItemIcon {
|
||||
float: left;
|
||||
margin-right: 0.5em;
|
||||
margin: 0 0.5em 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import { usePopup } from '../../../../lib/popup';
|
||||
import selectors from '../../../../selectors';
|
||||
import entryActions from '../../../../entry-actions';
|
||||
import { BoardContexts, BoardViews } from '../../../../constants/Enums';
|
||||
import { BoardContextIcons, BoardViewIcons } from '../../../../constants/Icons';
|
||||
import { BoardViewIcons } from '../../../../constants/Icons';
|
||||
import ActionsStep from './ActionsStep';
|
||||
|
||||
import styles from './RightSide.module.scss';
|
||||
@@ -56,7 +56,7 @@ const RightSide = React.memo(() => {
|
||||
<div className={styles.action}>
|
||||
<ActionsPopup>
|
||||
<button type="button" className={styles.button}>
|
||||
<Icon fitted name={BoardContextIcons[board.context]} />
|
||||
<Icon fitted name="ellipsis vertical" />
|
||||
</button>
|
||||
</ActionsPopup>
|
||||
</div>
|
||||
|
||||
@@ -43,6 +43,14 @@ const Others = React.memo(() => {
|
||||
className={styles.radio}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<Radio
|
||||
toggle
|
||||
name="expandTaskListsByDefault"
|
||||
checked={board.expandTaskListsByDefault}
|
||||
label={t('common.expandTaskListsByDefault')}
|
||||
className={styles.radio}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</Segment>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -13,7 +13,7 @@ import selectors from '../../../selectors';
|
||||
import entryActions from '../../../entry-actions';
|
||||
import DroppableTypes from '../../../constants/DroppableTypes';
|
||||
import Item from './Item';
|
||||
import AddStep from './AddStep';
|
||||
import AddBoardStep from '../AddBoardStep';
|
||||
|
||||
import styles from './Boards.module.scss';
|
||||
import globalStyles from '../../../styles.module.scss';
|
||||
@@ -59,7 +59,7 @@ const Boards = React.memo(() => {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const AddPopup = usePopup(AddStep);
|
||||
const AddBoardPopup = usePopup(AddBoardStep);
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper} onWheel={handleWheel}>
|
||||
@@ -74,9 +74,9 @@ const Boards = React.memo(() => {
|
||||
))}
|
||||
{placeholder}
|
||||
{canAdd && (
|
||||
<AddPopup>
|
||||
<AddBoardPopup>
|
||||
<Button icon="plus" className={styles.addButton} />
|
||||
</AddPopup>
|
||||
</AddBoardPopup>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -5,22 +5,23 @@
|
||||
|
||||
import upperFirst from 'lodash/upperFirst';
|
||||
import camelCase from 'lodash/camelCase';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import React, { useCallback, useContext, 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 { BoardShortcutsContext } from '../../../contexts';
|
||||
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';
|
||||
import EditName from './EditName';
|
||||
import ActionsStep from './ActionsStep';
|
||||
import CardActionsStep from '../CardActionsStep';
|
||||
|
||||
import styles from './Card.module.scss';
|
||||
import globalStyles from '../../../styles.module.scss';
|
||||
@@ -50,6 +51,9 @@ const Card = React.memo(({ id, isInline }) => {
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const [isEditNameOpened, setIsEditNameOpened] = useState(false);
|
||||
const [handleCardMouseEnter, handleCardMouseLeave] = useContext(BoardShortcutsContext);
|
||||
|
||||
const actionsPopupRef = useRef(null);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (document.activeElement) {
|
||||
@@ -59,6 +63,33 @@ const Card = React.memo(({ id, isInline }) => {
|
||||
dispatch(push(Paths.CARDS.replace(':id', id)));
|
||||
}, [id, dispatch]);
|
||||
|
||||
const handleMouseEnter = useCallback(() => {
|
||||
handleCardMouseEnter(
|
||||
id,
|
||||
() => {
|
||||
setIsEditNameOpened(true);
|
||||
},
|
||||
(step) => {
|
||||
closePopup();
|
||||
|
||||
actionsPopupRef.current.open({
|
||||
defaultStep: step,
|
||||
});
|
||||
},
|
||||
);
|
||||
}, [id, handleCardMouseEnter]);
|
||||
|
||||
const handleContextMenu = useCallback((event) => {
|
||||
if (!actionsPopupRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
closePopup();
|
||||
actionsPopupRef.current.open();
|
||||
}, []);
|
||||
|
||||
const handleNameEdit = useCallback(() => {
|
||||
setIsEditNameOpened(true);
|
||||
}, []);
|
||||
@@ -67,7 +98,7 @@ const Card = React.memo(({ id, isInline }) => {
|
||||
setIsEditNameOpened(false);
|
||||
}, []);
|
||||
|
||||
const ActionsPopup = usePopup(ActionsStep);
|
||||
const CardActionsPopup = usePopup(CardActionsStep);
|
||||
|
||||
if (isEditNameOpened) {
|
||||
return <EditName cardId={id} onClose={handleEditNameClose} />;
|
||||
@@ -108,30 +139,25 @@ 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)}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleCardMouseLeave}
|
||||
onClick={handleClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
>
|
||||
<Content cardId={id} />
|
||||
{colorLineNode}
|
||||
</div>
|
||||
{canUseActions && (
|
||||
<ActionsPopup cardId={id} onNameEdit={handleNameEdit}>
|
||||
<CardActionsPopup ref={actionsPopupRef} cardId={id} onNameEdit={handleNameEdit}>
|
||||
<Button className={styles.actionsButton}>
|
||||
<Icon fitted name="pencil" size="small" />
|
||||
</Button>
|
||||
</ActionsPopup>
|
||||
</CardActionsPopup>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<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>
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
top: 2px;
|
||||
transition: background 85ms ease;
|
||||
width: 20px;
|
||||
z-index: 1000;
|
||||
|
||||
&:hover {
|
||||
background: #ebeef0;
|
||||
@@ -52,6 +53,7 @@
|
||||
background: #fff;
|
||||
border-radius: 3px;
|
||||
box-shadow: 0 1px 0 #ccc;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
|
||||
@@ -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} />
|
||||
@@ -190,7 +190,8 @@ const ProjectContent = React.memo(({ cardId }) => {
|
||||
<DueDateChip
|
||||
value={card.dueDate}
|
||||
size="tiny"
|
||||
withStatus={list.type !== ListTypes.CLOSED && !isListArchiveOrTrash(list)}
|
||||
isCompleted={card.isDueCompleted}
|
||||
withStatus={!card.isClosed}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Progress } from 'semantic-ui-react';
|
||||
import { useToggle } from '../../../../lib/hooks';
|
||||
import { useDidUpdate, useToggle } from '../../../../lib/hooks';
|
||||
|
||||
import selectors from '../../../../selectors';
|
||||
import Task from './Task';
|
||||
@@ -16,11 +16,22 @@ import Task from './Task';
|
||||
import styles from './TaskList.module.scss';
|
||||
|
||||
const TaskList = React.memo(({ id }) => {
|
||||
const selectTaskListById = useMemo(() => selectors.makeSelectTaskListById(), []);
|
||||
const selectTasksByTaskListId = useMemo(() => selectors.makeSelectTasksByTaskListId(), []);
|
||||
|
||||
const taskLists = useSelector((state) => selectTaskListById(state, id));
|
||||
const tasks = useSelector((state) => selectTasksByTaskListId(state, id));
|
||||
|
||||
const [isOpened, toggleOpened] = useToggle();
|
||||
const defaultIsOpened = useSelector(
|
||||
(state) => selectors.selectCurrentBoard(state).expandTaskListsByDefault,
|
||||
);
|
||||
|
||||
const [isOpened, toggleOpened] = useToggle(defaultIsOpened);
|
||||
|
||||
const filteredTasks = useMemo(
|
||||
() => (taskLists.hideCompletedTasks ? tasks.filter((task) => !task.isCompleted) : tasks),
|
||||
[taskLists.hideCompletedTasks, tasks],
|
||||
);
|
||||
|
||||
// TODO: move to selector?
|
||||
const completedTasksTotal = useMemo(
|
||||
@@ -30,12 +41,20 @@ const TaskList = React.memo(({ id }) => {
|
||||
|
||||
const handleToggleClick = useCallback(
|
||||
(event) => {
|
||||
if (filteredTasks.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.stopPropagation();
|
||||
toggleOpened();
|
||||
},
|
||||
[toggleOpened],
|
||||
[toggleOpened, filteredTasks.length],
|
||||
);
|
||||
|
||||
useDidUpdate(() => {
|
||||
toggleOpened();
|
||||
}, [defaultIsOpened]);
|
||||
|
||||
if (tasks.length === 0) {
|
||||
return null;
|
||||
}
|
||||
@@ -44,7 +63,7 @@ const TaskList = React.memo(({ id }) => {
|
||||
<>
|
||||
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,
|
||||
jsx-a11y/no-static-element-interactions */}
|
||||
<div className={styles.button} onClick={handleToggleClick}>
|
||||
<div className={styles.progressRow} onClick={handleToggleClick}>
|
||||
<span className={styles.progressWrapper}>
|
||||
<Progress
|
||||
autoSuccess
|
||||
@@ -56,14 +75,18 @@ const TaskList = React.memo(({ id }) => {
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
className={classNames(styles.count, isOpened ? styles.countOpened : styles.countClosed)}
|
||||
className={classNames(
|
||||
styles.count,
|
||||
filteredTasks.length > 0 && styles.countOpenable,
|
||||
filteredTasks.length > 0 && (isOpened ? styles.countOpened : styles.countClosed),
|
||||
)}
|
||||
>
|
||||
{completedTasksTotal}/{tasks.length}
|
||||
</span>
|
||||
</div>
|
||||
{isOpened && (
|
||||
{isOpened && filteredTasks.length > 0 && (
|
||||
<ul className={styles.tasks}>
|
||||
{tasks.map((task) => (
|
||||
{filteredTasks.map((task) => (
|
||||
<Task key={task.id} id={task.id} />
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -4,29 +4,21 @@
|
||||
*/
|
||||
|
||||
:global(#app) {
|
||||
.button {
|
||||
background: transparent;
|
||||
border: none;
|
||||
line-height: 0;
|
||||
margin: 0 -8px;
|
||||
outline: none;
|
||||
padding: 0px 8px 8px;
|
||||
width: calc(100% + 16px);
|
||||
}
|
||||
|
||||
.count {
|
||||
color: #888;
|
||||
display: inline-block;
|
||||
font-size: 12px;
|
||||
line-height: 12px;
|
||||
text-align: right;
|
||||
vertical-align: top;
|
||||
width: 50px;
|
||||
|
||||
&:after {
|
||||
content: "";
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
.countOpenable {
|
||||
&:after {
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
opacity: 0.75;
|
||||
@@ -35,13 +27,11 @@
|
||||
|
||||
.countOpened:after {
|
||||
background: url("data:image/gif;base64,R0lGODlhCwALAJEAAAAAAP///xUVFf///yH5BAEAAAMALAAAAAALAAsAAAIPnI+py+0/hJzz0IruwjsVADs=") no-repeat center right;
|
||||
margin-left: 2px;
|
||||
padding: 6px 6px 0px;
|
||||
}
|
||||
|
||||
.countClosed:after {
|
||||
background: url("data:image/gif;base64,R0lGODlhCwALAJEAAAAAAP///xUVFf///yH5BAEAAAMALAAAAAALAAsAAAIRnC2nKLnT4or00Puy3rx7VQAAOw==") no-repeat center right;
|
||||
margin-left: 2px;
|
||||
padding: 0 6px 6px;
|
||||
}
|
||||
|
||||
@@ -49,11 +39,17 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.progressRow {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: space-between;
|
||||
margin: 0 -8px;
|
||||
padding: 0px 8px 8px;
|
||||
}
|
||||
|
||||
.progressWrapper {
|
||||
display: inline-block;
|
||||
padding: 3px 0;
|
||||
vertical-align: top;
|
||||
width: calc(100% - 50px);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tasks {
|
||||
|
||||
@@ -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';
|
||||
@@ -23,12 +23,12 @@ import ConfirmationStep from '../../common/ConfirmationStep';
|
||||
import BoardMembershipsStep from '../../board-memberships/BoardMembershipsStep';
|
||||
import LabelsStep from '../../labels/LabelsStep';
|
||||
|
||||
import styles from './ActionsStep.module.scss';
|
||||
import styles from './CardActionsStep.module.scss';
|
||||
|
||||
const StepTypes = {
|
||||
EDIT_TYPE: 'EDIT_TYPE',
|
||||
USERS: 'USERS',
|
||||
MEMBERS: 'MEMBERS',
|
||||
LABELS: 'LABELS',
|
||||
EDIT_TYPE: 'EDIT_TYPE',
|
||||
EDIT_DUE_DATE: 'EDIT_DUE_DATE',
|
||||
EDIT_STOPWATCH: 'EDIT_STOPWATCH',
|
||||
MOVE: 'MOVE',
|
||||
@@ -36,7 +36,7 @@ const StepTypes = {
|
||||
DELETE: 'DELETE',
|
||||
};
|
||||
|
||||
const ActionsStep = React.memo(({ cardId, onNameEdit, onClose }) => {
|
||||
const CardActionsStep = React.memo(({ cardId, defaultStep, onNameEdit, onClose }) => {
|
||||
const selectCardById = useMemo(() => selectors.makeSelectCardById(), []);
|
||||
const selectListById = useMemo(() => selectors.makeSelectListById(), []);
|
||||
const selectPrevListById = useMemo(() => selectors.makeSelectListById(), []);
|
||||
@@ -104,7 +104,7 @@ const ActionsStep = React.memo(({ cardId, onNameEdit, onClose }) => {
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const [t] = useTranslation();
|
||||
const [step, openStep, handleBack] = useSteps();
|
||||
const [step, openStep, handleBack] = useSteps(defaultStep || null);
|
||||
|
||||
const handleTypeSelect = useCallback(
|
||||
(type) => {
|
||||
@@ -180,18 +180,18 @@ const ActionsStep = React.memo(({ cardId, onNameEdit, onClose }) => {
|
||||
onClose();
|
||||
}, [onNameEdit, onClose]);
|
||||
|
||||
const handleEditTypeClick = useCallback(() => {
|
||||
openStep(StepTypes.EDIT_TYPE);
|
||||
}, [openStep]);
|
||||
|
||||
const handleUsersClick = useCallback(() => {
|
||||
openStep(StepTypes.USERS);
|
||||
const handleMembersClick = useCallback(() => {
|
||||
openStep(StepTypes.MEMBERS);
|
||||
}, [openStep]);
|
||||
|
||||
const handleLabelsClick = useCallback(() => {
|
||||
openStep(StepTypes.LABELS);
|
||||
}, [openStep]);
|
||||
|
||||
const handleEditTypeClick = useCallback(() => {
|
||||
openStep(StepTypes.EDIT_TYPE);
|
||||
}, [openStep]);
|
||||
|
||||
const handleEditDueDateClick = useCallback(() => {
|
||||
openStep(StepTypes.EDIT_DUE_DATE);
|
||||
}, [openStep]);
|
||||
@@ -214,19 +214,7 @@ const ActionsStep = React.memo(({ cardId, onNameEdit, onClose }) => {
|
||||
|
||||
if (step) {
|
||||
switch (step.type) {
|
||||
case StepTypes.EDIT_TYPE:
|
||||
return (
|
||||
<SelectCardTypeStep
|
||||
withButton
|
||||
defaultValue={card.type}
|
||||
title="common.editType"
|
||||
buttonContent="action.save"
|
||||
onSelect={handleTypeSelect}
|
||||
onBack={handleBack}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
case StepTypes.USERS:
|
||||
case StepTypes.MEMBERS:
|
||||
return (
|
||||
<BoardMembershipsStep
|
||||
currentUserIds={userIds}
|
||||
@@ -245,6 +233,18 @@ const ActionsStep = React.memo(({ cardId, onNameEdit, onClose }) => {
|
||||
onBack={handleBack}
|
||||
/>
|
||||
);
|
||||
case StepTypes.EDIT_TYPE:
|
||||
return (
|
||||
<SelectCardTypeStep
|
||||
withButton
|
||||
defaultValue={card.type}
|
||||
title="common.editType"
|
||||
buttonContent="action.save"
|
||||
onSelect={handleTypeSelect}
|
||||
onBack={handleBack}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
case StepTypes.EDIT_DUE_DATE:
|
||||
return <EditDueDateStep cardId={cardId} onBack={handleBack} onClose={onClose} />;
|
||||
case StepTypes.EDIT_STOPWATCH:
|
||||
@@ -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,13 +298,15 @@ 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',
|
||||
})}
|
||||
</Menu.Item>
|
||||
)}
|
||||
{card.type === CardTypes.PROJECT && canUseMembers && (
|
||||
<Menu.Item className={styles.menuItem} onClick={handleUsersClick}>
|
||||
<Menu.Item className={styles.menuItem} onClick={handleMembersClick}>
|
||||
<Icon name="user outline" className={styles.menuItemIcon} />
|
||||
{t('common.members', {
|
||||
context: 'title',
|
||||
})}
|
||||
@@ -311,13 +314,15 @@ 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',
|
||||
})}
|
||||
</Menu.Item>
|
||||
)}
|
||||
{card.type === CardTypes.STORY && canUseMembers && (
|
||||
<Menu.Item className={styles.menuItem} onClick={handleUsersClick}>
|
||||
<Menu.Item className={styles.menuItem} onClick={handleMembersClick}>
|
||||
<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',
|
||||
@@ -383,10 +395,17 @@ const ActionsStep = React.memo(({ cardId, onNameEdit, onClose }) => {
|
||||
);
|
||||
});
|
||||
|
||||
ActionsStep.propTypes = {
|
||||
CardActionsStep.propTypes = {
|
||||
cardId: PropTypes.string.isRequired,
|
||||
defaultStep: PropTypes.string,
|
||||
onNameEdit: PropTypes.func.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default ActionsStep;
|
||||
CardActionsStep.defaultProps = {
|
||||
defaultStep: undefined,
|
||||
};
|
||||
|
||||
CardActionsStep.StepTypes = StepTypes;
|
||||
|
||||
export default CardActionsStep;
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
:global(#app) {
|
||||
.menu {
|
||||
margin: -7px -12px -5px;
|
||||
margin: 0 -12px -5px;
|
||||
width: calc(100% + 24px);
|
||||
}
|
||||
|
||||
@@ -13,4 +13,9 @@
|
||||
margin: 0;
|
||||
padding-left: 14px;
|
||||
}
|
||||
|
||||
.menuItemIcon {
|
||||
float: left;
|
||||
margin: 0 0.5em 0 0;
|
||||
}
|
||||
}
|
||||
3
client/src/components/cards/CardActionsStep/index.js
Normal file
3
client/src/components/cards/CardActionsStep/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import CardActionsStep from './CardActionsStep';
|
||||
|
||||
export default CardActionsStep;
|
||||
@@ -6,6 +6,7 @@
|
||||
import React, { useCallback, useEffect, useMemo } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { Icon } from 'semantic-ui-react';
|
||||
import { push } from '../../../lib/redux-router';
|
||||
|
||||
import selectors from '../../../selectors';
|
||||
@@ -30,6 +31,7 @@ const CardModal = React.memo(() => {
|
||||
const selectListById = useMemo(() => selectors.makeSelectListById(), []);
|
||||
|
||||
const card = useSelector(selectors.selectCurrentCard);
|
||||
const prevCardId = useSelector(selectors.selectPrevCardId);
|
||||
|
||||
const canEdit = useSelector((state) => {
|
||||
const list = selectListById(state, card.listId);
|
||||
@@ -48,6 +50,10 @@ const CardModal = React.memo(() => {
|
||||
dispatch(push(Paths.BOARDS.replace(':id', card.boardId)));
|
||||
}, [card.boardId, dispatch]);
|
||||
|
||||
const handlePrevClick = useCallback(() => {
|
||||
dispatch(push(Paths.CARDS.replace(':id', prevCardId)));
|
||||
}, [prevCardId, dispatch]);
|
||||
|
||||
const [ClosableModal, isClosableActiveRef] = useClosableModal();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -95,6 +101,11 @@ const CardModal = React.memo(() => {
|
||||
className={classNames(styles.wrapper, card.type === CardTypes.STORY && styles.wrapperStory)}
|
||||
onClose={handleClose}
|
||||
>
|
||||
{prevCardId && (
|
||||
<button type="button" className={styles.prevButton} onClick={handlePrevClick}>
|
||||
<Icon fitted name="arrow left" size="large" className={styles.prevButtonIcon} />
|
||||
</button>
|
||||
)}
|
||||
{canEdit ? (
|
||||
<AddAttachmentZone>
|
||||
<Content onClose={handleClose} />
|
||||
|
||||
@@ -9,6 +9,34 @@
|
||||
width: 95%;
|
||||
}
|
||||
|
||||
.prevButton {
|
||||
background: rgba(255, 255, 255, 0.036);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
left: -120px;
|
||||
padding: 18px 0;
|
||||
position: absolute;
|
||||
width: 100px;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.072);
|
||||
|
||||
.prevkButtonIcon {
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.prevButtonIcon {
|
||||
color: rgba(255, 255, 255, 0.36);
|
||||
position: sticky;
|
||||
top: 5px;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
margin: 2rem auto;
|
||||
width: 880px;
|
||||
@@ -24,5 +52,13 @@
|
||||
@media (width < 1026px) {
|
||||
@include smallScreen;
|
||||
}
|
||||
|
||||
.prevButton {
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.prevButtonIcon {
|
||||
top: 7px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Icon } from 'semantic-ui-react';
|
||||
|
||||
import selectors from '../../../selectors';
|
||||
import { StaticUserIds } from '../../../constants/StaticUsers';
|
||||
import { isUserStatic } from '../../../utils/record-helpers';
|
||||
import TimeAgo from '../../common/TimeAgo';
|
||||
import UserAvatar from '../../users/UserAvatar';
|
||||
|
||||
@@ -32,7 +32,7 @@ const CreationDetailsStep = React.memo(({ userId }) => {
|
||||
</span>
|
||||
<span className={styles.content}>
|
||||
<div className={styles.name}>
|
||||
{user.id === StaticUserIds.DELETED
|
||||
{isUserStatic(user)
|
||||
? t(`common.${user.name}`, {
|
||||
context: 'title',
|
||||
})
|
||||
|
||||
@@ -43,5 +43,6 @@
|
||||
font-size: 14px;
|
||||
line-height: 1.2;
|
||||
padding: 2px 0 2px 2px;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
151
client/src/components/cards/CardModal/MoreActionsStep.jsx
Normal file
151
client/src/components/cards/CardModal/MoreActionsStep.jsx
Normal file
@@ -0,0 +1,151 @@
|
||||
/*!
|
||||
* 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 { shallowEqual, useDispatch, useSelector } from 'react-redux';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
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 { isListArchiveOrTrash } from '../../../utils/record-helpers';
|
||||
import { BoardMembershipRoles } from '../../../constants/Enums';
|
||||
import SelectCardTypeStep from '../SelectCardTypeStep';
|
||||
import MoveCardStep from '../MoveCardStep';
|
||||
|
||||
import styles from './MoreActionsStep.module.scss';
|
||||
|
||||
const StepTypes = {
|
||||
EDIT_TYPE: 'EDIT_TYPE',
|
||||
MOVE: 'MOVE',
|
||||
};
|
||||
|
||||
const MoreActionsStep = React.memo(({ onClose }) => {
|
||||
const selectListById = useMemo(() => selectors.makeSelectListById(), []);
|
||||
|
||||
const card = useSelector(selectors.selectCurrentCard);
|
||||
const board = useSelector(selectors.selectCurrentBoard);
|
||||
|
||||
const { canEditType, canDuplicate, canMove } = useSelector((state) => {
|
||||
const list = selectListById(state, card.listId);
|
||||
|
||||
if (isListArchiveOrTrash(list)) {
|
||||
return {
|
||||
canEditType: false,
|
||||
canDuplicate: false,
|
||||
canMove: false,
|
||||
};
|
||||
}
|
||||
|
||||
const boardMembership = selectors.selectCurrentUserMembershipForCurrentBoard(state);
|
||||
const isEditor = !!boardMembership && boardMembership.role === BoardMembershipRoles.EDITOR;
|
||||
|
||||
return {
|
||||
canEditType: isEditor,
|
||||
canDuplicate: isEditor,
|
||||
canMove: isEditor,
|
||||
};
|
||||
}, shallowEqual);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const [t] = useTranslation();
|
||||
const [step, openStep, handleBack] = useSteps();
|
||||
|
||||
const handleTypeSelect = useCallback(
|
||||
(type) => {
|
||||
dispatch(
|
||||
entryActions.updateCurrentCard({
|
||||
type,
|
||||
}),
|
||||
);
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const handleDuplicateClick = useCallback(() => {
|
||||
dispatch(
|
||||
entryActions.duplicateCurrentCard({
|
||||
name: `${card.name} (${t('common.copy', {
|
||||
context: 'inline',
|
||||
})})`,
|
||||
}),
|
||||
);
|
||||
}, [card.name, dispatch, t]);
|
||||
|
||||
const handleEditTypeClick = useCallback(() => {
|
||||
openStep(StepTypes.EDIT_TYPE);
|
||||
}, [openStep]);
|
||||
|
||||
const handleMoveClick = useCallback(() => {
|
||||
openStep(StepTypes.MOVE);
|
||||
}, [openStep]);
|
||||
|
||||
if (step) {
|
||||
switch (step.type) {
|
||||
case StepTypes.EDIT_TYPE:
|
||||
return (
|
||||
<SelectCardTypeStep
|
||||
withButton
|
||||
defaultValue={card.type}
|
||||
title="common.editType"
|
||||
buttonContent="action.save"
|
||||
onSelect={handleTypeSelect}
|
||||
onBack={handleBack}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
case StepTypes.MOVE:
|
||||
return <MoveCardStep id={card.id} onBack={handleBack} onClose={onClose} />;
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popup.Header>
|
||||
{t('common.moreActions', {
|
||||
context: 'title',
|
||||
})}
|
||||
</Popup.Header>
|
||||
<Popup.Content>
|
||||
<Menu secondary vertical className={styles.menu}>
|
||||
{!board.limitCardTypesToDefaultOne && canEditType && (
|
||||
<Menu.Item className={styles.menuItem} onClick={handleEditTypeClick}>
|
||||
<Icon name="map outline" className={styles.menuItemIcon} />
|
||||
{t('action.editType', {
|
||||
context: 'title',
|
||||
})}
|
||||
</Menu.Item>
|
||||
)}
|
||||
{canDuplicate && (
|
||||
<Menu.Item className={styles.menuItem} onClick={handleDuplicateClick}>
|
||||
<Icon name="copy outline" className={styles.menuItemIcon} />
|
||||
{t('action.duplicateCard', {
|
||||
context: 'title',
|
||||
})}
|
||||
</Menu.Item>
|
||||
)}
|
||||
{canMove && (
|
||||
<Menu.Item className={styles.menuItem} onClick={handleMoveClick}>
|
||||
<Icon name="share square outline" className={styles.menuItemIcon} />
|
||||
{t('action.moveCard', {
|
||||
context: 'title',
|
||||
})}
|
||||
</Menu.Item>
|
||||
)}
|
||||
</Menu>
|
||||
</Popup.Content>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
MoreActionsStep.propTypes = {
|
||||
onClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default MoreActionsStep;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user