Compare commits

..

2 Commits

Author SHA1 Message Date
Maksim Eltyshev
cec09b2db2 chore: Update version 2025-09-04 13:53:03 +02:00
Maksim Eltyshev
94410ee994 fix: Patch react-photoswipe-gallery to prevent XSS in captions 2025-09-04 13:50:54 +02:00
695 changed files with 5724 additions and 37918 deletions

View File

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

View File

@@ -13,9 +13,9 @@ jobs:
runs-on: ubuntu-latest
env:
POSTGRES_USERNAME: planka
POSTGRES_PASSWORD: planka
POSTGRES_DATABASE: planka
POSTGRES_DB: planka
POSTGRES_USER: user
POSTGRES_PASSWORD: password
steps:
- name: Checkout repository
@@ -24,15 +24,15 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
node-version: '18'
cache: 'npm'
- name: Set up PostgreSQL
uses: ikalnytskyi/action-setup-postgres@v5
with:
username: ${{ env.POSTGRES_USERNAME }}
database: ${{ env.POSTGRES_DB }}
username: ${{ env.POSTGRES_USER }}
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_USERNAME}:${POSTGRES_PASSWORD}@localhost/${POSTGRES_DATABASE}|" .env
sed -i "s|^DATABASE_URL=.*|DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost/${POSTGRES_DB}|" .env
npm run db:init
npm start --prod &
@@ -67,12 +67,6 @@ 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: '22'
node-version: '18'
cache: 'npm'
- name: Cache Node.js modules

View File

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

View File

@@ -1,4 +1,4 @@
FROM node:22-alpine
FROM node:lts-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 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.
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.
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 Dritte zu 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 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 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.
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.
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 Dritte zu 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 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 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.
- 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.
- You may not alter, remove, or obscure any licensing, copyright, or other notices from the software provided by the licensor. Any use of the licensor's trademarks is subject to applicable law.

View File

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

View File

