Compare commits

..

6 Commits

Author SHA1 Message Date
Daniel Hiller a26c468a9d Add: AI-generated translations
Adding missing translation from the top 10 most spoken languages

* Hindi
* Arabic
* Bengali

* Dutch (because requested)
2 years ago
Daniel Hiller 0f076f4cc4 Add: Translation Bengali (AI) 2 years ago
Daniel Hiller f1865db477 Add: Translation Arabic (AI) 2 years ago
Daniel Hiller b80127e952 Add: Translation Dutch (AI) 2 years ago
Daniel Hiller 5e400c5c4e Add: Translation Hindi (AI) 2 years ago
Daniel Hiller 8367fa4a29 Add: Dutch translation (AI) 2 years ago

@ -1,53 +0,0 @@
name: "🐛 Bug Report"
description: Report a bug found while using Planka
title: "[Bug]: "
labels: ["Type: Bug", "Status: Triage"]
body:
- type: dropdown
id: issue-type
attributes:
label: Where is the problem occurring?
description: Select the part of the application where you encountered the issue.
options:
- "I encountered the problem while using the application (Frontend)"
- "I encountered the problem while interacting with the server (Backend)"
- "I'm not sure"
- type: dropdown
id: browsers
attributes:
label: What browsers are you seeing the problem on?
multiple: true
options:
- Brave
- Chrome
- Firefox
- Microsoft Edge
- Safari
- Other
- type: textarea
id: current-behavior
attributes:
label: Current behaviour
description: A description of what is currently happening, including screenshots and other useful information (**DO NOT INCLUDE PRIVATE INFORMATION**).
placeholder: Currently...
validations:
required: true
- type: textarea
id: desired-behavior
attributes:
label: Desired behaviour
description: A clear description of what you think should happen.
placeholder: In this situation, I expected ...
- type: textarea
id: reproduction
attributes:
label: Steps to reproduce
description: Clearly describe which steps or actions you have taken to arrive at the problem. If you have some experience with the code, please link to the specific pieces of code.
placeholder: I did X, then Y, before arriving at Z, when ERROR ...
validations:
required: true
- type: textarea
id: other
attributes:
label: Other information
description: Any other details?

@ -1,33 +0,0 @@
name: "✨ Feature Request"
description: Suggest a feature or enhancement to improve Planka.
labels: ["Type: Idea"]
body:
- type: dropdown
id: idea-type
attributes:
label: Is this a feature for the backend or frontend?
multiple: true
options:
- Backend
- Frontend
validations:
required: true
- type: textarea
id: feature
attributes:
label: What would you like?
description: A clear description of the feature or enhancement wanted.
placeholder: I'd like to be able to...
validations:
required: true
- type: textarea
id: reason
attributes:
label: Why is this needed?
description: A clear description of why this would be useful to have.
placeholder: I want this because...
- type: textarea
id: other
attributes:
label: Other information
placeholder: Any other details?

@ -40,5 +40,5 @@ jobs:
build-args: ALPINE_VERSION=${{ env.ALPINE_VERSION }}
push: true
tags: |
ghcr.io/${{ github.repository }}:base-latest
ghcr.io/${{ github.repository }}:base-${{ env.ALPINE_VERSION }}
ghcr.io/plankanban/planka:base-latest
ghcr.io/plankanban/planka:base-${{ env.ALPINE_VERSION }}

