Compare commits

..

142 Commits

Author SHA1 Message Date
Maksim Eltyshev
a52b41207a chore: Fix version mismatches 2025-10-05 20:53:32 +02:00
patroldo
18104a486f fix: Switch to bitnamilegacy Postgres repo in Helm, add ingress labels (#1368)
Closes #1362
2025-10-05 20:47:36 +02:00
Salvador Moreno
213f6cddc9 fix: Update Spanish translation (#1355) 2025-09-26 13:27:31 +02:00
Miklós Márton
393fdbc6d1 fix: Update Hungarian translation (#1354) 2025-09-26 13:21:22 +02:00
Maksim Eltyshev
c6f4dcdb70 feat: Add ability to configure and test SMTP via UI 2025-09-22 20:35:13 +02:00
Maksim Eltyshev
3a12bb7457 fix: Add missing default for showOnFrontOfCard in custom fields 2025-09-16 18:36:43 +02:00
Maksim Eltyshev
4985643e68 fix: Prevent app crash when board is added before its project in the store 2025-09-16 18:15:54 +02:00
Maksim Eltyshev
709dd91e82 fix: Increase browser cache time for attachment files 2025-09-16 17:35:05 +02:00
Maksim Eltyshev
97ddefc236 docs: Correct invalid/missing details in Swagger 2025-09-16 15:51:14 +02:00
Maksim Eltyshev
33eef6e068 fix: Change background color for own comments 2025-09-16 13:27:45 +02:00
Maksim Eltyshev
230b31914c fix: Support multiple CORS origins via comma-separated BASE_URL
Closes #1344
2025-09-15 19:38:31 +02:00
Maksim Eltyshev
98f537c2bc fix: Prevent accidental modal close when switching windows on Windows 2025-09-15 15:28:34 +02:00
Maksim Eltyshev
c4e2221444 docs: Fix typos in Swagger 2025-09-12 21:01:24 +02:00
Maksim Eltyshev
b4979d214f docs: Improve Swagger JSDoc 2025-09-12 12:17:01 +02:00
dependabot[bot]
494800d131 build(deps): Bump vite from 6.3.5 to 6.3.6 in /client (#1341)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 6.3.5 to 6.3.6.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v6.3.6/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v6.3.6/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 6.3.6
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-10 11:47:26 +02:00
Maksim Eltyshev
dca4b65921 docs: Add missing details to Swagger 2025-09-08 23:40:36 +02:00
Miklós Márton
9a81c83f03 fix: Display users alphabetically in board memberships popup (#1334) 2025-09-08 23:14:34 +02:00
Maksim Eltyshev
2c716a53c9 docs: Quote example ids in Swagger 2025-09-08 19:14:31 +02:00
Maksim Eltyshev
5636cf27c0 docs: Add base path for API endpoints to Swagger 2025-09-08 18:25:26 +02:00
leroyloren
68549d0da8 fix: Update Czech translation (#1337) 2025-09-08 17:58:39 +02:00
Maksim Eltyshev
4910600e6c chore: Add Swagger config 2025-09-08 17:41:43 +02:00
Maksim Eltyshev
7fea8f5362 chore: Bump missing image version
Closes #1339
2025-09-08 16:25:50 +02:00
Maksim Eltyshev
5ad3134519 docs: Add full Swagger JSDoc coverage 2025-09-08 16:20:27 +02:00
Maksim Eltyshev
e6b4c33542 fix: Reflect task lists expansion instantly after switching setting 2025-09-06 15:00:43 +02:00
Maksim Eltyshev
984b789e2e feat: Add board setting to expand task lists by default
Closes #683, closes #1333
2025-09-05 23:21:08 +02:00
Maksim Eltyshev
b47a337a4f chore: Add missing dash to compose files 2025-09-05 19:10:17 +02:00
Mary Ojo
5dc783c5b5 feat: Add ability to use Gravatar as avatar provider (#1319)
Closes #1243
2025-09-05 19:00:17 +02:00
Steven Correia
37bd4d1349 feat: Restore toggleable due dates (#1332) 2025-09-05 13:55:20 +02:00
leroyloren
7da241c5a3 fix: Update Czech translation (#1331) 2025-09-05 00:29:40 +02:00
Maksim Eltyshev
203fbd152d fix: Correctly parse environment variables, little refactoring 2025-09-05 00:19:35 +02:00
Maksim Eltyshev
eb259a1482 fix: Patch react-photoswipe-gallery to prevent XSS in captions 2025-09-04 16:02:41 +02:00
Symon Baikov
9683227fbc feat: Add ability to move lists between boards (#1208) 2025-09-04 00:07:10 +02:00
Andrew
5f34a737bb fix: Update Ukrainian translation (#1326) 2025-09-03 13:01:57 +02:00
Niccolò Pedrini
069813e49d fix: Update Italian translation (#1324) 2025-09-03 12:56:57 +02:00
Miklós Márton
fc8f434843 fix: Update Hungarian translation (#1318) 2025-09-01 16:34:26 +02:00
Maksim Eltyshev
2b5068de21 fix: Handle saga watcher errors gracefully 2025-08-30 23:43:04 +02:00
Maksim Eltyshev
d792e4064b fix: Handle markdown-to-text errors gracefully 2025-08-30 17:28:23 +02:00
Maksim Eltyshev
6863f557a3 chore: Update dependencies 2025-08-30 17:25:25 +02:00
Maksim Eltyshev
21033ad8bc fix: Remove meta markdown plugin 2025-08-30 17:10:21 +02:00
Maksim Eltyshev
4c5c7799f2 ref: Little refactoring 2025-08-30 17:09:17 +02:00
Maksim Eltyshev
60a94f33fc ref: Little refactoring 2025-08-25 17:23:59 +02:00
Maksim Eltyshev
4d77a1f596 feat: Track storage usage 2025-08-23 00:03:20 +02:00
Maksim Eltyshev
2f4bcb0583 feat: Add legal requirements (#1306) 2025-08-21 15:10:02 +02:00
Maksim Eltyshev
bb40a22563 chore: Update dependencies 2025-08-20 13:10:16 +02:00
Maksim Eltyshev
22cde183ff fix: Fix regex-based id validation when fetching user
Closes #1302
2025-08-19 13:53:40 +02:00
Saulo Costa
168e556f06 fix: Update Brazilian Portuguese translation (#1301) 2025-08-19 12:36:18 +02:00
Miklós Márton
2fc7d672f6 fix: Update Hungarian translation (#1298) 2025-08-19 12:30:51 +02:00
sivertillia
3dab4b8bea fix: Update Ukrainian translation (#1299) 2025-08-18 22:04:28 +02:00
Maksim Eltyshev
d3cf23d3aa chore: Restore deleted Polish translation keys 2025-08-18 15:03:17 +02:00
Maksim Eltyshev
a22afd0bed chore: Mark all missing translation keys 2025-08-18 14:34:45 +02:00
Maksim Eltyshev
12c40d8ebe fix: Fix projects order popup styles 2025-08-12 13:57:20 +02:00
Maksim Eltyshev
4d85b46e8f fix: Replace user settings icon 2025-08-11 15:18:47 +02:00
Maksim Eltyshev
6515877eb6 fix: Improve mentions behavior 2025-08-11 13:52:17 +02:00
Luis Chacón
cbb00d1d59 feat: Add OAuth callback support for OIDC (#1290)
Closes #593, closes #690, closes #1289
2025-08-09 18:58:55 +02:00
Maksim Eltyshev
b4f6e24442 fix: Prevent editors from deleting any comment
Closes #1288
2025-08-06 13:37:16 +02:00
Maksim Eltyshev
48e261ffc4 fix: Improve upgrade script, log statuses
Closes #1280
2025-08-04 20:51:40 +02:00
Maksim Eltyshev
0049ba2145 feat: Track navigation path when switching between cards 2025-07-31 16:59:16 +02:00
Maksim Eltyshev
a37a6b45b1 fix: Read secret files without requiring trailing newline
Closes #1222, closes #1272
2025-07-30 13:51:24 +02:00
dependabot[bot]
c272a65015 build(deps): Bump linkifyjs from 4.3.1 to 4.3.2 in /client (#1273)
Bumps [linkifyjs](https://github.com/nfrasser/linkifyjs/tree/HEAD/packages/linkifyjs) from 4.3.1 to 4.3.2.
- [Release notes](https://github.com/nfrasser/linkifyjs/releases)
- [Changelog](https://github.com/nfrasser/linkifyjs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/nfrasser/linkifyjs/commits/v4.3.2/packages/linkifyjs)

---
updated-dependencies:
- dependency-name: linkifyjs
  dependency-version: 4.3.2
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-30 12:37:49 +02:00
Niccolò Pedrini
fb8a4a1c45 fix: Update Italian translation (#1268) 2025-07-28 11:40:27 +02:00
Maksim Eltyshev
e2a9b30f46 feat: Parse dates as UTC without relying on TZ environment variable
Closes #1266
2025-07-25 11:23:20 +02:00
Maksim Eltyshev
4effc0ce23 fix: Preserve newlines in markdown with mentions 2025-07-24 16:11:15 +02:00
Maksim Eltyshev
001621eec7 fix: Fix mentions input styles 2025-07-24 15:40:17 +02:00
Maksim Eltyshev
5beafca07e fix: Improve current board context indication 2025-07-23 17:59:57 +02:00
Maksim Eltyshev
d40b4d3224 feat: Add dedicated button to make project private 2025-07-23 15:37:16 +02:00
Maksim Eltyshev
ef35ec279e ref: Little refactoring 2025-07-22 22:08:18 +02:00
Maksim Eltyshev
6dfb5ba024 feat: Move infrequent card actions to more actions menu 2025-07-22 21:43:55 +02:00
dependabot[bot]
df023439c1 build(deps-dev): Bump form-data from 4.0.3 to 4.0.4 in /server (#1264)
Bumps [form-data](https://github.com/form-data/form-data) from 4.0.3 to 4.0.4.
- [Release notes](https://github.com/form-data/form-data/releases)
- [Changelog](https://github.com/form-data/form-data/blob/master/CHANGELOG.md)
- [Commits](https://github.com/form-data/form-data/compare/v4.0.3...v4.0.4)

---
updated-dependencies:
- dependency-name: form-data
  dependency-version: 4.0.4
  dependency-type: indirect
...

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

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

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

View File

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

View File

@@ -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

View File

@@ -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

View File

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

View File

@@ -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 \

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -52,7 +52,7 @@ Die Lizenz gewährt Ihnen das kostenlose Recht, die Software zu nutzen, zu modif
- Sie dürfen PLANKA nutzen oder modifizieren (a) für persönliche, Hobby- oder Bildungszwecke, (b) intern innerhalb Ihrer eigenen Organisation, (c) für privates Hosting für eine typische Anzahl von Freunden, Familie oder persönlichen Projekten, (d) um gemeinnützigen Organisationen (wie von den jeweiligen Steuerbehörden anerkannt) kostenlosen Zugang zu gewähren, oder wenn Sie selbst eine anerkannte gemeinnützige Organisation sind, die externe Nutzer in Ihre Mission einbezieht, und (e) öffentliche Bildungseinrichtungen ausschließlich für akademische/Forschungszwecke.
- 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.

View File

@@ -52,7 +52,7 @@ The license allows you the free right to use, modify, create derivative works, a
- You may use or modify PLANKA (a) for personal, hobby, or educational purposes, (b) internally within your own organization, (c) for private hosting for a typical number of friends, family, or personal projects, (d) to provide free access to non-profit organizations (as recognized by applicable tax authorities), or if you are a recognized non-profit organization yourself involving external users into your mission, and (e) public educational institutions for academic/research purposes only.
- 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.

View File

@@ -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.3
version: 1.0.5
# 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

View File

@@ -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 }}

View File

@@ -68,6 +68,7 @@ service:
ingress:
enabled: false
className: ""
labels: {}
annotations: {}
# kubernetes.io/ingress.class: nginx
# kubernetes.io/tls-acme: "true"
@@ -108,6 +109,11 @@ tolerations: []
affinity: {}
postgresql:
global:
security:
allowInsecureImages: true
image:
repository: bitnamilegacy/postgresql
enabled: true
auth:
database: planka
@@ -213,3 +219,22 @@ oidc:
## key: key-inside-the-secret
##
extraEnv: []
## Example extraEnv for configuring SMTP
## extraEnv:
## - name: SMTP_HOST
## value: "smtp.example.com"
## - name: SMTP_PORT
## value: "587"
## - name: SMTP_NAME
## value: "Your Name"
## - name: SMTP_SECURE
## value: "true"
## - name: SMTP_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"

4037
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

44
client/package.json Executable file → Normal file
View File

@@ -80,12 +80,12 @@
"dependencies": {
"@ballerina/highlightjs-ballerina": "^1.0.1",
"@diplodoc/cut-extension": "^0.7.4",
"@diplodoc/transform": "^4.57.7",
"@gravity-ui/components": "^4.4.0",
"@gravity-ui/markdown-editor": "^15.13.2",
"@gravity-ui/uikit": "^7.13.0",
"@diplodoc/transform": "^4.61.0",
"@gravity-ui/components": "^4.12.0",
"@gravity-ui/markdown-editor": "^15.19.1",
"@gravity-ui/uikit": "^7.20.0",
"@juggle/resize-observer": "^3.4.0",
"@vitejs/plugin-react": "^4.5.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,14 +114,14 @@
"highlightjs-zenscript": "^2.0.0",
"hightlightjs-papyrus": "^0.0.4",
"history": "^5.3.0",
"i18next": "^25.2.1",
"i18next-browser-languagedetector": "^8.1.0",
"i18next": "^25.4.2",
"i18next-browser-languagedetector": "^8.2.0",
"initials": "^3.1.2",
"javascript-time-ago": "^2.5.11",
"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",
@@ -136,8 +136,8 @@
"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.2",
"react-hot-toast": "^2.6.0",
"react-i18next": "^15.7.3",
"react-input-mask": "^2.0.4",
"react-intersection-observer": "^9.16.0",
"react-mentions": "^4.4.10",
@@ -152,34 +152,34 @@
"redux-saga": "^1.3.0",
"reselect": "^4.1.8",
"sails.io.js": "^1.2.1",
"sass-embedded": "^1.89.1",
"sass-embedded": "^1.91.0",
"semantic-ui-react": "^2.1.5",
"socket.io-client": "^2.5.0",
"validator": "^13.15.15",
"vite": "^6.3.5",
"vite": "^6.3.6",
"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",
"@babel/eslint-parser": "^7.28.0",
"@babel/preset-env": "^7.28.3",
"@cucumber/cucumber": "^11.3.0",
"@cucumber/pretty-formatter": "^1.0.1",
"@playwright/test": "^1.52.0",
"@playwright/test": "^1.55.0",
"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.1",
"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.55.0",
"prettier": "3.3.3"
}
}

View File

@@ -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 0e615c4..383338d 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) => {
@@ -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 });

View 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

View 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 || '';
});
}
});

View File

@@ -1,5 +1,5 @@
diff --git a/node_modules/semantic-ui-react/dist/es/lib/doesNodeContainClick.js b/node_modules/semantic-ui-react/dist/es/lib/doesNodeContainClick.js
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

View 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,
};

View File

@@ -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: {},
});

View File

@@ -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,

View File

@@ -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,
},
});

View File

@@ -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,
};

View File

@@ -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,

View File

@@ -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,

View File

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

View File

@@ -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,
};

View File

@@ -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
View 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,
};

View File

@@ -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,
};

View File

@@ -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,

View File

@@ -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,

View File

@@ -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
View 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,
};

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

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

View File

@@ -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',
})}

View File

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

View File

@@ -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}

View File

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

View File

@@ -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>

View File

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

View File

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

View File

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

View File

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

View File

@@ -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],
);

View File

@@ -21,7 +21,7 @@ const FiniteContent = React.memo(() => {
const handleCardCreate = useCallback(
(data, autoOpen) => {
dispatch(entryActions.createCardInFirstFiniteList(data, autoOpen));
dispatch(entryActions.createCardInFirstFiniteList(data, undefined, autoOpen));
},
[dispatch],
);

View File

@@ -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 />

View File

@@ -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;

View File

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

View File

@@ -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>

View File

@@ -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>
);
});

