Compare commits
273
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0f584e814 | ||
|
|
135f5eccb5 | ||
|
|
10001db8cf | ||
|
|
7ee351fe30 | ||
|
|
73746b83d1 | ||
|
|
f9a7b38ce9 | ||
|
|
1f261fe7b6 | ||
|
|
80bee30463 | ||
|
|
a9957481b6 | ||
|
|
36b05a5478 | ||
|
|
af91512abf | ||
|
|
8fd0f682d9 | ||
|
|
9699fbe76a | ||
|
|
4176a62f1a | ||
|
|
c12f0844d7 | ||
|
|
e6644eb745 | ||
|
|
b2e1fba9a0 | ||
|
|
818ae4a6df | ||
|
|
0de0e809ac | ||
|
|
da727c0a0b | ||
|
|
4841a65c77 | ||
|
|
0ec4b619d9 | ||
|
|
12f05adde7 | ||
|
|
c594e8bd71 | ||
|
|
f84166406f | ||
|
|
82b2ba298d | ||
|
|
38b6805ba1 | ||
|
|
6a7a582045 | ||
|
|
c53a00b4ba | ||
|
|
52103c775b | ||
|
|
eeb1bd80cc | ||
|
|
e6b8538863 | ||
|
|
9dc38932fb | ||
|
|
e9b4a4adfc | ||
|
|
76c78c7252 | ||
|
|
04f52a63f2 | ||
|
|
6b16a104b1 | ||
|
|
aff853c602 | ||
|
|
ad2966c5d6 | ||
|
|
8d74cc1732 | ||
|
|
ea94fe8bf3 | ||
|
|
6052f8999f | ||
|
|
67d1b1c194 | ||
|
|
b3cb8405f5 | ||
|
|
7acaec6d04 | ||
|
|
72896a66b3 | ||
|
|
4be4783251 | ||
|
|
5c6c6a0bcf | ||
|
|
ba178d66fa | ||
|
|
270ce29f87 | ||
|
|
de1ed3c784 | ||
|
|
caf8790f28 | ||
|
|
81436b373d | ||
|
|
cc1e886a31 | ||
|
|
f0e9fe7904 | ||
|
|
5fcea5d651 | ||
|
|
a963d85a66 | ||
|
|
04b42decef | ||
|
|
c065566c15 | ||
|
|
3779bdb053 | ||
|
|
4124ab17d2 | ||
|
|
0e3bc92a61 | ||
|
|
b8d7e713b9 | ||
|
|
017eee8302 | ||
|
|
1b28892cf5 | ||
|
|
013ea1b869 | ||
|
|
4975fa2eeb | ||
|
|
3e28ad87c7 | ||
|
|
2d6666d693 | ||
|
|
4cbd2f85c7 | ||
|
|
fae9de4e1c | ||
|
|
89c1ed71e1 | ||
|
|
1725f3cb8a | ||
|
|
f7be49e402 | ||
|
|
c8d732d936 | ||
|
|
5a32b6327c | ||
|
|
8488105810 | ||
|
|
57ebfa51d8 | ||
|
|
40170fdf90 | ||
|
|
1629f62d09 | ||
|
|
fff0552081 | ||
|
|
e792cb26b1 | ||
|
|
7ee2d76be4 | ||
|
|
eb56b2147b | ||
|
|
8747aa59de | ||
|
|
934dcdf39b | ||
|
|
2c84316fe0 | ||
|
|
03825c3bed | ||
|
|
200ea3bbc0 | ||
|
|
5b4b0cd635 | ||
|
|
a1323cd516 | ||
|
|
92f75789fa | ||
|
|
8a8c1fee0c | ||
|
|
16499052f7 | ||
|
|
cc32daa009 | ||
|
|
ea1b3b7f92 | ||
|
|
4e2863faa7 | ||
|
|
b46fb43e6f | ||
|
|
bcacfa8fe2 | ||
|
|
4df2becd6f | ||
|
|
2990ea593a | ||
|
|
b976673826 | ||
|
|
16e034efcb | ||
|
|
6712625c3f | ||
|
|
b5bbf6a6a4 | ||
|
|
3d3e7761f5 | ||
|
|
fa56b9c01f | ||
|
|
882213784f | ||
|
|
251bd793f3 | ||
|
|
088b0e760b | ||
|
|
cc1fa1cc3b | ||
|
|
bcd3ea86e8 | ||
|
|
0176650f67 | ||
|
|
f3963aded3 | ||
|
|
bbe720481a | ||
|
|
04f9030474 | ||
|
|
6d135be859 | ||
|
|
63cbe7063e | ||
|
|
7985bea2b8 | ||
|
|
733abccaa9 | ||
|
|
119cce82b4 | ||
|
|
988e70ec40 | ||
|
|
5e3fca3771 | ||
|
|
6d9ce56f4a | ||
|
|
3c8c04fd37 | ||
|
|
790a1d5cc9 | ||
|
|
4b352fd878 | ||
|
|
a196f2ef11 | ||
|
|
6c65d135f7 | ||
|
|
a1a1e9a86a | ||
|
|
0d39a7567f | ||
|
|
76a18d06d6 | ||
|
|
ce52fc5af7 | ||
|
|
0a2863a79c | ||
|
|
39e320fe4b | ||
|
|
2f3dfe775e | ||
|
|
22964cb375 | ||
|
|
fd89cf7f2b | ||
|
|
5acf84c20b | ||
|
|
6cd9da844f | ||
|
|
d7847fe89b | ||
|
|
d033520927 | ||
|
|
d911030831 | ||
|
|
9d95ed6c41 | ||
|
|
1a5a853fa3 | ||
|
|
bb358e01dd | ||
|
|
9f0fce098e | ||
|
|
60ab09771d | ||
|
|
cb3ef783ba | ||
|
|
fe376b10c1 | ||
|
|
786441cda0 | ||
|
|
0d3cf0a32d | ||
|
|
b4e01d7256 | ||
|
|
73a641b935 | ||
|
|
6802a0dc69 | ||
|
|
81d6c43cd9 | ||
|
|
ca6a1a14f5 | ||
|
|
c096444acb | ||
|
|
51d05f3cd3 | ||
|
|
221da3c455 | ||
|
|
92098e136c | ||
|
|
fd5c33dd5d | ||
|
|
0c1c7e2192 | ||
|
|
b8d262f745 | ||
|
|
a1fd694248 | ||
|
|
634d6ceab1 | ||
|
|
31d4d5f38d | ||
|
|
3f99438402 | ||
|
|
7e7727e3e1 | ||
|
|
273d994750 | ||
|
|
86d21d6abd | ||
|
|
a9c200cc3b | ||
|
|
3fedc14062 | ||
|
|
c42e1fb5dd | ||
|
|
9961340118 | ||
|
|
f54bd22757 | ||
|
|
1043dacd67 | ||
|
|
616304a1fa | ||
|
|
1e01148138 | ||
|
|
ede56e1ae3 | ||
|
|
b2e0c45539 | ||
|
|
389648b546 | ||
|
|
f4789c28db | ||
|
|
668092de7d | ||
|
|
920f9c097d | ||
|
|
da63598de3 | ||
|
|
517cb34cfe | ||
|
|
079b8eba53 | ||
|
|
af02cdd8bf | ||
|
|
e1bc189b88 | ||
|
|
5f2723623a | ||
|
|
fee6841d0a | ||
|
|
ca6fb3b962 | ||
|
|
28c3f28e01 | ||
|
|
80d12aaeeb | ||
|
|
89af209a2e | ||
|
|
f1fed3e533 | ||
|
|
eddeb96b16 | ||
|
|
9e8ff3b579 | ||
|
|
fc1278011c | ||
|
|
24163463fc | ||
|
|
fdef87e3b9 | ||
|
|
964498dbaa | ||
|
|
5b519cb62f | ||
|
|
11fc3b4485 | ||
|
|
37c0b59f82 | ||
|
|
239611ad51 | ||
|
|
0575e665fe | ||
|
|
019766dced | ||
|
|
6dc9e4ed99 | ||
|
|
d4b64b90fc | ||
|
|
1a49826b85 | ||
|
|
815643eb2c | ||
|
|
bcc4c0d923 | ||
|
|
cf7810b067 | ||
|
|
f1c72df8b6 | ||
|
|
5056d38848 | ||
|
|
3ef5b3ead8 | ||
|
|
ded58dc8b0 | ||
|
|
d8ee1ecdd1 | ||
|
|
fc2f4cd5d3 | ||
|
|
7ef457157e | ||
|
|
23ee815200 | ||
|
|
40c04c35ff | ||
|
|
743f2956c8 | ||
|
|
5bf5db27d8 | ||
|
|
667c5daf25 | ||
|
|
6662b0a717 | ||
|
|
348bf4d6c4 | ||
|
|
3ac2d289d0 | ||
|
|
3bc73c43ea | ||
|
|
8e0c60f5be | ||
|
|
aae69cb5e4 | ||
|
|
782c0356c8 | ||
|
|
757cdb14a4 | ||
|
|
8a4bdbd1fe | ||
|
|
5bf42be786 | ||
|
|
d896c3183b | ||
|
|
13ee0c3466 | ||
|
|
211c841379 | ||
|
|
90a30961ff | ||
|
|
7cb9774e05 | ||
|
|
875895b331 | ||
|
|
91bc889fed | ||
|
|
a34d8e9753 | ||
|
|
107cb85ba2 | ||
|
|
9333ef562e | ||
|
|
7d28abc7e6 | ||
|
|
5317950b5c | ||
|
|
7501619b7e | ||
|
|
080e29aa03 | ||
|
|
48c532b2c2 | ||
|
|
a62815f825 | ||
|
|
6938232dc4 | ||
|
|
4c4cfa9c66 | ||
|
|
ba89d41b19 | ||
|
|
5c435414f7 | ||
|
|
2d7812a679 | ||
|
|
d386c82973 | ||
|
|
6c4db967c2 | ||
|
|
932d8225ed | ||
|
|
0d481703da | ||
|
|
23e2b4a622 | ||
|
|
f4cc51e1c0 | ||
|
|
793ddd5e20 | ||
|
|
3d84888eb1 | ||
|
|
02eabd203c | ||
|
|
d0a2734161 | ||
|
|
eea57ff121 | ||
|
|
7e2b74627c | ||
|
|
bee199c13b | ||
|
|
8dd331b895 | ||
|
|
13f26b4be1 |
@@ -0,0 +1,53 @@
|
||||
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?
|
||||
@@ -0,0 +1,33 @@
|
||||
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?
|
||||
@@ -0,0 +1,57 @@
|
||||
name: Build and publish release package
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [created]
|
||||
|
||||
|
||||
jobs:
|
||||
build-and-publish-release-package:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '18'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Workflow install pnpm
|
||||
run: npm install pnpm -g
|
||||
|
||||
- name: Client install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Server install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Server include into dist
|
||||
run: mv server/ dist/
|
||||
|
||||
- name: Client build production
|
||||
run: |
|
||||
npm run build
|
||||
working-directory: ./client
|
||||
|
||||
- name: Client include into dist
|
||||
run: |
|
||||
mv build/index.html ../dist/views/index.ejs
|
||||
mv build/* ../dist/public/
|
||||
working-directory: ./client
|
||||
|
||||
- name: Dist include README.md SECURITY.md LICENSE start.sh
|
||||
run: mv README.md SECURITY.md LICENSE start.sh dist/
|
||||
|
||||
- name: Dist Remove node modules
|
||||
run: rm -R dist/node_modules
|
||||
|
||||
- name: Dist create .zip file
|
||||
run: |
|
||||
mv dist/ planka/
|
||||
zip -r planka-prebuild-${{ github.event.release.tag_name }}.zip planka
|
||||
|
||||
- name: Dist upload assets
|
||||
run: |
|
||||
gh release upload ${{ github.event.release.tag_name }} planka-prebuild-${{ github.event.release.tag_name }}.zip
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
@@ -13,26 +13,26 @@ env:
|
||||
|
||||
jobs:
|
||||
build-and-push-docker-base-image:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: self-hosted
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v1
|
||||
uses: docker/setup-qemu-action@v2
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v1
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v2
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
@@ -40,5 +40,5 @@ jobs:
|
||||
build-args: ALPINE_VERSION=${{ env.ALPINE_VERSION }}
|
||||
push: true
|
||||
tags: |
|
||||
ghcr.io/plankanban/planka:base-latest
|
||||
ghcr.io/plankanban/planka:base-${{ env.ALPINE_VERSION }}
|
||||
ghcr.io/${{ github.repository }}:base-latest
|
||||
ghcr.io/${{ github.repository }}:base-${{ env.ALPINE_VERSION }}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
# 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:
|
||||
runs-on: self-hosted
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
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
|
||||
@@ -6,19 +6,19 @@ on:
|
||||
|
||||
jobs:
|
||||
build-and-push-docker-image:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: self-hosted
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v1
|
||||
uses: docker/setup-qemu-action@v2
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v1
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -31,12 +31,23 @@ 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@v2
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
push: true
|
||||
tags: |
|
||||
ghcr.io/plankanban/planka:latest
|
||||
ghcr.io/plankanban/planka:${{ steps.set-version.outputs.result }}
|
||||
tags: ${{ steps.metadata.outputs.tags }}
|
||||
labels: ${{ steps.metadata.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
name: Release Charts
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- "charts/**"
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
release:
|
||||
# depending on default permission settings for your org (contents being read-only or read-write for workloads), you will have to add permissions
|
||||
# see: https://docs.github.com/en/actions/security-guides/automatic-token-authentication#modifying-the-permissions-for-the-github_token
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: self-hosted
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Configure Git
|
||||
run: |
|
||||
git config user.name "$GITHUB_ACTOR"
|
||||
git config user.email "$GITHUB_ACTOR@users.noreply.github.com"
|
||||
|
||||
- name: Install Helm
|
||||
uses: azure/setup-helm@v3
|
||||
|
||||
- name: Add repositories
|
||||
run: |
|
||||
for dir in $(ls -d charts/*/); do
|
||||
helm dependency list $dir 2> /dev/null | tail +2 | head -n -1 | awk '{ print "helm repo add " $1 " " $3 }' | while read cmd; do $cmd; done
|
||||
done
|
||||
|
||||
- name: Run chart-releaser for stable
|
||||
uses: helm/chart-releaser-action@v1.6.0
|
||||
with:
|
||||
charts_dir: charts
|
||||
mark_as_latest: false
|
||||
env:
|
||||
CR_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
|
||||
@@ -10,3 +10,6 @@ docker-compose.override.yml
|
||||
# `npm i --package-lock-only`
|
||||
pnpm-lock.yaml
|
||||
yarn.lock
|
||||
|
||||
# Chart dependencies
|
||||
**/charts/*.tgz
|
||||
|
||||
Executable → Regular
-2
@@ -1,4 +1,2 @@
|
||||
#!/bin/sh
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
npx lint-staged
|
||||
|
||||
+23
-8
@@ -1,33 +1,44 @@
|
||||
FROM ghcr.io/plankanban/planka:base-latest as server-dependencies
|
||||
FROM node:18-alpine as server-dependencies
|
||||
|
||||
RUN apk -U upgrade \
|
||||
&& apk add build-base python3 \
|
||||
--no-cache
|
||||
|
||||
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 clean-install --omit=dev
|
||||
&& 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 clean-install --omit=dev
|
||||
&& npm install pnpm --global \
|
||||
&& pnpm import \
|
||||
&& pnpm install --prod
|
||||
|
||||
COPY client .
|
||||
RUN DISABLE_ESLINT_PLUGIN=true npm run build
|
||||
|
||||
FROM ghcr.io/plankanban/planka:base-latest
|
||||
FROM node:18-alpine
|
||||
|
||||
RUN apk del vips-dependencies --purge
|
||||
RUN apk -U upgrade \
|
||||
&& apk add bash \
|
||||
--no-cache
|
||||
|
||||
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
|
||||
|
||||
@@ -42,4 +53,8 @@ VOLUME /app/private/attachments
|
||||
|
||||
EXPOSE 1337
|
||||
|
||||
CMD ["./start.sh"]
|
||||
HEALTHCHECK --interval=10s --timeout=2s --start-period=15s \
|
||||
CMD node ./healthcheck.js
|
||||
|
||||
|
||||
CMD [ "bash", "start.sh" ]
|
||||
|
||||
+12
-17
@@ -1,27 +1,22 @@
|
||||
FROM node:lts-alpine
|
||||
FROM node:18-alpine
|
||||
|
||||
ARG ALPINE_VERSION=3.16
|
||||
ARG VIPS_VERSION=8.13.3
|
||||
ARG VIPS_VERSION=8.14.5
|
||||
|
||||
RUN apk -U upgrade \
|
||||
&& apk add \
|
||||
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/ \
|
||||
bash pkgconf \
|
||||
libjpeg-turbo libexif librsvg cgif tiff libspng libimagequant \
|
||||
--no-cache \
|
||||
&& apk add \
|
||||
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 \
|
||||
build-base gobject-introspection-dev meson \
|
||||
libjpeg-turbo-dev libexif-dev librsvg-dev cgif-dev tiff-dev libspng-dev libimagequant-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.gz | tar xzC /tmp \
|
||||
&& wget -O- https://github.com/libvips/libvips/releases/download/v${VIPS_VERSION}/vips-${VIPS_VERSION}.tar.xz | tar xJC /tmp \
|
||||
&& cd /tmp/vips-${VIPS_VERSION} \
|
||||
&& ./configure \
|
||||
&& make \
|
||||
&& make install-strip \
|
||||
&& meson setup build-dir \
|
||||
&& cd build-dir \
|
||||
&& ninja \
|
||||
&& ninja test \
|
||||
&& ninja install \
|
||||
&& rm -rf /tmp/vips-${VIPS_VERSION}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
# Planka
|
||||
#### Elegant open source project tracking.
|
||||
|
||||
  
|
||||
|
||||
A Trello-like kanban board built with React and Redux.
|
||||
  
|
||||
|
||||

|
||||
|
||||
@@ -11,314 +10,31 @@ A Trello-like kanban board built with React and Redux.
|
||||
## Features
|
||||
|
||||
- Create projects, boards, lists, cards, labels and tasks
|
||||
- Add card members, track time, set a due date, add attachments, write comments
|
||||
- Markdown support in a card description and comment
|
||||
- Add card members, track time, set due dates, add attachments, write comments
|
||||
- Markdown support in card description and comments
|
||||
- Filter by members and labels
|
||||
- Customize project background
|
||||
- Customize project backgrounds
|
||||
- Real-time updates
|
||||
- User notifications
|
||||
- Internationalization
|
||||
- Internal notifications
|
||||
- Multiple interface languages
|
||||
- Single sign-on via OpenID Connect
|
||||
|
||||
## Deploy
|
||||
## How to deploy Planka
|
||||
|
||||
There are 2 types of installation:
|
||||
1. [Dockerized](#1-docker-compose)
|
||||
2. [Without Docker](#2-without-docker)
|
||||
There are many ways to install Planka, [check them out](https://docs.planka.cloud/docs/intro).
|
||||
|
||||
### 1. Docker Compose
|
||||
For configuration, please see the [configuration section](https://docs.planka.cloud/docs/category/configuration).
|
||||
|
||||
[](https://github.com/plankanban/planka/pkgs/container/planka)
|
||||
## Contact
|
||||
|
||||
- Make sure you have [Docker](https://docs.docker.com/install/) and [Docker Compose](https://docs.docker.com/compose/install/) installed and operational.
|
||||
- Create `docker-compose.yml` based on [the example](https://raw.githubusercontent.com/plankanban/planka/master/docker-compose.yml). This is the ONLY file you will need. You can create this file on your own machine by copy and pasting the content.
|
||||
- Edit `BASE_URL` to match your domain name or IP address.
|
||||
- Edit `SECRET_KEY` with random value. You can generate it by `openssl rand -hex 64`.
|
||||
- If you want to get a hosted version of Planka, you can contact us via email contact@planka.cloud
|
||||
- For any security issues, please do not create a public issue on GitHub, instead please write to security@planka.cloud
|
||||
|
||||
Download the docker-compose.yml:
|
||||
|
||||
```
|
||||
curl -L https://raw.githubusercontent.com/plankanban/planka/master/docker-compose.yml -o docker-compose.yml
|
||||
```
|
||||
|
||||
Pull images and start services:
|
||||
|
||||
```
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
Demo user: demo@demo.demo demo
|
||||
|
||||
### 2. Without Docker
|
||||
|
||||
Installing without Docker is a bit more complicated, here's what you need to do:
|
||||
|
||||
1. Clone this repository into a directory of your choice. (e.g. `/var/www/planka`)
|
||||
|
||||
```bash
|
||||
mkdir -p /var/www/planka
|
||||
cd /var/www/planka
|
||||
git clone https://github.com/plankanban/planka.git .
|
||||
```
|
||||
|
||||
2. Install dependencies and build the client.
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run client:build
|
||||
```
|
||||
|
||||
**Note**: You can use `yarn` or `pnpm` instead of `npm`.
|
||||
|
||||
3. Copy the content of the `client/build` directory to the server.
|
||||
|
||||
```bash
|
||||
cp -r client/build/. server/public
|
||||
cp client/build/index.html server/views/index.ejs
|
||||
```
|
||||
|
||||
4. Copy the start script and navigate to the `server` directory.
|
||||
|
||||
```bash
|
||||
cp start.sh server
|
||||
cd server
|
||||
```
|
||||
|
||||
5. Configure environment variables.
|
||||
|
||||
```bash
|
||||
cp .env.sample .env
|
||||
|
||||
# Edit .env file (You could use nano, vim, etc.)
|
||||
nano .env
|
||||
```
|
||||
|
||||
**Note**: Before continuing, make sure you have your selected database created and running.
|
||||
|
||||
6. Start the server.
|
||||
|
||||
```bash
|
||||
./start.sh
|
||||
```
|
||||
|
||||
**Note**: You can use `pm2` or `systemd` to run the server in the background.
|
||||
|
||||
## Additional information
|
||||
|
||||
### Nginx configuration
|
||||
|
||||
Here is an example of Nginx configuration for Planka, make sure to replace `<domain>` with your domain name, and make sure to configure the SSL.
|
||||
|
||||
```nginx
|
||||
upstream planka {
|
||||
server localhost:1337;
|
||||
keepalive 32;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
server_name <domain>;
|
||||
|
||||
access_log /var/log/nginx/planka-access.log;
|
||||
error_log /var/log/nginx/planka-error.log error;
|
||||
|
||||
# SSL Configuration - Replace the example <domain> with your domain
|
||||
ssl_certificate /etc/letsencrypt/live/<domain>/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/<domain>/privkey.pem;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers "ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384";
|
||||
ssl_prefer_server_ciphers on;
|
||||
|
||||
client_max_body_size 120M;
|
||||
add_header Access-Control-Allow-Origin *;
|
||||
add_header Access-Control-Max-Age 3600;
|
||||
add_header Access-Control-Expose-Headers Content-Length;
|
||||
add_header Access-Control-Allow-Headers Range;
|
||||
|
||||
# Make sure to allow socket.io connections
|
||||
location ~* \.io {
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
client_max_body_size 50M;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Frame-Options SAMEORIGIN;
|
||||
proxy_buffers 256 16k;
|
||||
proxy_buffer_size 16k;
|
||||
client_body_timeout 60;
|
||||
send_timeout 300;
|
||||
lingering_timeout 5;
|
||||
proxy_connect_timeout 1d;
|
||||
proxy_send_timeout 1d;
|
||||
proxy_read_timeout 1d;
|
||||
proxy_pass http://planka;
|
||||
}
|
||||
|
||||
location / {
|
||||
client_max_body_size 50M;
|
||||
proxy_set_header Connection "";
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Frame-Options SAMEORIGIN;
|
||||
proxy_buffers 256 16k;
|
||||
proxy_buffer_size 16k;
|
||||
proxy_read_timeout 600s;
|
||||
proxy_cache_revalidate on;
|
||||
proxy_cache_min_uses 2;
|
||||
proxy_cache_use_stale timeout;
|
||||
proxy_cache_lock on;
|
||||
proxy_http_version 1.1;
|
||||
proxy_pass http://planka;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Logging
|
||||
|
||||
Planka currently allows you to expose the application's logfile directory to the host machine via a shared volume. This feature is not enabled by default.
|
||||
|
||||
To expose the logfile director to the host machine, add the item `./logs/:/app/logs/` under `services.planka.volumes`.
|
||||
|
||||
Note that the directory to the left of the semicolon is regarding the host machine while the directory to the right of the semicolon is regarding the Docker container.
|
||||
|
||||
For example, in the above step, `./logs/:/app/logs/` will create the folder `logs` in the same directory where the `docker-compose.yml` file lives.
|
||||
|
||||
### Rotating Logs
|
||||
|
||||
Logrotate is designed to ease administration of systems that generate large numbers of log files. It allows automatic rotation, compression, removal, and mailing of log files. Each log file may be handled daily, weekly, monthly, or when it grows too large.
|
||||
|
||||
#### Setup logrotate for Planka logs
|
||||
|
||||
Create a file in `/etc/logrotate.d` named `planka` with the following contents:
|
||||
|
||||
```
|
||||
/path/to/planka/logs/planka.log {
|
||||
daily
|
||||
missingok
|
||||
rotate 14
|
||||
compress
|
||||
delaycompress
|
||||
notifempty
|
||||
create 640 root adm
|
||||
sharedscripts
|
||||
}
|
||||
```
|
||||
|
||||
Ensure to replace logfile directory with your installation’s `/logs/planka.log` location.
|
||||
|
||||
Restart the logrotate service.
|
||||
|
||||
### Fail2ban
|
||||
|
||||
Fail2ban is a service that uses iptables to automatically drop connections for a pre-defined amount of time from IPs that continuously failed to authenticate to the configured services.
|
||||
|
||||
#### Setup a filter and a jail for Planka
|
||||
|
||||
A filter defines regex rules to identify when users fail to authenticate on Planka's user interface.
|
||||
|
||||
Create a file in `/etc/fail2ban/filter.d` named `planka.conf` with the following contents:
|
||||
|
||||
```conf
|
||||
[Definition]
|
||||
failregex = ^(.*) Invalid (email or username:|password!) (\"(.*)\"!)? ?\(IP: <ADDR>\)$
|
||||
ignoreregex =
|
||||
```
|
||||
|
||||
The jail file defines how to handle the failed authentication attempts found by the Planka filter.
|
||||
|
||||
Create a file in `/etc/fail2ban/jail.d` named `planka.local` with the following contents:
|
||||
|
||||
```conf
|
||||
[planka]
|
||||
enabled = true
|
||||
port = http,https
|
||||
filter = planka
|
||||
logpath = /path/to/planka/logs/planka.log
|
||||
maxretry = 5
|
||||
bantime = 900
|
||||
```
|
||||
|
||||
Ensure to replace `logpath`'s value with your installation’s `/logs/planka.log` location. If you are using ports other than 80 and 443 for your Web server you should replace those too. The bantime and findtime are defined in seconds.
|
||||
|
||||
Restart the fail2ban service. You can check the status of your Planka jail by running:
|
||||
|
||||
```bash
|
||||
fail2ban-client status planka
|
||||
```
|
||||
|
||||
### Import from Trello
|
||||
|
||||
It's already available in Planka, or you can also use the great tool [trello2planka](https://github.com/christophenne/trello2planka) to do the import.
|
||||
|
||||
### Backup and Restore
|
||||
|
||||
Planka comes with two scripts that allow for manual backup and restore when running Planka with docker-compose.yml.
|
||||
Backups can be triggered with `docker-backup.sh` which will export the Database, User Avatars, Project Backgrounds and Attachments into a single tgz file.
|
||||
|
||||
```
|
||||
$ ./docker-backup.sh
|
||||
Exporting postgres database ... Success!
|
||||
Exporting user-avatars ... Success!
|
||||
Exporting project-background-images ... Success!
|
||||
Exporting attachments ... Success!
|
||||
Creating final tarball 2023-01-17T15-37-22Z-backup.tgz ... Success!
|
||||
Cleaning up temporary files and folders ... Success!
|
||||
Backup Complete!
|
||||
```
|
||||
|
||||
The resulting backup can be restored using the `docker-restore.sh` script.
|
||||
|
||||
```
|
||||
$ ./docker-restore.sh 2023-01-17T15-37-22Z-backup.tgz
|
||||
Extracting tarball 2023-01-17T11-10-54Z-backup.tgz ... Success!
|
||||
Importing postgres database ...
|
||||
|
||||
[Many lines of postgres output]
|
||||
...
|
||||
|
||||
Success!
|
||||
Importing user-avatars ... Success!
|
||||
Importing project-background-images ... Success!
|
||||
Importing attachments ... Success!
|
||||
Cleaning up temporary files and folders ... Success!
|
||||
Restore complete!
|
||||
```
|
||||
We do NOT offer any public support via email, please use GitHub.
|
||||
|
||||
## Development
|
||||
|
||||
Clone the repository and install dependencies:
|
||||
|
||||
```
|
||||
git clone https://github.com/plankanban/planka.git
|
||||
|
||||
cd planka
|
||||
npm install
|
||||
```
|
||||
|
||||
Either use a local database or start the provided development database:
|
||||
|
||||
```
|
||||
docker-compose -f docker-compose-dev.yml up
|
||||
```
|
||||
|
||||
Create `server/.env` based on `server/.env.sample` and edit `DATABASE_URL` if needed, then initialize the database:
|
||||
|
||||
```
|
||||
npm run server:db:init
|
||||
```
|
||||
|
||||
Start the development server:
|
||||
|
||||
```
|
||||
npm start
|
||||
```
|
||||
|
||||
Demo user: demo@demo.demo demo
|
||||
See the [development section](https://docs.planka.cloud/docs/Development).
|
||||
|
||||
## Tech stack
|
||||
|
||||
@@ -329,3 +45,7 @@ Demo user: demo@demo.demo demo
|
||||
## License
|
||||
|
||||
Planka is [AGPL-3.0 licensed](https://github.com/plankanban/planka/blob/master/LICENSE).
|
||||
|
||||
## Contributors
|
||||
|
||||
[](https://github.com/plankanban/planka/graphs/contributors)
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ Most recent release.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Please report any security issues you discovered to meltyshev@gmail.com. If the issue is confirmed, we will release a patch as soon as possible depending on complexity.
|
||||
Please report any security issues you discovered to security@planka.cloud. If the issue is confirmed, we will release a patch as soon as possible depending on complexity.
|
||||
|
||||
**Do NOT create public issues on GitHub for security vulnerabilities.**
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# Patterns to ignore when building packages.
|
||||
# This supports shell glob matching, relative path matching, and
|
||||
# negation (prefixed with !). Only one pattern per line.
|
||||
.DS_Store
|
||||
# Common VCS dirs
|
||||
.git/
|
||||
.gitignore
|
||||
.bzr/
|
||||
.bzrignore
|
||||
.hg/
|
||||
.hgignore
|
||||
.svn/
|
||||
# Common backup files
|
||||
*.swp
|
||||
*.bak
|
||||
*.tmp
|
||||
*.orig
|
||||
*~
|
||||
# Various IDEs
|
||||
.project
|
||||
.idea/
|
||||
*.tmproj
|
||||
.vscode/
|
||||
@@ -0,0 +1,6 @@
|
||||
dependencies:
|
||||
- name: postgresql
|
||||
repository: https://charts.bitnami.com/bitnami
|
||||
version: 12.5.1
|
||||
digest: sha256:01dfb2d07ab6800b4a5a6c81f20f3377a758124b2b96b891d0cd6b4f64cf783b
|
||||
generated: "2023-05-15T00:54:48.1308917+01:00"
|
||||
@@ -0,0 +1,31 @@
|
||||
apiVersion: v2
|
||||
name: planka
|
||||
description: A Helm chart to deploy Planka and it's dependencies.
|
||||
|
||||
# A chart can be either an 'application' or a 'library' chart.
|
||||
#
|
||||
# Application charts are a collection of templates that can be packaged into versioned archives
|
||||
# to be deployed.
|
||||
#
|
||||
# Library charts provide useful utilities or functions for the chart developer. They're included as
|
||||
# a dependency of application charts to inject those utilities and functions into the rendering
|
||||
# pipeline. Library charts do not define any templates and therefore cannot be deployed.
|
||||
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
|
||||
|
||||
# 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"
|
||||
|
||||
dependencies:
|
||||
- alias: postgresql
|
||||
condition: postgresql.enabled
|
||||
name: postgresql
|
||||
repository: &bitnami-repo https://charts.bitnami.com/bitnami
|
||||
version: 12.5.1
|
||||
@@ -0,0 +1,111 @@
|
||||
# Planka Helm Chart
|
||||
|
||||
[Planka](https://github.com/plankanban/planka) is an OSS alternative to Trello that you can host yourself, and this is a Helm Chart to make it easier to deploy to K8s.
|
||||
|
||||
Shoutout to [this issue](https://github.com/plankanban/planka/issues/192) who have been asking for this Helm Chart.
|
||||
|
||||
## Issues
|
||||
|
||||
By using the Bitnami chart for PostgreSQL, there is an issue where once deployed, if trying to use a different password then it will be ignored as the Persistant Volume (PV) will already exist with the previous password. See warning from Bitnami below:
|
||||
|
||||
> **Warning!** Setting a password will be ignored on new installation in the case when previous Posgresql release was deleted through the helm command. In that case, old PVC will have an old password, and setting it through helm won't take effect. Deleting persistent volumes (PVs) will solve the issue. Refer to [issue 2061](https://github.com/bitnami/charts/issues/2061) for more details
|
||||
|
||||
If you want to fully uninstall this chart including the data, follow [these steps](https://github.com/bitnami/charts/blob/main/bitnami/postgresql/README.md#uninstalling-the-chart) from the Bitnami Chart's docs.
|
||||
|
||||
## 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
|
||||
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"
|
||||
```
|
||||
|
||||
> **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.
|
||||
|
||||
To access Planka you can port forward using the following command:
|
||||
|
||||
```bash
|
||||
kubectl port-forward $POD_NAME 3000:1337
|
||||
```
|
||||
|
||||
### Accessing Externally
|
||||
|
||||
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 \
|
||||
--set ingress.tls[0].hosts[0]=planka.example.dev \
|
||||
```
|
||||
|
||||
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:
|
||||
- host: planka.example.dev
|
||||
paths:
|
||||
- path: /
|
||||
pathType: ImplementationSpecific
|
||||
|
||||
# Needed for HTTPS
|
||||
tls:
|
||||
- secretName: planka-tls # existing TLS secret in k8s
|
||||
hosts:
|
||||
- planka.example.dev
|
||||
```
|
||||
|
||||
```bash
|
||||
helm install planka . -f values.yaml
|
||||
```
|
||||
|
||||
### Things to consider if production hosting
|
||||
|
||||
If you want to host Planka for more than just playing around with, you might want to do the following things:
|
||||
|
||||
- Create a `values.yaml` with your config, as this will make applying upgrades much easier in the future.
|
||||
- Create your `secretkey` once and store it either in a secure vault, or in your `values.yaml` file so it will be the same for upgrading in the future.
|
||||
- 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).
|
||||
@@ -0,0 +1,22 @@
|
||||
1. Get the application URL by running these commands:
|
||||
{{- if .Values.ingress.enabled }}
|
||||
{{- range $host := .Values.ingress.hosts }}
|
||||
{{- range .paths }}
|
||||
http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ .path }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- else if contains "NodePort" .Values.service.type }}
|
||||
export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "planka.fullname" . }})
|
||||
export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}")
|
||||
echo http://$NODE_IP:$NODE_PORT
|
||||
{{- else if contains "LoadBalancer" .Values.service.type }}
|
||||
NOTE: It may take a few minutes for the LoadBalancer IP to be available.
|
||||
You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "planka.fullname" . }}'
|
||||
export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "planka.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}")
|
||||
echo http://$SERVICE_IP:{{ .Values.service.port }}
|
||||
{{- else if contains "ClusterIP" .Values.service.type }}
|
||||
export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "planka.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}")
|
||||
export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}")
|
||||
echo "Visit http://localhost:3000 to use your application"
|
||||
kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 3000:$CONTAINER_PORT
|
||||
{{- end }}
|
||||
@@ -0,0 +1,62 @@
|
||||
{{/*
|
||||
Expand the name of the chart.
|
||||
*/}}
|
||||
{{- define "planka.name" -}}
|
||||
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Create a default fully qualified app name.
|
||||
We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
|
||||
If release name contains chart name it will be used as a full name.
|
||||
*/}}
|
||||
{{- define "planka.fullname" -}}
|
||||
{{- if .Values.fullnameOverride }}
|
||||
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
|
||||
{{- else }}
|
||||
{{- $name := default .Chart.Name .Values.nameOverride }}
|
||||
{{- if contains $name .Release.Name }}
|
||||
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
|
||||
{{- else }}
|
||||
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Create chart name and version as used by the chart label.
|
||||
*/}}
|
||||
{{- define "planka.chart" -}}
|
||||
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Common labels
|
||||
*/}}
|
||||
{{- define "planka.labels" -}}
|
||||
helm.sh/chart: {{ include "planka.chart" . }}
|
||||
{{ include "planka.selectorLabels" . }}
|
||||
{{- if .Chart.AppVersion }}
|
||||
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
|
||||
{{- end }}
|
||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Selector labels
|
||||
*/}}
|
||||
{{- define "planka.selectorLabels" -}}
|
||||
app.kubernetes.io/name: {{ include "planka.name" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Create the name of the service account to use
|
||||
*/}}
|
||||
{{- define "planka.serviceAccountName" -}}
|
||||
{{- if .Values.serviceAccount.create }}
|
||||
{{- default (include "planka.fullname" .) .Values.serviceAccount.name }}
|
||||
{{- else }}
|
||||
{{- default "default" .Values.serviceAccount.name }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,182 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ include "planka.fullname" . }}
|
||||
labels:
|
||||
{{- include "planka.labels" . | nindent 4 }}
|
||||
spec:
|
||||
{{- if not .Values.autoscaling.enabled }}
|
||||
replicas: {{ .Values.replicaCount }}
|
||||
{{- end }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "planka.selectorLabels" . | nindent 6 }}
|
||||
{{- if .Values.persistence.enabled }}
|
||||
strategy:
|
||||
type: Recreate
|
||||
{{- end }}
|
||||
template:
|
||||
metadata:
|
||||
{{- with .Values.podAnnotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
labels:
|
||||
{{- include "planka.selectorLabels" . | nindent 8 }}
|
||||
spec:
|
||||
{{- with .Values.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
serviceAccountName: {{ include "planka.serviceAccountName" . }}
|
||||
securityContext:
|
||||
{{- toYaml .Values.podSecurityContext | nindent 8 }}
|
||||
containers:
|
||||
- name: {{ .Chart.Name }}
|
||||
securityContext:
|
||||
{{- toYaml .Values.securityContext | nindent 12 }}
|
||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
|
||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: {{ .Values.service.containerPort | default 1337 }}
|
||||
protocol: TCP
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: http
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: http
|
||||
volumeMounts:
|
||||
- mountPath: /app/public/user-avatars
|
||||
subPath: user-avatars
|
||||
name: planka
|
||||
- mountPath: /app/public/project-background-images
|
||||
subPath: project-background-images
|
||||
name: planka
|
||||
- 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 }}
|
||||
{{- else if .Values.ingress.enabled }}
|
||||
value: {{ printf "https://%s" (first .Values.ingress.hosts).host }}
|
||||
{{- else }}
|
||||
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 }}
|
||||
{{- end }}
|
||||
{{- with .Values.affinity }}
|
||||
affinity:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.tolerations }}
|
||||
tolerations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
volumes:
|
||||
- name: planka
|
||||
{{- if .Values.persistence.enabled }}
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ .Values.persistence.existingClaim | default (include "planka.fullname" .) }}
|
||||
{{- else }}
|
||||
emptyDir: {}
|
||||
{{- end }}
|
||||
{{- if .Values.securityContext.readOnlyRootFilesystem }}
|
||||
- name: emptydir
|
||||
emptyDir: {}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,32 @@
|
||||
{{- if .Values.autoscaling.enabled }}
|
||||
apiVersion: autoscaling/v2
|
||||
kind: HorizontalPodAutoscaler
|
||||
metadata:
|
||||
name: {{ include "planka.fullname" . }}
|
||||
labels:
|
||||
{{- include "planka.labels" . | nindent 4 }}
|
||||
spec:
|
||||
scaleTargetRef:
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
name: {{ include "planka.fullname" . }}
|
||||
minReplicas: {{ .Values.autoscaling.minReplicas }}
|
||||
maxReplicas: {{ .Values.autoscaling.maxReplicas }}
|
||||
metrics:
|
||||
{{- if .Values.autoscaling.targetCPUUtilizationPercentage }}
|
||||
- type: Resource
|
||||
resource:
|
||||
name: cpu
|
||||
target:
|
||||
type: Utilization
|
||||
averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}
|
||||
{{- end }}
|
||||
{{- if .Values.autoscaling.targetMemoryUtilizationPercentage }}
|
||||
- type: Resource
|
||||
resource:
|
||||
name: memory
|
||||
target:
|
||||
type: Utilization
|
||||
averageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,61 @@
|
||||
{{- if .Values.ingress.enabled -}}
|
||||
{{- $fullName := include "planka.fullname" . -}}
|
||||
{{- $svcPort := .Values.service.port -}}
|
||||
{{- if and .Values.ingress.className (not (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion)) }}
|
||||
{{- if not (hasKey .Values.ingress.annotations "kubernetes.io/ingress.class") }}
|
||||
{{- $_ := set .Values.ingress.annotations "kubernetes.io/ingress.class" .Values.ingress.className}}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion -}}
|
||||
apiVersion: networking.k8s.io/v1
|
||||
{{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}}
|
||||
apiVersion: networking.k8s.io/v1beta1
|
||||
{{- else -}}
|
||||
apiVersion: extensions/v1beta1
|
||||
{{- end }}
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: {{ $fullName }}
|
||||
labels:
|
||||
{{- include "planka.labels" . | nindent 4 }}
|
||||
{{- with .Values.ingress.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- if and .Values.ingress.className (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }}
|
||||
ingressClassName: {{ .Values.ingress.className }}
|
||||
{{- end }}
|
||||
{{- if .Values.ingress.tls }}
|
||||
tls:
|
||||
{{- range .Values.ingress.tls }}
|
||||
- hosts:
|
||||
{{- range .hosts }}
|
||||
- {{ . | quote }}
|
||||
{{- end }}
|
||||
secretName: {{ .secretName }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
rules:
|
||||
{{- range .Values.ingress.hosts }}
|
||||
- host: {{ .host | quote }}
|
||||
http:
|
||||
paths:
|
||||
{{- range .paths }}
|
||||
- path: {{ .path }}
|
||||
{{- if and .pathType (semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion) }}
|
||||
pathType: {{ .pathType }}
|
||||
{{- end }}
|
||||
backend:
|
||||
{{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }}
|
||||
service:
|
||||
name: {{ $fullName }}
|
||||
port:
|
||||
number: {{ $svcPort }}
|
||||
{{- else }}
|
||||
serviceName: {{ $fullName }}
|
||||
servicePort: {{ $svcPort }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,25 @@
|
||||
{{- if and .Values.persistence.enabled (not .Values.persistence.existingClaim) }}
|
||||
---
|
||||
kind: PersistentVolumeClaim
|
||||
apiVersion: v1
|
||||
metadata:
|
||||
name: {{ include "planka.fullname" . }}
|
||||
labels:
|
||||
app.kubernetes.io/name: {{ include "planka.name" . }}
|
||||
helm.sh/chart: {{ include "planka.chart" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||
spec:
|
||||
accessModes:
|
||||
- {{ .Values.persistence.accessMode }}
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.persistence.size | quote }}
|
||||
{{- if .Values.persistence.storageClass }}
|
||||
{{- if (eq "-" .Values.persistence.storageClass) }}
|
||||
storageClassName: ""
|
||||
{{- else }}
|
||||
storageClassName: "{{ .Values.persistence.storageClass }}"
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,17 @@
|
||||
{{- 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 }}
|
||||
@@ -0,0 +1,15 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ include "planka.fullname" . }}
|
||||
labels:
|
||||
{{- include "planka.labels" . | nindent 4 }}
|
||||
spec:
|
||||
type: {{ .Values.service.type }}
|
||||
ports:
|
||||
- port: {{ .Values.service.port }}
|
||||
targetPort: http
|
||||
protocol: TCP
|
||||
name: http
|
||||
selector:
|
||||
{{- include "planka.selectorLabels" . | nindent 4 }}
|
||||
@@ -0,0 +1,12 @@
|
||||
{{- if .Values.serviceAccount.create -}}
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: {{ include "planka.serviceAccountName" . }}
|
||||
labels:
|
||||
{{- include "planka.labels" . | nindent 4 }}
|
||||
{{- with .Values.serviceAccount.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,15 @@
|
||||
apiVersion: v1
|
||||
kind: Pod
|
||||
metadata:
|
||||
name: "{{ include "planka.fullname" . }}-test-connection"
|
||||
labels:
|
||||
{{- include "planka.labels" . | nindent 4 }}
|
||||
annotations:
|
||||
"helm.sh/hook": test
|
||||
spec:
|
||||
containers:
|
||||
- name: wget
|
||||
image: busybox
|
||||
command: ['wget']
|
||||
args: ['{{ include "planka.fullname" . }}:{{ .Values.service.port }}']
|
||||
restartPolicy: Never
|
||||
@@ -0,0 +1,198 @@
|
||||
# Default values for planka.
|
||||
# This is a YAML-formatted file.
|
||||
# Declare variables to be passed into your templates.
|
||||
|
||||
replicaCount: 1
|
||||
|
||||
image:
|
||||
repository: ghcr.io/plankanban/planka
|
||||
pullPolicy: IfNotPresent
|
||||
# Overrides the image tag whose default is the chart appVersion.
|
||||
tag: ""
|
||||
|
||||
imagePullSecrets: []
|
||||
nameOverride: ""
|
||||
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: ""
|
||||
|
||||
serviceAccount:
|
||||
# Specifies whether a service account should be created
|
||||
create: true
|
||||
# Annotations to add to the service account
|
||||
annotations: {}
|
||||
# The name of the service account to use.
|
||||
# If not set and create is true, a name is generated using the fullname template
|
||||
name: ""
|
||||
|
||||
podAnnotations: {}
|
||||
|
||||
podSecurityContext: {}
|
||||
# fsGroup: 2000
|
||||
|
||||
securityContext: {}
|
||||
# capabilities:
|
||||
# drop:
|
||||
# - ALL
|
||||
# readOnlyRootFilesystem: true
|
||||
# runAsNonRoot: true
|
||||
# runAsUser: 1000
|
||||
|
||||
service:
|
||||
type: ClusterIP
|
||||
port: 1337
|
||||
## @param service.containerPort Planka HTTP container port
|
||||
## If empty will default to 1337
|
||||
##
|
||||
containerPort: 1337
|
||||
|
||||
ingress:
|
||||
enabled: false
|
||||
className: ""
|
||||
annotations: {}
|
||||
# kubernetes.io/ingress.class: nginx
|
||||
# kubernetes.io/tls-acme: "true"
|
||||
hosts:
|
||||
# Used to set planka BASE_URL if no `baseurl` is provided.
|
||||
- host: planka.local
|
||||
paths:
|
||||
- path: /
|
||||
pathType: ImplementationSpecific
|
||||
tls: []
|
||||
# - secretName: planka-tls
|
||||
# hosts:
|
||||
# - planka.local
|
||||
|
||||
resources: {}
|
||||
# We usually recommend not to specify default resources and to leave this as a conscious
|
||||
# choice for the user. This also increases chances charts run on environments with little
|
||||
# resources, such as Minikube. If you do want to specify resources, uncomment the following
|
||||
# lines, adjust them as necessary, and remove the curly braces after 'resources:'.
|
||||
# limits:
|
||||
# cpu: 100m
|
||||
# memory: 128Mi
|
||||
# requests:
|
||||
# cpu: 100m
|
||||
# memory: 128Mi
|
||||
|
||||
autoscaling:
|
||||
enabled: false
|
||||
minReplicas: 1
|
||||
maxReplicas: 100
|
||||
targetCPUUtilizationPercentage: 80
|
||||
# targetMemoryUtilizationPercentage: 80
|
||||
|
||||
nodeSelector: {}
|
||||
|
||||
tolerations: []
|
||||
|
||||
affinity: {}
|
||||
|
||||
postgresql:
|
||||
enabled: true
|
||||
auth:
|
||||
database: planka
|
||||
username: planka
|
||||
password: ""
|
||||
postgresPassword: ""
|
||||
replicationPassword: ""
|
||||
# existingSecret: planka-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
|
||||
@@ -1 +0,0 @@
|
||||
REACT_APP_VERSION=1.10.2
|
||||
@@ -22,7 +22,9 @@ const replaceBaseUrl = (compiler) => {
|
||||
if (info.content.indexOf(BASE_URL_PLACEHOLDER) >= 0) {
|
||||
if (/\.css$/.exec(info.targetPath)) {
|
||||
// For CSS 'url(...)' import we can use relative import
|
||||
const relPath = path.relative(path.dirname(info.targetPath), info.outputPath);
|
||||
const relPath = path
|
||||
.relative(path.dirname(info.targetPath), info.outputPath)
|
||||
.replace(/\\/g, '/');
|
||||
replaceInFile(info.targetPath, BASE_URL_PLACEHOLDER, `${relPath}/`);
|
||||
} else if (/\.js$/.exec(info.targetPath)) {
|
||||
// For JS 'import ... from "some-asset"' we can get the variable injected in the window object
|
||||
|
||||
Generated
+4926
-22660
File diff suppressed because it is too large
Load Diff
+44
-34
@@ -49,66 +49,76 @@
|
||||
"**/*.test.js"
|
||||
]
|
||||
}
|
||||
],
|
||||
"prettier/prettier": [
|
||||
"error",
|
||||
{
|
||||
"endOfLine": "auto"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@juggle/resize-observer": "^3.4.0",
|
||||
"classnames": "^2.3.2",
|
||||
"date-fns": "^2.29.3",
|
||||
"classnames": "^2.5.1",
|
||||
"date-fns": "^2.30.0",
|
||||
"dequal": "^2.0.3",
|
||||
"easymde": "^2.18.0",
|
||||
"history": "^5.3.0",
|
||||
"i18next": "^22.0.6",
|
||||
"i18next-browser-languagedetector": "^7.0.1",
|
||||
"i18next": "^23.15.1",
|
||||
"i18next-browser-languagedetector": "^8.0.0",
|
||||
"initials": "^3.1.2",
|
||||
"js-cookie": "^3.0.1",
|
||||
"jwt-decode": "^3.1.2",
|
||||
"js-cookie": "^3.0.5",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"linkify-react": "^4.1.3",
|
||||
"linkifyjs": "^4.1.3",
|
||||
"lodash": "^4.17.21",
|
||||
"node-sass": "^8.0.0",
|
||||
"photoswipe": "^5.3.3",
|
||||
"nanoid": "^5.0.7",
|
||||
"node-sass": "^9.0.0",
|
||||
"photoswipe": "^5.4.4",
|
||||
"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.8.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-datepicker": "^4.25.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-dropzone": "^14.2.3",
|
||||
"react-i18next": "^12.0.0",
|
||||
"react-i18next": "^15.0.2",
|
||||
"react-input-mask": "^2.0.4",
|
||||
"react-markdown": "^8.0.3",
|
||||
"react-photoswipe-gallery": "^2.2.2",
|
||||
"react-redux": "^8.0.5",
|
||||
"react-router-dom": "^6.4.3",
|
||||
"react-markdown": "^8.0.7",
|
||||
"react-photoswipe-gallery": "^2.2.7",
|
||||
"react-redux": "^8.1.3",
|
||||
"react-router-dom": "^6.26.2",
|
||||
"react-scripts": "5.0.1",
|
||||
"react-simplemde-editor": "^5.2.0",
|
||||
"react-textarea-autosize": "^8.4.0",
|
||||
"redux": "^4.2.0",
|
||||
"react-textarea-autosize": "^8.5.3",
|
||||
"redux": "^4.2.1",
|
||||
"redux-logger": "^3.0.6",
|
||||
"redux-orm": "^0.16.2",
|
||||
"redux-saga": "^1.2.1",
|
||||
"remark-breaks": "^3.0.2",
|
||||
"redux-saga": "^1.3.0",
|
||||
"remark-breaks": "^4.0.0",
|
||||
"remark-gfm": "^3.0.1",
|
||||
"reselect": "^4.1.7",
|
||||
"reselect": "^4.1.8",
|
||||
"sails.io.js": "^1.2.1",
|
||||
"semantic-ui-react": "^2.1.3",
|
||||
"semantic-ui-react": "^2.1.5",
|
||||
"socket.io-client": "^2.5.0",
|
||||
"validator": "^13.7.0",
|
||||
"whatwg-fetch": "^3.6.2",
|
||||
"validator": "^13.12.0",
|
||||
"whatwg-fetch": "^3.6.20",
|
||||
"zxcvbn": "^4.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^5.16.5",
|
||||
"@testing-library/react": "^13.4.0",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
"@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",
|
||||
"babel-preset-airbnb": "^5.0.0",
|
||||
"chai": "^4.3.7",
|
||||
"eslint": "^8.28.0",
|
||||
"chai": "^4.5.0",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-airbnb": "^19.0.4",
|
||||
"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"
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta
|
||||
name="description"
|
||||
|
||||
@@ -23,10 +23,14 @@ createCard.failure = (localId, error) => ({
|
||||
},
|
||||
});
|
||||
|
||||
const handleCardCreate = (card) => ({
|
||||
const handleCardCreate = (card, cardMemberships, cardLabels, tasks, attachments) => ({
|
||||
type: ActionTypes.CARD_CREATE_HANDLE,
|
||||
payload: {
|
||||
card,
|
||||
cardMemberships,
|
||||
cardLabels,
|
||||
tasks,
|
||||
attachments,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -53,10 +57,43 @@ updateCard.failure = (id, error) => ({
|
||||
},
|
||||
});
|
||||
|
||||
const handleCardUpdate = (card) => ({
|
||||
const handleCardUpdate = (card, isFetched, cardMemberships, cardLabels, tasks, attachments) => ({
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -89,11 +126,21 @@ 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,9 +39,19 @@ const initializeCore = (
|
||||
},
|
||||
});
|
||||
|
||||
const logout = () => ({
|
||||
// TODO: with success?
|
||||
initializeCore.fetchConfig = (config) => ({
|
||||
type: ActionTypes.CORE_INITIALIZE__CONFIG_FETCH,
|
||||
payload: {
|
||||
config,
|
||||
},
|
||||
});
|
||||
|
||||
const logout = (invalidateAccessToken) => ({
|
||||
type: ActionTypes.LOGOUT,
|
||||
payload: {},
|
||||
payload: {
|
||||
invalidateAccessToken,
|
||||
},
|
||||
});
|
||||
|
||||
logout.invalidateAccessToken = () => ({
|
||||
|
||||
@@ -60,6 +60,38 @@ 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: {
|
||||
@@ -94,6 +126,8 @@ export default {
|
||||
handleListCreate,
|
||||
updateList,
|
||||
handleListUpdate,
|
||||
sortList,
|
||||
handleListSort,
|
||||
deleteList,
|
||||
handleListDelete,
|
||||
};
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import ActionTypes from '../constants/ActionTypes';
|
||||
|
||||
const initializeLogin = (config) => ({
|
||||
type: ActionTypes.LOGIN_INITIALIZE,
|
||||
payload: {
|
||||
config,
|
||||
},
|
||||
});
|
||||
|
||||
const authenticate = (data) => ({
|
||||
type: ActionTypes.AUTHENTICATE,
|
||||
payload: {
|
||||
@@ -21,12 +28,33 @@ 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,14 +1,17 @@
|
||||
import http from './http';
|
||||
import socket from './socket';
|
||||
|
||||
/* Actions */
|
||||
|
||||
const createAccessToken = (data, headers) => http.post('/access-tokens', data, headers);
|
||||
const createAccessToken = (data, headers) =>
|
||||
http.post('/access-tokens?withHttpOnlyToken=true', data, headers);
|
||||
|
||||
const deleteCurrentAccessToken = (headers) =>
|
||||
socket.delete('/access-tokens/me', undefined, 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);
|
||||
|
||||
export default {
|
||||
createAccessToken,
|
||||
exchangeForAccessTokenUsingOidc,
|
||||
deleteCurrentAccessToken,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import socket from './socket';
|
||||
import { transformUser } from './users';
|
||||
|
||||
/* Transformers */
|
||||
|
||||
@@ -13,6 +14,10 @@ const getActivities = (cardId, data, headers) =>
|
||||
socket.get(`/cards/${cardId}/actions`, data, headers).then((body) => ({
|
||||
...body,
|
||||
items: body.items.map(transformActivity),
|
||||
included: {
|
||||
...body.included,
|
||||
users: body.included.users.map(transformUser),
|
||||
},
|
||||
}));
|
||||
|
||||
/* Event handlers */
|
||||
|
||||
@@ -1,18 +1,50 @@
|
||||
import socket from './socket';
|
||||
|
||||
/* Transformers */
|
||||
|
||||
export const transformBoardMembership = (boardMembership) => ({
|
||||
...boardMembership,
|
||||
createdAt: new Date(boardMembership.createdAt),
|
||||
});
|
||||
|
||||
/* Actions */
|
||||
|
||||
const createBoardMembership = (boardId, data, headers) =>
|
||||
socket.post(`/boards/${boardId}/memberships`, data, headers);
|
||||
socket.post(`/boards/${boardId}/memberships`, data, headers).then((body) => ({
|
||||
...body,
|
||||
item: transformBoardMembership(body.item),
|
||||
}));
|
||||
|
||||
const updateBoardMembership = (id, data, headers) =>
|
||||
socket.patch(`/board-memberships/${id}`, data, headers);
|
||||
socket.patch(`/board-memberships/${id}`, data, headers).then((body) => ({
|
||||
...body,
|
||||
item: transformBoardMembership(body.item),
|
||||
}));
|
||||
|
||||
const deleteBoardMembership = (id, headers) =>
|
||||
socket.delete(`/board-memberships/${id}`, undefined, headers);
|
||||
socket.delete(`/board-memberships/${id}`, undefined, headers).then((body) => ({
|
||||
...body,
|
||||
item: transformBoardMembership(body.item),
|
||||
}));
|
||||
|
||||
/* Event handlers */
|
||||
|
||||
const makeHandleBoardMembershipCreate = (next) => (body) => {
|
||||
next({
|
||||
...body,
|
||||
item: transformBoardMembership(body.item),
|
||||
});
|
||||
};
|
||||
|
||||
const makeHandleBoardMembershipUpdate = makeHandleBoardMembershipCreate;
|
||||
|
||||
const makeHandleBoardMembershipDelete = makeHandleBoardMembershipCreate;
|
||||
|
||||
export default {
|
||||
createBoardMembership,
|
||||
updateBoardMembership,
|
||||
deleteBoardMembership,
|
||||
makeHandleBoardMembershipCreate,
|
||||
makeHandleBoardMembershipUpdate,
|
||||
makeHandleBoardMembershipDelete,
|
||||
};
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
import socket from './socket';
|
||||
import http from './http';
|
||||
import { transformUser } from './users';
|
||||
import { transformBoardMembership } from './board-memberships';
|
||||
import { transformCard } from './cards';
|
||||
import { transformAttachment } from './attachments';
|
||||
|
||||
/* Actions */
|
||||
|
||||
const createBoard = (projectId, data, headers) =>
|
||||
socket.post(`/projects/${projectId}/boards`, data, headers);
|
||||
socket.post(`/projects/${projectId}/boards`, data, headers).then((body) => ({
|
||||
...body,
|
||||
included: {
|
||||
...body.included,
|
||||
boardMemberships: body.included.boardMemberships.map(transformBoardMembership),
|
||||
},
|
||||
}));
|
||||
|
||||
const createBoardWithImport = (projectId, data, requestId, headers) =>
|
||||
http.post(`/projects/${projectId}/boards?requestId=${requestId}`, data, headers);
|
||||
@@ -18,6 +26,8 @@ const getBoard = (id, subscribe, headers) =>
|
||||
...body,
|
||||
included: {
|
||||
...body.included,
|
||||
users: body.included.users.map(transformUser),
|
||||
boardMemberships: body.included.boardMemberships.map(transformBoardMembership),
|
||||
cards: body.included.cards.map(transformCard),
|
||||
attachments: body.included.attachments.map(transformAttachment),
|
||||
},
|
||||
|
||||
+17
-10
@@ -8,11 +8,11 @@ export const transformCard = (card) => ({
|
||||
...(card.dueDate && {
|
||||
dueDate: new Date(card.dueDate),
|
||||
}),
|
||||
...(card.timer && {
|
||||
timer: {
|
||||
...card.timer,
|
||||
...(card.timer.startedAt && {
|
||||
startedAt: new Date(card.timer.startedAt),
|
||||
...(card.stopwatch && {
|
||||
stopwatch: {
|
||||
...card.stopwatch,
|
||||
...(card.stopwatch.startedAt && {
|
||||
startedAt: new Date(card.stopwatch.startedAt),
|
||||
}),
|
||||
},
|
||||
}),
|
||||
@@ -23,11 +23,11 @@ export const transformCardData = (data) => ({
|
||||
...(data.dueDate && {
|
||||
dueDate: data.dueDate.toISOString(),
|
||||
}),
|
||||
...(data.timer && {
|
||||
timer: {
|
||||
...data.timer,
|
||||
...(data.timer.startedAt && {
|
||||
startedAt: data.timer.startedAt.toISOString(),
|
||||
...(data.stopwatch && {
|
||||
stopwatch: {
|
||||
...data.stopwatch,
|
||||
...(data.stopwatch.startedAt && {
|
||||
startedAt: data.stopwatch.startedAt.toISOString(),
|
||||
}),
|
||||
},
|
||||
}),
|
||||
@@ -57,6 +57,12 @@ 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,
|
||||
@@ -81,6 +87,7 @@ export default {
|
||||
getCard,
|
||||
updateCard,
|
||||
deleteCard,
|
||||
duplicateCard,
|
||||
makeHandleCardCreate,
|
||||
makeHandleCardUpdate,
|
||||
makeHandleCardDelete,
|
||||
|
||||
@@ -5,9 +5,11 @@ import Config from '../constants/Config';
|
||||
const http = {};
|
||||
|
||||
// TODO: add all methods
|
||||
['POST'].forEach((method) => {
|
||||
['GET', 'POST', 'DELETE'].forEach((method) => {
|
||||
http[method.toLowerCase()] = (url, data, headers) => {
|
||||
const formData = Object.keys(data).reduce((result, key) => {
|
||||
const formData =
|
||||
data &&
|
||||
Object.keys(data).reduce((result, key) => {
|
||||
result.append(key, data[key]);
|
||||
|
||||
return result;
|
||||
@@ -17,6 +19,7 @@ const http = {};
|
||||
method,
|
||||
headers,
|
||||
body: formData,
|
||||
credentials: 'include',
|
||||
})
|
||||
.then((response) =>
|
||||
response.json().then((body) => ({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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';
|
||||
@@ -20,6 +21,7 @@ import notifications from './notifications';
|
||||
export { http, socket };
|
||||
|
||||
export default {
|
||||
...root,
|
||||
...accessTokens,
|
||||
...users,
|
||||
...projects,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import socket from './socket';
|
||||
import { transformCard } from './cards';
|
||||
|
||||
/* Actions */
|
||||
|
||||
@@ -7,10 +8,33 @@ 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,6 +1,7 @@
|
||||
import omit from 'lodash/omit';
|
||||
|
||||
import socket from './socket';
|
||||
import { transformUser } from './users';
|
||||
import { transformCard } from './cards';
|
||||
import { transformActivity } from './activities';
|
||||
|
||||
@@ -19,6 +20,7 @@ const getNotifications = (headers) =>
|
||||
items: body.items.map(transformNotification),
|
||||
included: {
|
||||
...omit(body.included, 'actions'),
|
||||
users: body.included.users.map(transformUser),
|
||||
cards: body.included.cards.map(transformCard),
|
||||
activities: body.included.actions.map(transformActivity),
|
||||
},
|
||||
@@ -30,6 +32,7 @@ const getNotification = (id, headers) =>
|
||||
item: transformNotification(body.item),
|
||||
included: {
|
||||
...omit(body.included, 'actions'),
|
||||
users: body.included.users.map(transformUser),
|
||||
cards: body.included.cards.map(transformCard),
|
||||
activities: body.included.actions.map(transformActivity),
|
||||
},
|
||||
|
||||
@@ -1,14 +1,40 @@
|
||||
import socket from './socket';
|
||||
|
||||
/* Transformers */
|
||||
|
||||
export const transformProjectManager = (projectManager) => ({
|
||||
...projectManager,
|
||||
createdAt: new Date(projectManager.createdAt),
|
||||
});
|
||||
|
||||
/* Actions */
|
||||
|
||||
const createProjectManager = (projectId, data, headers) =>
|
||||
socket.post(`/projects/${projectId}/managers`, data, headers);
|
||||
socket.post(`/projects/${projectId}/managers`, data, headers).then((body) => ({
|
||||
...body,
|
||||
item: transformProjectManager(body.item),
|
||||
}));
|
||||
|
||||
const deleteProjectManager = (id, headers) =>
|
||||
socket.delete(`/project-managers/${id}`, undefined, headers);
|
||||
socket.delete(`/project-managers/${id}`, undefined, headers).then((body) => ({
|
||||
...body,
|
||||
item: transformProjectManager(body.item),
|
||||
}));
|
||||
|
||||
/* Event handlers */
|
||||
|
||||
const makeHandleProjectManagerCreate = (next) => (body) => {
|
||||
next({
|
||||
...body,
|
||||
item: transformProjectManager(body.item),
|
||||
});
|
||||
};
|
||||
|
||||
const makeHandleProjectManagerDelete = makeHandleProjectManagerCreate;
|
||||
|
||||
export default {
|
||||
createProjectManager,
|
||||
deleteProjectManager,
|
||||
makeHandleProjectManagerCreate,
|
||||
makeHandleProjectManagerDelete,
|
||||
};
|
||||
|
||||
@@ -1,13 +1,41 @@
|
||||
import http from './http';
|
||||
import socket from './socket';
|
||||
import { transformUser } from './users';
|
||||
import { transformProjectManager } from './project-managers';
|
||||
import { transformBoardMembership } from './board-memberships';
|
||||
|
||||
/* Actions */
|
||||
|
||||
const getProjects = (headers) => socket.get('/projects', undefined, headers);
|
||||
const getProjects = (headers) =>
|
||||
socket.get('/projects', undefined, headers).then((body) => ({
|
||||
...body,
|
||||
included: {
|
||||
...body.included,
|
||||
users: body.included.users.map(transformUser),
|
||||
projectManagers: body.included.projectManagers.map(transformProjectManager),
|
||||
boardMemberships: body.included.boardMemberships.map(transformBoardMembership),
|
||||
},
|
||||
}));
|
||||
|
||||
const createProject = (data, headers) => socket.post('/projects', data, headers);
|
||||
const createProject = (data, headers) =>
|
||||
socket.post('/projects', data, headers).then((body) => ({
|
||||
...body,
|
||||
included: {
|
||||
...body.included,
|
||||
projectManagers: body.included.projectManagers.map(transformProjectManager),
|
||||
},
|
||||
}));
|
||||
|
||||
const getProject = (id, headers) => socket.get(`/projects/${id}`, undefined, headers);
|
||||
const getProject = (id, headers) =>
|
||||
socket.get(`/projects/${id}`, undefined, headers).then((body) => ({
|
||||
...body,
|
||||
included: {
|
||||
...body.included,
|
||||
users: body.included.users.map(transformUser),
|
||||
projectManagers: body.included.projectManagers.map(transformProjectManager),
|
||||
boardMemberships: body.included.boardMemberships.map(transformBoardMembership),
|
||||
},
|
||||
}));
|
||||
|
||||
const updateProject = (id, data, headers) => socket.patch(`/projects/${id}`, data, headers);
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
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.BASE_PATH}/socket.io`;
|
||||
socket.path = `${Config.SERVER_BASE_PATH}/socket.io`;
|
||||
socket.connect = socket._connect; // eslint-disable-line no-underscore-dangle
|
||||
|
||||
['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].forEach((method) => {
|
||||
|
||||
+70
-10
@@ -1,30 +1,87 @@
|
||||
import http from './http';
|
||||
import socket from './socket';
|
||||
|
||||
/* Transformers */
|
||||
|
||||
export const transformUser = (user) => ({
|
||||
...user,
|
||||
createdAt: new Date(user.createdAt),
|
||||
});
|
||||
|
||||
/* Actions */
|
||||
|
||||
const getUsers = (headers) => socket.get('/users', undefined, headers);
|
||||
const getUsers = (headers) =>
|
||||
socket.get('/users', undefined, headers).then((body) => ({
|
||||
...body,
|
||||
items: body.items.map(transformUser),
|
||||
}));
|
||||
|
||||
const createUser = (data, headers) => socket.post('/users', data, headers);
|
||||
const createUser = (data, headers) =>
|
||||
socket.post('/users', data, headers).then((body) => ({
|
||||
...body,
|
||||
item: transformUser(body.item),
|
||||
}));
|
||||
|
||||
const getUser = (id, headers) => socket.get(`/users/${id}`, undefined, headers);
|
||||
const getUser = (id, headers) =>
|
||||
socket.get(`/users/${id}`, undefined, headers).then((body) => ({
|
||||
...body,
|
||||
item: transformUser(body.item),
|
||||
}));
|
||||
|
||||
const getCurrentUser = (subscribe, headers) =>
|
||||
socket.get(`/users/me${subscribe ? '?subscribe=true' : ''}`, undefined, headers);
|
||||
socket.get(`/users/me${subscribe ? '?subscribe=true' : ''}`, undefined, headers).then((body) => ({
|
||||
...body,
|
||||
item: transformUser(body.item),
|
||||
}));
|
||||
|
||||
const updateUser = (id, data, headers) => socket.patch(`/users/${id}`, data, headers);
|
||||
const updateUser = (id, data, headers) =>
|
||||
socket.patch(`/users/${id}`, data, headers).then((body) => ({
|
||||
...body,
|
||||
item: transformUser(body.item),
|
||||
}));
|
||||
|
||||
const updateUserEmail = (id, data, headers) => socket.patch(`/users/${id}/email`, data, headers);
|
||||
const updateUserEmail = (id, data, headers) =>
|
||||
socket.patch(`/users/${id}/email`, data, headers).then((body) => ({
|
||||
...body,
|
||||
item: transformUser(body.item),
|
||||
}));
|
||||
|
||||
const updateUserPassword = (id, data, headers) =>
|
||||
socket.patch(`/users/${id}/password`, data, headers);
|
||||
socket.patch(`/users/${id}/password`, data, headers).then((body) => ({
|
||||
...body,
|
||||
item: transformUser(body.item),
|
||||
}));
|
||||
|
||||
const updateUserUsername = (id, data, headers) =>
|
||||
socket.patch(`/users/${id}/username`, data, headers);
|
||||
socket.patch(`/users/${id}/username`, data, headers).then((body) => ({
|
||||
...body,
|
||||
item: transformUser(body.item),
|
||||
}));
|
||||
|
||||
const updateUserAvatar = (id, data, headers) => http.post(`/users/${id}/avatar`, data, headers);
|
||||
const updateUserAvatar = (id, data, headers) =>
|
||||
http.post(`/users/${id}/avatar`, data, headers).then((body) => ({
|
||||
...body,
|
||||
item: transformUser(body.item),
|
||||
}));
|
||||
|
||||
const deleteUser = (id, headers) => socket.delete(`/users/${id}`, undefined, headers);
|
||||
const deleteUser = (id, headers) =>
|
||||
socket.delete(`/users/${id}`, undefined, headers).then((body) => ({
|
||||
...body,
|
||||
item: transformUser(body.item),
|
||||
}));
|
||||
|
||||
/* Event handlers */
|
||||
|
||||
const makeHandleUserCreate = (next) => (body) => {
|
||||
next({
|
||||
...body,
|
||||
item: transformUser(body.item),
|
||||
});
|
||||
};
|
||||
|
||||
const makeHandleUserUpdate = makeHandleUserCreate;
|
||||
|
||||
const makeHandleUserDelete = makeHandleUserCreate;
|
||||
|
||||
export default {
|
||||
getUsers,
|
||||
@@ -37,4 +94,7 @@ export default {
|
||||
updateUserUsername,
|
||||
updateUserAvatar,
|
||||
deleteUser,
|
||||
makeHandleUserCreate,
|
||||
makeHandleUserUpdate,
|
||||
makeHandleUserDelete,
|
||||
};
|
||||
|
||||
+2337
File diff suppressed because it is too large
Load Diff
+4
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load Diff
|
After Width: | Height: | Size: 434 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,6 +1,8 @@
|
||||
:global(#app) {
|
||||
.wrapper {
|
||||
height: 100%;
|
||||
max-height: 100vh;
|
||||
max-width: 100vw;
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
z-index: -1;
|
||||
|
||||
@@ -62,6 +62,11 @@ const Board = React.memo(
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
(event) => {
|
||||
// If button is defined and not equal to 0 (left click)
|
||||
if (event.button) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.target !== wrapper.current && !event.target.dataset.dragScroller) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -48,16 +48,6 @@
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.panel {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.panelItem {
|
||||
margin-right: 20px;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
margin: 0 20px;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ const BoardActions = React.memo(
|
||||
labels,
|
||||
filterUsers,
|
||||
filterLabels,
|
||||
filterText,
|
||||
allUsers,
|
||||
canEdit,
|
||||
canEditMemberships,
|
||||
@@ -27,6 +28,7 @@ const BoardActions = React.memo(
|
||||
onLabelUpdate,
|
||||
onLabelMove,
|
||||
onLabelDelete,
|
||||
onTextFilterUpdate,
|
||||
}) => {
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
@@ -46,6 +48,7 @@ const BoardActions = React.memo(
|
||||
<Filters
|
||||
users={filterUsers}
|
||||
labels={filterLabels}
|
||||
filterText={filterText}
|
||||
allBoardMemberships={memberships}
|
||||
allLabels={labels}
|
||||
canEdit={canEdit}
|
||||
@@ -57,6 +60,7 @@ const BoardActions = React.memo(
|
||||
onLabelUpdate={onLabelUpdate}
|
||||
onLabelMove={onLabelMove}
|
||||
onLabelDelete={onLabelDelete}
|
||||
onTextFilterUpdate={onTextFilterUpdate}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -71,6 +75,7 @@ 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,
|
||||
@@ -86,6 +91,7 @@ BoardActions.propTypes = {
|
||||
onLabelUpdate: PropTypes.func.isRequired,
|
||||
onLabelMove: PropTypes.func.isRequired,
|
||||
onLabelDelete: PropTypes.func.isRequired,
|
||||
onTextFilterUpdate: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default BoardActions;
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
: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,7 +1,10 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import React, { useCallback, useRef, useState } 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';
|
||||
@@ -14,6 +17,7 @@ const Filters = React.memo(
|
||||
({
|
||||
users,
|
||||
labels,
|
||||
filterText,
|
||||
allBoardMemberships,
|
||||
allLabels,
|
||||
canEdit,
|
||||
@@ -25,8 +29,17 @@ 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) => {
|
||||
@@ -42,9 +55,39 @@ 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}>
|
||||
@@ -100,6 +143,25 @@ 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>
|
||||
</>
|
||||
);
|
||||
},
|
||||
@@ -109,6 +171,7 @@ 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 */
|
||||
@@ -121,6 +184,7 @@ Filters.propTypes = {
|
||||
onLabelUpdate: PropTypes.func.isRequired,
|
||||
onLabelMove: PropTypes.func.isRequired,
|
||||
onLabelDelete: PropTypes.func.isRequired,
|
||||
onTextFilterUpdate: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default Filters;
|
||||
|
||||
@@ -43,4 +43,36 @@
|
||||
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,7 +4,6 @@
|
||||
max-height: 60vh;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
width: 100%;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
|
||||
@@ -85,7 +85,6 @@
|
||||
height: 56px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: thin;
|
||||
|
||||
&:hover {
|
||||
height: 38px;
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useSteps } from '../../hooks';
|
||||
import BoardMembershipsStep from '../BoardMembershipsStep';
|
||||
import LabelsStep from '../LabelsStep';
|
||||
import DueDateEditStep from '../DueDateEditStep';
|
||||
import TimerEditStep from '../TimerEditStep';
|
||||
import StopwatchEditStep from '../StopwatchEditStep';
|
||||
import CardMoveStep from '../CardMoveStep';
|
||||
import DeleteStep from '../DeleteStep';
|
||||
|
||||
@@ -19,7 +19,7 @@ const StepTypes = {
|
||||
USERS: 'USERS',
|
||||
LABELS: 'LABELS',
|
||||
EDIT_DUE_DATE: 'EDIT_DUE_DATE',
|
||||
EDIT_TIMER: 'EDIT_TIMER',
|
||||
EDIT_STOPWATCH: 'EDIT_STOPWATCH',
|
||||
MOVE: 'MOVE',
|
||||
DELETE: 'DELETE',
|
||||
};
|
||||
@@ -36,6 +36,7 @@ const ActionsStep = React.memo(
|
||||
onUpdate,
|
||||
onMove,
|
||||
onTransfer,
|
||||
onDuplicate,
|
||||
onDelete,
|
||||
onUserAdd,
|
||||
onUserRemove,
|
||||
@@ -68,14 +69,19 @@ const ActionsStep = React.memo(
|
||||
openStep(StepTypes.EDIT_DUE_DATE);
|
||||
}, [openStep]);
|
||||
|
||||
const handleEditTimerClick = useCallback(() => {
|
||||
openStep(StepTypes.EDIT_TIMER);
|
||||
const handleEditStopwatchClick = useCallback(() => {
|
||||
openStep(StepTypes.EDIT_STOPWATCH);
|
||||
}, [openStep]);
|
||||
|
||||
const handleMoveClick = useCallback(() => {
|
||||
openStep(StepTypes.MOVE);
|
||||
}, [openStep]);
|
||||
|
||||
const handleDuplicateClick = useCallback(() => {
|
||||
onDuplicate();
|
||||
onClose();
|
||||
}, [onDuplicate, onClose]);
|
||||
|
||||
const handleDeleteClick = useCallback(() => {
|
||||
openStep(StepTypes.DELETE);
|
||||
}, [openStep]);
|
||||
@@ -89,10 +95,10 @@ const ActionsStep = React.memo(
|
||||
[onUpdate],
|
||||
);
|
||||
|
||||
const handleTimerUpdate = useCallback(
|
||||
(timer) => {
|
||||
const handleStopwatchUpdate = useCallback(
|
||||
(stopwatch) => {
|
||||
onUpdate({
|
||||
timer,
|
||||
stopwatch,
|
||||
});
|
||||
},
|
||||
[onUpdate],
|
||||
@@ -133,11 +139,11 @@ const ActionsStep = React.memo(
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
case StepTypes.EDIT_TIMER:
|
||||
case StepTypes.EDIT_STOPWATCH:
|
||||
return (
|
||||
<TimerEditStep
|
||||
defaultValue={card.timer}
|
||||
onUpdate={handleTimerUpdate}
|
||||
<StopwatchEditStep
|
||||
defaultValue={card.stopwatch}
|
||||
onUpdate={handleStopwatchUpdate}
|
||||
onBack={handleBack}
|
||||
onClose={onClose}
|
||||
/>
|
||||
@@ -197,8 +203,8 @@ const ActionsStep = React.memo(
|
||||
context: 'title',
|
||||
})}
|
||||
</Menu.Item>
|
||||
<Menu.Item className={styles.menuItem} onClick={handleEditTimerClick}>
|
||||
{t('action.editTimer', {
|
||||
<Menu.Item className={styles.menuItem} onClick={handleEditStopwatchClick}>
|
||||
{t('action.editStopwatch', {
|
||||
context: 'title',
|
||||
})}
|
||||
</Menu.Item>
|
||||
@@ -207,6 +213,11 @@ 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',
|
||||
@@ -232,6 +243,7 @@ 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,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Link } from 'react-router-dom';
|
||||
import { Draggable } from 'react-beautiful-dnd';
|
||||
import { usePopup } from '../../lib/popup';
|
||||
|
||||
import { startTimer, stopTimer } from '../../utils/timer';
|
||||
import { startStopwatch, stopStopwatch } from '../../utils/stopwatch';
|
||||
import Paths from '../../constants/Paths';
|
||||
import Tasks from './Tasks';
|
||||
import NameEdit from './NameEdit';
|
||||
@@ -14,7 +14,7 @@ import ActionsStep from './ActionsStep';
|
||||
import User from '../User';
|
||||
import Label from '../Label';
|
||||
import DueDate from '../DueDate';
|
||||
import Timer from '../Timer';
|
||||
import Stopwatch from '../Stopwatch';
|
||||
|
||||
import styles from './Card.module.scss';
|
||||
|
||||
@@ -24,7 +24,8 @@ const Card = React.memo(
|
||||
index,
|
||||
name,
|
||||
dueDate,
|
||||
timer,
|
||||
isDueDateCompleted,
|
||||
stopwatch,
|
||||
coverUrl,
|
||||
boardId,
|
||||
listId,
|
||||
@@ -41,6 +42,7 @@ const Card = React.memo(
|
||||
onUpdate,
|
||||
onMove,
|
||||
onTransfer,
|
||||
onDuplicate,
|
||||
onDelete,
|
||||
onUserAdd,
|
||||
onUserRemove,
|
||||
@@ -60,15 +62,15 @@ const Card = React.memo(
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleToggleTimerClick = useCallback(
|
||||
const handleToggleStopwatchClick = useCallback(
|
||||
(event) => {
|
||||
event.preventDefault();
|
||||
|
||||
onUpdate({
|
||||
timer: timer.startedAt ? stopTimer(timer) : startTimer(timer),
|
||||
stopwatch: stopwatch.startedAt ? stopStopwatch(stopwatch) : startStopwatch(stopwatch),
|
||||
});
|
||||
},
|
||||
[timer, onUpdate],
|
||||
[stopwatch, onUpdate],
|
||||
);
|
||||
|
||||
const handleNameUpdate = useCallback(
|
||||
@@ -104,7 +106,7 @@ const Card = React.memo(
|
||||
)}
|
||||
<div className={styles.name}>{name}</div>
|
||||
{tasks.length > 0 && <Tasks items={tasks} />}
|
||||
{(dueDate || timer || notificationsTotal > 0) && (
|
||||
{(dueDate || stopwatch || notificationsTotal > 0) && (
|
||||
<span className={styles.attachments}>
|
||||
{notificationsTotal > 0 && (
|
||||
<span
|
||||
@@ -119,17 +121,17 @@ const Card = React.memo(
|
||||
)}
|
||||
{dueDate && (
|
||||
<span className={classNames(styles.attachment, styles.attachmentLeft)}>
|
||||
<DueDate value={dueDate} size="tiny" />
|
||||
<DueDate value={dueDate} isCompleted={isDueDateCompleted} size="tiny" />
|
||||
</span>
|
||||
)}
|
||||
{timer && (
|
||||
{stopwatch && (
|
||||
<span className={classNames(styles.attachment, styles.attachmentLeft)}>
|
||||
<Timer
|
||||
<Stopwatch
|
||||
as="span"
|
||||
startedAt={timer.startedAt}
|
||||
total={timer.total}
|
||||
startedAt={stopwatch.startedAt}
|
||||
total={stopwatch.total}
|
||||
size="tiny"
|
||||
onClick={canEdit ? handleToggleTimerClick : undefined}
|
||||
onClick={canEdit ? handleToggleStopwatchClick : undefined}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
@@ -171,7 +173,7 @@ const Card = React.memo(
|
||||
<ActionsPopup
|
||||
card={{
|
||||
dueDate,
|
||||
timer,
|
||||
stopwatch,
|
||||
boardId,
|
||||
listId,
|
||||
projectId,
|
||||
@@ -185,6 +187,7 @@ const Card = React.memo(
|
||||
onUpdate={onUpdate}
|
||||
onMove={onMove}
|
||||
onTransfer={onTransfer}
|
||||
onDuplicate={onDuplicate}
|
||||
onDelete={onDelete}
|
||||
onUserAdd={onUserAdd}
|
||||
onUserRemove={onUserRemove}
|
||||
@@ -219,7 +222,8 @@ Card.propTypes = {
|
||||
index: PropTypes.number.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
dueDate: PropTypes.instanceOf(Date),
|
||||
timer: PropTypes.object, // eslint-disable-line react/forbid-prop-types
|
||||
isDueDateCompleted: PropTypes.bool,
|
||||
stopwatch: PropTypes.object, // eslint-disable-line react/forbid-prop-types
|
||||
coverUrl: PropTypes.string,
|
||||
boardId: PropTypes.string.isRequired,
|
||||
listId: PropTypes.string.isRequired,
|
||||
@@ -238,6 +242,7 @@ 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,
|
||||
@@ -252,7 +257,8 @@ Card.propTypes = {
|
||||
|
||||
Card.defaultProps = {
|
||||
dueDate: undefined,
|
||||
timer: undefined,
|
||||
isDueDateCompleted: undefined,
|
||||
stopwatch: undefined,
|
||||
coverUrl: undefined,
|
||||
};
|
||||
|
||||
|
||||
@@ -21,6 +21,14 @@
|
||||
background: #ebeef0;
|
||||
color: #516b7a;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 797px) {
|
||||
&:focus,
|
||||
&:active {
|
||||
background: #ebeef0;
|
||||
color: #516b7a;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.attachment {
|
||||
@@ -55,6 +63,12 @@
|
||||
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,6 +5,7 @@ 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';
|
||||
|
||||
@@ -79,7 +80,7 @@ const NameEdit = React.forwardRef(({ children, defaultValue, onUpdate }, ref) =>
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpened) {
|
||||
field.current.ref.current.focus();
|
||||
focusEnd(field.current.ref.current);
|
||||
}
|
||||
}, [isOpened]);
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ 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 }) => {
|
||||
@@ -48,7 +50,7 @@ const Tasks = React.memo(({ items }) => {
|
||||
key={item.id}
|
||||
className={classNames(styles.task, item.isCompleted && styles.taskCompleted)}
|
||||
>
|
||||
{item.name}
|
||||
<Linkify linkStopPropagation>{item.name}</Linkify>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -55,8 +55,10 @@
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
line-height: 14px;
|
||||
overflow: hidden;
|
||||
padding-bottom: 6px;
|
||||
padding-left: 14px;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
&:before {
|
||||
content: "–";
|
||||
|
||||
@@ -5,7 +5,8 @@ import { useTranslation } from 'react-i18next';
|
||||
import TextareaAutosize from 'react-textarea-autosize';
|
||||
import { Button, Form, TextArea } from 'semantic-ui-react';
|
||||
|
||||
import { useClosableForm, useForm } from '../../../hooks';
|
||||
import { useForm } from '../../../hooks';
|
||||
import { focusEnd } from '../../../utils/element-helpers';
|
||||
|
||||
import styles from './CommentEdit.module.scss';
|
||||
|
||||
@@ -35,12 +36,7 @@ const CommentEdit = React.forwardRef(({ children, defaultData, onUpdate }, ref)
|
||||
text: data.text.trim(),
|
||||
};
|
||||
|
||||
if (!cleanData.text) {
|
||||
textField.current.ref.current.select();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!dequal(cleanData, defaultData)) {
|
||||
if (cleanData.text && !dequal(cleanData, defaultData)) {
|
||||
onUpdate(cleanData);
|
||||
}
|
||||
|
||||
@@ -65,10 +61,9 @@ const CommentEdit = React.forwardRef(({ children, defaultData, onUpdate }, ref)
|
||||
[submit],
|
||||
);
|
||||
|
||||
const [handleFieldBlur, handleControlMouseOver, handleControlMouseOut] = useClosableForm(
|
||||
close,
|
||||
isOpened,
|
||||
);
|
||||
const handleFieldBlur = useCallback(() => {
|
||||
submit();
|
||||
}, [submit]);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
submit();
|
||||
@@ -76,7 +71,7 @@ const CommentEdit = React.forwardRef(({ children, defaultData, onUpdate }, ref)
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpened) {
|
||||
textField.current.ref.current.focus();
|
||||
focusEnd(textField.current.ref.current);
|
||||
}
|
||||
}, [isOpened]);
|
||||
|
||||
@@ -99,13 +94,7 @@ const CommentEdit = React.forwardRef(({ children, defaultData, onUpdate }, ref)
|
||||
onBlur={handleFieldBlur}
|
||||
/>
|
||||
<div className={styles.controls}>
|
||||
{/* eslint-disable-next-line jsx-a11y/mouse-events-have-key-events */}
|
||||
<Button
|
||||
positive
|
||||
content={t('action.save')}
|
||||
onMouseOver={handleControlMouseOver}
|
||||
onMouseOut={handleControlMouseOut}
|
||||
/>
|
||||
<Button positive content={t('action.save')} />
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ 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';
|
||||
@@ -66,7 +67,7 @@ const Item = React.memo(({ type, data, createdAt, user }) => {
|
||||
<div className={classNames(styles.content)}>
|
||||
<div>{contentNode}</div>
|
||||
<span className={styles.date}>
|
||||
{t('format:longDateTime', {
|
||||
{t(`format:${getDateFormat(createdAt)}`, {
|
||||
postProcess: 'formatDate',
|
||||
value: createdAt,
|
||||
})}
|
||||
|
||||
@@ -6,6 +6,7 @@ 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';
|
||||
@@ -33,7 +34,7 @@ const ItemComment = React.memo(
|
||||
<div className={styles.title}>
|
||||
<span className={styles.author}>{user.name}</span>
|
||||
<span className={styles.date}>
|
||||
{t('format:longDateTime', {
|
||||
{t(`format:${getDateFormat(createdAt)}`, {
|
||||
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()} className={styles.wrapper}>
|
||||
<div {...getRootProps()}>
|
||||
{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: 700;
|
||||
font-weight: bold;
|
||||
height: 100%;
|
||||
line-height: 30px;
|
||||
opacity: 0.7;
|
||||
@@ -12,8 +12,4 @@
|
||||
width: 100%;
|
||||
z-index: 2001;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
.contentError {
|
||||
color: #fff;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
font-weight: bold;
|
||||
height: 20px;
|
||||
width: 470px;
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
color: #5e6c84;
|
||||
display: block;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
font-weight: bold;
|
||||
height: 100%;
|
||||
line-height: 80px;
|
||||
overflow: hidden;
|
||||
@@ -50,7 +50,7 @@
|
||||
.name {
|
||||
color: #17394d;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
font-weight: bold;
|
||||
line-height: 20px;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import React, { useCallback, useRef } from 'react';
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Grid, Icon, Modal } from 'semantic-ui-react';
|
||||
import { Button, Checkbox, Grid, Icon, Modal } from 'semantic-ui-react';
|
||||
import { usePopup } from '../../lib/popup';
|
||||
import { Markdown } from '../../lib/custom-ui';
|
||||
|
||||
import { startTimer, stopTimer } from '../../utils/timer';
|
||||
import { startStopwatch, stopStopwatch } from '../../utils/stopwatch';
|
||||
import NameField from './NameField';
|
||||
import DescriptionEdit from './DescriptionEdit';
|
||||
import Tasks from './Tasks';
|
||||
@@ -17,11 +17,11 @@ import Activities from './Activities';
|
||||
import User from '../User';
|
||||
import Label from '../Label';
|
||||
import DueDate from '../DueDate';
|
||||
import Timer from '../Timer';
|
||||
import Stopwatch from '../Stopwatch';
|
||||
import BoardMembershipsStep from '../BoardMembershipsStep';
|
||||
import LabelsStep from '../LabelsStep';
|
||||
import DueDateEditStep from '../DueDateEditStep';
|
||||
import TimerEditStep from '../TimerEditStep';
|
||||
import StopwatchEditStep from '../StopwatchEditStep';
|
||||
import CardMoveStep from '../CardMoveStep';
|
||||
import DeleteStep from '../DeleteStep';
|
||||
|
||||
@@ -32,7 +32,8 @@ const CardModal = React.memo(
|
||||
name,
|
||||
description,
|
||||
dueDate,
|
||||
timer,
|
||||
isDueDateCompleted,
|
||||
stopwatch,
|
||||
isSubscribed,
|
||||
isActivitiesFetching,
|
||||
isAllActivitiesFetched,
|
||||
@@ -55,6 +56,7 @@ const CardModal = React.memo(
|
||||
onUpdate,
|
||||
onMove,
|
||||
onTransfer,
|
||||
onDuplicate,
|
||||
onDelete,
|
||||
onUserAdd,
|
||||
onUserRemove,
|
||||
@@ -80,14 +82,15 @@ const CardModal = React.memo(
|
||||
onClose,
|
||||
}) => {
|
||||
const [t] = useTranslation();
|
||||
const [isLinkCopied, setIsLinkCopied] = useState(false);
|
||||
|
||||
const isGalleryOpened = useRef(false);
|
||||
|
||||
const handleToggleTimerClick = useCallback(() => {
|
||||
const handleToggleStopwatchClick = useCallback(() => {
|
||||
onUpdate({
|
||||
timer: timer.startedAt ? stopTimer(timer) : startTimer(timer),
|
||||
stopwatch: stopwatch.startedAt ? stopStopwatch(stopwatch) : startStopwatch(stopwatch),
|
||||
});
|
||||
}, [timer, onUpdate]);
|
||||
}, [stopwatch, onUpdate]);
|
||||
|
||||
const handleNameUpdate = useCallback(
|
||||
(newName) => {
|
||||
@@ -116,10 +119,16 @@ const CardModal = React.memo(
|
||||
[onUpdate],
|
||||
);
|
||||
|
||||
const handleTimerUpdate = useCallback(
|
||||
(newTimer) => {
|
||||
const handleDueDateCompletionChange = useCallback(() => {
|
||||
onUpdate({
|
||||
timer: newTimer,
|
||||
isDueDateCompleted: !isDueDateCompleted,
|
||||
});
|
||||
}, [isDueDateCompleted, onUpdate]);
|
||||
|
||||
const handleStopwatchUpdate = useCallback(
|
||||
(newStopwatch) => {
|
||||
onUpdate({
|
||||
stopwatch: newStopwatch,
|
||||
});
|
||||
},
|
||||
[onUpdate],
|
||||
@@ -140,6 +149,19 @@ 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;
|
||||
}, []);
|
||||
@@ -160,7 +182,7 @@ const CardModal = React.memo(
|
||||
const BoardMembershipsPopup = usePopup(BoardMembershipsStep);
|
||||
const LabelsPopup = usePopup(LabelsStep);
|
||||
const DueDateEditPopup = usePopup(DueDateEditStep);
|
||||
const TimerEditPopup = usePopup(TimerEditStep);
|
||||
const StopwatchEditPopup = usePopup(StopwatchEditStep);
|
||||
const CardMovePopup = usePopup(CardMoveStep);
|
||||
const DeletePopup = usePopup(DeleteStep);
|
||||
|
||||
@@ -185,7 +207,7 @@ const CardModal = React.memo(
|
||||
</Grid.Row>
|
||||
<Grid.Row className={styles.modalPadding}>
|
||||
<Grid.Column width={canEdit ? 12 : 16} className={styles.contentPadding}>
|
||||
{(users.length > 0 || labels.length > 0 || dueDate || timer) && (
|
||||
{(users.length > 0 || labels.length > 0 || dueDate || stopwatch) && (
|
||||
<div className={styles.moduleWrapper}>
|
||||
{users.length > 0 && (
|
||||
<div className={styles.attachments}>
|
||||
@@ -217,6 +239,7 @@ 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)}
|
||||
@@ -266,6 +289,7 @@ 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)}
|
||||
@@ -283,41 +307,56 @@ const CardModal = React.memo(
|
||||
context: 'title',
|
||||
})}
|
||||
</div>
|
||||
<span className={styles.attachment}>
|
||||
<span className={classNames(styles.attachment, styles.attachmentDueDate)}>
|
||||
{canEdit ? (
|
||||
<>
|
||||
<Checkbox
|
||||
checked={isDueDateCompleted}
|
||||
disabled={!canEdit}
|
||||
onChange={handleDueDateCompletionChange}
|
||||
/>
|
||||
<DueDateEditPopup defaultValue={dueDate} onUpdate={handleDueDateUpdate}>
|
||||
<DueDate value={dueDate} />
|
||||
<DueDate
|
||||
withStatusIcon
|
||||
value={dueDate}
|
||||
isCompleted={isDueDateCompleted}
|
||||
/>
|
||||
</DueDateEditPopup>
|
||||
</>
|
||||
) : (
|
||||
<DueDate value={dueDate} />
|
||||
<DueDate withStatusIcon value={dueDate} isCompleted={isDueDateCompleted} />
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{timer && (
|
||||
{stopwatch && (
|
||||
<div className={styles.attachments}>
|
||||
<div className={styles.text}>
|
||||
{t('common.timer', {
|
||||
{t('common.stopwatch', {
|
||||
context: 'title',
|
||||
})}
|
||||
</div>
|
||||
<span className={styles.attachment}>
|
||||
{canEdit ? (
|
||||
<TimerEditPopup defaultValue={timer} onUpdate={handleTimerUpdate}>
|
||||
<Timer startedAt={timer.startedAt} total={timer.total} />
|
||||
</TimerEditPopup>
|
||||
<StopwatchEditPopup
|
||||
defaultValue={stopwatch}
|
||||
onUpdate={handleStopwatchUpdate}
|
||||
>
|
||||
<Stopwatch startedAt={stopwatch.startedAt} total={stopwatch.total} />
|
||||
</StopwatchEditPopup>
|
||||
) : (
|
||||
<Timer startedAt={timer.startedAt} total={timer.total} />
|
||||
<Stopwatch startedAt={stopwatch.startedAt} total={stopwatch.total} />
|
||||
)}
|
||||
</span>
|
||||
{canEdit && (
|
||||
// eslint-disable-next-line jsx-a11y/control-has-associated-label
|
||||
<button
|
||||
onClick={handleToggleTimerClick}
|
||||
type="button"
|
||||
className={classNames(styles.attachment, styles.dueDate)}
|
||||
onClick={handleToggleStopwatchClick}
|
||||
>
|
||||
<Icon
|
||||
name={timer.startedAt ? 'pause' : 'play'}
|
||||
name={stopwatch.startedAt ? 'pause' : 'play'}
|
||||
size="small"
|
||||
className={styles.addAttachment}
|
||||
/>
|
||||
@@ -447,12 +486,12 @@ const CardModal = React.memo(
|
||||
})}
|
||||
</Button>
|
||||
</DueDateEditPopup>
|
||||
<TimerEditPopup defaultValue={timer} onUpdate={handleTimerUpdate}>
|
||||
<StopwatchEditPopup defaultValue={stopwatch} onUpdate={handleStopwatchUpdate}>
|
||||
<Button fluid className={styles.actionButton}>
|
||||
<Icon name="clock outline" className={styles.actionIcon} />
|
||||
{t('common.timer')}
|
||||
{t('common.stopwatch')}
|
||||
</Button>
|
||||
</TimerEditPopup>
|
||||
</StopwatchEditPopup>
|
||||
<AttachmentAddPopup onCreate={onAttachmentCreate}>
|
||||
<Button fluid className={styles.actionButton}>
|
||||
<Icon name="attach" className={styles.actionIcon} />
|
||||
@@ -490,6 +529,23 @@ 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"
|
||||
@@ -524,7 +580,8 @@ CardModal.propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
description: PropTypes.string,
|
||||
dueDate: PropTypes.instanceOf(Date),
|
||||
timer: PropTypes.object, // eslint-disable-line react/forbid-prop-types
|
||||
isDueDateCompleted: PropTypes.bool,
|
||||
stopwatch: PropTypes.object, // eslint-disable-line react/forbid-prop-types
|
||||
isSubscribed: PropTypes.bool.isRequired,
|
||||
isActivitiesFetching: PropTypes.bool.isRequired,
|
||||
isAllActivitiesFetched: PropTypes.bool.isRequired,
|
||||
@@ -549,6 +606,7 @@ 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,
|
||||
@@ -577,7 +635,8 @@ CardModal.propTypes = {
|
||||
CardModal.defaultProps = {
|
||||
description: undefined,
|
||||
dueDate: undefined,
|
||||
timer: undefined,
|
||||
isDueDateCompleted: false,
|
||||
stopwatch: undefined,
|
||||
};
|
||||
|
||||
export default CardModal;
|
||||
|
||||
@@ -20,18 +20,26 @@
|
||||
|
||||
.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: 500;
|
||||
font-weight: normal;
|
||||
letter-spacing: 0.04em;
|
||||
margin-top: 16px;
|
||||
text-transform: uppercase;
|
||||
@@ -50,6 +58,12 @@
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.attachmentDueDate {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.attachments {
|
||||
display: inline-block;
|
||||
margin: 0 8px 8px 0;
|
||||
@@ -63,6 +77,11 @@
|
||||
|
||||
.contentPadding {
|
||||
padding: 8px 8px 0 16px;
|
||||
|
||||
@media only screen and (max-width: 797px) {
|
||||
padding-right: 16px;
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
|
||||
.cursorPointer {
|
||||
@@ -139,7 +158,7 @@
|
||||
.headerTitle {
|
||||
color: #17394d;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
font-weight: bold;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
@@ -155,6 +174,11 @@
|
||||
|
||||
.modalPadding {
|
||||
padding: 0px;
|
||||
|
||||
@media only screen and (max-width: 797px) {
|
||||
display: flex;
|
||||
flex-flow: column nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.moduleHeader {
|
||||
@@ -185,6 +209,15 @@
|
||||
|
||||
.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,6 +57,7 @@ const DescriptionEdit = React.forwardRef(({ children, defaultValue, onUpdate },
|
||||
|
||||
const mdEditorOptions = useMemo(
|
||||
() => ({
|
||||
autoDownloadFontAwesome: false,
|
||||
autofocus: true,
|
||||
spellChecker: false,
|
||||
status: false,
|
||||
@@ -100,7 +101,6 @@ const DescriptionEdit = React.forwardRef(({ children, defaultValue, onUpdate },
|
||||
onChange={setValue}
|
||||
/>
|
||||
<div className={styles.controls}>
|
||||
{/* eslint-disable-next-line jsx-a11y/mouse-events-have-key-events */}
|
||||
<Button positive content={t('action.save')} />
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
box-shadow: none;
|
||||
color: #17394d;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
font-weight: bold;
|
||||
line-height: 24px;
|
||||
margin: -5px;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -8,6 +8,7 @@ import { usePopup } from '../../../lib/popup';
|
||||
|
||||
import NameEdit from './NameEdit';
|
||||
import ActionsStep from './ActionsStep';
|
||||
import Linkify from '../../Linkify';
|
||||
|
||||
import styles from './Item.module.scss';
|
||||
|
||||
@@ -65,7 +66,7 @@ const Item = React.memo(
|
||||
onClick={handleClick}
|
||||
>
|
||||
<span className={classNames(styles.task, isCompleted && styles.taskCompleted)}>
|
||||
{name}
|
||||
<Linkify linkStopPropagation>{name}</Linkify>
|
||||
</span>
|
||||
</span>
|
||||
{isPersisted && canEdit && (
|
||||
|
||||
@@ -4,7 +4,8 @@ import { useTranslation } from 'react-i18next';
|
||||
import TextareaAutosize from 'react-textarea-autosize';
|
||||
import { Button, Form, TextArea } from 'semantic-ui-react';
|
||||
|
||||
import { useClosableForm, useField } from '../../../hooks';
|
||||
import { useField } from '../../../hooks';
|
||||
import { focusEnd } from '../../../utils/element-helpers';
|
||||
|
||||
import styles from './NameEdit.module.scss';
|
||||
|
||||
@@ -28,12 +29,7 @@ const NameEdit = React.forwardRef(({ children, defaultValue, onUpdate }, ref) =>
|
||||
const submit = useCallback(() => {
|
||||
const cleanValue = value.trim();
|
||||
|
||||
if (!cleanValue) {
|
||||
field.current.ref.current.select();
|
||||
return;
|
||||
}
|
||||
|
||||
if (cleanValue !== defaultValue) {
|
||||
if (cleanValue && cleanValue !== defaultValue) {
|
||||
onUpdate(cleanValue);
|
||||
}
|
||||
|
||||
@@ -60,10 +56,9 @@ const NameEdit = React.forwardRef(({ children, defaultValue, onUpdate }, ref) =>
|
||||
[submit],
|
||||
);
|
||||
|
||||
const [handleFieldBlur, handleControlMouseOver, handleControlMouseOut] = useClosableForm(
|
||||
close,
|
||||
isOpened,
|
||||
);
|
||||
const handleFieldBlur = useCallback(() => {
|
||||
submit();
|
||||
}, [submit]);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
submit();
|
||||
@@ -71,7 +66,7 @@ const NameEdit = React.forwardRef(({ children, defaultValue, onUpdate }, ref) =>
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpened) {
|
||||
field.current.ref.current.focus();
|
||||
focusEnd(field.current.ref.current);
|
||||
}
|
||||
}, [isOpened]);
|
||||
|
||||
@@ -93,13 +88,7 @@ const NameEdit = React.forwardRef(({ children, defaultValue, onUpdate }, ref) =>
|
||||
onBlur={handleFieldBlur}
|
||||
/>
|
||||
<div className={styles.controls}>
|
||||
{/* eslint-disable-next-line jsx-a11y/mouse-events-have-key-events */}
|
||||
<Button
|
||||
positive
|
||||
content={t('action.save')}
|
||||
onMouseOver={handleControlMouseOver}
|
||||
onMouseOut={handleControlMouseOut}
|
||||
/>
|
||||
<Button positive content={t('action.save')} />
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
|
||||
@@ -48,6 +48,8 @@ const Tasks = React.memo(({ items, canEdit, onCreate, onUpdate, onMove, onDelete
|
||||
return (
|
||||
<>
|
||||
{items.length > 0 && (
|
||||
<>
|
||||
<span className={styles.progressWrapper}>
|
||||
<Progress
|
||||
autoSuccess
|
||||
value={completedItems.length}
|
||||
@@ -56,6 +58,11 @@ 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,6 +3,23 @@
|
||||
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,6 +4,7 @@
|
||||
border-radius: 4px;
|
||||
bottom: 20px;
|
||||
box-shadow: #b04632 0 1px 0;
|
||||
max-width: calc(100% - 40px);
|
||||
padding: 12px 18px;
|
||||
position: fixed;
|
||||
right: 20px;
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import upperFirst from 'lodash/upperFirst';
|
||||
import React from 'react';
|
||||
import React, { useEffect, useRef } 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';
|
||||
|
||||
@@ -12,27 +16,111 @@ const SIZES = {
|
||||
MEDIUM: 'medium',
|
||||
};
|
||||
|
||||
const FORMATS = {
|
||||
const STATUSES = {
|
||||
DUE_SOON: 'dueSoon',
|
||||
OVERDUE: 'overdue',
|
||||
COMPLETED: 'completed',
|
||||
};
|
||||
|
||||
const LONG_DATE_FORMAT_BY_SIZE = {
|
||||
tiny: 'longDate',
|
||||
small: 'longDate',
|
||||
medium: 'longDateTime',
|
||||
};
|
||||
|
||||
const DueDate = React.memo(({ value, size, isDisabled, onClick }) => {
|
||||
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 [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:${FORMATS[size]}`, {
|
||||
{t(`format:${dateFormat}`, {
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -48,14 +136,19 @@ const DueDate = React.memo(({ value, size, isDisabled, onClick }) => {
|
||||
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,16 +8,17 @@
|
||||
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 {
|
||||
@@ -43,4 +44,21 @@
|
||||
line-height: 20px;
|
||||
padding: 6px 12px;
|
||||
}
|
||||
|
||||
/* Statuses */
|
||||
|
||||
.wrapperDueSoon {
|
||||
background: #f2711c;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.wrapperOverdue {
|
||||
background: #db2828;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.wrapperCompleted {
|
||||
background: #21ba45;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
:global(#app) {
|
||||
.wrapper {
|
||||
max-width: 100vw;
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
z-index: 1;
|
||||
|
||||
@@ -2,11 +2,12 @@ import React, { useCallback } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Icon, Menu } from 'semantic-ui-react';
|
||||
import { Button, Icon, Menu } from 'semantic-ui-react';
|
||||
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';
|
||||
@@ -55,15 +56,16 @@ const Header = React.memo(
|
||||
>
|
||||
<Icon fitted name="arrow left" />
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
className={classNames(
|
||||
styles.item,
|
||||
canEditProject && styles.itemHoverable,
|
||||
styles.title,
|
||||
)}
|
||||
<Menu.Item className={classNames(styles.item, styles.title)}>
|
||||
{project.name}
|
||||
{canEditProject && (
|
||||
<Button
|
||||
className={classNames(styles.editButton, styles.target)}
|
||||
onClick={handleProjectSettingsClick}
|
||||
>
|
||||
{project.name}
|
||||
<Icon fitted name="pencil" size="small" />
|
||||
</Button>
|
||||
)}
|
||||
</Menu.Item>
|
||||
</Menu.Menu>
|
||||
)}
|
||||
@@ -90,7 +92,8 @@ const Header = React.memo(
|
||||
onLogout={onLogout}
|
||||
>
|
||||
<Menu.Item className={classNames(styles.item, styles.itemHoverable)}>
|
||||
{user.name}
|
||||
<span className={styles.userName}>{user.name}</span>
|
||||
<User name={user.name} avatarUrl={user.avatarUrl} size="small" />
|
||||
</Menu.Item>
|
||||
</UserPopup>
|
||||
</Menu.Menu>
|
||||
|
||||
@@ -1,4 +1,20 @@
|
||||
:global(#app) {
|
||||
.editButton {
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
color: #fff;
|
||||
font-size: 15px;
|
||||
line-height: 34px;
|
||||
margin-left: 8px;
|
||||
opacity: 0;
|
||||
padding: 0;
|
||||
width: 34px;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
.item {
|
||||
cursor: auto;
|
||||
user-select: auto;
|
||||
@@ -11,6 +27,10 @@
|
||||
&:hover {
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
|
||||
.target {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +86,15 @@
|
||||
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,7 +49,6 @@
|
||||
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 = (
|
||||
<div
|
||||
<span
|
||||
title={name}
|
||||
className={classNames(
|
||||
styles.wrapper,
|
||||
@@ -28,7 +28,7 @@ const Label = React.memo(({ name, color, size, isDisabled, onClick }) => {
|
||||
)}
|
||||
>
|
||||
{name || '\u00A0'}
|
||||
</div>
|
||||
</span>
|
||||
);
|
||||
|
||||
return onClick ? (
|
||||
|
||||
@@ -11,13 +11,13 @@
|
||||
|
||||
.wrapper {
|
||||
border-radius: 3px;
|
||||
box-sizing: border-box;
|
||||
color: #fff;
|
||||
font-weight: 400;
|
||||
outline: none;
|
||||
display: inline-block;
|
||||
font-weight: normal;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-shadow: 1px 1px 0 rgba(0, 0, 0, 0.2);
|
||||
vertical-align: top;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,6 @@
|
||||
max-height: 60vh;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 5px;
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
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;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user