@ -1,20 +1,11 @@
# https://docs.docker.com/build/ci/github-actions/multi-platform/
name: Build and push Docker DEV image
on:
push:
paths-ignore:
- '.github/**'
- 'charts/**'
- 'docker-*.sh'
- '*.md'
branches: [master]
workflow_dispatch:
env:
REGISTRY_IMAGE: ghcr.io/${{ github.repository }}
jobs:
build:
build-and-push-docker-image-dev:
runs-on: self-hosted
steps:
- name: Checkout
@ -33,21 +24,11 @@ jobs:
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Generate docker image tags
id: metadata
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY_IMAGE }}
tags: |
type=raw,value=dev
- name: Build and push
uses: docker/build-push-action@v4
with:
context: .
platforms: linux/amd64,linux/arm64,linux/arm/v7
push: true
tags: ${{ steps.metadata.outputs.tags }}
labels: ${{ steps.metadata.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
tags: |
ghcr.io/plankanban/planka:dev

@ -31,23 +31,12 @@ jobs:
result-encoding: string
script: return context.payload.release.tag_name.replace('v', '')
- name: Generate docker image tags
id: metadata
uses: docker/metadata-action@v5
with:
images: |
name=ghcr.io/${{ github.repository }}
tags: |
type=raw,value=${{ steps.set-version.outputs.result }}
type=raw,value=latest
- name: Build and push
uses: docker/build-push-action@v4
with:
context: .
platforms: linux/amd64,linux/arm64,linux/arm/v7
push: true
tags: ${{ steps.metadata.outputs.tags }}
labels: ${{ steps.metadata.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
tags: |
ghcr.io/plankanban/planka:latest
ghcr.io/plankanban/planka:${{ steps.set-version.outputs.result }}

@ -35,9 +35,8 @@ jobs:
done
- name: Run chart-releaser for stable
uses: helm/chart-releaser-action@v1.6.0
uses: helm/chart-releaser-action@v1.5.0
with:
charts_dir: charts
mark_as_latest: false
env:
CR_TOKEN: "${{ secrets.GITHUB_TOKEN }}"

@ -1,2 +1,4 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
npx lint-staged

@ -1,44 +1,35 @@
FROM node:18-alpine as server-dependencies
RUN apk -U upgrade \
&& apk add build-base python3 \
--no-cache
FROM ghcr.io/plankanban/planka:base-latest as server-dependencies
WORKDIR /app
COPY server/package.json server/package-lock.json ./
COPY server/package.json server/package-lock.json .
RUN npm install npm@latest --global \
&& npm install pnpm --global \
&& pnpm import \
&& pnpm install --prod
FROM node:lts AS client
WORKDIR /app
COPY client/package.json client/package-lock.json ./
COPY client/package.json client/package-lock.json .
RUN npm install npm@latest --global \
&& npm install pnpm --global \
&& pnpm import \
&& pnpm install --prod
COPY client .
RUN DISABLE_ESLINT_PLUGIN=true npm run build
FROM node:18-alpine
FROM ghcr.io/plankanban/planka:base-latest
RUN apk -U upgrade \
&& apk add bash \
--no-cache
RUN apk del vips-dependencies --purge
USER node
WORKDIR /app
COPY --chown=node:node start.sh .
COPY --chown=node:node server .
COPY --chown=node:node healthcheck.js .
RUN mv .env.sample .env
@ -53,8 +44,4 @@ VOLUME /app/private/attachments
EXPOSE 1337
HEALTHCHECK --interval=10s --timeout=2s --start-period=15s \
CMD node ./healthcheck.js
CMD [ "bash", "start.sh" ]
CMD ["./start.sh"]

@ -1,22 +1,27 @@
FROM node:18-alpine
FROM node:lts-alpine
ARG VIPS_VERSION=8.14.5
ARG ALPINE_VERSION=3.16
ARG VIPS_VERSION=8.13.3
RUN apk -U upgrade \
&& apk add \
bash pkgconf \
libjpeg-turbo libexif librsvg cgif tiff libspng libimagequant \
bash giflib glib lcms2 libexif \
libgsf libjpeg-turbo libpng librsvg libwebp \
orc pango tiff \
--repository https://alpine.global.ssl.fastly.net/alpine/v${ALPINE_VERSION}/community/ \
--repository https://alpine.global.ssl.fastly.net/alpine/v${ALPINE_VERSION}/main/ \
--no-cache \
&& apk add \
build-base gobject-introspection-dev meson \
libjpeg-turbo-dev libexif-dev librsvg-dev cgif-dev tiff-dev libspng-dev libimagequant-dev \
build-base giflib-dev glib-dev lcms2-dev libexif-dev \
libgsf-dev libjpeg-turbo-dev libpng-dev librsvg-dev libwebp-dev \
orc-dev pango-dev tiff-dev \
--virtual vips-dependencies \
--repository https://alpine.global.ssl.fastly.net/alpine/v${ALPINE_VERSION}/community/ \
--repository https://alpine.global.ssl.fastly.net/alpine/v${ALPINE_VERSION}/main/ \
--no-cache \
&& wget -O- https://github.com/libvips/libvips/releases/download/v${VIPS_VERSION}/vips-${VIPS_VERSION}.tar.xz | tar xJC /tmp \
&& wget -O- https://github.com/libvips/libvips/releases/download/v${VIPS_VERSION}/vips-${VIPS_VERSION}.tar.gz | tar xzC /tmp \
&& cd /tmp/vips-${VIPS_VERSION} \
&& meson setup build-dir \
&& cd build-dir \
&& ninja \
&& ninja test \
&& ninja install \
&& ./configure \
&& make \
&& make install-strip \
&& rm -rf /tmp/vips-${VIPS_VERSION}

@ -1,7 +1,7 @@
# Planka
#### Elegant open source project tracking.
#### Elegant open source project tracking
![David (path)](https://img.shields.io/github/package-json/v/plankanban/planka) ![Docker Pulls](https://img.shields.io/badge/docker_pulls-4M%2B-%23066da5) ![GitHub](https://img.shields.io/github/license/plankanban/planka)
![David (path)](https://img.shields.io/github/package-json/v/plankanban/planka) ![Docker Pulls](https://img.shields.io/docker/pulls/meltyshev/planka) ![GitHub](https://img.shields.io/github/license/plankanban/planka)
![](https://raw.githubusercontent.com/plankanban/planka/master/demo.gif)
@ -10,18 +10,20 @@
## Features
- Create projects, boards, lists, cards, labels and tasks
- Add card members, track time, set due dates, add attachments, write comments
- Markdown support in card description and comments
- Add card members, track time, set a due date, add attachments, write comments
- Markdown support in a card description and comment
- Filter by members and labels
- Customize project backgrounds
- Customize project background
- Real-time updates
- Internal notifications
- Multiple interface languages
- Single sign-on via OpenID Connect
- User notifications
- Internationalization
## How to deploy Planka
There are many ways to install Planka, [check them out](https://docs.planka.cloud/docs/intro).
There are 2 types of installation:
- [Without Docker](https://docs.planka.cloud/docs/installl-planka/Debian%20&%20Ubuntu) ([for Windows](https://docs.planka.cloud/docs/installl-planka/Windows))
- [Dockerized](https://docs.planka.cloud/docs/installl-planka/Docker%20Compose)
- [Automated installation](https://github.com/plankanban/planka-installer)
For configuration, please see the [configuration section](https://docs.planka.cloud/docs/category/configuration).
@ -45,7 +47,3 @@ See the [development section](https://docs.planka.cloud/docs/Development).
## License
Planka is [AGPL-3.0 licensed](https://github.com/plankanban/planka/blob/master/LICENSE).
## Contributors
[![](https://contrib.rocks/image?repo=plankanban/planka)](https://github.com/plankanban/planka/graphs/contributors)

@ -15,16 +15,16 @@ 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: 0.2.8
version: 0.1.2
# This is the version number of the application being deployed. This version number should be
# incremented each time you make changes to the application. Versions are not expected to
# follow Semantic Versioning. They should reflect the version the application is using.
# It is recommended to use it with quotes.
appVersion: "1.22.0"
appVersion: "1.12.0"
dependencies:
- alias: postgresql
- alias: postgresql
condition: postgresql.enabled
name: postgresql
repository: &bitnami-repo https://charts.bitnami.com/bitnami

@ -14,22 +14,14 @@ If you want to fully uninstall this chart including the data, follow [these step
## Usage
If you just want to spin up an instance using help, please see [these docs](https://docs.planka.cloud/docs/installation/kubernetes/helm_chart/). If you want to make changes to the chart locally, and deploy them, see the below section.
## Local Building and Using the Chart
The basic usage of the chart can be found below:
```bash
git clone https://github.com/plankanban/planka.git
cd planka/charts/planka
git clone https://github.com/Chris-Greaves/planka-helm-chart.git
cd planka-helm-chart
helm dependency build
export SECRETKEY=$(openssl rand -hex 64)
helm install planka . --set secretkey=$SECRETKEY \
--set admin_email="demo@demo.demo" \
--set admin_password="demo" \
--set admin_name="Demo Demo" \
--set admin_username="demo"
helm install planka . --set secretkey=$SECRETKEY
```
> **NOTE:** The command `openssl rand -hex 64` is needed to create a random hexadecimal key for planka. On Windows you can use Git Bash to run that command.
@ -47,19 +39,11 @@ To access Planka externally you can use the following configuration
```bash
# HTTP only
helm install planka . --set secretkey=$SECRETKEY \
--set admin_email="demo@demo.demo" \
--set admin_password="demo" \
--set admin_name="Demo Demo" \
--set admin_username="demo" \
--set ingress.enabled=true \
--set ingress.hosts[0].host=planka.example.dev \
# HTTPS
helm install planka . --set secretkey=$SECRETKEY \
--set admin_email="demo@demo.demo" \
--set admin_password="demo" \
--set admin_name="Demo Demo" \
--set admin_username="demo" \
--set ingress.enabled=true \
--set ingress.hosts[0].host=planka.example.dev \
--set ingress.tls[0].secretName=planka-tls \
@ -70,17 +54,6 @@ or create a values.yaml file like:
```yaml
secretkey: "<InsertSecretKey>"
# The admin section needs to be present for new instances of Planka, after the first start you can remove the lines starting with admin_. If you want the admin user to be unchangeable admin_email: has to stay
# After changing the config you have to run ```helm upgrade planka . -f values.yaml```
# Admin user
admin_email: "demo@demo.demo" # Do not remove if you want to prevent this user from being edited/deleted
admin_password: "demo"
admin_name: "Demo Demo"
admin_username: "demo"
# Admin user
# Ingress
ingress:
enabled: true
hosts:

@ -11,10 +11,6 @@ spec:
selector:
matchLabels:
{{- include "planka.selectorLabels" . | nindent 6 }}
{{- if .Values.persistence.enabled }}
strategy:
type: Recreate
{{- end }}
template:
metadata:
{{- with .Values.podAnnotations }}
@ -39,7 +35,7 @@ spec:
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: {{ .Values.service.containerPort | default 1337 }}
containerPort: {{ .Values.service.port }}
protocol: TCP
livenessProbe:
httpGet:
@ -59,32 +55,14 @@ spec:
- mountPath: /app/private/attachments
subPath: attachments
name: planka
{{- if .Values.securityContext.readOnlyRootFilesystem }}
- mountPath: /app/logs
subPath: app-logs
name: emptydir
{{- end }}
resources:
{{- toYaml .Values.resources | nindent 12 }}
env:
{{- if not .Values.postgresql.enabled }}
{{- if .Values.existingDburlSecret }}
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: {{ .Values.existingDburlSecret }}
key: uri
{{- else }}
- name: DATABASE_URL
value: {{ required "If the included postgresql deployment is disabled you need to provide an existing secret in .Values.existingDburlSecret or define a Database URL in 'dburl'" .Values.dburl }}
{{- end }}
{{- else }}
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: planka-postgresql-svcbind-custom-user
key: uri
{{- end }}
- name: BASE_URL
{{- if .Values.baseUrl }}
value: {{ .Values.baseUrl }}
@ -94,68 +72,9 @@ spec:
value: http://localhost:3000
{{- end }}
- name: SECRET_KEY
{{- if .Values.existingSecretkeySecret }}
valueFrom:
secretKeyRef:
name: {{ .Values.existingSecretkeySecret }}
key: key
{{- else }}
value: {{ required "A secret key needs to be generated using 'openssl rand -hex 64' and assigned to secretkey." .Values.secretkey }}
{{- end }}
- name: TRUST_PROXY
value: "0"
- name: DEFAULT_ADMIN_EMAIL
value: {{ .Values.admin_email }}
- name: DEFAULT_ADMIN_NAME
value: {{ .Values.admin_name }}
{{- if .Values.existingAdminCredsSecret }}
- name: DEFAULT_ADMIN_USERNAME
valueFrom:
secretKeyRef:
name: {{ .Values.existingAdminCredsSecret }}
key: username
- name: DEFAULT_ADMIN_PASSWORD
valueFrom:
secretKeyRef:
name: {{ .Values.existingAdminCredsSecret }}
key: password
{{- else }}
- name: DEFAULT_ADMIN_USERNAME
value: {{ .Values.admin_username }}
- name: DEFAULT_ADMIN_PASSWORD
value: {{ .Values.admin_password }}
{{- end }}
{{ range $k, $v := .Values.env }}
- name: {{ $k | quote }}
value: {{ $v | quote }}
{{- end }}
{{- if .Values.oidc.enabled }}
{{- $secretName := default (printf "%s-oidc" (include "planka.fullname" .)) .Values.oidc.existingSecret }}
- name: OIDC_CLIENT_ID
valueFrom:
secretKeyRef:
key: clientId
name: {{ $secretName }}
- name: OIDC_CLIENT_SECRET
valueFrom:
secretKeyRef:
key: clientSecret
name: {{ $secretName }}
- name: OIDC_ISSUER
value: {{ required "issuerUrl is required when configuring OIDC" .Values.oidc.issuerUrl | quote }}
- name: OIDC_SCOPES
value: {{ join " " .Values.oidc.scopes | default "openid profile email" | quote }}
{{- if .Values.oidc.admin.roles }}
- name: OIDC_ADMIN_ROLES
value: {{ join "," .Values.oidc.admin.roles | quote }}
{{- end }}
- name: OIDC_ROLES_ATTRIBUTE
value: {{ .Values.oidc.admin.rolesAttribute | default "groups" | quote }}
{{- if .Values.oidc.admin.ignoreRoles }}
- name: OIDC_IGNORE_ROLES
value: {{ .Values.oidc.admin.ignoreRoles | quote }}
{{- end }}
{{- end }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
@ -176,7 +95,3 @@ spec:
{{- else }}
emptyDir: {}
{{- end }}
{{- if .Values.securityContext.readOnlyRootFilesystem }}
- name: emptydir
emptyDir: {}
{{- end }}

@ -1,5 +1,5 @@
{{- if .Values.autoscaling.enabled }}
apiVersion: autoscaling/v2
apiVersion: autoscaling/v2beta1
kind: HorizontalPodAutoscaler
metadata:
name: {{ include "planka.fullname" . }}
@ -17,16 +17,12 @@ spec:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}
targetAverageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}
{{- end }}
{{- if .Values.autoscaling.targetMemoryUtilizationPercentage }}
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }}
targetAverageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }}
{{- end }}
{{- end }}

@ -1,17 +0,0 @@
{{- if .Values.oidc.enabled }}
{{- if eq (and (not (empty .Values.oidc.clientId)) (not (empty .Values.oidc.clientSecret))) (not (empty .Values.oidc.existingSecret)) -}}
{{- fail "Either specify inline `clientId` and `clientSecret` or refer to them via `existingSecret`" -}}
{{- end }}
{{- if (and (and (not (empty .Values.oidc.clientId)) (not (empty .Values.oidc.clientSecret))) (empty .Values.oidc.existingSecret)) -}}
apiVersion: v1
kind: Secret
metadata:
name: {{ include "planka.fullname" . }}-oidc
labels:
{{- include "planka.labels" . | nindent 4 }}
type: Opaque
data:
clientId: {{ .Values.oidc.clientId | b64enc | quote }}
clientSecret: {{ .Values.oidc.clientSecret | b64enc | quote }}
{{- end }}
{{- end }}

@ -17,16 +17,6 @@ fullnameOverride: ""
# Generate a secret using openssl rand -base64 45
secretkey: ""
## @param existingSecretkeySecret Name of an existing secret containing the session key string
## NOTE: Must contain key `key`
## NOTE: When it's set, the secretkey parameter is ignored
existingSecretkeySecret: ""
## @param existingAdminCredsSecret Name of an existing secret containing the admin username and password
## NOTE: Must contain keys `username` and `password`
## NOTE: When it's set, the `admin_username` and `admin_password` parameters are ignored
existingAdminCredsSecret: ""
# Base url for Planka. Will override `ingress.hosts[0].host`
# Defaults to `http://localhost:3000` if ingress is disabled.
baseUrl: ""
@ -56,10 +46,6 @@ securityContext: {}
service:
type: ClusterIP
port: 1337
## @param service.containerPort Planka HTTP container port
## If empty will default to 1337
##
containerPort: 1337
ingress:
enabled: false
@ -115,84 +101,11 @@ postgresql:
serviceBindings:
enabled: true
## Set this or existingDburlSecret if you disable the built-in postgresql deployment
dburl:
## @param existingDburlSecret Name of an existing secret containing a DBurl connection string
## NOTE: Must contain key `uri`
## NOTE: When it's set, the `dburl` parameter is ignored
##
existingDburlSecret: ""
## PVC-based data storage configuration
persistence:
enabled: false
# existingClaim: netbox-data
# storageClass: "-"
accessMode: ReadWriteOnce
size: 10Gi
## OpenID Identity Management configuration
##
## Example:
## ---------------
## oidc:
## enabled: true
## clientId: sxxaAIAxVXlCxTmc1YLHBbQr8NL8MqLI2DUbt42d
## clientSecret: om4RTMRVHRszU7bqxB7RZNkHIzA8e4sGYWxeCwIMYQXPwEBWe4SY5a0wwCe9ltB3zrq5f0dnFnp34cEHD7QSMHsKvV9AiV5Z7eqDraMnv0I8IFivmuV5wovAECAYreSI
## issuerUrl: https://auth.local/application/o/planka/
## admin:
## roles:
## - planka-admin
##
## ---------------
## NOTE: A minimal configuration requires setting `clientId`, `clientSecret` and `issuerUrl`. (plus `admin.roles` for administrators)
## ref: https://docs.planka.cloud/docs/Configuration/OIDC
##
oidc:
## @param oidc.enabled Enable single sign-on (SSO) with OpenID Connect (OIDC)
##
enabled: false
## OIDC credentials
## @param oidc.clientId A string unique to the provider that identifies your app.
## @param oidc.clientSecret A secret string that the provider uses to confirm ownership of a client ID.
##
## NOTE: Either specify inline `clientId` and `clientSecret` or refer to them via `existingSecret`
##
clientId: ""
clientSecret: ""
## @param oidc.existingSecret Name of an existing secret containing OIDC credentials
## NOTE: Must contain key `clientId` and `clientSecret`
## NOTE: When it's set, the `clientId` and `clientSecret` parameters are ignored
##
existingSecret: ""
## @param oidc.issuerUrl The OpenID connect metadata document endpoint
##
issuerUrl: ""
## @param oidc.scopes A list of scopes required for OIDC client.
## If empty will default to `openid`, `profile` and `email`
## NOTE: Planka needs the email and name claims
##
scopes: []
## Admin permissions configuration
admin:
## @param oidc.admin.ignoreRoles If set to true, the admin roles will be ignored.
## It is useful if you want to use OIDC for authentication but not for authorization.
## If empty will default to `false`
##
ignoreRoles: false
## @param oidc.admin.rolesAttribute The name of a custom group claim that you have configured in your OIDC provider
## If empty will default to `groups`
##
rolesAttribute: groups
## @param oidc.admin.roles The names of the admin groups
##
roles: []
# - planka-admin

@ -0,0 +1 @@
REACT_APP_VERSION=1.12.0

46096
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -49,76 +49,67 @@
"**/*.test.js"
]
}
],
"prettier/prettier": [
"error",
{
"endOfLine": "auto"
}
]
}
},
"dependencies": {
"@juggle/resize-observer": "^3.4.0",
"classnames": "^2.5.1",
"date-fns": "^2.30.0",
"classnames": "^2.3.2",
"date-fns": "^2.29.3",
"dequal": "^2.0.3",
"easymde": "^2.18.0",
"history": "^5.3.0",
"i18next": "^23.15.1",
"i18next-browser-languagedetector": "^8.0.0",
"i18next": "^22.0.6",
"i18next-browser-languagedetector": "^7.0.1",
"initials": "^3.1.2",
"js-cookie": "^3.0.5",
"jwt-decode": "^4.0.0",
"linkify-react": "^4.1.3",
"linkifyjs": "^4.1.3",
"js-cookie": "^3.0.1",
"jwt-decode": "^3.1.2",
"lodash": "^4.17.21",
"nanoid": "^5.0.7",
"node-sass": "^9.0.0",
"photoswipe": "^5.4.4",
"node-sass": "^8.0.0",
"photoswipe": "^5.3.3",
"prop-types": "^15.8.1",
"react": "18.2.0",
"react": "^18.2.0",
"react-app-rewired": "^2.2.1",
"react-beautiful-dnd": "^13.1.1",
"react-datepicker": "^4.25.0",
"react-dom": "18.2.0",
"react-datepicker": "^4.8.0",
"react-dom": "^18.2.0",
"react-dropzone": "^14.2.3",
"react-i18next": "^15.0.2",
"react-i18next": "^12.0.0",
"react-input-mask": "^2.0.4",
"react-markdown": "^8.0.7",
"react-photoswipe-gallery": "^2.2.7",
"react-redux": "^8.1.3",
"react-router-dom": "^6.26.2",
"react-markdown": "^8.0.3",
"react-oidc-context": "^2.2.2",
"react-photoswipe-gallery": "^2.2.2",
"react-redux": "^8.0.5",
"react-router-dom": "^6.4.3",
"react-scripts": "5.0.1",
"react-simplemde-editor": "^5.2.0",
"react-textarea-autosize": "^8.5.3",
"redux": "^4.2.1",
"react-textarea-autosize": "^8.4.0",
"redux": "^4.2.0",
"redux-logger": "^3.0.6",
"redux-orm": "^0.16.2",
"redux-saga": "^1.3.0",
"remark-breaks": "^4.0.0",
"redux-saga": "^1.2.1",
"remark-breaks": "^3.0.2",
"remark-gfm": "^3.0.1",
"reselect": "^4.1.8",
"reselect": "^4.1.7",
"sails.io.js": "^1.2.1",
"semantic-ui-react": "^2.1.5",
"semantic-ui-react": "^2.1.3",
"socket.io-client": "^2.5.0",
"validator": "^13.12.0",
"whatwg-fetch": "^3.6.20",
"validator": "^13.7.0",
"whatwg-fetch": "^3.6.2",
"zxcvbn": "^4.4.2"
},
"devDependencies": {
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"@testing-library/jest-dom": "^6.5.0",
"@testing-library/react": "^15.0.7",
"@testing-library/user-event": "^14.5.2",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
"babel-preset-airbnb": "^5.0.0",
"chai": "^4.5.0",
"eslint": "^8.57.0",
"chai": "^4.3.7",
"eslint": "^8.28.0",
"eslint-config-airbnb": "^19.0.4",
"eslint-plugin-import": "^2.30.0",
"eslint-plugin-jsx-a11y": "^6.10.0",
"eslint-plugin-react": "^7.36.1",
"eslint-plugin-react-hooks": "^4.6.2",
"react-test-renderer": "18.2.0"
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-jsx-a11y": "^6.6.1",
"eslint-plugin-react": "^7.31.11",
"eslint-plugin-react-hooks": "^4.6.0",
"react-test-renderer": "^18.2.0"
}
}

@ -3,7 +3,7 @@
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"

@ -23,14 +23,10 @@ createCard.failure = (localId, error) => ({
},
});
const handleCardCreate = (card, cardMemberships, cardLabels, tasks, attachments) => ({
const handleCardCreate = (card) => ({
type: ActionTypes.CARD_CREATE_HANDLE,
payload: {
card,
cardMemberships,
cardLabels,
tasks,
attachments,
},
});
@ -57,43 +53,10 @@ updateCard.failure = (id, error) => ({
},
});
const handleCardUpdate = (card, isFetched, cardMemberships, cardLabels, tasks, attachments) => ({
const handleCardUpdate = (card) => ({
type: ActionTypes.CARD_UPDATE_HANDLE,
payload: {
card,
isFetched,
cardMemberships,
cardLabels,
tasks,
attachments,
},
});
const duplicateCard = (id, card, taskIds) => ({
type: ActionTypes.CARD_DUPLICATE,
payload: {
id,
card,
taskIds,
},
});
duplicateCard.success = (localId, card, cardMemberships, cardLabels, tasks) => ({
type: ActionTypes.CARD_DUPLICATE__SUCCESS,
payload: {
localId,
card,
cardMemberships,
cardLabels,
tasks,
},
});
duplicateCard.failure = (id, error) => ({
type: ActionTypes.CARD_DUPLICATE__FAILURE,
payload: {
id,
error,
},
});
@ -126,21 +89,11 @@ const handleCardDelete = (card) => ({
},
});
const filterText = (boardId, text) => ({
type: ActionTypes.TEXT_FILTER_IN_CURRENT_BOARD,
payload: {
boardId,
text,
},
});
export default {
createCard,
handleCardCreate,
updateCard,
handleCardUpdate,
duplicateCard,
deleteCard,
handleCardDelete,
filterText,
};

@ -39,19 +39,9 @@ const initializeCore = (
},
});
// TODO: with success?
initializeCore.fetchConfig = (config) => ({
type: ActionTypes.CORE_INITIALIZE__CONFIG_FETCH,
payload: {
config,
},
});
const logout = (invalidateAccessToken) => ({
const logout = () => ({
type: ActionTypes.LOGOUT,
payload: {
invalidateAccessToken,
},
payload: {},
});
logout.invalidateAccessToken = () => ({

@ -60,38 +60,6 @@ const handleListUpdate = (list) => ({
},
});
const sortList = (id, data) => ({
type: ActionTypes.LIST_SORT,
payload: {
id,
data,
},
});
sortList.success = (list, cards) => ({
type: ActionTypes.LIST_SORT__SUCCESS,
payload: {
list,
cards,
},
});
sortList.failure = (id, error) => ({
type: ActionTypes.LIST_SORT__FAILURE,
payload: {
id,
error,
},
});
const handleListSort = (list, cards) => ({
type: ActionTypes.LIST_SORT_HANDLE,
payload: {
list,
cards,
},
});
const deleteList = (id) => ({
type: ActionTypes.LIST_DELETE,
payload: {
@ -126,8 +94,6 @@ export default {
handleListCreate,
updateList,
handleListUpdate,
sortList,
handleListSort,
deleteList,
handleListDelete,
};

@ -1,12 +1,5 @@
import ActionTypes from '../constants/ActionTypes';
const initializeLogin = (config) => ({
type: ActionTypes.LOGIN_INITIALIZE,
payload: {
config,
},
});
const authenticate = (data) => ({
type: ActionTypes.AUTHENTICATE,
payload: {
@ -28,33 +21,12 @@ authenticate.failure = (error) => ({
},
});
const authenticateUsingOidc = () => ({
type: ActionTypes.USING_OIDC_AUTHENTICATE,
payload: {},
});
authenticateUsingOidc.success = (accessToken) => ({
type: ActionTypes.USING_OIDC_AUTHENTICATE__SUCCESS,
payload: {
accessToken,
},
});
authenticateUsingOidc.failure = (error) => ({
type: ActionTypes.USING_OIDC_AUTHENTICATE__FAILURE,
payload: {
error,
},
});
const clearAuthenticateError = () => ({
type: ActionTypes.AUTHENTICATE_ERROR_CLEAR,
payload: {},
});
export default {
initializeLogin,
authenticate,
authenticateUsingOidc,
clearAuthenticateError,
};

@ -1,17 +1,17 @@
import http from './http';
import socket from './socket';
/* Actions */
const createAccessToken = (data, headers) =>
http.post('/access-tokens?withHttpOnlyToken=true', data, headers);
const createAccessToken = (data, headers) => http.post('/access-tokens', data, headers);
const exchangeOidcToken = (accessToken, headers) =>
http.post('/access-tokens/exchange', { token: accessToken }, headers);
const exchangeForAccessTokenUsingOidc = (data, headers) =>
http.post('/access-tokens/exchange-using-oidc?withHttpOnlyToken=true', data, headers);
const deleteCurrentAccessToken = (headers) => http.delete('/access-tokens/me', undefined, headers);
const deleteCurrentAccessToken = (headers) =>
socket.delete('/access-tokens/me', undefined, headers);
export default {
createAccessToken,
exchangeForAccessTokenUsingOidc,
deleteCurrentAccessToken,
exchangeOidcToken,
};

@ -57,12 +57,6 @@ const updateCard = (id, data, headers) =>
item: transformCard(body.item),
}));
const duplicateCard = (id, data, headers) =>
socket.post(`/cards/${id}/duplicate`, data, headers).then((body) => ({
...body,
item: transformCard(body.item),
}));
const deleteCard = (id, headers) =>
socket.delete(`/cards/${id}`, undefined, headers).then((body) => ({
...body,
@ -87,7 +81,6 @@ export default {
getCard,
updateCard,
deleteCard,
duplicateCard,
makeHandleCardCreate,
makeHandleCardUpdate,
makeHandleCardDelete,

@ -5,11 +5,9 @@ import Config from '../constants/Config';
const http = {};
// TODO: add all methods
['GET', 'POST', 'DELETE'].forEach((method) => {
['POST'].forEach((method) => {
http[method.toLowerCase()] = (url, data, headers) => {
const formData =
data &&
Object.keys(data).reduce((result, key) => {
const formData = Object.keys(data).reduce((result, key) => {
result.append(key, data[key]);
return result;
@ -19,7 +17,6 @@ const http = {};
method,
headers,
body: formData,
credentials: 'include',
})
.then((response) =>
response.json().then((body) => ({

@ -1,6 +1,5 @@
import http from './http';
import socket from './socket';
import root from './root';
import accessTokens from './access-tokens';
import users from './users';
import projects from './projects';
@ -21,7 +20,6 @@ import notifications from './notifications';
export { http, socket };
export default {
...root,
...accessTokens,
...users,
...projects,

@ -1,5 +1,4 @@
import socket from './socket';
import { transformCard } from './cards';
/* Actions */
@ -8,33 +7,10 @@ const createList = (boardId, data, headers) =>
const updateList = (id, data, headers) => socket.patch(`/lists/${id}`, data, headers);
const sortList = (id, data, headers) =>
socket.post(`/lists/${id}/sort`, data, headers).then((body) => ({
...body,
included: {
...body.included,
cards: body.included.cards.map(transformCard),
},
}));
const deleteList = (id, headers) => socket.delete(`/lists/${id}`, undefined, headers);
/* Event handlers */
const makeHandleListSort = (next) => (body) => {
next({
...body,
included: {
...body.included,
cards: body.included.cards.map(transformCard),
},
});
};
export default {
createList,
updateList,
sortList,
deleteList,
makeHandleListSort,
};

@ -1,9 +0,0 @@
import http from './http';
/* Actions */
const getConfig = (headers) => http.get('/config', undefined, headers);
export default {
getConfig,
};

@ -13,7 +13,7 @@ io.sails.environment = process.env.NODE_ENV;
const { socket } = io;
socket.path = `${Config.SERVER_BASE_PATH}/socket.io`;
socket.path = `${Config.BASE_PATH}/socket.io`;
socket.connect = socket._connect; // eslint-disable-line no-underscore-dangle
['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].forEach((method) => {

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 434 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 184 KiB

After

Width:  |  Height:  |  Size: 559 KiB

@ -1,8 +1,6 @@
:global(#app) {
.wrapper {
height: 100%;
max-height: 100vh;
max-width: 100vw;
position: fixed;
width: 100%;
z-index: -1;

@ -48,6 +48,16 @@
min-width: 100%;
}
.panel {
align-items: center;
display: flex;
margin-bottom: 20px;
}
.panelItem {
margin-right: 20px;
}
.wrapper {
margin: 0 20px;
}

@ -13,7 +13,6 @@ const BoardActions = React.memo(
labels,
filterUsers,
filterLabels,
filterText,
allUsers,
canEdit,
canEditMemberships,
@ -28,7 +27,6 @@ const BoardActions = React.memo(
onLabelUpdate,
onLabelMove,
onLabelDelete,
onTextFilterUpdate,
}) => {
return (
<div className={styles.wrapper}>
@ -48,7 +46,6 @@ const BoardActions = React.memo(
<Filters
users={filterUsers}
labels={filterLabels}
filterText={filterText}
allBoardMemberships={memberships}
allLabels={labels}
canEdit={canEdit}
@ -60,7 +57,6 @@ const BoardActions = React.memo(
onLabelUpdate={onLabelUpdate}
onLabelMove={onLabelMove}
onLabelDelete={onLabelDelete}
onTextFilterUpdate={onTextFilterUpdate}
/>
</div>
</div>
@ -75,7 +71,6 @@ BoardActions.propTypes = {
labels: PropTypes.array.isRequired,
filterUsers: PropTypes.array.isRequired,
filterLabels: PropTypes.array.isRequired,
filterText: PropTypes.string.isRequired,
allUsers: PropTypes.array.isRequired,
/* eslint-enable react/forbid-prop-types */
canEdit: PropTypes.bool.isRequired,
@ -91,7 +86,6 @@ BoardActions.propTypes = {
onLabelUpdate: PropTypes.func.isRequired,
onLabelMove: PropTypes.func.isRequired,
onLabelDelete: PropTypes.func.isRequired,
onTextFilterUpdate: PropTypes.func.isRequired,
};
export default BoardActions;

@ -1,15 +1,12 @@
:global(#app) {
.action {
align-items: center;
display: flex;
flex: 0 0 auto;
margin-right: 20px;
}
.actions {
align-items: center;
display: flex;
gap: 20px;
justify-content: flex-start;
margin: 20px 20px;
}

@ -1,10 +1,7 @@
import React, { useCallback, useRef, useState } from 'react';
import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useTranslation } from 'react-i18next';
import { Icon } from 'semantic-ui-react';
import { usePopup } from '../../lib/popup';
import { Input } from '../../lib/custom-ui';
import User from '../User';
import Label from '../Label';
@ -17,7 +14,6 @@ const Filters = React.memo(
({
users,
labels,
filterText,
allBoardMemberships,
allLabels,
canEdit,
@ -29,17 +25,8 @@ const Filters = React.memo(
onLabelUpdate,
onLabelMove,
onLabelDelete,
onTextFilterUpdate,
}) => {
const [t] = useTranslation();
const [isSearchFocused, setIsSearchFocused] = useState(false);
const searchFieldRef = useRef(null);
const cancelSearch = useCallback(() => {
onTextFilterUpdate('');
searchFieldRef.current.blur();
}, [onTextFilterUpdate]);
const handleRemoveUserClick = useCallback(
(id) => {
@ -55,39 +42,9 @@ const Filters = React.memo(
[onLabelRemove],
);
const handleSearchChange = useCallback(
(_, { value }) => {
onTextFilterUpdate(value);
},
[onTextFilterUpdate],
);
const handleSearchFocus = useCallback(() => {
setIsSearchFocused(true);
}, []);
const handleSearchKeyDown = useCallback(
(event) => {
if (event.key === 'Escape') {
cancelSearch();
}
},
[cancelSearch],
);
const handleSearchBlur = useCallback(() => {
setIsSearchFocused(false);
}, []);
const handleCancelSearchClick = useCallback(() => {
cancelSearch();
}, [cancelSearch]);
const BoardMembershipsPopup = usePopup(BoardMembershipsStep);
const LabelsPopup = usePopup(LabelsStep);
const isSearchActive = filterText || isSearchFocused;
return (
<>
<span className={styles.filter}>
@ -143,25 +100,6 @@ const Filters = React.memo(
</span>
))}
</span>
<span className={styles.filter}>
<Input
ref={searchFieldRef}
value={filterText}
placeholder={t('common.searchCards')}
icon={
isSearchActive ? (
<Icon link name="cancel" onClick={handleCancelSearchClick} />
) : (
'search'
)
}
className={classNames(styles.search, !isSearchActive && styles.searchInactive)}
onFocus={handleSearchFocus}
onKeyDown={handleSearchKeyDown}
onChange={handleSearchChange}
onBlur={handleSearchBlur}
/>
</span>
</>
);
},
@ -171,7 +109,6 @@ Filters.propTypes = {
/* eslint-disable react/forbid-prop-types */
users: PropTypes.array.isRequired,
labels: PropTypes.array.isRequired,
filterText: PropTypes.string.isRequired,
allBoardMemberships: PropTypes.array.isRequired,
allLabels: PropTypes.array.isRequired,
/* eslint-enable react/forbid-prop-types */
@ -184,7 +121,6 @@ Filters.propTypes = {
onLabelUpdate: PropTypes.func.isRequired,
onLabelMove: PropTypes.func.isRequired,
onLabelDelete: PropTypes.func.isRequired,
onTextFilterUpdate: PropTypes.func.isRequired,
};
export default Filters;

@ -43,36 +43,4 @@
line-height: 20px;
padding: 2px 12px;
}
.search {
height: 30px;
margin: 0 12px;
transition: width 0.2s ease;
width: 280px;
@media only screen and (max-width: 797px) {
width: 220px;
}
input {
font-size: 13px;
}
}
.searchInactive {
color: #fff;
height: 24px;
width: 220px;
input {
background: rgba(0, 0, 0, 0.24);
border: none;
color: #fff !important;
font-size: 12px;
&::placeholder {
color: #fff;
}
}
}
}

@ -4,6 +4,7 @@
max-height: 60vh;
overflow-x: hidden;
overflow-y: auto;
scrollbar-width: thin;
width: 100%;
&::-webkit-scrollbar {

@ -85,6 +85,7 @@
height: 56px;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: thin;
&:hover {
height: 38px;

@ -36,7 +36,6 @@ const ActionsStep = React.memo(
onUpdate,
onMove,
onTransfer,
onDuplicate,
onDelete,
onUserAdd,
onUserRemove,
@ -77,11 +76,6 @@ const ActionsStep = React.memo(
openStep(StepTypes.MOVE);
}, [openStep]);
const handleDuplicateClick = useCallback(() => {
onDuplicate();
onClose();
}, [onDuplicate, onClose]);
const handleDeleteClick = useCallback(() => {
openStep(StepTypes.DELETE);
}, [openStep]);
@ -213,11 +207,6 @@ const ActionsStep = React.memo(
context: 'title',
})}
</Menu.Item>
<Menu.Item className={styles.menuItem} onClick={handleDuplicateClick}>
{t('action.duplicateCard', {
context: 'title',
})}
</Menu.Item>
<Menu.Item className={styles.menuItem} onClick={handleDeleteClick}>
{t('action.deleteCard', {
context: 'title',
@ -243,7 +232,6 @@ ActionsStep.propTypes = {
onUpdate: PropTypes.func.isRequired,
onMove: PropTypes.func.isRequired,
onTransfer: PropTypes.func.isRequired,
onDuplicate: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
onUserAdd: PropTypes.func.isRequired,
onUserRemove: PropTypes.func.isRequired,

@ -24,7 +24,6 @@ const Card = React.memo(
index,
name,
dueDate,
isDueDateCompleted,
stopwatch,
coverUrl,
boardId,
@ -42,7 +41,6 @@ const Card = React.memo(
onUpdate,
onMove,
onTransfer,
onDuplicate,
onDelete,
onUserAdd,
onUserRemove,
@ -121,7 +119,7 @@ const Card = React.memo(
)}
{dueDate && (
<span className={classNames(styles.attachment, styles.attachmentLeft)}>
<DueDate value={dueDate} isCompleted={isDueDateCompleted} size="tiny" />
<DueDate value={dueDate} size="tiny" />
</span>
)}
{stopwatch && (
@ -187,7 +185,6 @@ const Card = React.memo(
onUpdate={onUpdate}
onMove={onMove}
onTransfer={onTransfer}
onDuplicate={onDuplicate}
onDelete={onDelete}
onUserAdd={onUserAdd}
onUserRemove={onUserRemove}
@ -222,7 +219,6 @@ Card.propTypes = {
index: PropTypes.number.isRequired,
name: PropTypes.string.isRequired,
dueDate: PropTypes.instanceOf(Date),
isDueDateCompleted: PropTypes.bool,
stopwatch: PropTypes.object, // eslint-disable-line react/forbid-prop-types
coverUrl: PropTypes.string,
boardId: PropTypes.string.isRequired,
@ -242,7 +238,6 @@ Card.propTypes = {
onUpdate: PropTypes.func.isRequired,
onMove: PropTypes.func.isRequired,
onTransfer: PropTypes.func.isRequired,
onDuplicate: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
onUserAdd: PropTypes.func.isRequired,
onUserRemove: PropTypes.func.isRequired,
@ -257,7 +252,6 @@ Card.propTypes = {
Card.defaultProps = {
dueDate: undefined,
isDueDateCompleted: undefined,
stopwatch: undefined,
coverUrl: undefined,
};

@ -21,14 +21,6 @@
background: #ebeef0;
color: #516b7a;
}
@media only screen and (max-width: 797px) {
&:focus,
&:active {
background: #ebeef0;
color: #516b7a;
}
}
}
.attachment {
@ -63,12 +55,6 @@
box-shadow: 0 1px 0 #ccc;
position: relative;
@media only screen and (max-width: 797px) {
.target {
opacity: 1;
}
}
&:hover {
background: #f5f6f7;
border-bottom-color: rgba(9, 30, 66, 0.25);

@ -5,7 +5,6 @@ import TextareaAutosize from 'react-textarea-autosize';
import { Button, Form, TextArea } from 'semantic-ui-react';
import { useClosableForm, useField } from '../../hooks';
import { focusEnd } from '../../utils/element-helpers';
import styles from './NameEdit.module.scss';
@ -80,7 +79,7 @@ const NameEdit = React.forwardRef(({ children, defaultValue, onUpdate }, ref) =>
useEffect(() => {
if (isOpened) {
focusEnd(field.current.ref.current);
field.current.ref.current.focus();
}
}, [isOpened]);

@ -4,8 +4,6 @@ import classNames from 'classnames';
import { Progress } from 'semantic-ui-react';
import { useToggle } from '../../lib/hooks';
import Linkify from '../Linkify';
import styles from './Tasks.module.scss';
const Tasks = React.memo(({ items }) => {
@ -50,7 +48,7 @@ const Tasks = React.memo(({ items }) => {
key={item.id}
className={classNames(styles.task, item.isCompleted && styles.taskCompleted)}
>
<Linkify linkStopPropagation>{item.name}</Linkify>
{item.name}
</li>
))}
</ul>

@ -55,10 +55,8 @@
display: block;
font-size: 12px;
line-height: 14px;
overflow: hidden;
padding-bottom: 6px;
padding-left: 14px;
text-overflow: ellipsis;
&:before {
content: "";

@ -6,7 +6,6 @@ import TextareaAutosize from 'react-textarea-autosize';
import { Button, Form, TextArea } from 'semantic-ui-react';
import { useForm } from '../../../hooks';
import { focusEnd } from '../../../utils/element-helpers';
import styles from './CommentEdit.module.scss';
@ -71,7 +70,7 @@ const CommentEdit = React.forwardRef(({ children, defaultData, onUpdate }, ref)
useEffect(() => {
if (isOpened) {
focusEnd(textField.current.ref.current);
textField.current.ref.current.focus();
}
}, [isOpened]);

@ -4,7 +4,6 @@ import classNames from 'classnames';
import { useTranslation, Trans } from 'react-i18next';
import { Comment } from 'semantic-ui-react';
import getDateFormat from '../../../utils/get-date-format';
import { ActivityTypes } from '../../../constants/Enums';
import ItemComment from './ItemComment';
import User from '../../User';
@ -67,7 +66,7 @@ const Item = React.memo(({ type, data, createdAt, user }) => {
<div className={classNames(styles.content)}>
<div>{contentNode}</div>
<span className={styles.date}>
{t(`format:${getDateFormat(createdAt)}`, {
{t('format:longDateTime', {
postProcess: 'formatDate',
value: createdAt,
})}

@ -6,7 +6,6 @@ import { Comment } from 'semantic-ui-react';
import { usePopup } from '../../../lib/popup';
import { Markdown } from '../../../lib/custom-ui';
import getDateFormat from '../../../utils/get-date-format';
import CommentEdit from './CommentEdit';
import User from '../../User';
import DeleteStep from '../../DeleteStep';
@ -34,7 +33,7 @@ const ItemComment = React.memo(
<div className={styles.title}>
<span className={styles.author}>{user.name}</span>
<span className={styles.date}>
{t(`format:${getDateFormat(createdAt)}`, {
{t('format:longDateTime', {
postProcess: 'formatDate',
value: createdAt,
})}

@ -94,7 +94,7 @@ const AttachmentAddZone = React.memo(({ children, onCreate }) => {
return (
<>
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
<div {...getRootProps()}>
<div {...getRootProps()} className={styles.wrapper}>
{isDragActive && <div className={styles.dropzone}>{t('common.dropFileToUpload')}</div>}
{children}
{/* eslint-disable-next-line react/jsx-props-no-spreading */}

@ -2,7 +2,7 @@
.dropzone {
background: white;
font-size: 20px;
font-weight: bold;
font-weight: 700;
height: 100%;
line-height: 30px;
opacity: 0.7;
@ -12,4 +12,8 @@
width: 100%;
z-index: 2001;
}
.wrapper {
overflow: hidden;
}
}

@ -16,7 +16,7 @@
.contentError {
color: #fff;
font-size: 20px;
font-weight: bold;
font-weight: 700;
height: 20px;
width: 470px;
}

@ -35,7 +35,7 @@
color: #5e6c84;
display: block;
font-size: 18px;
font-weight: bold;
font-weight: 700;
height: 100%;
line-height: 80px;
overflow: hidden;
@ -50,7 +50,7 @@
.name {
color: #17394d;
font-size: 14px;
font-weight: bold;
font-weight: 700;
line-height: 20px;
word-wrap: break-word;
}

@ -1,8 +1,8 @@
import React, { useCallback, useRef, useState } from 'react';
import React, { useCallback, useRef } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useTranslation } from 'react-i18next';
import { Button, Checkbox, Grid, Icon, Modal } from 'semantic-ui-react';
import { Button, Grid, Icon, Modal } from 'semantic-ui-react';
import { usePopup } from '../../lib/popup';
import { Markdown } from '../../lib/custom-ui';
@ -32,7 +32,6 @@ const CardModal = React.memo(
name,
description,
dueDate,
isDueDateCompleted,
stopwatch,
isSubscribed,
isActivitiesFetching,
@ -56,7 +55,6 @@ const CardModal = React.memo(
onUpdate,
onMove,
onTransfer,
onDuplicate,
onDelete,
onUserAdd,
onUserRemove,
@ -82,7 +80,6 @@ const CardModal = React.memo(
onClose,
}) => {
const [t] = useTranslation();
const [isLinkCopied, setIsLinkCopied] = useState(false);
const isGalleryOpened = useRef(false);
@ -119,12 +116,6 @@ const CardModal = React.memo(
[onUpdate],
);
const handleDueDateCompletionChange = useCallback(() => {
onUpdate({
isDueDateCompleted: !isDueDateCompleted,
});
}, [isDueDateCompleted, onUpdate]);
const handleStopwatchUpdate = useCallback(
(newStopwatch) => {
onUpdate({
@ -149,19 +140,6 @@ const CardModal = React.memo(
});
}, [isSubscribed, onUpdate]);
const handleDuplicateClick = useCallback(() => {
onDuplicate();
onClose();
}, [onDuplicate, onClose]);
const handleCopyLinkClick = useCallback(() => {
navigator.clipboard.writeText(window.location.href);
setIsLinkCopied(true);
setTimeout(() => {
setIsLinkCopied(false);
}, 5000);
}, []);
const handleGalleryOpen = useCallback(() => {
isGalleryOpened.current = true;
}, []);
@ -239,7 +217,6 @@ const CardModal = React.memo(
onUserSelect={onUserAdd}
onUserDeselect={onUserRemove}
>
{/* eslint-disable-next-line jsx-a11y/control-has-associated-label */}
<button
type="button"
className={classNames(styles.attachment, styles.dueDate)}
@ -289,7 +266,6 @@ const CardModal = React.memo(
onMove={onLabelMove}
onDelete={onLabelDelete}
>
{/* eslint-disable-next-line jsx-a11y/control-has-associated-label */}
<button
type="button"
className={classNames(styles.attachment, styles.dueDate)}
@ -307,24 +283,13 @@ const CardModal = React.memo(
context: 'title',
})}
</div>
<span className={classNames(styles.attachment, styles.attachmentDueDate)}>
<span className={styles.attachment}>
{canEdit ? (
<>
<Checkbox
checked={isDueDateCompleted}
disabled={!canEdit}
onChange={handleDueDateCompletionChange}
/>
<DueDateEditPopup defaultValue={dueDate} onUpdate={handleDueDateUpdate}>
<DueDate
withStatusIcon
value={dueDate}
isCompleted={isDueDateCompleted}
/>
<DueDate value={dueDate} />
</DueDateEditPopup>
</>
) : (
<DueDate withStatusIcon value={dueDate} isCompleted={isDueDateCompleted} />
<DueDate value={dueDate} />
)}
</span>
</div>
@ -349,11 +314,10 @@ const CardModal = React.memo(
)}
</span>
{canEdit && (
// eslint-disable-next-line jsx-a11y/control-has-associated-label
<button
onClick={handleToggleStopwatchClick}
type="button"
className={classNames(styles.attachment, styles.dueDate)}
onClick={handleToggleStopwatchClick}
>
<Icon
name={stopwatch.startedAt ? 'pause' : 'play'}
@ -529,23 +493,6 @@ const CardModal = React.memo(
{t('action.move')}
</Button>
</CardMovePopup>
<Button fluid className={styles.actionButton} onClick={handleDuplicateClick}>
<Icon name="copy outline" className={styles.actionIcon} />
{t('action.duplicate')}
</Button>
{window.isSecureContext && (
<Button fluid className={styles.actionButton} onClick={handleCopyLinkClick}>
<Icon
name={isLinkCopied ? 'linkify' : 'unlink'}
className={styles.actionIcon}
/>
{isLinkCopied
? t('common.linkIsCopied')
: t('action.copyLink', {
context: 'title',
})}
</Button>
)}
<DeletePopup
title="common.deleteCard"
content="common.areYouSureYouWantToDeleteThisCard"
@ -580,7 +527,6 @@ CardModal.propTypes = {
name: PropTypes.string.isRequired,
description: PropTypes.string,
dueDate: PropTypes.instanceOf(Date),
isDueDateCompleted: PropTypes.bool,
stopwatch: PropTypes.object, // eslint-disable-line react/forbid-prop-types
isSubscribed: PropTypes.bool.isRequired,
isActivitiesFetching: PropTypes.bool.isRequired,
@ -606,7 +552,6 @@ CardModal.propTypes = {
onUpdate: PropTypes.func.isRequired,
onMove: PropTypes.func.isRequired,
onTransfer: PropTypes.func.isRequired,
onDuplicate: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
onUserAdd: PropTypes.func.isRequired,
onUserRemove: PropTypes.func.isRequired,
@ -635,7 +580,6 @@ CardModal.propTypes = {
CardModal.defaultProps = {
description: undefined,
dueDate: undefined,
isDueDateCompleted: false,
stopwatch: undefined,
};

@ -20,26 +20,18 @@
.actionIcon {
color: #17394d;
display: inline;
margin-right: 8px;
}
.actions {
margin-bottom: 24px;
@media only screen and (max-width: 797px) {
flex: 1;
}
@media only screen and (max-width: 425px) {
padding: 0;
width: 100%;
}
}
.actionsTitle {
color: #8c8c8c;
font-size: 12px;
font-weight: normal;
font-weight: 500;
letter-spacing: 0.04em;
margin-top: 16px;
text-transform: uppercase;
@ -58,12 +50,6 @@
max-width: 100%;
}
.attachmentDueDate {
align-items: center;
display: flex;
gap: 4px;
}
.attachments {
display: inline-block;
margin: 0 8px 8px 0;
@ -77,11 +63,6 @@
.contentPadding {
padding: 8px 8px 0 16px;
@media only screen and (max-width: 797px) {
padding-right: 16px;
width: 100% !important;
}
}
.cursorPointer {
@ -158,7 +139,7 @@
.headerTitle {
color: #17394d;
font-size: 20px;
font-weight: bold;
font-weight: 700;
line-height: 24px;
}
@ -174,11 +155,6 @@
.modalPadding {
padding: 0px;
@media only screen and (max-width: 797px) {
display: flex;
flex-flow: column nowrap;
}
}
.moduleHeader {
@ -209,15 +185,6 @@
.sidebarPadding {
padding: 8px 16px 0 8px;
@media only screen and (max-width: 797px) {
align-items: flex-start;
display: flex;
flex-flow: row wrap;
gap: 10px;
padding-left: 16px;
width: 100% !important;
}
}
.text {

@ -57,7 +57,6 @@ const DescriptionEdit = React.forwardRef(({ children, defaultValue, onUpdate },
const mdEditorOptions = useMemo(
() => ({
autoDownloadFontAwesome: false,
autofocus: true,
spellChecker: false,
status: false,

@ -6,7 +6,7 @@
box-shadow: none;
color: #17394d;
font-size: 20px;
font-weight: bold;
font-weight: 700;
line-height: 24px;
margin: -5px;
overflow: hidden;

@ -8,7 +8,6 @@ import { usePopup } from '../../../lib/popup';
import NameEdit from './NameEdit';
import ActionsStep from './ActionsStep';
import Linkify from '../../Linkify';
import styles from './Item.module.scss';
@ -66,7 +65,7 @@ const Item = React.memo(
onClick={handleClick}
>
<span className={classNames(styles.task, isCompleted && styles.taskCompleted)}>
<Linkify linkStopPropagation>{name}</Linkify>
{name}
</span>
</span>
{isPersisted && canEdit && (

@ -5,7 +5,6 @@ import TextareaAutosize from 'react-textarea-autosize';
import { Button, Form, TextArea } from 'semantic-ui-react';
import { useField } from '../../../hooks';
import { focusEnd } from '../../../utils/element-helpers';
import styles from './NameEdit.module.scss';
@ -66,7 +65,7 @@ const NameEdit = React.forwardRef(({ children, defaultValue, onUpdate }, ref) =>
useEffect(() => {
if (isOpened) {
focusEnd(field.current.ref.current);
field.current.ref.current.focus();
}
}, [isOpened]);

@ -48,8 +48,6 @@ const Tasks = React.memo(({ items, canEdit, onCreate, onUpdate, onMove, onDelete
return (
<>
{items.length > 0 && (
<>
<span className={styles.progressWrapper}>
<Progress
autoSuccess
value={completedItems.length}
@ -58,11 +56,6 @@ const Tasks = React.memo(({ items, canEdit, onCreate, onUpdate, onMove, onDelete
size="tiny"
className={styles.progress}
/>
</span>
<span className={styles.count}>
{completedItems.length}/{items.length}
</span>
</>
)}
<DragDropContext onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
<Droppable droppableId="tasks" type={DroppableTypes.TASK}>

@ -3,23 +3,6 @@
margin: 0 0 16px;
}
.progressWrapper {
display: inline-block;
padding: 3px 0;
vertical-align: top;
width: calc(100% - 50px);
}
.count {
color: #8c8c8c;
display: inline-block;
font-size: 14px;
line-height: 14px;
text-align: right;
vertical-align: top;
width: 50px;
}
.taskButton {
background: transparent;
border: none;

@ -4,7 +4,6 @@
border-radius: 4px;
bottom: 20px;
box-shadow: #b04632 0 1px 0;
max-width: calc(100% - 40px);
padding: 12px 18px;
position: fixed;
right: 20px;

@ -1,12 +1,8 @@
import upperFirst from 'lodash/upperFirst';
import React, { useEffect, useRef } from 'react';
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useTranslation } from 'react-i18next';
import { Icon } from 'semantic-ui-react';
import { useForceUpdate } from '../../lib/hooks';
import getDateFormat from '../../utils/get-date-format';
import styles from './DueDate.module.scss';
@ -16,111 +12,27 @@ const SIZES = {
MEDIUM: 'medium',
};
const STATUSES = {
DUE_SOON: 'dueSoon',
OVERDUE: 'overdue',
COMPLETED: 'completed',
};
const LONG_DATE_FORMAT_BY_SIZE = {
const FORMATS = {
tiny: 'longDate',
small: 'longDate',
medium: 'longDateTime',
};
const FULL_DATE_FORMAT_BY_SIZE = {
tiny: 'fullDate',
small: 'fullDate',
medium: 'fullDateTime',
};
const STATUS_ICON_PROPS_BY_STATUS = {
[STATUSES.DUE_SOON]: {
name: 'hourglass half',
color: 'orange',
},
[STATUSES.OVERDUE]: {
name: 'hourglass end',
color: 'red',
},
[STATUSES.COMPLETED]: {
name: 'checkmark',
color: 'green',
},
};
const getStatus = (dateTime, isCompleted) => {
if (isCompleted) {
return STATUSES.COMPLETED;
}
const secondsLeft = Math.floor((dateTime.getTime() - new Date().getTime()) / 1000);
if (secondsLeft <= 0) {
return STATUSES.OVERDUE;
}
if (secondsLeft <= 24 * 60 * 60) {
return STATUSES.DUE_SOON;
}
return null;
};
const DueDate = React.memo(({ value, size, isCompleted, isDisabled, withStatusIcon, onClick }) => {
const DueDate = React.memo(({ value, size, isDisabled, onClick }) => {
const [t] = useTranslation();
const forceUpdate = useForceUpdate();
const statusRef = useRef(null);
statusRef.current = getStatus(value, isCompleted);
const intervalRef = useRef(null);
const dateFormat = getDateFormat(
value,
LONG_DATE_FORMAT_BY_SIZE[size],
FULL_DATE_FORMAT_BY_SIZE[size],
);
useEffect(() => {
if ([null, STATUSES.DUE_SOON].includes(statusRef.current)) {
intervalRef.current = setInterval(() => {
const status = getStatus(value, isCompleted);
if (status !== statusRef.current) {
forceUpdate();
}
if (status === STATUSES.OVERDUE) {
clearInterval(intervalRef.current);
}
}, 1000);
}
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
}, [value, isCompleted, forceUpdate]);
const contentNode = (
<span
className={classNames(
styles.wrapper,
styles[`wrapper${upperFirst(size)}`],
!withStatusIcon && statusRef.current && styles[`wrapper${upperFirst(statusRef.current)}`],
onClick && styles.wrapperHoverable,
)}
>
{t(`format:${dateFormat}`, {
{t(`format:${FORMATS[size]}`, {
value,
postProcess: 'formatDate',
})}
{withStatusIcon && statusRef.current && (
// eslint-disable-next-line react/jsx-props-no-spreading
<Icon {...STATUS_ICON_PROPS_BY_STATUS[statusRef.current]} className={styles.statusIcon} />
)}
</span>
);
@ -136,19 +48,14 @@ const DueDate = React.memo(({ value, size, isCompleted, isDisabled, withStatusIc
DueDate.propTypes = {
value: PropTypes.instanceOf(Date).isRequired,
size: PropTypes.oneOf(Object.values(SIZES)),
isCompleted: PropTypes.bool.isRequired,
isDisabled: PropTypes.bool,
withStatusIcon: PropTypes.bool,
onClick: PropTypes.func,
onCompletionToggle: PropTypes.func,
};
DueDate.defaultProps = {
size: SIZES.MEDIUM,
isDisabled: false,
withStatusIcon: false,
onClick: undefined,
onCompletionToggle: undefined,
};
export default DueDate;

@ -8,17 +8,16 @@
padding: 0;
}
.statusIcon {
line-height: 1;
margin: 0 0 0 8px;
}
.wrapper {
background: #dce0e4;
border: none;
border-radius: 3px;
color: #6a808b;
display: inline-block;
outline: none;
text-align: left;
transition: background 0.3s ease;
vertical-align: top;
}
.wrapperHoverable:hover {
@ -44,21 +43,4 @@
line-height: 20px;
padding: 6px 12px;
}
/* Statuses */
.wrapperDueSoon {
background: #f2711c;
color: #fff;
}
.wrapperOverdue {
background: #db2828;
color: #fff;
}
.wrapperCompleted {
background: #21ba45;
color: #fff;
}
}

@ -1,6 +1,5 @@
:global(#app) {
.wrapper {
max-width: 100vw;
position: fixed;
width: 100%;
z-index: 1;

@ -3,11 +3,11 @@ import PropTypes from 'prop-types';
import classNames from 'classnames';
import { Link } from 'react-router-dom';
import { Button, Icon, Menu } from 'semantic-ui-react';
import { useAuth } from 'react-oidc-context';
import { usePopup } from '../../lib/popup';
import Paths from '../../constants/Paths';
import NotificationsStep from './NotificationsStep';
import User from '../User';
import UserStep from '../UserStep';
import styles from './Header.module.scss';
@ -30,6 +30,7 @@ const Header = React.memo(
onUserSettingsClick,
onLogout,
}) => {
const auth = useAuth();
const handleProjectSettingsClick = useCallback(() => {
if (canEditProject) {
onProjectSettingsClick();
@ -39,6 +40,11 @@ const Header = React.memo(
const NotificationsPopup = usePopup(NotificationsStep, POPUP_PROPS);
const UserPopup = usePopup(UserStep, POPUP_PROPS);
const onFullLogout = () => {
auth.signoutSilent();
onLogout();
};
return (
<div className={styles.wrapper}>
{!project && (
@ -89,11 +95,10 @@ const Header = React.memo(
<UserPopup
isLogouting={isLogouting}
onSettingsClick={onUserSettingsClick}
onLogout={onLogout}
onLogout={onFullLogout}
>
<Menu.Item className={classNames(styles.item, styles.itemHoverable)}>
<span className={styles.userName}>{user.name}</span>
<User name={user.name} avatarUrl={user.avatarUrl} size="small" />
{user.name}
</Menu.Item>
</UserPopup>
</Menu.Menu>

@ -86,15 +86,6 @@
font-weight: bold;
}
.userName {
display: none;
margin-right: 10px;
@media only screen and (min-width: 797px) {
display: block;
}
}
.wrapper {
background: rgba(0, 0, 0, 0.24);
display: flex;

@ -49,6 +49,7 @@
max-height: 60vh;
overflow-x: hidden;
overflow-y: auto;
scrollbar-width: thin;
&::-webkit-scrollbar {
width: 5px;

@ -17,7 +17,7 @@ const SIZES = {
const Label = React.memo(({ name, color, size, isDisabled, onClick }) => {
const contentNode = (
<span
<div
title={name}
className={classNames(
styles.wrapper,
@ -28,7 +28,7 @@ const Label = React.memo(({ name, color, size, isDisabled, onClick }) => {
)}
>
{name || '\u00A0'}
</span>
</div>
);
return onClick ? (

@ -11,17 +11,17 @@
.wrapper {
border-radius: 3px;
box-sizing: border-box;
color: #fff;
display: inline-block;
font-weight: normal;
font-weight: 400;
outline: none;
overflow: hidden;
text-overflow: ellipsis;
text-shadow: 1px 1px 0 rgba(0, 0, 0, 0.2);
vertical-align: top;
white-space: nowrap;
}
.wrapperNameless {
.wrapperNameless{
width: 40px;
}

@ -25,6 +25,7 @@
max-height: 60vh;
overflow-x: hidden;
overflow-y: auto;
scrollbar-width: thin;
&::-webkit-scrollbar {
width: 5px;

@ -1,68 +0,0 @@
import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import LinkifyReact from 'linkify-react';
import history from '../history';
const Linkify = React.memo(({ children, linkStopPropagation, ...props }) => {
const handleLinkClick = useCallback(
(event) => {
if (linkStopPropagation) {
event.stopPropagation();
}
if (!event.target.getAttribute('target')) {
event.preventDefault();
history.push(event.target.href);
}
},
[linkStopPropagation],
);
const linkRenderer = useCallback(
({ attributes: { href, ...linkProps }, content }) => {
let url;
try {
url = new URL(href, window.location);
} catch (error) {} // eslint-disable-line no-empty
const isSameSite = !!url && url.origin === window.location.origin;
return (
<a
{...linkProps} // eslint-disable-line react/jsx-props-no-spreading
href={href}
target={isSameSite ? undefined : '_blank'}
rel={isSameSite ? undefined : 'noreferrer'}
onClick={handleLinkClick}
>
{isSameSite ? url.pathname : content}
</a>
);
},
[handleLinkClick],
);
return (
<LinkifyReact
{...props} // eslint-disable-line react/jsx-props-no-spreading
options={{
defaultProtocol: 'https',
render: linkRenderer,
}}
>
{children}
</LinkifyReact>
);
});
Linkify.propTypes = {
children: PropTypes.string.isRequired,
linkStopPropagation: PropTypes.bool,
};
Linkify.defaultProps = {
linkStopPropagation: false,
};
export default Linkify;

@ -5,17 +5,15 @@ import { Menu } from 'semantic-ui-react';
import { Popup } from '../../lib/custom-ui';
import { useSteps } from '../../hooks';
import ListSortStep from '../ListSortStep';
import DeleteStep from '../DeleteStep';
import styles from './ActionsStep.module.scss';
const StepTypes = {
DELETE: 'DELETE',
SORT: 'SORT',
};
const ActionsStep = React.memo(({ onNameEdit, onCardAdd, onSort, onDelete, onClose }) => {
const ActionsStep = React.memo(({ onNameEdit, onCardAdd, onDelete, onClose }) => {
const [t] = useTranslation();
const [step, openStep, handleBack] = useSteps();
@ -29,30 +27,11 @@ const ActionsStep = React.memo(({ onNameEdit, onCardAdd, onSort, onDelete, onClo
onClose();
}, [onCardAdd, onClose]);
const handleSortClick = useCallback(() => {
openStep(StepTypes.SORT);
}, [openStep]);
const handleDeleteClick = useCallback(() => {
openStep(StepTypes.DELETE);
}, [openStep]);
const handleSortTypeSelect = useCallback(
(type) => {
onSort({
type,
});
onClose();
},
[onSort, onClose],
);
if (step && step.type) {
switch (step.type) {
case StepTypes.SORT:
return <ListSortStep onTypeSelect={handleSortTypeSelect} onBack={handleBack} />;
case StepTypes.DELETE:
if (step && step.type === StepTypes.DELETE) {
return (
<DeleteStep
title="common.deleteList"
@ -62,8 +41,6 @@ const ActionsStep = React.memo(({ onNameEdit, onCardAdd, onSort, onDelete, onClo
onBack={handleBack}
/>
);
default:
}
}
return (
@ -85,11 +62,6 @@ const ActionsStep = React.memo(({ onNameEdit, onCardAdd, onSort, onDelete, onClo
context: 'title',
})}
</Menu.Item>
<Menu.Item className={styles.menuItem} onClick={handleSortClick}>
{t('action.sortList', {
context: 'title',
})}
</Menu.Item>
<Menu.Item className={styles.menuItem} onClick={handleDeleteClick}>
{t('action.deleteList', {
context: 'title',
@ -105,7 +77,6 @@ ActionsStep.propTypes = {
onNameEdit: PropTypes.func.isRequired,
onCardAdd: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
onSort: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
};

@ -16,18 +16,7 @@ import { ReactComponent as PlusMathIcon } from '../../assets/images/plus-math-ic
import styles from './List.module.scss';
const List = React.memo(
({
id,
index,
name,
isPersisted,
cardIds,
canEdit,
onUpdate,
onDelete,
onSort,
onCardCreate,
}) => {
({ id, index, name, isPersisted, cardIds, canEdit, onUpdate, onDelete, onCardCreate }) => {
const [t] = useTranslation();
const [isAddCardOpened, setIsAddCardOpened] = useState(false);
@ -125,7 +114,6 @@ const List = React.memo(
onNameEdit={handleNameEdit}
onCardAdd={handleCardAdd}
onDelete={onDelete}
onSort={onSort}
>
<Button className={classNames(styles.headerButton, styles.target)}>
<Icon fitted name="pencil" size="small" />
@ -171,7 +159,6 @@ List.propTypes = {
cardIds: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types
canEdit: PropTypes.bool.isRequired,
onUpdate: PropTypes.func.isRequired,
onSort: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
onCardCreate: PropTypes.func.isRequired,
};

@ -43,6 +43,7 @@
max-height: calc(100vh - 268px);
overflow-x: hidden;
overflow-y: auto;
scrollbar-width: thin;
width: 290px;
&:hover {
@ -80,12 +81,6 @@
&:hover .target {
opacity: 1;
}
@media only screen and (max-width: 797px) {
.target {
opacity: 1;
}
}
}
.headerEditable {
@ -109,15 +104,6 @@
background: rgba(9, 30, 66, 0.13);
color: #516b7a;
}
@media only screen and (max-width: 797px) {
&:focus,
&:active {
background: rgba(9, 30, 66, 0.13);
color: #516b7a;
}
}
}
.headerName {

@ -4,7 +4,6 @@ import TextareaAutosize from 'react-textarea-autosize';
import { TextArea } from 'semantic-ui-react';
import { useField } from '../../hooks';
import { focusEnd } from '../../utils/element-helpers';
import styles from './NameEdit.module.scss';
@ -72,7 +71,7 @@ const NameEdit = React.forwardRef(({ children, defaultValue, onUpdate }, ref) =>
useEffect(() => {
if (isOpened) {
focusEnd(field.current.ref.current);
field.current.ref.current.select();
}
}, [isOpened]);

@ -1,61 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { Menu } from 'semantic-ui-react';
import { Popup } from '../../lib/custom-ui';
import { ListSortTypes } from '../../constants/Enums';
import styles from './ListSortStep.module.scss';
const ListSortStep = React.memo(({ onTypeSelect, onBack }) => {
const [t] = useTranslation();
return (
<>
<Popup.Header onBack={onBack}>
{t('common.sortList', {
context: 'title',
})}
</Popup.Header>
<Popup.Content>
<Menu secondary vertical className={styles.menu}>
<Menu.Item
className={styles.menuItem}
onClick={() => onTypeSelect(ListSortTypes.NAME_ASC)}
>
{t('common.title')}
</Menu.Item>
<Menu.Item
className={styles.menuItem}
onClick={() => onTypeSelect(ListSortTypes.DUE_DATE_ASC)}
>
{t('common.dueDate')}
</Menu.Item>
<Menu.Item
className={styles.menuItem}
onClick={() => onTypeSelect(ListSortTypes.CREATED_AT_ASC)}
>
{t('common.oldestFirst')}
</Menu.Item>
<Menu.Item
className={styles.menuItem}
onClick={() => onTypeSelect(ListSortTypes.CREATED_AT_DESC)}
>
{t('common.newestFirst')}
</Menu.Item>
</Menu>
</Popup.Content>
</>
);
});
ListSortStep.propTypes = {
onTypeSelect: PropTypes.func.isRequired,
onBack: PropTypes.func,
};
ListSortStep.defaultProps = {
onBack: undefined,
};
export default ListSortStep;

@ -1,11 +0,0 @@
:global(#app) {
.menu {
margin: -7px -12px -5px;
width: calc(100% + 24px);
}
.menuItem {
margin: 0;
padding-left: 14px;
}
}

@ -1,3 +0,0 @@
import ListSortStep from './ListSortStep';
export default ListSortStep;

@ -1,9 +1,10 @@
import isEmail from 'validator/lib/isEmail';
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import { useAuth } from 'react-oidc-context';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useTranslation } from 'react-i18next';
import { Button, Form, Grid, Header, Message } from 'semantic-ui-react';
import { Form, Grid, Header, Message } from 'semantic-ui-react';
import { useDidUpdate, usePrevious, useToggle } from '../../lib/hooks';
import { Input } from '../../lib/custom-ui';
@ -18,11 +19,6 @@ const createMessage = (error) => {
}
switch (error.message) {
case 'Invalid credentials':
return {
type: 'error',
content: 'common.invalidCredentials',
};
case 'Invalid email or username':
return {
type: 'error',
@ -33,21 +29,6 @@ const createMessage = (error) => {
type: 'error',
content: 'common.invalidPassword',
};
case 'Use single sign-on':
return {
type: 'error',
content: 'common.useSingleSignOn',
};
case 'Email already in use':
return {
type: 'error',
content: 'common.emailAlreadyInUse',
};
case 'Username already in use':
return {
type: 'error',
content: 'common.usernameAlreadyInUse',
};
case 'Failed to fetch':
return {
type: 'warning',
@ -67,17 +48,8 @@ const createMessage = (error) => {
};
const Login = React.memo(
({
defaultData,
isSubmitting,
isSubmittingUsingOidc,
error,
withOidc,
isOidcEnforced,
onAuthenticate,
onAuthenticateUsingOidc,
onMessageDismiss,
}) => {
({ defaultData, isSubmitting, error, onAuthenticate, onMessageDismiss }) => {
const auth = useAuth();
const [t] = useTranslation();
const wasSubmitting = usePrevious(isSubmitting);
@ -113,15 +85,12 @@ const Login = React.memo(
}, [onAuthenticate, data]);
useEffect(() => {
if (!isOidcEnforced) {
emailOrUsernameField.current.focus();
}
}, [isOidcEnforced]);
}, []);
useEffect(() => {
if (wasSubmitting && !isSubmitting && error) {
switch (error.message) {
case 'Invalid credentials':
case 'Invalid email or username':
emailOrUsernameField.current.select();
@ -168,7 +137,6 @@ const Login = React.memo(
onDismiss={onMessageDismiss}
/>
)}
{!isOidcEnforced && (
<Form size="large" onSubmit={handleSubmit}>
<div className={styles.inputWrapper}>
<div className={styles.inputLabel}>{t('common.emailOrUsername')}</div>
@ -202,24 +170,12 @@ const Login = React.memo(
content={t('action.logIn')}
floated="right"
loading={isSubmitting}
disabled={isSubmitting || isSubmittingUsingOidc}
disabled={isSubmitting}
/>
</Form>
)}
{withOidc && (
<Button
type="button"
fluid={isOidcEnforced}
primary={isOidcEnforced}
size={isOidcEnforced ? 'large' : undefined}
icon={isOidcEnforced ? 'right arrow' : undefined}
labelPosition={isOidcEnforced ? 'right' : undefined}
content={t('action.logInWithSSO')}
loading={isSubmittingUsingOidc}
disabled={isSubmitting || isSubmittingUsingOidc}
onClick={onAuthenticateUsingOidc}
/>
)}
<Form.Button type="button" onClick={() => auth.signinRedirect()}>
Log in with SSO
</Form.Button>
</div>
</div>
</Grid.Column>
@ -250,16 +206,10 @@ const Login = React.memo(
);
Login.propTypes = {
/* eslint-disable react/forbid-prop-types */
defaultData: PropTypes.object.isRequired,
/* eslint-enable react/forbid-prop-types */
defaultData: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
isSubmitting: PropTypes.bool.isRequired,
isSubmittingUsingOidc: PropTypes.bool.isRequired,
error: PropTypes.object, // eslint-disable-line react/forbid-prop-types
withOidc: PropTypes.bool.isRequired,
isOidcEnforced: PropTypes.bool.isRequired,
onAuthenticate: PropTypes.func.isRequired,
onAuthenticateUsingOidc: PropTypes.func.isRequired,
onMessageDismiss: PropTypes.func.isRequired,
};

@ -1,19 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Loader } from 'semantic-ui-react';
import LoginContainer from '../containers/LoginContainer';
const LoginWrapper = React.memo(({ isInitializing }) => {
if (isInitializing) {
return <Loader active size="massive" />;
}
return <LoginContainer />;
});
LoginWrapper.propTypes = {
isInitializing: PropTypes.bool.isRequired,
};
export default LoginWrapper;

@ -3,7 +3,6 @@ import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { Button } from 'semantic-ui-react';
import { Popup } from '../../lib/custom-ui';
import { useSteps } from '../../hooks';
import User from '../User';
@ -20,7 +19,6 @@ const ActionsStep = React.memo(
({
membership,
permissionsSelectStep,
title,
leaveButtonContent,
leaveConfirmationTitle,
leaveConfirmationContent,
@ -33,7 +31,6 @@ const ActionsStep = React.memo(
canLeave,
onUpdate,
onDelete,
onBack,
onClose,
}) => {
const [t] = useTranslation();
@ -56,11 +53,6 @@ const ActionsStep = React.memo(
[onUpdate],
);
const handleDeleteConfirm = useCallback(() => {
onDelete();
onClose();
}, [onDelete, onClose]);
if (step) {
switch (step.type) {
case StepTypes.EDIT_PERMISSIONS: {
@ -89,7 +81,7 @@ const ActionsStep = React.memo(
? leaveConfirmationButtonContent
: deleteConfirmationButtonContent
}
onConfirm={handleDeleteConfirm}
onConfirm={onDelete}
onBack={handleBack}
/>
);
@ -97,7 +89,7 @@ const ActionsStep = React.memo(
}
}
const contentNode = (
return (
<>
<span className={styles.user}>
<User name={membership.user.name} avatarUrl={membership.user.avatarUrl} size="large" />
@ -133,26 +125,12 @@ const ActionsStep = React.memo(
)}
</>
);
return onBack ? (
<>
<Popup.Header onBack={onBack}>
{t(title, {
context: 'title',
})}
</Popup.Header>
<Popup.Content>{contentNode}</Popup.Content>
</>
) : (
contentNode
);
},
);
ActionsStep.propTypes = {
membership: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
permissionsSelectStep: PropTypes.elementType,
title: PropTypes.string,
leaveButtonContent: PropTypes.string,
leaveConfirmationTitle: PropTypes.string,
leaveConfirmationContent: PropTypes.string,
@ -165,13 +143,11 @@ ActionsStep.propTypes = {
canLeave: PropTypes.bool.isRequired,
onUpdate: PropTypes.func,
onDelete: PropTypes.func.isRequired,
onBack: PropTypes.func,
onClose: PropTypes.func.isRequired,
};
ActionsStep.defaultProps = {
permissionsSelectStep: undefined,
title: 'common.memberActions',
leaveButtonContent: 'action.leaveBoard',
leaveConfirmationTitle: 'common.leaveBoard',
leaveConfirmationContent: 'common.areYouSureYouWantToLeaveBoard',
@ -181,7 +157,6 @@ ActionsStep.defaultProps = {
deleteConfirmationContent: 'common.areYouSureYouWantToRemoveThisMemberFromBoard',
deleteConfirmationButtonContent: 'action.removeMember',
onUpdate: undefined,
onBack: undefined,
};
export default ActionsStep;

@ -4,6 +4,7 @@
max-height: 60vh;
overflow-x: hidden;
overflow-y: auto;
scrollbar-width: thin;
&::-webkit-scrollbar {
width: 5px;

@ -5,21 +5,16 @@ import { usePopup } from '../../lib/popup';
import AddStep from './AddStep';
import ActionsStep from './ActionsStep';
import MembershipsStep from './MembershipsStep';
import User from '../User';
import styles from './Memberships.module.scss';
const MAX_MEMBERS = 6;
const Memberships = React.memo(
({
items,
allUsers,
permissionsSelectStep,
title,
addTitle,
actionsTitle,
leaveButtonContent,
leaveConfirmationTitle,
leaveConfirmationContent,
@ -36,14 +31,11 @@ const Memberships = React.memo(
}) => {
const AddPopup = usePopup(AddStep);
const ActionsPopup = usePopup(ActionsStep);
const MembershipsPopup = usePopup(MembershipsStep);
const remainMembersCount = items.length - MAX_MEMBERS;
return (
<>
<span className={styles.users}>
{items.slice(0, MAX_MEMBERS).map((item) => (
{items.map((item) => (
<span key={item.id} className={styles.user}>
<ActionsPopup
membership={item}
@ -71,30 +63,6 @@ const Memberships = React.memo(
</span>
))}
</span>
{remainMembersCount > 0 && (
<MembershipsPopup
items={items}
permissionsSelectStep={permissionsSelectStep}
title={title}
actionsTitle={actionsTitle}
leaveButtonContent={leaveButtonContent}
leaveConfirmationTitle={leaveConfirmationTitle}
leaveConfirmationContent={leaveConfirmationContent}
leaveConfirmationButtonContent={leaveConfirmationButtonContent}
deleteButtonContent={deleteButtonContent}
deleteConfirmationTitle={deleteConfirmationTitle}
deleteConfirmationContent={deleteConfirmationContent}
deleteConfirmationButtonContent={deleteConfirmationButtonContent}
canEdit={canEdit}
canLeave={items.length > 1 || canLeaveIfLast}
onUpdate={onUpdate}
onDelete={onDelete}
>
<Button icon className={styles.addUser}>
+{remainMembersCount < 99 ? remainMembersCount : 99}
</Button>
</MembershipsPopup>
)}
{canEdit && (
<AddPopup
users={allUsers}
@ -117,9 +85,7 @@ Memberships.propTypes = {
allUsers: PropTypes.array.isRequired,
/* eslint-enable react/forbid-prop-types */
permissionsSelectStep: PropTypes.elementType,
title: PropTypes.string,
addTitle: PropTypes.string,
actionsTitle: PropTypes.string,
leaveButtonContent: PropTypes.string,
leaveConfirmationTitle: PropTypes.string,
leaveConfirmationContent: PropTypes.string,
@ -137,9 +103,7 @@ Memberships.propTypes = {
Memberships.defaultProps = {
permissionsSelectStep: undefined,
title: undefined,
addTitle: undefined,
actionsTitle: undefined,
leaveButtonContent: undefined,
leaveConfirmationTitle: undefined,
leaveConfirmationContent: undefined,

@ -11,10 +11,6 @@
vertical-align: top;
width: 36px;
@media only screen and (max-width: 797px) {
margin-left: 10px;
}
&:hover {
background: rgba(0, 0, 0, 0.32);
}

@ -1,121 +0,0 @@
import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import { useSteps } from '../../hooks';
import ActionsStep from './ActionsStep';
import BoardMembershipsStep from '../BoardMembershipsStep';
const StepTypes = {
EDIT: 'EDIT',
};
const MembershipsStep = React.memo(
({
items,
permissionsSelectStep,
title,
actionsTitle,
leaveButtonContent,
leaveConfirmationTitle,
leaveConfirmationContent,
leaveConfirmationButtonContent,
deleteButtonContent,
deleteConfirmationTitle,
deleteConfirmationContent,
deleteConfirmationButtonContent,
canEdit,
canLeave,
onUpdate,
onDelete,
onClose,
}) => {
const [step, openStep, handleBack] = useSteps();
const handleUserSelect = useCallback(
(userId) => {
openStep(StepTypes.EDIT, {
userId,
});
},
[openStep],
);
if (step && step.type === StepTypes.EDIT) {
const currentItem = items.find((item) => item.userId === step.params.userId);
if (currentItem) {
return (
<ActionsStep
membership={currentItem}
permissionsSelectStep={permissionsSelectStep}
title={actionsTitle}
leaveButtonContent={leaveButtonContent}
leaveConfirmationTitle={leaveConfirmationTitle}
leaveConfirmationContent={leaveConfirmationContent}
leaveConfirmationButtonContent={leaveConfirmationButtonContent}
deleteButtonContent={deleteButtonContent}
deleteConfirmationTitle={deleteConfirmationTitle}
deleteConfirmationContent={deleteConfirmationContent}
deleteConfirmationButtonContent={deleteConfirmationButtonContent}
canEdit={canEdit}
canLeave={canLeave}
onUpdate={(data) => onUpdate(currentItem.id, data)}
onDelete={() => onDelete(currentItem.id)}
onBack={handleBack}
onClose={onClose}
/>
);
}
openStep(null);
}
return (
// FIXME: hack
<BoardMembershipsStep
items={items}
currentUserIds={[]}
title={title}
onUserSelect={handleUserSelect}
onUserDeselect={() => {}}
/>
);
},
);
MembershipsStep.propTypes = {
items: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types
permissionsSelectStep: PropTypes.elementType,
title: PropTypes.string,
actionsTitle: PropTypes.string,
leaveButtonContent: PropTypes.string,
leaveConfirmationTitle: PropTypes.string,
leaveConfirmationContent: PropTypes.string,
leaveConfirmationButtonContent: PropTypes.string,
deleteButtonContent: PropTypes.string,
deleteConfirmationTitle: PropTypes.string,
deleteConfirmationContent: PropTypes.string,
deleteConfirmationButtonContent: PropTypes.string,
canEdit: PropTypes.bool.isRequired,
canLeave: PropTypes.bool.isRequired,
onUpdate: PropTypes.func,
onDelete: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
};
MembershipsStep.defaultProps = {
permissionsSelectStep: undefined,
title: undefined,
actionsTitle: undefined,
leaveButtonContent: undefined,
leaveConfirmationTitle: undefined,
leaveConfirmationContent: undefined,
leaveConfirmationButtonContent: undefined,
deleteButtonContent: undefined,
deleteConfirmationTitle: undefined,
deleteConfirmationContent: undefined,
deleteConfirmationButtonContent: undefined,
onUpdate: undefined,
};
export default MembershipsStep;

@ -0,0 +1,19 @@
import { useAuth } from 'react-oidc-context';
import PropTypes from 'prop-types';
import React from 'react';
let isLoggingIn = true;
const OidcLogin = React.memo(({ onAuthenticate }) => {
const auth = useAuth();
if (isLoggingIn && auth.user) {
isLoggingIn = false;
const { user } = auth;
onAuthenticate(user);
}
});
OidcLogin.propTypes = {
onAuthenticate: PropTypes.func.isRequired,
};
export default OidcLogin;

@ -0,0 +1,3 @@
import OidcLogin from './OidcLogin';
export default OidcLogin;

@ -12,9 +12,7 @@ const ManagersPane = React.memo(({ items, allUsers, onCreate, onDelete }) => {
<Memberships
items={items}
allUsers={allUsers}
title="common.managers"
addTitle="common.addManager"
actionsTitle="common.managerActions"
leaveButtonContent="action.leaveProject"
leaveConfirmationTitle="common.leaveProject"
leaveConfirmationContent="common.areYouSureYouWantToLeaveProject"

@ -1,11 +1,12 @@
import React from 'react';
import PropTypes from 'prop-types';
import { AuthProvider } from 'react-oidc-context';
import { Provider } from 'react-redux';
import { Route, Routes } from 'react-router-dom';
import { ReduxRouter } from '../lib/redux-router';
import Paths from '../constants/Paths';
import LoginWrapperContainer from '../containers/LoginWrapperContainer';
import LoginContainer from '../containers/LoginContainer';
import CoreContainer from '../containers/CoreContainer';
import NotFound from './NotFound';
@ -13,16 +14,25 @@ import 'react-datepicker/dist/react-datepicker.css';
import 'photoswipe/dist/photoswipe.css';
import 'easymde/dist/easymde.min.css';
import '../lib/custom-ui/styles.css';
import '../assets/css/font-awesome.css';
import '../styles.module.scss';
import OidcLoginContainer from '../containers/OidcLoginContainer';
function Root({ store, history }) {
function Root({ store, history, config }) {
return (
<AuthProvider
authority={config.authority}
client_id={config.clientId}
redirect_uri={config.redirectUri}
scope={config.scopes}
onSigninCallback={() => {
window.history.replaceState({}, document.title, window.location.pathname);
}}
>
<Provider store={store}>
<ReduxRouter history={history}>
<Routes>
<Route path={Paths.LOGIN} element={<LoginWrapperContainer />} />
<Route path={Paths.OIDC_CALLBACK} element={<LoginWrapperContainer />} />
<Route path={Paths.LOGIN} element={<LoginContainer />} />
<Route path={Paths.OIDC_LOGIN} element={<OidcLoginContainer />} />
<Route path={Paths.ROOT} element={<CoreContainer />} />
<Route path={Paths.PROJECTS} element={<CoreContainer />} />
<Route path={Paths.BOARDS} element={<CoreContainer />} />
@ -31,13 +41,14 @@ function Root({ store, history }) {
</Routes>
</ReduxRouter>
</Provider>
</AuthProvider>
);
}
Root.propTypes = {
/* eslint-disable react/forbid-prop-types */
store: PropTypes.object.isRequired,
history: PropTypes.object.isRequired,
config: PropTypes.object.isRequired,
/* eslint-enable react/forbid-prop-types */
};

@ -10,10 +10,13 @@
.wrapper {
background: #dce0e4;
border: none;
border-radius: 3px;
color: #6a808b;
display: inline-block;
font-variant-numeric: tabular-nums;
outline: none;
text-align: left;
transition: background 0.3s ease;
vertical-align: top;
}

@ -14,10 +14,12 @@
}
.wrapper {
border: none;
border-radius: 50%;
color: #fff;
display: inline-block;
line-height: 1;
outline: none;
text-align: center;
vertical-align: top;
}
@ -30,6 +32,7 @@
.wrapperTiny {
font-size: 10px;
font-weight: 400;
height: 24px;
line-height: 20px;
padding: 2px 0;
@ -38,6 +41,7 @@
.wrapperSmall {
font-size: 12px;
font-weight: 400;
height: 28px;
padding: 8px 0;
width: 28px;
@ -45,6 +49,7 @@
.wrapperMedium {
font-size: 14px;
font-weight: 500;
height: 32px;
padding: 10px 0;
width: 32px;
@ -52,6 +57,7 @@
.wrapperLarge {
font-size: 14px;
font-weight: 500;
height: 36px;
padding: 12px 0 10px;
width: 36px;
@ -59,6 +65,7 @@
.wrapperMassive {
font-size: 36px;
font-weight: 500;
height: 100px;
padding: 32px 0 10px;
width: 100px;

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

Loading…
Cancel
Save