@@ -109,122 +109,3 @@ If you want to host PLANKA for more than just playing around with, you might wan
- Specify a password for `postgresql.auth.password` as there have been issues with the postgresql chart generating new passwords locking you out of the data you've already stored. (see [this issue](https://github.com/bitnami/charts/issues/2061))
Any questions or concerns, [raise an issue](https://github.com/Chris-Greaves/planka-helm-chart/issues/new).
## Advanced Configuration
### Extra Volume Mounts
The Helm chart supports mounting arbitrary ConfigMaps, Secrets, and Volumes to the PLANKA deployment using the `extraMounts` configuration. This is especially useful for scenarios like:
- Mounting custom CA certificates for OIDC with self-hosted identity providers
- Adding custom configuration files
- Mounting TLS certificates from existing secrets
- Adding temporary or persistent storage volumes
**Note**: ConfigMaps and Secrets must be created separately before referencing them in `extraMounts`.
#### Basic Usage
Use the `extraMounts` section to mount any type of volume:
```yaml
extraMounts:
# Mount CA certificate from existing ConfigMap
- name: ca-certs
mountPath: /etc/ssl/certs/custom-ca.crt
subPath: ca.crt
readOnly: true
configMap:
name: ca-certificates # Must exist
# Mount TLS certificates from existing Secret
- name: tls-certs
mountPath: /etc/ssl/private
readOnly: true
secret:
secretName: planka-tls-secret # Must exist
items:
- key: tls.crt
path: server.crt
- key: tls.key
path: server.key
# Temporary storage
- name: temp-storage
mountPath: /tmp/planka-temp
readOnly: false
emptyDir:
sizeLimit: 1Gi
# Host path mount
- name: backup-storage
mountPath: /var/lib/planka-backups
readOnly: false
hostPath:
path: /var/lib/planka-backups
type: DirectoryOrCreate
# NFS mount
- name: nfs-storage
mountPath: /shared/data
readOnly: false
nfs:
server: nfs.example.com
path: /exports/planka
```
### OIDC with Self-Hosted Keycloak
A common use case is configuring OIDC with a self-hosted Keycloak instance that uses custom CA certificates.
First, create the CA certificate ConfigMap:
```bash
kubectl create configmap ca-certificates --from-file=ca.crt=/path/to/your/ca.crt
```
Then configure the chart:
```yaml
# Mount custom CA certificate from existing ConfigMap
extraMounts:
- name: keycloak-ca
mountPath: /etc/ssl/certs/keycloak-ca.crt
subPath: ca.crt
readOnly: true
configMap:
name: ca-certificates
# Configure Node.js to trust the custom CA
extraEnv:
- name: NODE_EXTRA_CA_CERTS
value: "/etc/ssl/certs/keycloak-ca.crt"
# Enable OIDC
oidc:
enabled: true
clientId: "planka-client"
clientSecret: "your-client-secret"
issuerUrl: "https://keycloak.example.com/realms/master"
admin:
roles:
- "planka-admin"
```
### Environment Variables from Secrets
You can reference values from existing secrets in environment variables:
```yaml
extraEnv:
- name: SMTP_PASSWORD
valueFrom:
secretName: smtp-credentials
key: password
- name: CUSTOM_API_KEY
valueFrom:
secretName: api-credentials
key: api-key
```
### Complete Example
See `values-example.yaml` for a comprehensive example that demonstrates all the advanced features including OIDC configuration with custom CA certificates.

View File

@@ -4,10 +4,6 @@ metadata:
name: {{ include "planka.fullname" . }}
labels:
{{- include "planka.labels" . | nindent 4 }}
{{- with .Values.deploymentAnnotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if not .Values.autoscaling.enabled }}
replicas: {{ .Values.replicaCount }}
@@ -71,19 +67,6 @@ spec:
subPath: app-logs
name: emptydir
{{- end }}
{{- /* Extra volume mounts */}}
{{- range .Values.extraMounts }}
- name: {{ .name }}
mountPath: {{ .mountPath }}
{{- if .subPath }}
subPath: {{ .subPath }}
{{- end }}
{{- if hasKey . "readOnly" }}
readOnly: {{ .readOnly }}
{{- else }}
readOnly: true
{{- end }}
{{- end }}
resources:
{{- toYaml .Values.resources | nindent 12 }}
env:
@@ -213,31 +196,4 @@ spec:
{{- if .Values.securityContext.readOnlyRootFilesystem }}
- name: emptydir
emptyDir: {}
{{- end }}
{{- /* Extra volumes */}}
{{- range .Values.extraMounts }}
- name: {{ .name }}
{{- if .configMap }}
configMap:
{{- toYaml .configMap | nindent 12 }}
{{- else if .secret }}
secret:
{{- toYaml .secret | nindent 12 }}
{{- else if .emptyDir }}
emptyDir:
{{- toYaml .emptyDir | nindent 12 }}
{{- else if .hostPath }}
hostPath:
{{- toYaml .hostPath | nindent 12 }}
{{- else if .persistentVolumeClaim }}
persistentVolumeClaim:
{{- toYaml .persistentVolumeClaim | nindent 12 }}
{{- else if .nfs }}
nfs:
{{- toYaml .nfs | nindent 12 }}
{{- else }}
{{- /* Support any other volume type by removing known mount-specific keys */}}
{{- $volume := omit . "name" "mountPath" "subPath" "readOnly" }}
{{- toYaml $volume | nindent 10 }}
{{- end }}
{{- end }}
{{- end }}

View File

@@ -18,9 +18,6 @@ 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

@@ -27,11 +27,6 @@ existingSecretkeySecret: ""
## NOTE: When it's set, the `admin_username` and `admin_password` parameters are ignored
existingAdminCredsSecret: ""
admin_email: ""
admin_password: ""
admin_name: ""
admin_username: ""
# Base url for PLANKA. Will override `ingress.hosts[0].host`
# Defaults to `http://localhost:3000` if ingress is disabled.
baseUrl: ""
@@ -50,9 +45,6 @@ podAnnotations: {}
podSecurityContext: {}
# fsGroup: 2000
# Annotations to add to the deployment
deploymentAnnotations: {}
securityContext: {}
# capabilities:
# drop:
@@ -73,7 +65,6 @@ service:
ingress:
enabled: false
className: ""
labels: {}
annotations: {}
# kubernetes.io/ingress.class: nginx
# kubernetes.io/tls-acme: "true"
@@ -114,11 +105,6 @@ tolerations: []
affinity: {}
postgresql:
global:
security:
allowInsecureImages: true
image:
repository: bitnamilegacy/postgresql
enabled: true
auth:
database: planka
@@ -224,65 +210,3 @@ oidc:
## key: key-inside-the-secret
##
extraEnv: []
## Example extraEnv for configuring SMTP
## extraEnv:
## - name: SMTP_HOST
## value: "smtp.example.com"
## - name: SMTP_PORT
## value: "587"
## - name: SMTP_NAME
## value: "Your Name"
## - name: SMTP_SECURE
## value: "true"
## - name: SMTP_TLS_REJECT_UNAUTHORIZED
## value: "false"
## - name: SMTP_USER
## value: "your_email@example.com"
## - name: SMTP_PASSWORD
## value: "your_password"
## - name: SMTP_FROM
## value: "your_email@example.com"
## Extra volume mounts configuration
## Mount ConfigMaps, Secrets, and arbitrary volumes to the PLANKA container
## This allows mounting any pre-existing ConfigMaps, Secrets, or other volume types
##
extraMounts: []
## Example extraMounts:
## extraMounts:
## - name: ca-certs
## mountPath: /etc/ssl/certs/ca-certificates.crt
## subPath: ca-bundle.crt
## readOnly: true
## configMap:
## name: ca-certificates
## - name: tls-certs
## mountPath: /etc/ssl/private
## readOnly: true
## secret:
## secretName: planka-tls
## items:
## - key: tls.crt
## path: server.crt
## - key: tls.key
## path: server.key
## - name: temp-storage
## mountPath: /tmp/planka-temp
## readOnly: false
## emptyDir:
## sizeLimit: 1Gi
## Example configuration for OIDC with self-hosted Keycloak using custom CA
## (Requires pre-existing ConfigMap "ca-certificates")
## extraMounts:
## - name: keycloak-ca
## mountPath: /etc/ssl/certs/keycloak-ca.crt
## subPath: ca.crt
## readOnly: true
## configMap:
## name: ca-certificates
##
## extraEnv:
## - name: NODE_EXTRA_CA_CERTS
## value: "/etc/ssl/certs/keycloak-ca.crt"

4629
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

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

@@ -79,13 +79,13 @@
},
"dependencies": {
"@ballerina/highlightjs-ballerina": "^1.0.1",
"@diplodoc/cut-extension": "^0.7.4",
"@diplodoc/transform": "^4.61.0",
"@gravity-ui/components": "^4.12.0",
"@gravity-ui/markdown-editor": "^15.19.1",
"@gravity-ui/uikit": "^7.20.0",
"@diplodoc/cut-extension": "^0.7.3",
"@diplodoc/transform": "^4.57.2",
"@gravity-ui/markdown-editor": "^15.11.0",
"@gravity-ui/uikit": "^7.11.0",
"@juggle/resize-observer": "^3.4.0",
"@vitejs/plugin-react": "^4.7.0",
"@types/papaparse": "^5.3.16",
"@vitejs/plugin-react": "^4.4.1",
"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.2.0",
"highlightjs-cshtml-razor": "^2.1.1",
"highlightjs-gf": "^1.0.1",
"highlightjs-jolie": "^0.1.8",
"highlightjs-lean": "^1.2.0",
@@ -114,20 +114,20 @@
"highlightjs-zenscript": "^2.0.0",
"hightlightjs-papyrus": "^0.0.4",
"history": "^5.3.0",
"i18next": "^25.4.2",
"i18next-browser-languagedetector": "^8.2.0",
"i18next": "23.15.2",
"i18next-browser-languagedetector": "^8.1.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.2",
"linkifyjs": "^4.3.2",
"linkify-react": "^4.3.1",
"linkifyjs": "^4.3.1",
"lodash": "^4.17.21",
"lowlight": "^3.3.0",
"markdown-it": "^13.0.2",
"nanoid": "^5.1.5",
"papaparse": "^5.5.3",
"patch-package": "^8.0.1",
"patch-package": "^8.0.0",
"photoswipe": "^5.4.4",
"prop-types": "^15.8.1",
"react": "18.2.0",
@@ -136,14 +136,13 @@
"react-dom": "18.2.0",
"react-dropzone": "^14.3.8",
"react-frame-component": "^5.2.7",
"react-hot-toast": "^2.6.0",
"react-i18next": "^15.7.3",
"react-hot-toast": "^2.5.2",
"react-i18next": "^15.5.1",
"react-input-mask": "^2.0.4",
"react-intersection-observer": "^9.16.0",
"react-mentions": "^4.4.10",
"react-photoswipe-gallery": "^2.2.7",
"react-redux": "^8.1.3",
"react-router-dom": "^6.30.1",
"react-router-dom": "^6.30.0",
"react-textarea-autosize": "^8.5.9",
"react-time-ago": "^7.3.3",
"redux": "^4.2.1",
@@ -152,34 +151,34 @@
"redux-saga": "^1.3.0",
"reselect": "^4.1.8",
"sails.io.js": "^1.2.1",
"sass-embedded": "^1.91.0",
"sass-embedded": "^1.87.0",
"semantic-ui-react": "^2.1.5",
"socket.io-client": "^2.5.0",
"validator": "^13.15.20",
"vite": "^6.4.1",
"validator": "^13.15.0",
"vite": "^6.3.5",
"vite-plugin-commonjs": "^0.10.4",
"vite-plugin-node-polyfills": "^0.23.0",
"vite-plugin-svgr": "^4.5.0",
"vite-plugin-svgr": "^4.3.0",
"zxcvbn": "^4.4.2"
},
"devDependencies": {
"@babel/eslint-parser": "^7.28.0",
"@babel/preset-env": "^7.28.3",
"@cucumber/cucumber": "^11.3.0",
"@babel/eslint-parser": "^7.27.1",
"@babel/preset-env": "^7.27.2",
"@cucumber/cucumber": "^11.2.0",
"@cucumber/pretty-formatter": "^1.0.1",
"@playwright/test": "^1.56.1",
"@playwright/test": "^1.52.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.2",
"eslint-plugin-import": "^2.32.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-prettier": "^5.5.4",
"eslint-plugin-prettier": "^5.4.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^4.6.2",
"jest": "^29.7.0",
"playwright": "^1.56.1",
"playwright": "^1.52.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 0e615c4..383338d 100644
index 2152fd6..ceda0c1 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,27 +22,14 @@ index 0e615c4..383338d 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..77f6d08 100644
index ed2a9db..f95b693 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

@@ -1,24 +0,0 @@
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

@@ -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..e22d4f0 100644
index 6d06078..fb7534d 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,113 +17,6 @@ index 6d06078..e22d4f0 100644
} // Below logic handles cases where the e.target is no longer in the document.
// The result of the click likely has removed the e.target node.
// Instead of node.contains(), we'll identify the click by X/Y position.
diff --git a/node_modules/semantic-ui-react/dist/es/modules/Dropdown/Dropdown.js b/node_modules/semantic-ui-react/dist/es/modules/Dropdown/Dropdown.js
index 1cc1bab..0e178ee 100644
--- a/node_modules/semantic-ui-react/dist/es/modules/Dropdown/Dropdown.js
+++ b/node_modules/semantic-ui-react/dist/es/modules/Dropdown/Dropdown.js
@@ -342,7 +342,7 @@ var Dropdown = /*#__PURE__*/function (_Component) {
return;
}
- if (searchQuery.length >= minCharacters || minCharacters === 1) {
+ if (searchQuery.length >= minCharacters || minCharacters === 0) {
_this.open(e);
return;
@@ -480,7 +480,7 @@ var Dropdown = /*#__PURE__*/function (_Component) {
} // close search dropdown if search query is too small
- if (open && minCharacters !== 1 && newQuery.length < minCharacters) _this.close();
+ if (open && minCharacters !== 0 && newQuery.length < minCharacters) _this.close();
};
_this.handleKeyDown = function (e) {
@@ -1048,7 +1048,7 @@ var Dropdown = /*#__PURE__*/function (_Component) {
if (!prevState.focus && this.state.focus) {
if (!this.isMouseDown) {
- var openable = !search || search && minCharacters === 1 && !this.state.open;
+ var openable = !search || search && minCharacters === 0 && !this.state.open;
if (openOnFocus && openable) this.open();
}
} else if (prevState.focus && !this.state.focus) {
@@ -1436,7 +1436,7 @@ Dropdown.defaultProps = {
closeOnEscape: true,
deburr: false,
icon: 'dropdown',
- minCharacters: 1,
+ minCharacters: 0,
noResultsMessage: 'No results found.',
openOnFocus: true,
renderLabel: renderItemContent,
diff --git a/node_modules/semantic-ui-react/dist/es/modules/Modal/Modal.js b/node_modules/semantic-ui-react/dist/es/modules/Modal/Modal.js
index a39f694..c9c616a 100644
--- a/node_modules/semantic-ui-react/dist/es/modules/Modal/Modal.js
+++ b/node_modules/semantic-ui-react/dist/es/modules/Modal/Modal.js
@@ -20,6 +20,7 @@ import ModalDescription from './ModalDescription';
import ModalDimmer from './ModalDimmer';
import ModalHeader from './ModalHeader';
import { canFit, getLegacyStyles, isLegacy } from './utils';
+var IS_WINDOWS = navigator.platform.startsWith('Win');
/**
* A modal displays content that temporarily blocks interactions with the main view of a site.
@@ -41,6 +42,7 @@ var Modal = /*#__PURE__*/function (_Component) {
_this.ref = /*#__PURE__*/createRef();
_this.dimmerRef = /*#__PURE__*/createRef();
_this.latestDocumentMouseDownEvent = null;
+ _this.latestWindowFocusTimeOnWindows = null;
_this.getMountNode = function () {
return isBrowser() ? _this.props.mountNode || document.body : null;
@@ -68,11 +70,17 @@ var Modal = /*#__PURE__*/function (_Component) {
}));
};
+ _this.handleWindowFocusOnWindows = function () {
+ _this.latestWindowFocusTimeOnWindows = Date.now();
+ };
+
_this.handleDocumentMouseDown = function (e) {
+ if (_this.latestWindowFocusTimeOnWindows && Date.now() - _this.latestWindowFocusTimeOnWindows < 50) return;
_this.latestDocumentMouseDownEvent = e;
};
_this.handleDocumentClick = function (e) {
+ if (!_this.latestDocumentMouseDownEvent) return;
var closeOnDimmerClick = _this.props.closeOnDimmerClick;
var currentDocumentMouseDownEvent = _this.latestDocumentMouseDownEvent;
_this.latestDocumentMouseDownEvent = null;
@@ -116,6 +124,13 @@ var Modal = /*#__PURE__*/function (_Component) {
_this.setPositionAndClassNames();
+ if (IS_WINDOWS) {
+ eventStack.sub('focus', _this.handleWindowFocusOnWindows, {
+ pool: eventPool,
+ target: window
+ });
+ }
+
eventStack.sub('mousedown', _this.handleDocumentMouseDown, {
pool: eventPool,
target: _this.dimmerRef.current
@@ -131,6 +146,14 @@ var Modal = /*#__PURE__*/function (_Component) {
_this.handlePortalUnmount = function (e) {
var eventPool = _this.props.eventPool;
cancelAnimationFrame(_this.animationRequestId);
+
+ if (IS_WINDOWS) {
+ eventStack.unsub('focus', _this.handleWindowFocusOnWindows, {
+ pool: eventPool,
+ target: window
+ });
+ }
+
eventStack.unsub('mousedown', _this.handleDocumentMouseDown, {
pool: eventPool,
target: _this.dimmerRef.current
diff --git a/node_modules/semantic-ui-react/src/lib/doesNodeContainClick.js b/node_modules/semantic-ui-react/src/lib/doesNodeContainClick.js
index d1ae271..43e1170 100644
--- a/node_modules/semantic-ui-react/src/lib/doesNodeContainClick.js

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 9.4 KiB

View File

@@ -1,59 +0,0 @@
/*!
* 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,10 +6,8 @@
import ActionTypes from '../constants/ActionTypes';
const initializeCore = (
config,
user,
board,
webhooks,
users,
projects,
projectManagers,
@@ -33,10 +31,8 @@ const initializeCore = (
) => ({
type: ActionTypes.CORE_INITIALIZE,
payload: {
config,
user,
board,
webhooks,
users,
projects,
projectManagers,
@@ -60,10 +56,10 @@ const initializeCore = (
},
});
initializeCore.fetchBootstrap = (bootstrap) => ({
type: ActionTypes.CORE_INITIALIZE__BOOTSTRAP_FETCH,
initializeCore.fetchConfig = (config) => ({
type: ActionTypes.CORE_INITIALIZE__CONFIG_FETCH,
payload: {
bootstrap,
config,
},
});
@@ -93,8 +89,8 @@ const logout = () => ({
payload: {},
});
logout.revokeAccessToken = () => ({
type: ActionTypes.LOGOUT__ACCESS_TOKEN_REVOKE,
logout.invalidateAccessToken = () => ({
type: ActionTypes.LOGOUT__ACCESS_TOKEN_INVALIDATE,
payload: {},
});

View File

@@ -8,8 +8,6 @@ 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';
@@ -37,8 +35,6 @@ export default {
...login,
...core,
...modals,
...config,
...webhooks,
...users,
...projects,
...projectManagers,

View File

@@ -58,34 +58,10 @@ updateList.failure = (id, error) => ({
},
});
const handleListUpdate = (
list,
isFetched,
users,
cards,
cardMemberships,
cardLabels,
taskLists,
tasks,
attachments,
customFieldGroups,
customFields,
customFieldValues,
) => ({
const handleListUpdate = (list) => ({
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 = (bootstrap) => ({
const initializeLogin = (config) => ({
type: ActionTypes.LOGIN_INITIALIZE,
payload: {
bootstrap,
config,
},
});
@@ -26,11 +26,10 @@ authenticate.success = (accessToken) => ({
},
});
authenticate.failure = (error, terms) => ({
authenticate.failure = (error) => ({
type: ActionTypes.AUTHENTICATE__FAILURE,
payload: {
error,
terms,
},
});
@@ -46,11 +45,10 @@ authenticateWithOidc.success = (accessToken) => ({
},
});
authenticateWithOidc.failure = (error, terms) => ({
authenticateWithOidc.failure = (error) => ({
type: ActionTypes.WITH_OIDC_AUTHENTICATE__FAILURE,
payload: {
error,
terms,
},
});
@@ -59,71 +57,9 @@ 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,11 +11,9 @@ const handleSocketDisconnect = () => ({
});
const handleSocketReconnect = (
bootstrap,
config,
user,
board,
webhooks,
users,
projects,
projectManagers,
@@ -39,11 +37,9 @@ const handleSocketReconnect = (
) => ({
type: ActionTypes.SOCKET_RECONNECT_HANDLE,
payload: {
bootstrap,
config,
user,
board,
webhooks,
users,
projects,
projectManagers,

View File

@@ -65,10 +65,8 @@ const handleUserUpdate = (
user,
projectIds,
boardIds,
bootstrap,
config,
board,
webhooks,
users,
projects,
projectManagers,
@@ -95,10 +93,8 @@ const handleUserUpdate = (
user,
projectIds,
boardIds,
bootstrap,
config,
board,
webhooks,
users,
projects,
projectManagers,

View File

@@ -1,104 +0,0 @@
/*!
* 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,18 +13,10 @@ 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 getBoardActivities = (boardId, data, headers) =>
const getActivitiesInBoard = (boardId, data, headers) =>
socket.get(`/boards/${boardId}/actions`, data, headers).then((body) => ({
...body,
items: body.items.map(transformActivity),
}));
const getCardActivities = (cardId, data, headers) =>
const getActivitiesInCard = (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 {
getBoardActivities,
getCardActivities,
getActivitiesInBoard,
getActivitiesInCard,
makeHandleActivityCreate,
};

View File

@@ -1,14 +0,0 @@
/*!
* 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,18 +3,12 @@
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import socket from './socket';
import http from './http';
/* Actions */
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);
const getConfig = (headers) => http.get('/config', undefined, headers);
export default {
getConfig,
updateConfig,
testSmtpConfig,
};

View File

@@ -7,10 +7,10 @@ import socket from './socket';
/* Actions */
const createBoardCustomFieldGroup = (cardId, data, headers) =>
const createCustomFieldGroupInBoard = (cardId, data, headers) =>
socket.post(`/boards/${cardId}/custom-field-groups`, data, headers);
const createCardCustomFieldGroup = (cardId, data, headers) =>
const createCustomFieldGroupInCard = (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 {
createBoardCustomFieldGroup,
createCardCustomFieldGroup,
createCustomFieldGroupInBoard,
createCustomFieldGroupInCard,
getCustomFieldGroup,
updateCustomFieldGroup,
deleteCustomFieldGroup,

View File

@@ -5,11 +5,8 @@
import http from './http';
import socket from './socket';
import bootstrap from './bootstrap';
import terms from './terms';
import accessTokens from './access-tokens';
import config from './config';
import webhooks from './webhooks';
import accessTokens from './access-tokens';
import users from './users';
import projects from './projects';
import projectManagers from './project-managers';
@@ -36,11 +33,8 @@ import notificationServices from './notification-services';
export { http, socket };
export default {
...bootstrap,
...terms,
...accessTokens,
...config,
...webhooks,
...accessTokens,
...users,
...projects,
...projectManagers,

View File

@@ -7,10 +7,10 @@ import socket from './socket';
/* Actions */
const createUserNotificationService = (userId, data, headers) =>
const createNotificationServiceInUser = (userId, data, headers) =>
socket.post(`/users/${userId}/notification-services`, data, headers);
const createBoardNotificationService = (boardId, data, headers) =>
const createNotificationServiceInBoard = (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 {
createUserNotificationService,
createBoardNotificationService,
createNotificationServiceInUser,
createNotificationServiceInBoard,
updateNotificationService,
testNotificationService,
deleteNotificationService,

View File

@@ -1,15 +0,0 @@
/*!
* 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,
};

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 25 KiB

View File

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

View File

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

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 { Icon, Menu } from 'semantic-ui-react';
import { Menu } from 'semantic-ui-react';
import { FilePicker, Popup } from '../../../lib/custom-ui';
import entryActions from '../../../entry-actions';
@@ -47,7 +47,6 @@ 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,11 +21,6 @@
padding-left: 14px;
}
.menuItemIcon {
float: left;
margin: 0 0.5em 0 0;
}
.tip {
opacity: 0.5;
}

View File

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

View File

@@ -16,7 +16,6 @@ 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';
@@ -41,8 +40,10 @@ const Item = React.memo(({ id, isVisible }) => {
switch (attachment.data.mimeType) {
case 'application/pdf':
content = (
<PdfViewer
src={attachment.data.url}
// eslint-disable-next-line jsx-a11y/alt-text
<object
data={attachment.data.url}
type={attachment.data.mimeType}
className={classNames(styles.content, styles.contentViewer)}
/>
);
@@ -59,15 +60,6 @@ const Item = React.memo(({ id, isVisible }) => {
<audio controls src={attachment.data.url} className={styles.content} />
);
break;
case 'text/csv':
content = (
<CsvViewer
src={attachment.data.url}
className={classNames(styles.content, styles.contentViewer)}
/>
);
break;
case 'video/mp4':
case 'video/ogg':
@@ -77,10 +69,19 @@ const Item = React.memo(({ id, isVisible }) => {
<video controls src={attachment.data.url} className={styles.content} />
);
break;
case 'text/csv':
content = (
<CsvViewer
src={attachment.data.url}
className={classNames(styles.content, styles.contentViewer)}
/>
);
break;
default:
if (attachment.data.encoding === Encodings.UTF8) {
if (attachment.data.size <= Config.MAX_SIZE_TO_DISPLAY_CONTENT) {
if (attachment.data.sizeInBytes <= Config.MAX_SIZE_IN_BYTES_TO_DISPLAY_CONTENT) {
content = (
<ContentViewer
src={attachment.data.url}

View File

@@ -23,7 +23,6 @@
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 '../../common/Favicon';
import Favicon from './Favicon';
import TimeAgo from '../../common/TimeAgo';
import styles from './ItemContent.module.scss';
@@ -54,19 +54,6 @@ 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();
@@ -127,35 +114,25 @@ const ItemContent = React.forwardRef(({ id, onOpen }, ref) => {
<span className={styles.information}>
<TimeAgo date={attachment.createdAt} />
</span>
{attachment.type === AttachmentTypes.FILE && (
{attachment.type === AttachmentTypes.FILE && attachment.data.image && canEdit && (
<span className={styles.options}>
<button type="button" className={styles.option} onClick={handleDownloadClick}>
<Icon name="download" size="small" className={styles.optionIcon} />
<button type="button" className={styles.option} onClick={handleToggleCoverClick}>
<Icon
name="window maximize outline"
flipped="vertically"
size="small"
className={styles.optionIcon}
/>
<span className={styles.optionText}>
{t('action.download', {
context: 'title',
})}
{isCover
? t('action.removeCover', {
context: 'title',
})
: t('action.makeCover', {
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,10 +52,6 @@
outline: none;
padding: 0;
&:not(:last-child) {
margin-right: 10px;
}
&:hover {
color: #172b4d;
}

View File

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

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

View File

@@ -3,7 +3,6 @@
* 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';
@@ -32,13 +31,10 @@ const PureBoardMembershipsStep = React.memo(
const filteredItems = useMemo(
() =>
orderBy(
items.filter(
({ user }) =>
user.name.toLowerCase().includes(cleanSearch) ||
(user.username && user.username.includes(cleanSearch)),
),
({ user }) => user.name.toLowerCase(),
items.filter(
({ user }) =>
user.name.toLowerCase().includes(cleanSearch) ||
(user.username && user.username.includes(cleanSearch)),
),
[items, cleanSearch],
);

View File

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

View File

@@ -6,12 +6,8 @@
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';
@@ -19,15 +15,7 @@ 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) {
@@ -37,19 +25,9 @@ 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,18 +31,6 @@
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

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

View File

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

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 { BoardViewIcons } from '../../../../constants/Icons';
import { BoardContextIcons, 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="ellipsis vertical" />
<Icon fitted name={BoardContextIcons[board.context]} />
</button>
</ActionsPopup>
</div>

View File

@@ -43,14 +43,6 @@ 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,6 +166,7 @@ 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 { Icon, Menu } from 'semantic-ui-react';
import { Menu } from 'semantic-ui-react';
import { Popup } from '../../../lib/custom-ui';
import selectors from '../../../selectors';
@@ -290,7 +290,6 @@ 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',
})}
@@ -298,7 +297,6 @@ 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',
})}
@@ -306,7 +304,6 @@ 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',
})}
@@ -314,7 +311,6 @@ 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',
})}
@@ -322,7 +318,6 @@ 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',
})}
@@ -330,7 +325,6 @@ 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',
})}
@@ -338,7 +332,6 @@ 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',
})}
@@ -346,7 +339,6 @@ 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',
})}
@@ -354,7 +346,6 @@ 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',
})}
@@ -362,7 +353,6 @@ 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}`),
@@ -371,7 +361,6 @@ 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',
})}
@@ -379,7 +368,6 @@ 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,9 +13,4 @@
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, useRef, useState } from 'react';
import React, { useCallback, useMemo, 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 { closePopup, usePopup } from '../../../lib/popup';
import { usePopup } from '../../../lib/popup';
import selectors from '../../../selectors';
import Paths from '../../../constants/Paths';
import { BoardMembershipRoles, CardTypes } from '../../../constants/Enums';
import { BoardMembershipRoles, CardTypes, ListTypes } from '../../../constants/Enums';
import ProjectContent from './ProjectContent';
import StoryContent from './StoryContent';
import InlineContent from './InlineContent';
@@ -51,8 +51,6 @@ 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();
@@ -61,17 +59,6 @@ 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);
}, []);
@@ -121,15 +108,17 @@ 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, card.isClosed && styles.contentDisabled)}
className={classNames(
styles.content,
list.type === ListTypes.CLOSED && styles.contentDisabled,
)}
onClick={handleClick}
onContextMenu={handleContextMenu}
>
<Content cardId={id} />
{colorLineNode}
</div>
{canUseActions && (
<ActionsPopup ref={actionsPopupRef} cardId={id} onNameEdit={handleNameEdit}>
<ActionsPopup cardId={id} onNameEdit={handleNameEdit}>
<Button className={styles.actionsButton}>
<Icon fitted name="pencil" size="small" />
</Button>
@@ -137,7 +126,12 @@ const Card = React.memo(({ id, isInline }) => {
)}
</>
) : (
<span className={classNames(styles.content, card.isClosed && styles.contentDisabled)}>
<span
className={classNames(
styles.content,
list.type === ListTypes.CLOSED && styles.contentDisabled,
)}
>
<Content cardId={id} />
{colorLineNode}
</span>

View File

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

View File

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

View File

@@ -50,10 +50,6 @@
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 } from '../../../constants/Enums';
import { BoardMembershipRoles, BoardViews, ListTypes } 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={classNames(styles.name, card.isClosed && styles.nameClosed)}>{card.name}</div>
<div className={styles.name}>{card.name}</div>
{coverUrl && (
<div className={styles.coverWrapper}>
<img src={coverUrl} alt="" className={styles.cover} />
@@ -190,8 +190,7 @@ const ProjectContent = React.memo(({ cardId }) => {
<DueDateChip
value={card.dueDate}
size="tiny"
isCompleted={card.isDueCompleted}
withStatus={!card.isClosed}
withStatus={list.type !== ListTypes.CLOSED && !isListArchiveOrTrash(list)}
/>
</span>
)}

View File

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

View File

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

View File

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

View File

@@ -3,49 +3,24 @@
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import React, { useCallback, useMemo } from 'react';
import React, { 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={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 className={classNames(styles.wrapper, task.isCompleted && styles.wrapperCompleted)}>
<Linkify linkStopPropagation>{task.name}</Linkify>
</li>
);
});

View File

@@ -4,21 +4,6 @@
*/
: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;
@@ -33,4 +18,9 @@
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 { useDidUpdate, useToggle } from '../../../../lib/hooks';
import { useToggle } from '../../../../lib/hooks';
import selectors from '../../../../selectors';
import Task from './Task';
@@ -16,22 +16,11 @@ 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 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],
);
const [isOpened, toggleOpened] = useToggle();
// TODO: move to selector?
const completedTasksTotal = useMemo(
@@ -41,20 +30,12 @@ const TaskList = React.memo(({ id }) => {
const handleToggleClick = useCallback(
(event) => {
if (filteredTasks.length === 0) {
return;
}
event.stopPropagation();
toggleOpened();
},
[toggleOpened, filteredTasks.length],
[toggleOpened],
);
useDidUpdate(() => {
toggleOpened();
}, [defaultIsOpened]);
if (tasks.length === 0) {
return null;
}
@@ -63,7 +44,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.progressRow} onClick={handleToggleClick}>
<div className={styles.button} onClick={handleToggleClick}>
<span className={styles.progressWrapper}>
<Progress
autoSuccess
@@ -75,18 +56,14 @@ const TaskList = React.memo(({ id }) => {
/>
</span>
<span
className={classNames(
styles.count,
filteredTasks.length > 0 && styles.countOpenable,
filteredTasks.length > 0 && (isOpened ? styles.countOpened : styles.countClosed),
)}
className={classNames(styles.count, isOpened ? styles.countOpened : styles.countClosed)}
>
{completedTasksTotal}/{tasks.length}
</span>
</div>
{isOpened && filteredTasks.length > 0 && (
{isOpened && (
<ul className={styles.tasks}>
{filteredTasks.map((task) => (
{tasks.map((task) => (
<Task key={task.id} id={task.id} />
))}
</ul>

View File

@@ -4,21 +4,29 @@
*/
: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;
@@ -27,11 +35,13 @@
.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;
}
@@ -39,17 +49,11 @@
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;
width: 100%;
vertical-align: top;
width: calc(100% - 50px);
}
.tasks {

View File

@@ -6,7 +6,6 @@
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';
@@ -31,7 +30,6 @@ 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);
@@ -50,10 +48,6 @@ 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(() => {
@@ -101,11 +95,6 @@ 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,34 +9,6 @@
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;
@@ -52,13 +24,5 @@
@media (width < 1026px) {
@include smallScreen;
}
.prevButton {
padding: 20px 0;
}
.prevButtonIcon {
top: 7px;
}
}
}

View File

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

View File

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

@@ -1,21 +0,0 @@
/*!
* 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,6 +82,7 @@ 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,10 +4,11 @@
*/
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, Checkbox, Grid, Icon } from 'semantic-ui-react';
import { Button, Grid, Icon } from 'semantic-ui-react';
import { useDidUpdate } from '../../../lib/hooks';
import selectors from '../../../selectors';
@@ -23,12 +24,13 @@ 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 ExpandableMarkdown from '../../common/ExpandableMarkdown';
import MoveCardStep from '../MoveCardStep';
import Markdown from '../../common/Markdown';
import EditMarkdown from '../../common/EditMarkdown';
import ConfirmationStep from '../../common/ConfirmationStep';
import UserAvatar from '../../users/UserAvatar';
@@ -43,7 +45,7 @@ import AddCustomFieldGroupStep from '../../custom-field-groups/AddCustomFieldGro
import styles from './ProjectContent.module.scss';
const ProjectContent = React.memo(() => {
const ProjectContent = React.memo(({ onClose }) => {
const selectListById = useMemo(() => selectors.makeSelectListById(), []);
const selectPrevListById = useMemo(() => selectors.makeSelectListById(), []);
@@ -153,6 +155,17 @@ const ProjectContent = React.memo(() => {
[dispatch],
);
const handleTypeSelect = useCallback(
(type) => {
dispatch(
entryActions.updateCurrentCard({
type,
}),
);
},
[dispatch],
);
const handleNameUpdate = useCallback(
(name) => {
dispatch(
@@ -175,14 +188,6 @@ const ProjectContent = React.memo(() => {
[dispatch],
);
const handleDueCompletionChange = useCallback(() => {
dispatch(
entryActions.updateCurrentCard({
isDueCompleted: !card.isDueCompleted,
}),
);
}, [card.isDueCompleted, dispatch]);
const handleToggleStopwatchClick = useCallback(() => {
dispatch(
entryActions.updateCurrentCard({
@@ -193,6 +198,18 @@ const ProjectContent = React.memo(() => {
);
}, [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]);
@@ -287,12 +304,13 @@ const ProjectContent = React.memo(() => {
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 MoreActionsPopup = usePopupInClosableContext(MoreActionsStep);
const MoveCardPopup = usePopupInClosableContext(MoveCardStep);
const ConfirmationPopup = usePopupInClosableContext(ConfirmationStep);
return (
@@ -418,31 +436,24 @@ const ProjectContent = React.memo(() => {
context: 'title',
})}
</div>
<span className={classNames(styles.attachment, styles.attachmentDueDate)}>
<span className={styles.attachment}>
{canEditDueDate ? (
<>
{!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>
</>
<EditDueDatePopup cardId={card.id}>
<DueDateChip
withStatusIcon
value={card.dueDate}
withStatus={
list.type !== ListTypes.CLOSED && !isInArchiveList && !isInTrashList
}
/>
</EditDueDatePopup>
) : (
<DueDateChip
withStatusIcon
value={card.dueDate}
isCompleted={card.isDueCompleted}
withStatus={!card.isClosed}
withStatus={
list.type !== ListTypes.CLOSED && !isInArchiveList && !isInTrashList
}
/>
)}
</span>
@@ -510,7 +521,7 @@ const ProjectContent = React.memo(() => {
<Button className={styles.editButton}>
<Icon fitted name="pencil" size="small" />
</Button>
<ExpandableMarkdown>{card.description}</ExpandableMarkdown>
<Markdown>{card.description}</Markdown>
</div>
) : (
<button
@@ -525,7 +536,7 @@ const ProjectContent = React.memo(() => {
))}
</>
)}
{!canEditDescription && <ExpandableMarkdown>{card.description}</ExpandableMarkdown>}
{!canEditDescription && <Markdown>{card.description}</Markdown>}
</div>
</div>
)}
@@ -699,6 +710,40 @@ const ProjectContent = React.memo(() => {
)}
</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
@@ -748,16 +793,6 @@ const ProjectContent = React.memo(() => {
</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>
@@ -767,4 +802,8 @@ const ProjectContent = React.memo(() => {
);
});
ProjectContent.propTypes = {
onClose: PropTypes.func.isRequired,
};
export default ProjectContent;

View File

@@ -60,12 +60,6 @@
vertical-align: top;
}
.attachmentDueDate {
align-items: center;
display: flex;
gap: 4px;
}
.attachments {
display: inline-block;
margin: 0 8px 8px 0;
@@ -266,28 +260,6 @@
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,6 +4,7 @@
*/
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';
@@ -23,7 +24,8 @@ import NameField from '../NameField';
import CustomFieldGroups from '../CustomFieldGroups';
import Communication from '../Communication';
import CreationDetailsStep from '../CreationDetailsStep';
import MoreActionsStep from '../MoreActionsStep';
import SelectCardTypeStep from '../../SelectCardTypeStep';
import MoveCardStep from '../../MoveCardStep';
import Markdown from '../../../common/Markdown';
import EditMarkdown from '../../../common/EditMarkdown';
import ConfirmationStep from '../../../common/ConfirmationStep';
@@ -38,7 +40,7 @@ import AddCustomFieldGroupStep from '../../../custom-field-groups/AddCustomField
import styles from './StoryContent.module.scss';
const StoryContent = React.memo(() => {
const StoryContent = React.memo(({ onClose }) => {
const selectListById = useMemo(() => selectors.makeSelectListById(), []);
const selectPrevListById = useMemo(() => selectors.makeSelectListById(), []);
const selectAttachmentById = useMemo(() => selectors.makeSelectAttachmentById(), []);
@@ -148,6 +150,17 @@ const StoryContent = React.memo(() => {
[dispatch],
);
const handleTypeSelect = useCallback(
(type) => {
dispatch(
entryActions.updateCurrentCard({
type,
}),
);
},
[dispatch],
);
const handleNameUpdate = useCallback(
(name) => {
dispatch(
@@ -170,6 +183,18 @@ const StoryContent = React.memo(() => {
[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]);
@@ -275,9 +300,10 @@ const StoryContent = React.memo(() => {
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 MoreActionsPopup = usePopupInClosableContext(MoreActionsStep);
const MoveCardPopup = usePopupInClosableContext(MoveCardStep);
const ConfirmationPopup = usePopupInClosableContext(ConfirmationStep);
return (
@@ -588,6 +614,40 @@ const StoryContent = React.memo(() => {
)}
</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
@@ -637,16 +697,6 @@ const StoryContent = React.memo(() => {
</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>
@@ -656,4 +706,8 @@ const StoryContent = React.memo(() => {
);
});
StoryContent.propTypes = {
onClose: PropTypes.func.isRequired,
};
export default StoryContent;

View File

@@ -195,7 +195,6 @@
font-size: 28px;
font-weight: bold;
line-height: 34px;
word-wrap: break-word;
}
.headerTitleWrapper {
@@ -286,28 +285,6 @@
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,9 +35,8 @@ const EditStep = React.memo(({ taskListId, onClose }) => {
() => ({
name: taskList.name,
showOnFrontOfCard: taskList.showOnFrontOfCard,
hideCompletedTasks: taskList.hideCompletedTasks,
}),
[taskList.name, taskList.showOnFrontOfCard, taskList.hideCompletedTasks],
[taskList.name, taskList.showOnFrontOfCard],
);
const [data, handleFieldChange] = useForm(() => ({
@@ -45,7 +44,6 @@ const EditStep = React.memo(({ taskListId, onClose }) => {
context: 'title',
}),
showOnFrontOfCard: true,
hideCompletedTasks: false,
...defaultData,
}));

View File

@@ -3,14 +3,13 @@
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import React, { useCallback, useMemo } from 'react';
import React, { 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';
@@ -30,16 +29,8 @@ 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}`}
@@ -60,37 +51,20 @@ const Item = React.memo(({ id, index }) => {
<div
className={classNames(
styles.moduleHeader,
withActions && styles.moduleHeaderWithActions,
taskList.hideCompletedTasks && canEdit && styles.both,
canEdit && styles.moduleHeaderEditable,
)}
>
{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>
{taskList.isPersisted && canEdit && (
<EditPopup taskListId={taskList.id}>
<Button className={styles.editButton}>
<Icon fitted name="pencil" size="small" />
</Button>
</EditPopup>
)}
<span className={styles.moduleHeaderTitle}>{taskList.name}</span>
</div>
</div>
<TaskList id={id} isCompletedVisible={isCompletedVisible} />
<TaskList id={id} />
</div>
</div>
);

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,22 +4,19 @@
*/
import { dequal } from 'dequal';
import { keyBy } from 'lodash';
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import React, { useCallback, useEffect, useMemo } from 'react';
import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { Mention, MentionsInput } from 'react-mentions';
import { Button, Form } from 'semantic-ui-react';
import TextareaAutosize from 'react-textarea-autosize';
import { Button, Form, TextArea } from 'semantic-ui-react';
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';
import styles from './Edit.module.scss';
@@ -27,7 +24,6 @@ const Edit = React.memo(({ commentId, onClose }) => {
const selectCommentById = useMemo(() => selectors.makeSelectCommentById(), []);
const comment = useSelector((state) => selectCommentById(state, commentId));
const boardMemberships = useSelector(selectors.selectMembershipsForCurrentBoard);
const dispatch = useDispatch();
const [t] = useTranslation();
@@ -39,30 +35,18 @@ const Edit = React.memo(({ commentId, onClose }) => {
[comment.text],
);
const [data, , setData] = useForm(() => ({
const [data, handleFieldChange] = useForm(() => ({
text: '',
...defaultData,
}));
const textFieldRef = useRef(null);
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 [textFieldRef, handleTextFieldRef] = useNestedRef();
const [buttonRef, handleButtonRef] = useNestedRef();
const submit = useCallback(() => {
const cleanData = {
...data,
text: mentionTextToMarkup(data.text.trim(), userByUsername),
text: data.text.trim(),
};
if (cleanData.text && !dequal(cleanData, defaultData)) {
@@ -70,21 +54,12 @@ const Edit = React.memo(({ commentId, onClose }) => {
}
onClose();
}, [commentId, onClose, dispatch, defaultData, data, userByUsername]);
}, [commentId, onClose, dispatch, defaultData, data]);
const handleSubmit = useCallback(() => {
submit();
}, [submit]);
const handleFieldChange = useCallback(
(_, text) => {
setData({
text: !isUsernameChar(text.slice(-1)) ? mentionTextToMarkup(text, userByUsername) : text,
});
},
[setData, userByUsername],
);
const handleFieldKeyDown = useCallback(
(event) => {
if (event.key === 'Enter') {
@@ -92,92 +67,48 @@ 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(() => {
textInputRef.current.focus();
}, []);
textFieldRef.current.focus();
}, [textFieldRef]);
const clickAwayProps = useClickAwayListener(
[textFieldRef, submitButtonRef, cancelButtonRef],
[textFieldRef, buttonRef],
submit,
handleClickAwayCancel,
);
const suggestionRenderer = useCallback(
(entry, _, highlightedDisplay) => (
<div className={styles.suggestion}>
<UserAvatar id={entry.id} size="tiny" />
{highlightedDisplay}
</div>
),
[],
);
useEffect(() => {
focusEnd(textInputRef.current);
}, []);
focusEnd(textFieldRef.current);
}, [textFieldRef]);
return (
<Form onSubmit={handleSubmit}>
<div ref={textFieldRef} className={styles.field}>
<MentionsInput
{...clickAwayProps} // eslint-disable-line react/jsx-props-no-spreading
allowSpaceInQuery
allowSuggestionsAboveCursor
ref={textMentionsRef}
inputRef={textInputRef}
value={data.text}
maxLength={1048576}
rows={3}
className="mentions-input"
style={{
control: {
minHeight: '79px',
},
}}
onChange={handleFieldChange}
onKeyDown={handleFieldKeyDown}
>
<Mention
appendSpaceOnAdd
data={boardMemberships.map(({ user }) => ({
id: user.id,
display: user.username || user.name,
}))}
displayTransform={(_, display) => `@${display}`}
renderSuggestion={suggestionRenderer}
className={styles.mention}
/>
</MentionsInput>
</div>
<TextArea
{...clickAwayProps} // eslint-disable-line react/jsx-props-no-spreading
ref={handleTextFieldRef}
as={TextareaAutosize}
name="text"
value={data.text}
maxLength={1048576}
minRows={3}
spellCheck={false}
className={styles.field}
onKeyDown={handleFieldKeyDown}
onChange={handleFieldChange}
/>
<div className={styles.controls}>
<Button
{...clickAwayProps} // eslint-disable-line react/jsx-props-no-spreading
positive
ref={handleSubmitButtonRef}
ref={handleButtonRef}
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,26 +11,21 @@
.field {
background: #fff;
border: 1px solid rgba(9, 30, 66, 0.13);
border-radius: 3px;
box-sizing: border-box;
color: #333;
display: block;
line-height: 1.4;
font-size: 14px;
margin-bottom: 4px;
overflow: hidden;
padding: 8px 12px;
resize: none;
width: 100%;
textarea {
border: 1px solid rgba(9, 30, 66, 0.13);
border-radius: 3px;
color: #333;
line-height: 1.4;
margin: 0 !important;
padding: 8px 12px;
&:focus {
outline: none;
}
}
.mention {
background-color: #f1f8ff;
border-radius: 3px;
}
.suggestion {
align-items: center;
display: flex;
gap: 8px;
}
}

View File

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

View File

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

View File

@@ -5,22 +5,17 @@
import React, { useCallback, useState } from 'react';
import classNames from 'classnames';
import { useDispatch, useSelector } from 'react-redux';
import { useDispatch } 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);
@@ -43,20 +38,6 @@ 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

@@ -1,227 +0,0 @@
/*!
* 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

@@ -1,41 +0,0 @@
/*!
* 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 { Icon, Menu } from 'semantic-ui-react';
import { Menu } from 'semantic-ui-react';
import { Popup } from '../../../../lib/custom-ui';
import selectors from '../../../../selectors';
@@ -180,14 +180,12 @@ 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',
})}
@@ -195,7 +193,6 @@ 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',
})}
@@ -203,7 +200,6 @@ 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',
})}
@@ -211,7 +207,6 @@ 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',
})}
@@ -226,7 +221,6 @@ 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',
@@ -237,7 +231,6 @@ 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',
})}

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