View File

@@ -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}

View File

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

View File

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

View File

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

View File

@@ -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}

View File

@@ -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 && (

View File

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

View File

@@ -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>
)}

View File

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

View File

@@ -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}>

View File

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

View File

@@ -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>
);
});

View File

@@ -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;
}
}

View File

@@ -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>

View File

@@ -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 {

View File

@@ -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} />

View File

@@ -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;
}
}
}

View File

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

View 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;

View File

@@ -0,0 +1,21 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.menu {
margin: -7px -12px -5px;
width: calc(100% + 24px);
}
.menuItem {
margin: 0;
padding-left: 14px;
}
.menuItemIcon {
float: left;
margin: 0 0.5em 0 0;
}
}

View File

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

View File

@@ -4,11 +4,10 @@
*/
import React, { useCallback, useContext, useMemo, useState } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { Button, Grid, Icon } from 'semantic-ui-react';
import { Button, Checkbox, Grid, Icon } from 'semantic-ui-react';
import { useDidUpdate } from '../../../lib/hooks';
import selectors from '../../../selectors';
@@ -24,13 +23,12 @@ import TaskLists from './TaskLists';
import CustomFieldGroups from './CustomFieldGroups';
import Communication from './Communication';
import CreationDetailsStep from './CreationDetailsStep';
import MoreActionsStep from './MoreActionsStep';
import DueDateChip from '../DueDateChip';
import StopwatchChip from '../StopwatchChip';
import SelectCardTypeStep from '../SelectCardTypeStep';
import EditDueDateStep from '../EditDueDateStep';
import EditStopwatchStep from '../EditStopwatchStep';
import MoveCardStep from '../MoveCardStep';
import Markdown from '../../common/Markdown';
import ExpandableMarkdown from '../../common/ExpandableMarkdown';
import EditMarkdown from '../../common/EditMarkdown';
import ConfirmationStep from '../../common/ConfirmationStep';
import UserAvatar from '../../users/UserAvatar';
@@ -45,7 +43,7 @@ import AddCustomFieldGroupStep from '../../custom-field-groups/AddCustomFieldGro
import styles from './ProjectContent.module.scss';
const ProjectContent = React.memo(({ onClose }) => {
const ProjectContent = React.memo(() => {
const selectListById = useMemo(() => selectors.makeSelectListById(), []);
const selectPrevListById = useMemo(() => selectors.makeSelectListById(), []);
@@ -155,17 +153,6 @@ const ProjectContent = React.memo(({ onClose }) => {
[dispatch],
);
const handleTypeSelect = useCallback(
(type) => {
dispatch(
entryActions.updateCurrentCard({
type,
}),
);
},
[dispatch],
);
const handleNameUpdate = useCallback(
(name) => {
dispatch(
@@ -188,6 +175,14 @@ const ProjectContent = React.memo(({ onClose }) => {
[dispatch],
);
const handleDueCompletionChange = useCallback(() => {
dispatch(
entryActions.updateCurrentCard({
isDueCompleted: !card.isDueCompleted,
}),
);
}, [card.isDueCompleted, dispatch]);
const handleToggleStopwatchClick = useCallback(() => {
dispatch(
entryActions.updateCurrentCard({
@@ -198,18 +193,6 @@ const ProjectContent = React.memo(({ onClose }) => {
);
}, [card.stopwatch, dispatch]);
const handleDuplicateClick = useCallback(() => {
dispatch(
entryActions.duplicateCurrentCard({
name: `${card.name} (${t('common.copy', {
context: 'inline',
})})`,
}),
);
onClose();
}, [onClose, card.name, dispatch, t]);
const handleRestoreClick = useCallback(() => {
dispatch(entryActions.moveCurrentCard(card.prevListId, undefined, true));
}, [card.prevListId, dispatch]);
@@ -304,13 +287,12 @@ const ProjectContent = React.memo(({ onClose }) => {
const BoardMembershipsPopup = usePopupInClosableContext(BoardMembershipsStep);
const LabelsPopup = usePopupInClosableContext(LabelsStep);
const ListsPopup = usePopupInClosableContext(ListsStep);
const SelectCardTypePopup = usePopupInClosableContext(SelectCardTypeStep);
const EditDueDatePopup = usePopupInClosableContext(EditDueDateStep);
const EditStopwatchPopup = usePopupInClosableContext(EditStopwatchStep);
const AddTaskListPopup = usePopupInClosableContext(AddTaskListStep);
const AddAttachmentPopup = usePopupInClosableContext(AddAttachmentStep);
const AddCustomFieldGroupPopup = usePopupInClosableContext(AddCustomFieldGroupStep);
const MoveCardPopup = usePopupInClosableContext(MoveCardStep);
const MoreActionsPopup = usePopupInClosableContext(MoreActionsStep);
const ConfirmationPopup = usePopupInClosableContext(ConfirmationStep);
return (
@@ -436,24 +418,31 @@ const ProjectContent = React.memo(({ onClose }) => {
context: 'title',
})}
</div>
<span className={styles.attachment}>
<span className={classNames(styles.attachment, styles.attachmentDueDate)}>
{canEditDueDate ? (
<EditDueDatePopup cardId={card.id}>
<DueDateChip
withStatusIcon
value={card.dueDate}
withStatus={
list.type !== ListTypes.CLOSED && !isInArchiveList && !isInTrashList
}
/>
</EditDueDatePopup>
<>
{!card.isClosed && (
<Checkbox
checked={card.isDueCompleted}
disabled={!canEditDueDate}
onChange={handleDueCompletionChange}
/>
)}
<EditDueDatePopup cardId={card.id}>
<DueDateChip
withStatusIcon
value={card.dueDate}
isCompleted={card.isDueCompleted}
withStatus={!card.isClosed}
/>
</EditDueDatePopup>
</>
) : (
<DueDateChip
withStatusIcon
value={card.dueDate}
withStatus={
list.type !== ListTypes.CLOSED && !isInArchiveList && !isInTrashList
}
isCompleted={card.isDueCompleted}
withStatus={!card.isClosed}
/>
)}
</span>
@@ -521,7 +510,7 @@ const ProjectContent = React.memo(({ onClose }) => {
<Button className={styles.editButton}>
<Icon fitted name="pencil" size="small" />
</Button>
<Markdown>{card.description}</Markdown>
<ExpandableMarkdown>{card.description}</ExpandableMarkdown>
</div>
) : (
<button
@@ -536,7 +525,7 @@ const ProjectContent = React.memo(({ onClose }) => {
))}
</>
)}
{!canEditDescription && <Markdown>{card.description}</Markdown>}
{!canEditDescription && <ExpandableMarkdown>{card.description}</ExpandableMarkdown>}
</div>
</div>
)}
@@ -710,40 +699,6 @@ const ProjectContent = React.memo(({ onClose }) => {
)}
</Button>
)}
{!board.limitCardTypesToDefaultOne && canEditType && (
<SelectCardTypePopup
withButton
defaultValue={card.type}
title="common.editType"
buttonContent="action.save"
onSelect={handleTypeSelect}
>
<Button fluid className={classNames(styles.actionButton, styles.hidable)}>
<Icon name="map outline" className={styles.actionIcon} />
{t('action.editType', {
context: 'title',
})}
</Button>
</SelectCardTypePopup>
)}
{canDuplicate && (
<Button
fluid
className={classNames(styles.actionButton, styles.hidable)}
onClick={handleDuplicateClick}
>
<Icon name="copy outline" className={styles.actionIcon} />
{t('action.duplicate')}
</Button>
)}
{canMove && (
<MoveCardPopup id={card.id}>
<Button fluid className={classNames(styles.actionButton, styles.hidable)}>
<Icon name="share square outline" className={styles.actionIcon} />
{t('action.move')}
</Button>
</MoveCardPopup>
)}
{canRestore && (isInArchiveList || isInTrashList) && (
<Button
fluid
@@ -793,6 +748,16 @@ const ProjectContent = React.memo(({ onClose }) => {
</Button>
</ConfirmationPopup>
)}
{((!board.limitCardTypesToDefaultOne && canEditType) ||
canDuplicate ||
canMove) && (
<MoreActionsPopup>
<Button fluid className={classNames(styles.moreActionsButton, styles.hidable)}>
<Icon name="ellipsis horizontal" className={styles.moreActionsButtonIcon} />
{t('common.moreActions')}
</Button>
</MoreActionsPopup>
)}
</div>
)}
</div>
@@ -802,8 +767,4 @@ const ProjectContent = React.memo(({ onClose }) => {
);
});
ProjectContent.propTypes = {
onClose: PropTypes.func.isRequired,
};
export default ProjectContent;

View File

@@ -60,6 +60,12 @@
vertical-align: top;
}
.attachmentDueDate {
align-items: center;
display: flex;
gap: 4px;
}
.attachments {
display: inline-block;
margin: 0 8px 8px 0;
@@ -260,6 +266,28 @@
position: relative;
}
.moreActionsButton {
background: transparent;
box-shadow: none;
color: #6b808c;
font-weight: normal;
margin-top: 8px;
padding: 6px 8px 6px 18px;
text-align: left;
text-decoration: underline;
transition: none;
&:hover {
background: rgba(9, 30, 66, 0.08);
color: #092d42;
}
}
.moreActionsButtonIcon {
margin-right: 8px;
text-decoration: none;
}
.sidebarPadding {
padding: 8px 16px 8px 8px;

View File

@@ -4,7 +4,6 @@
*/
import React, { useCallback, useContext, useMemo, useState } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
@@ -24,8 +23,7 @@ import NameField from '../NameField';
import CustomFieldGroups from '../CustomFieldGroups';
import Communication from '../Communication';
import CreationDetailsStep from '../CreationDetailsStep';
import SelectCardTypeStep from '../../SelectCardTypeStep';
import MoveCardStep from '../../MoveCardStep';
import MoreActionsStep from '../MoreActionsStep';
import Markdown from '../../../common/Markdown';
import EditMarkdown from '../../../common/EditMarkdown';
import ConfirmationStep from '../../../common/ConfirmationStep';
@@ -40,7 +38,7 @@ import AddCustomFieldGroupStep from '../../../custom-field-groups/AddCustomField
import styles from './StoryContent.module.scss';
const StoryContent = React.memo(({ onClose }) => {
const StoryContent = React.memo(() => {
const selectListById = useMemo(() => selectors.makeSelectListById(), []);
const selectPrevListById = useMemo(() => selectors.makeSelectListById(), []);
const selectAttachmentById = useMemo(() => selectors.makeSelectAttachmentById(), []);
@@ -150,17 +148,6 @@ const StoryContent = React.memo(({ onClose }) => {
[dispatch],
);
const handleTypeSelect = useCallback(
(type) => {
dispatch(
entryActions.updateCurrentCard({
type,
}),
);
},
[dispatch],
);
const handleNameUpdate = useCallback(
(name) => {
dispatch(
@@ -183,18 +170,6 @@ const StoryContent = React.memo(({ onClose }) => {
[dispatch],
);
const handleDuplicateClick = useCallback(() => {
dispatch(
entryActions.duplicateCurrentCard({
name: `${card.name} (${t('common.copy', {
context: 'inline',
})})`,
}),
);
onClose();
}, [onClose, card.name, dispatch, t]);
const handleRestoreClick = useCallback(() => {
dispatch(entryActions.moveCurrentCard(card.prevListId, undefined, true));
}, [card.prevListId, dispatch]);
@@ -300,10 +275,9 @@ const StoryContent = React.memo(({ onClose }) => {
const BoardMembershipsPopup = usePopupInClosableContext(BoardMembershipsStep);
const LabelsPopup = usePopupInClosableContext(LabelsStep);
const ListsPopup = usePopupInClosableContext(ListsStep);
const SelectCardTypePopup = usePopupInClosableContext(SelectCardTypeStep);
const AddAttachmentPopup = usePopupInClosableContext(AddAttachmentStep);
const AddCustomFieldGroupPopup = usePopupInClosableContext(AddCustomFieldGroupStep);
const MoveCardPopup = usePopupInClosableContext(MoveCardStep);
const MoreActionsPopup = usePopupInClosableContext(MoreActionsStep);
const ConfirmationPopup = usePopupInClosableContext(ConfirmationStep);
return (
@@ -614,40 +588,6 @@ const StoryContent = React.memo(({ onClose }) => {
)}
</Button>
)}
{!board.limitCardTypesToDefaultOne && canEditType && (
<SelectCardTypePopup
withButton
defaultValue={card.type}
title="common.editType"
buttonContent="action.save"
onSelect={handleTypeSelect}
>
<Button fluid className={classNames(styles.actionButton, styles.hidable)}>
<Icon name="map outline" className={styles.actionIcon} />
{t('action.editType', {
context: 'title',
})}
</Button>
</SelectCardTypePopup>
)}
{canDuplicate && (
<Button
fluid
className={classNames(styles.actionButton, styles.hidable)}
onClick={handleDuplicateClick}
>
<Icon name="copy outline" className={styles.actionIcon} />
{t('action.duplicate')}
</Button>
)}
{canMove && (
<MoveCardPopup id={card.id}>
<Button fluid className={classNames(styles.actionButton, styles.hidable)}>
<Icon name="share square outline" className={styles.actionIcon} />
{t('action.move')}
</Button>
</MoveCardPopup>
)}
{canRestore && (isInArchiveList || isInTrashList) && (
<Button
fluid
@@ -697,6 +637,16 @@ const StoryContent = React.memo(({ onClose }) => {
</Button>
</ConfirmationPopup>
)}
{((!board.limitCardTypesToDefaultOne && canEditType) ||
canDuplicate ||
canMove) && (
<MoreActionsPopup>
<Button fluid className={classNames(styles.moreActionsButton, styles.hidable)}>
<Icon name="ellipsis horizontal" className={styles.moreActionsButtonIcon} />
{t('common.moreActions')}
</Button>
</MoreActionsPopup>
)}
</div>
)}
</div>
@@ -706,8 +656,4 @@ const StoryContent = React.memo(({ onClose }) => {
);
});
StoryContent.propTypes = {
onClose: PropTypes.func.isRequired,
};
export default StoryContent;

View File

@@ -195,6 +195,7 @@
font-size: 28px;
font-weight: bold;
line-height: 34px;
word-wrap: break-word;
}
.headerTitleWrapper {
@@ -285,6 +286,28 @@
margin-bottom: 20px;
}
.moreActionsButton {
background: transparent;
box-shadow: none;
color: #6b808c;
font-weight: normal;
margin-top: 8px;
padding: 6px 8px 6px 18px;
text-align: left;
text-decoration: underline;
transition: none;
&:hover {
background: rgba(9, 30, 66, 0.08);
color: #092d42;
}
}
.moreActionsButtonIcon {
margin-right: 8px;
text-decoration: none;
}
.sidebarPadding {
padding: 8px 16px 8px 8px;

View File

@@ -35,8 +35,9 @@ const EditStep = React.memo(({ taskListId, onClose }) => {
() => ({
name: taskList.name,
showOnFrontOfCard: taskList.showOnFrontOfCard,
hideCompletedTasks: taskList.hideCompletedTasks,
}),
[taskList.name, taskList.showOnFrontOfCard],
[taskList.name, taskList.showOnFrontOfCard, taskList.hideCompletedTasks],
);
const [data, handleFieldChange] = useForm(() => ({
@@ -44,6 +45,7 @@ const EditStep = React.memo(({ taskListId, onClose }) => {
context: 'title',
}),
showOnFrontOfCard: true,
hideCompletedTasks: false,
...defaultData,
}));

View File

@@ -3,13 +3,14 @@
* 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 ReactDOM from 'react-dom';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useSelector } from 'react-redux';
import { Draggable } from 'react-beautiful-dnd';
import { Button, Icon } from 'semantic-ui-react';
import { useToggle } from '../../../../lib/hooks';
import selectors from '../../../../selectors';
import { usePopupInClosableContext } from '../../../../hooks';
@@ -29,8 +30,16 @@ const Item = React.memo(({ id, index }) => {
return !!boardMembership && boardMembership.role === BoardMembershipRoles.EDITOR;
});
const [isCompletedVisible, toggleCompletedVisible] = useToggle();
const handleToggleCompletedVisibleClick = useCallback(() => {
toggleCompletedVisible();
}, [toggleCompletedVisible]);
const EditPopup = usePopupInClosableContext(EditStep);
const withActions = taskList.hideCompletedTasks || canEdit;
return (
<Draggable
draggableId={`task-list:${id}`}
@@ -51,20 +60,37 @@ const Item = React.memo(({ id, index }) => {
<div
className={classNames(
styles.moduleHeader,
canEdit && styles.moduleHeaderEditable,
withActions && styles.moduleHeaderWithActions,
taskList.hideCompletedTasks && canEdit && styles.both,
)}
>
{taskList.isPersisted && canEdit && (
<EditPopup taskListId={taskList.id}>
<Button className={styles.editButton}>
<Icon fitted name="pencil" size="small" />
</Button>
</EditPopup>
{taskList.isPersisted && withActions && (
<div className={classNames(styles.actions)}>
{taskList.hideCompletedTasks && (
<Button
className={styles.button}
onClick={handleToggleCompletedVisibleClick}
>
<Icon
fitted
name={isCompletedVisible ? 'eye slash' : 'eye'}
size="small"
/>
</Button>
)}
{canEdit && (
<EditPopup taskListId={taskList.id}>
<Button className={styles.button}>
<Icon fitted name="pencil" size="small" />
</Button>
</EditPopup>
)}
</div>
)}
<span className={styles.moduleHeaderTitle}>{taskList.name}</span>
</div>
</div>
<TaskList id={id} />
<TaskList id={id} isCompletedVisible={isCompletedVisible} />
</div>
</div>
);

View File

@@ -4,7 +4,15 @@
*/
:global(#app) {
.editButton {
.actions {
display: flex;
gap: 2px;
position: absolute;
right: 0;
top: 4px;
}
.button {
background: transparent;
box-shadow: none;
line-height: 28px;
@@ -12,9 +20,6 @@
min-height: auto;
opacity: 0;
padding: 0;
position: absolute;
right: 0;
top: 4px;
width: 28px;
&:hover {
@@ -32,14 +37,18 @@
padding: 6px 0;
}
.moduleHeaderEditable {
.moduleHeaderWithActions {
padding-right: 32px;
&:hover {
.editButton {
.button {
opacity: 1;
}
}
&.both {
padding-right: 62px;
}
}
.moduleHeaderTitle {

View File

@@ -24,6 +24,7 @@ const Sizes = {
const Statuses = {
DUE_SOON: 'dueSoon',
OVERDUE: 'overdue',
COMPLETED: 'completed',
};
const LONG_DATE_FORMAT_BY_SIZE = {
@@ -47,9 +48,17 @@ const STATUS_ICON_PROPS_BY_STATUS = {
name: 'hourglass end',
color: 'red',
},
[Statuses.COMPLETED]: {
name: 'checkmark',
color: 'green',
},
};
const getStatus = (date) => {
const getStatus = (date, isCompleted) => {
if (isCompleted) {
return Statuses.COMPLETED;
}
const secondsLeft = Math.floor((date.getTime() - new Date().getTime()) / 1000);
if (secondsLeft <= 0) {
@@ -64,12 +73,12 @@ const getStatus = (date) => {
};
const DueDateChip = React.memo(
({ value, size, isDisabled, withStatus, withStatusIcon, onClick }) => {
({ value, size, isCompleted, isDisabled, withStatus, withStatusIcon, onClick }) => {
const [t] = useTranslation();
const forceUpdate = useForceUpdate();
const statusRef = useRef(null);
statusRef.current = withStatus ? getStatus(value) : null;
statusRef.current = withStatus ? getStatus(value, isCompleted) : null;
const intervalRef = useRef(null);
@@ -80,9 +89,13 @@ const DueDateChip = React.memo(
);
useEffect(() => {
if (withStatus && statusRef.current !== Statuses.OVERDUE) {
if (
withStatus &&
statusRef.current !== Statuses.OVERDUE &&
statusRef.current !== Statuses.COMPLETED
) {
intervalRef.current = setInterval(() => {
const status = getStatus(value);
const status = getStatus(value, isCompleted);
if (status !== statusRef.current) {
forceUpdate();
@@ -99,7 +112,7 @@ const DueDateChip = React.memo(
clearInterval(intervalRef.current);
}
};
}, [value, withStatus, forceUpdate]);
}, [value, isCompleted, withStatus, forceUpdate]);
const contentNode = (
<span
@@ -134,6 +147,7 @@ const DueDateChip = React.memo(
DueDateChip.propTypes = {
value: PropTypes.instanceOf(Date).isRequired,
size: PropTypes.oneOf(Object.values(Sizes)),
isCompleted: PropTypes.bool.isRequired,
isDisabled: PropTypes.bool,
withStatus: PropTypes.bool.isRequired,
withStatusIcon: PropTypes.bool,

View File

@@ -62,4 +62,9 @@
background: #db2828;
color: #fff;
}
.wrapperCompleted {
background: #21ba45;
color: #fff;
}
}

View File

@@ -3,7 +3,8 @@
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import React, { useCallback, useState, useRef } from 'react';
import keyBy from 'lodash/keyBy';
import React, { useCallback, useState, useRef, useMemo } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { Mention, MentionsInput } from 'react-mentions';
@@ -13,6 +14,7 @@ import { useClickAwayListener, useDidUpdate, useToggle } from '../../../lib/hook
import selectors from '../../../selectors';
import entryActions from '../../../entry-actions';
import { useEscapeInterceptor, useForm, useNestedRef } from '../../../hooks';
import { isUsernameChar, mentionTextToMarkup } from '../../../utils/mentions';
import { isModifierKeyPressed } from '../../../utils/event-helpers';
import UserAvatar from '../../users/UserAvatar';
@@ -31,35 +33,45 @@ const Add = React.memo(() => {
const [isOpened, setIsOpened] = useState(false);
const [selectTextFieldState, selectTextField] = useToggle();
const mentionsInputRef = useRef(null);
const textFieldRef = useRef(null);
const textMentionsRef = useRef(null);
const textInputRef = useRef(null);
const [buttonRef, handleButtonRef] = useNestedRef();
const userByUsername = useMemo(
() =>
keyBy(
boardMemberships.flatMap(({ user }) => (user.username ? user : [])),
'username',
),
[boardMemberships],
);
const submit = useCallback(() => {
const cleanData = {
...data,
text: data.text.trim(),
text: mentionTextToMarkup(data.text.trim(), userByUsername),
};
if (!cleanData.text) {
textFieldRef.current.select();
textInputRef.current.select();
return;
}
dispatch(entryActions.createCommentInCurrentCard(cleanData));
setData(DEFAULT_DATA);
selectTextField();
}, [dispatch, data, setData, selectTextField, textFieldRef]);
}, [dispatch, data, setData, selectTextField, userByUsername]);
const handleEscape = useCallback(() => {
if (mentionsInputRef.current.isOpened()) {
mentionsInputRef.current.clearSuggestions();
if (textMentionsRef.current.isOpened()) {
textMentionsRef.current.clearSuggestions();
return;
}
setIsOpened(false);
textFieldRef.current.blur();
}, [textFieldRef]);
textInputRef.current.blur();
}, []);
const [activateEscapeInterceptor, deactivateEscapeInterceptor] =
useEscapeInterceptor(handleEscape);
@@ -75,10 +87,10 @@ const Add = React.memo(() => {
const handleFieldChange = useCallback(
(_, text) => {
setData({
text,
text: !isUsernameChar(text.slice(-1)) ? mentionTextToMarkup(text, userByUsername) : text,
});
},
[setData],
[setData, userByUsername],
);
const handleFieldKeyDown = useCallback(
@@ -95,8 +107,8 @@ const Add = React.memo(() => {
}, []);
const handleClickAwayCancel = useCallback(() => {
textFieldRef.current.focus();
}, [textFieldRef]);
textInputRef.current.focus();
}, []);
const clickAwayProps = useClickAwayListener(
[textFieldRef, buttonRef],
@@ -123,18 +135,18 @@ const Add = React.memo(() => {
}, [isOpened]);
useDidUpdate(() => {
textFieldRef.current.focus();
textInputRef.current.focus();
}, [selectTextFieldState]);
return (
<Form onSubmit={handleSubmit}>
<div className={styles.field}>
<div ref={textFieldRef} className={styles.field}>
<MentionsInput
{...clickAwayProps} // eslint-disable-line react/jsx-props-no-spreading
allowSpaceInQuery
allowSuggestionsAboveCursor
ref={mentionsInputRef}
inputRef={textFieldRef}
ref={textMentionsRef}
inputRef={textInputRef}
value={data.text}
placeholder={t('common.writeComment')}
maxLength={1048576}

View File

@@ -17,23 +17,15 @@
.field {
background: #fff;
margin-bottom: 8px !important;
border-radius: 3px;
textarea {
border: 1px solid rgba(9, 30, 66, 0.13);
border-radius: 3px;
box-sizing: border-box;
color: #333;
display: block;
line-height: 1.4;
font-size: 14px;
overflow: hidden;
margin: 0 !important;
padding: 8px 12px;
resize: none;
&:focus {
outline: none;
}
}
}

View File

@@ -4,6 +4,7 @@
*/
import { dequal } from 'dequal';
import { keyBy } from 'lodash';
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
@@ -15,6 +16,7 @@ import { useClickAwayListener } from '../../../lib/hooks';
import selectors from '../../../selectors';
import entryActions from '../../../entry-actions';
import { useForm, useNestedRef } from '../../../hooks';
import { isUsernameChar, mentionTextToMarkup } from '../../../utils/mentions';
import { focusEnd } from '../../../utils/element-helpers';
import { isModifierKeyPressed } from '../../../utils/event-helpers';
import UserAvatar from '../../users/UserAvatar';
@@ -42,14 +44,25 @@ const Edit = React.memo(({ commentId, onClose }) => {
...defaultData,
}));
const mentionsInputRef = useRef(null);
const textFieldRef = useRef(null);
const [buttonRef, handleButtonRef] = useNestedRef();
const textMentionsRef = useRef(null);
const textInputRef = useRef(null);
const [submitButtonRef, handleSubmitButtonRef] = useNestedRef();
const [cancelButtonRef, handleCancelButtonRef] = useNestedRef();
const userByUsername = useMemo(
() =>
keyBy(
boardMemberships.flatMap(({ user }) => (user.username ? user : [])),
'username',
),
[boardMemberships],
);
const submit = useCallback(() => {
const cleanData = {
...data,
text: data.text.trim(),
text: mentionTextToMarkup(data.text.trim(), userByUsername),
};
if (cleanData.text && !dequal(cleanData, defaultData)) {
@@ -57,7 +70,7 @@ const Edit = React.memo(({ commentId, onClose }) => {
}
onClose();
}, [commentId, onClose, dispatch, defaultData, data]);
}, [commentId, onClose, dispatch, defaultData, data, userByUsername]);
const handleSubmit = useCallback(() => {
submit();
@@ -66,10 +79,10 @@ const Edit = React.memo(({ commentId, onClose }) => {
const handleFieldChange = useCallback(
(_, text) => {
setData({
text,
text: !isUsernameChar(text.slice(-1)) ? mentionTextToMarkup(text, userByUsername) : text,
});
},
[setData],
[setData, userByUsername],
);
const handleFieldKeyDown = useCallback(
@@ -79,18 +92,27 @@ const Edit = React.memo(({ commentId, onClose }) => {
submit();
}
} else if (event.key === 'Escape') {
if (textMentionsRef.current.isOpened()) {
textMentionsRef.current.clearSuggestions();
return;
}
onClose();
}
},
[onClose, submit],
);
const handleCancelClick = useCallback(() => {
onClose();
}, [onClose]);
const handleClickAwayCancel = useCallback(() => {
textFieldRef.current.focus();
}, [textFieldRef]);
textInputRef.current.focus();
}, []);
const clickAwayProps = useClickAwayListener(
[textFieldRef, buttonRef],
[textFieldRef, submitButtonRef, cancelButtonRef],
submit,
handleClickAwayCancel,
);
@@ -106,18 +128,18 @@ const Edit = React.memo(({ commentId, onClose }) => {
);
useEffect(() => {
focusEnd(textFieldRef.current);
}, [textFieldRef]);
focusEnd(textInputRef.current);
}, []);
return (
<Form onSubmit={handleSubmit}>
<div className={styles.field}>
<div ref={textFieldRef} className={styles.field}>
<MentionsInput
{...clickAwayProps} // eslint-disable-line react/jsx-props-no-spreading
allowSpaceInQuery
allowSuggestionsAboveCursor
ref={mentionsInputRef}
inputRef={textFieldRef}
ref={textMentionsRef}
inputRef={textInputRef}
value={data.text}
maxLength={1048576}
rows={3}
@@ -146,9 +168,16 @@ const Edit = React.memo(({ commentId, onClose }) => {
<Button
{...clickAwayProps} // eslint-disable-line react/jsx-props-no-spreading
positive
ref={handleButtonRef}
ref={handleSubmitButtonRef}
content={t('action.save')}
/>
<Button
{...clickAwayProps} // eslint-disable-line react/jsx-props-no-spreading
ref={handleCancelButtonRef}
type="button"
content={t('action.cancel')}
onClick={handleCancelClick}
/>
</div>
</Form>
);

View File

@@ -11,22 +11,15 @@
.field {
background: #fff;
border-radius: 3px;
textarea {
border: 1px solid rgba(9, 30, 66, 0.13);
border-radius: 3px;
box-sizing: border-box;
color: #333;
display: block;
line-height: 1.4;
font-size: 14px;
overflow: hidden;
margin: 0 !important;
padding: 8px 12px;
resize: none;
&:focus {
outline: none;
}
}
}

View File

@@ -60,15 +60,14 @@ const Item = React.memo(({ id }) => {
isEditor = boardMembership.role === BoardMembershipRoles.EDITOR;
}
const canEditOrDeleteAsMember =
isMember &&
comment.userId === boardMembership.userId &&
(isEditor || boardMembership.canComment);
return {
canEdit:
isMember &&
comment.userId === boardMembership.userId &&
(isEditor || boardMembership.canComment),
canDelete:
isManager ||
isEditor ||
(isMember && comment.userId === boardMembership.userId && boardMembership.canComment),
canEdit: canEditOrDeleteAsMember,
canDelete: isManager || canEditOrDeleteAsMember,
};
}, shallowEqual);

View File

@@ -23,7 +23,7 @@
}
.bubbleRight {
background: #e7f0e2;
background: #dee7f4;
border-radius: 8px 0 8px 8px;
float: right;
}

View File

@@ -5,17 +5,22 @@
import React, { useCallback, useState } from 'react';
import classNames from 'classnames';
import { useDispatch } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { Modal, Tab } from 'semantic-ui-react';
import selectors from '../../../selectors';
import entryActions from '../../../entry-actions';
import { useClosableModal } from '../../../hooks';
import UsersPane from './UsersPane';
import SmtpPane from './SmtpPane';
import WebhooksPane from './WebhooksPane';
import styles from './AdministrationModal.module.scss';
const AdministrationModal = React.memo(() => {
const config = useSelector(selectors.selectConfig);
const dispatch = useDispatch();
const [t] = useTranslation();
const [activeTabIndex, setActiveTabIndex] = useState(0);
@@ -38,6 +43,20 @@ const AdministrationModal = React.memo(() => {
render: () => <UsersPane />,
},
];
if (config.smtpHost !== undefined) {
panes.push({
menuItem: t('common.smtp', {
context: 'title',
}),
render: () => <SmtpPane />,
});
}
panes.push({
menuItem: t('common.webhooks', {
context: 'title',
}),
render: () => <WebhooksPane />,
});
const isUsersPaneActive = activeTabIndex === 0;

View File

@@ -0,0 +1,227 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import { dequal } from 'dequal';
import React, { useCallback, useMemo } from 'react';
import classNames from 'classnames';
import { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import TextareaAutosize from 'react-textarea-autosize';
import { Button, Checkbox, Divider, Form, Header, Tab, TextArea } from 'semantic-ui-react';
import { Input } from '../../../lib/custom-ui';
import selectors from '../../../selectors';
import entryActions from '../../../entry-actions';
import { useForm, useNestedRef } from '../../../hooks';
import styles from './SmtpPane.module.scss';
const SmtpPane = React.memo(() => {
const config = useSelector(selectors.selectConfig);
const smtpTest = useSelector(selectors.selectSmtpTest);
const dispatch = useDispatch();
const [t] = useTranslation();
const [passwordFieldRef, handlePasswordFieldRef] = useNestedRef('inputRef');
const defaultData = useMemo(
() => ({
smtpHost: config.smtpHost,
smtpPort: config.smtpPort,
smtpName: config.smtpName,
smtpSecure: config.smtpSecure,
smtpTlsRejectUnauthorized: config.smtpTlsRejectUnauthorized,
smtpUser: config.smtpUser,
smtpPassword: config.smtpPassword,
smtpFrom: config.smtpFrom,
}),
[config],
);
const [data, handleFieldChange] = useForm(() => ({
...defaultData,
smtpHost: defaultData.smtpHost || '',
smtpPort: defaultData.smtpPort || '',
smtpName: defaultData.smtpName || '',
smtpSecure: defaultData.smtpSecure,
smtpTlsRejectUnauthorized: defaultData.smtpTlsRejectUnauthorized,
smtpUser: defaultData.smtpUser || '',
smtpPassword: defaultData.smtpPassword || '',
smtpFrom: defaultData.smtpFrom || '',
}));
const isPasswordSet = defaultData.smtpPassword === undefined;
const cleanData = useMemo(
() => ({
...data,
smtpHost: data.smtpHost.trim() || null,
smtpPort: parseInt(data.smtpPort, 10) || null,
smtpName: data.smtpName.trim() || null,
smtpUser: data.smtpUser.trim() || null,
smtpPassword: data.smtpPassword || (isPasswordSet ? undefined : null),
smtpFrom: data.smtpFrom.trim() || null,
}),
[data, isPasswordSet],
);
const handleSubmit = useCallback(() => {
dispatch(entryActions.updateConfig(cleanData));
}, [dispatch, cleanData]);
const handlePasswordClear = useCallback(() => {
dispatch(
entryActions.updateConfig({
smtpPassword: null,
}),
);
passwordFieldRef.current.focus();
}, [dispatch, passwordFieldRef]);
const handleTestClick = useCallback(() => {
dispatch(entryActions.testSmtpConfig());
}, [dispatch]);
const isModified = !dequal(cleanData, defaultData);
return (
<Tab.Pane attached={false} className={styles.wrapper}>
<Form onSubmit={handleSubmit}>
<div className={styles.text}>{t('common.host')}</div>
<Input
fluid
name="smtpHost"
value={data.smtpHost}
maxLength={256}
className={styles.field}
onChange={handleFieldChange}
/>
<div className={styles.text}>{t('common.port')}</div>
<Input
fluid
type="number"
name="smtpPort"
value={data.smtpPort}
placeholder={data.smtpSecure ? '465' : '587'}
min={0}
max={65535}
step={1}
className={styles.field}
onChange={handleFieldChange}
/>
<div className={styles.text}>
{t('common.clientHostnameInEhlo')} (
{t('common.optional', {
context: 'inline',
})}
)
</div>
<Input
fluid
name="smtpName"
value={data.smtpName}
maxLength={256}
className={styles.field}
onChange={handleFieldChange}
/>
<Checkbox
name="smtpSecure"
checked={data.smtpSecure}
label={t('common.useSecureConnection')}
className={styles.checkbox}
onChange={handleFieldChange}
/>
<Checkbox
name="smtpTlsRejectUnauthorized"
checked={data.smtpTlsRejectUnauthorized}
label={t('common.rejectUnauthorizedTlsCertificates')}
className={classNames(styles.field, styles.checkbox)}
onChange={handleFieldChange}
/>
<div className={styles.text}>
{t('common.username')} (
{t('common.optional', {
context: 'inline',
})}
)
</div>
<Input
fluid
name="smtpUser"
value={data.smtpUser}
maxLength={256}
className={styles.field}
onChange={handleFieldChange}
/>
<div className={styles.text}>
{t('common.password')} (
{t('common.optional', {
context: 'inline',
})}
)
</div>
<Input.Password
fluid
ref={handlePasswordFieldRef}
name="smtpPassword"
value={data.smtpPassword}
placeholder={isPasswordSet ? t('common.passwordIsSet') : undefined}
maxLength={256}
className={styles.field}
onClear={!data.smtpPassword && isPasswordSet ? handlePasswordClear : undefined}
onChange={handleFieldChange}
/>
<div className={styles.text}>
{t('common.defaultFrom')} (
{t('common.optional', {
context: 'inline',
})}
)
</div>
<Input
fluid
name="smtpFrom"
value={data.smtpFrom}
maxLength={256}
className={styles.field}
onChange={handleFieldChange}
/>
<div className={styles.controls}>
<Button positive disabled={!isModified} content={t('action.save')} />
{config.smtpHost && !isModified && (
<Button
type="button"
content={t('action.sendTestEmail')}
loading={smtpTest.isLoading}
disabled={smtpTest.isLoading}
onClick={handleTestClick}
/>
)}
</div>
</Form>
{smtpTest.logs && (
<>
<Divider horizontal>
<Header as="h4">
{t('common.testLog', {
context: 'title',
})}
</Header>
</Divider>
<TextArea
readOnly
as={TextareaAutosize}
value={smtpTest.logs.join('\n')}
className={styles.testLog}
/>
</>
)}
</Tab.Pane>
);
});
export default SmtpPane;

View File

@@ -0,0 +1,41 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.checkbox {
display: block;
padding: 8px 0;
}
.controls {
display: flex;
justify-content: space-between;
}
.field {
margin-bottom: 8px;
}
.testLog {
border: 1px solid rgba(9, 30, 66, 0.13);
border-radius: 3px;
color: #333;
line-height: 1.4;
padding: 8px 12px;
width: 100%;
}
.text {
color: #444444;
font-size: 12px;
font-weight: bold;
padding-bottom: 6px;
}
.wrapper {
border: none;
box-shadow: none;
}
}

View File

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

View File

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

View File

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

View File

@@ -22,8 +22,8 @@ const UsersPane = React.memo(() => {
const activeUsersTotal = useSelector(selectors.selectActiveUsersTotal);
const canAdd = useSelector((state) => {
const oidcConfig = selectors.selectOidcConfig(state);
return !oidcConfig || !oidcConfig.isEnforced;
const oidcBootstrap = selectors.selectOidcBootstrap(state);
return !oidcBootstrap || !oidcBootstrap.isEnforced;
});
const [t] = useTranslation();

View File

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

View File

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

View File

@@ -19,7 +19,17 @@ const ButtonTypes = {
};
const ConfirmationStep = React.memo(
({ title, content, buttonType, buttonContent, typeValue, typeContent, onConfirm, onBack }) => {
({
title,
content,
buttonType,
buttonContent,
typeValue,
typeContent,
onConfirm,
onBack,
onClose,
}) => {
const [t] = useTranslation();
const [data, handleFieldChange] = useForm({
@@ -28,21 +38,30 @@ const ConfirmationStep = React.memo(
const [nameFieldRef, handleNameFieldRef] = useNestedRef('inputRef');
const handleSubmit = useCallback(() => {
if (typeValue) {
const cleanData = {
...data,
typeValue: data.typeValue.trim(),
};
const handleSubmit = useCallback(
(event) => {
event.stopPropagation();
if (cleanData.typeValue.toLowerCase() !== typeValue.toLowerCase()) {
nameFieldRef.current.select();
return;
if (typeValue) {
const cleanData = {
...data,
typeValue: data.typeValue.trim(),
};
if (cleanData.typeValue.toLowerCase() !== typeValue.toLowerCase()) {
nameFieldRef.current.select();
return;
}
}
}
onConfirm();
}, [typeValue, onConfirm, data, nameFieldRef]);
onConfirm();
if (onClose) {
onClose();
}
},
[typeValue, onConfirm, onClose, data, nameFieldRef],
);
useEffect(() => {
if (typeValue) {
@@ -96,6 +115,7 @@ ConfirmationStep.propTypes = {
typeContent: PropTypes.string,
onConfirm: PropTypes.func.isRequired,
onBack: PropTypes.func,
onClose: PropTypes.func,
};
ConfirmationStep.defaultProps = {
@@ -103,6 +123,7 @@ ConfirmationStep.defaultProps = {
typeValue: undefined,
typeContent: undefined,
onBack: undefined,
onClose: undefined,
};
export default ConfirmationStep;

View File

@@ -30,8 +30,8 @@ const Core = React.memo(() => {
// TODO: move to selector?
const isNewVersionAvailable = useSelector((state) => {
const config = selectors.selectConfig(state);
return !!config && config.version !== version;
const bootstrap = selectors.selectBootstrap(state);
return !!bootstrap && bootstrap.version !== version;
});
const [t] = useTranslation();

View File

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

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