Compare commits

...
10 Commits
57 changed files with 1303 additions and 147 deletions
@@ -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 }}
@@ -9,99 +9,45 @@ on:
- 'docker-*.sh'
- '*.md'
branches: [master]
workflow_dispatch:
env:
REGISTRY_IMAGE: ghcr.io/plankanban/planka
REGISTRY_IMAGE: ghcr.io/${{ github.repository }}
jobs:
build:
strategy:
fail-fast: false
matrix:
include:
- os: [self-hosted, x64]
platform: linux/amd64
- os: [self-hosted, arm64]
platform: linux/arm64
- os: [self-hosted, arm64]
platform: linux/arm/v7
runs-on: ${{ matrix.os }}
runs-on: self-hosted
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY_IMAGE }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v2
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push by digest
id: build
uses: docker/build-push-action@v5
- 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: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,name=${{ env.REGISTRY_IMAGE }},push-by-digest=true,name-canonical=true,push=true
- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v3
with:
name: digests
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
merge:
runs-on: [self-hosted]
needs:
- build
steps:
- name: Download digests
uses: actions/download-artifact@v3
with:
name: digests
path: /tmp/digests
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY_IMAGE }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Create manifest list and push
working-directory: /tmp/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.REGISTRY_IMAGE }}@sha256:%s ' *)
- name: Inspect image
run: |
docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:${{ steps.meta.outputs.version }}
rerun-failed-jobs:
runs-on: [self-hosted]
needs: [ build, merge]
if: failure()
steps:
- name: Rerun failed jobs in the current workflow
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh run rerun ${{ github.run_id }} --failed
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
@@ -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@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
+2 -2
View File
@@ -15,13 +15,13 @@ type: application
# This is the chart version. This version number should be incremented each time you make changes
# to the chart and its templates, including the app version.
# Versions are expected to follow Semantic Versioning (https://semver.org/)
version: 0.1.26
version: 0.1.27
# 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.16.4"
appVersion: "1.17.0"
dependencies:
- alias: postgresql
+1 -1
View File
@@ -1 +1 @@
REACT_APP_VERSION=1.16.4
REACT_APP_VERSION=1.17.0
+9
View File
@@ -121,6 +121,14 @@ const handleCardDelete = (card) => ({
},
});
const filterText = (boardId, text) => ({
type: ActionTypes.TEXT_FILTER_IN_CURRENT_BOARD,
payload: {
boardId,
text,
},
});
export default {
createCard,
handleCardCreate,
@@ -129,4 +137,5 @@ export default {
duplicateCard,
deleteCard,
handleCardDelete,
filterText,
};
+34
View File
@@ -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,
};
+24
View File
@@ -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,
};
@@ -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,5 +1,7 @@
:global(#app) {
.action {
align-items: center;
display: flex;
flex: 0 0 auto;
margin-right: 20px;
}
+65 -1
View File
@@ -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,32 @@
line-height: 20px;
padding: 2px 12px;
}
.search {
height: 30px;
margin: 0 12px;
transition: width 0.2s ease;
width: 280px;
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;
}
}
}
}
+3 -1
View File
@@ -7,6 +7,7 @@ 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';
@@ -91,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>
@@ -86,6 +86,10 @@
font-weight: bold;
}
.userName {
margin-right: 10px;
}
.wrapper {
background: rgba(0, 0, 0, 0.24);
display: flex;
+31 -2
View File
@@ -5,15 +5,17 @@ import { Menu } from 'semantic-ui-react';
import { Popup } from '../../lib/custom-ui';
import { useSteps } from '../../hooks';
import ListSortStep from '../ListSortStep';
import DeleteStep from '../DeleteStep';
import styles from './ActionsStep.module.scss';
const StepTypes = {
DELETE: 'DELETE',
SORT: 'SORT',
};
const ActionsStep = React.memo(({ onNameEdit, onCardAdd, onDelete, onClose }) => {
const ActionsStep = React.memo(({ onNameEdit, onCardAdd, onSort, onDelete, onClose }) => {
const [t] = useTranslation();
const [step, openStep, handleBack] = useSteps();
@@ -27,11 +29,30 @@ const ActionsStep = React.memo(({ onNameEdit, onCardAdd, onDelete, onClose }) =>
onClose();
}, [onCardAdd, onClose]);
const handleSortClick = useCallback(() => {
openStep(StepTypes.SORT);
}, [openStep]);
const handleDeleteClick = useCallback(() => {
openStep(StepTypes.DELETE);
}, [openStep]);
if (step && step.type === StepTypes.DELETE) {
const handleSortTypeSelect = useCallback(
(type) => {
onSort({
type,
});
onClose();
},
[onSort, onClose],
);
if (step && step.type) {
switch (step.type) {
case StepTypes.SORT:
return <ListSortStep onTypeSelect={handleSortTypeSelect} onBack={handleBack} />;
case StepTypes.DELETE:
return (
<DeleteStep
title="common.deleteList"
@@ -41,6 +62,8 @@ const ActionsStep = React.memo(({ onNameEdit, onCardAdd, onDelete, onClose }) =>
onBack={handleBack}
/>
);
default:
}
}
return (
@@ -62,6 +85,11 @@ const ActionsStep = React.memo(({ onNameEdit, onCardAdd, onDelete, onClose }) =>
context: 'title',
})}
</Menu.Item>
<Menu.Item className={styles.menuItem} onClick={handleSortClick}>
{t('action.sortList', {
context: 'title',
})}
</Menu.Item>
<Menu.Item className={styles.menuItem} onClick={handleDeleteClick}>
{t('action.deleteList', {
context: 'title',
@@ -77,6 +105,7 @@ ActionsStep.propTypes = {
onNameEdit: PropTypes.func.isRequired,
onCardAdd: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
onSort: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
};
+14 -1
View File
@@ -16,7 +16,18 @@ import { ReactComponent as PlusMathIcon } from '../../assets/images/plus-math-ic
import styles from './List.module.scss';
const List = React.memo(
({ id, index, name, isPersisted, cardIds, canEdit, onUpdate, onDelete, onCardCreate }) => {
({
id,
index,
name,
isPersisted,
cardIds,
canEdit,
onUpdate,
onDelete,
onSort,
onCardCreate,
}) => {
const [t] = useTranslation();
const [isAddCardOpened, setIsAddCardOpened] = useState(false);
@@ -114,6 +125,7 @@ const List = React.memo(
onNameEdit={handleNameEdit}
onCardAdd={handleCardAdd}
onDelete={onDelete}
onSort={onSort}
>
<Button className={classNames(styles.headerButton, styles.target)}>
<Icon fitted name="pencil" size="small" />
@@ -159,6 +171,7 @@ List.propTypes = {
cardIds: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types
canEdit: PropTypes.bool.isRequired,
onUpdate: PropTypes.func.isRequired,
onSort: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
onCardCreate: PropTypes.func.isRequired,
};
@@ -0,0 +1,61 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { Menu } from 'semantic-ui-react';
import { Popup } from '../../lib/custom-ui';
import { ListSortTypes } from '../../constants/Enums';
import styles from './ListSortStep.module.scss';
const ListSortStep = React.memo(({ onTypeSelect, onBack }) => {
const [t] = useTranslation();
return (
<>
<Popup.Header onBack={onBack}>
{t('common.sortList', {
context: 'title',
})}
</Popup.Header>
<Popup.Content>
<Menu secondary vertical className={styles.menu}>
<Menu.Item
className={styles.menuItem}
onClick={() => onTypeSelect(ListSortTypes.NAME_ASC)}
>
{t('common.title')}
</Menu.Item>
<Menu.Item
className={styles.menuItem}
onClick={() => onTypeSelect(ListSortTypes.DUE_DATE_ASC)}
>
{t('common.dueDate')}
</Menu.Item>
<Menu.Item
className={styles.menuItem}
onClick={() => onTypeSelect(ListSortTypes.CREATED_AT_ASC)}
>
{t('common.oldestFirst')}
</Menu.Item>
<Menu.Item
className={styles.menuItem}
onClick={() => onTypeSelect(ListSortTypes.CREATED_AT_DESC)}
>
{t('common.newestFirst')}
</Menu.Item>
</Menu>
</Popup.Content>
</>
);
});
ListSortStep.propTypes = {
onTypeSelect: PropTypes.func.isRequired,
onBack: PropTypes.func,
};
ListSortStep.defaultProps = {
onBack: undefined,
};
export default ListSortStep;
@@ -0,0 +1,11 @@
:global(#app) {
.menu {
margin: -7px -12px -5px;
width: calc(100% + 24px);
}
.menuItem {
margin: 0;
padding-left: 14px;
}
}
@@ -0,0 +1,3 @@
import ListSortStep from './ListSortStep';
export default ListSortStep;
@@ -8,6 +8,9 @@ import ActionsStep from './ActionsStep';
import User from '../User';
import styles from './Memberships.module.scss';
import MembershipsStep from './MembershipsStep';
const MAX_MEMBERS = 6;
const Memberships = React.memo(
({
@@ -31,11 +34,14 @@ const Memberships = React.memo(
}) => {
const AddPopup = usePopup(AddStep);
const ActionsPopup = usePopup(ActionsStep);
const MembershipsPopup = usePopup(MembershipsStep);
const remainMembersCount = items.length - MAX_MEMBERS;
return (
<>
<span className={styles.users}>
{items.map((item) => (
{items.slice(0, MAX_MEMBERS).map((item) => (
<span key={item.id} className={styles.user}>
<ActionsPopup
membership={item}
@@ -63,6 +69,28 @@ const Memberships = React.memo(
</span>
))}
</span>
{remainMembersCount > 0 && (
<MembershipsPopup
items={items}
permissionsSelectStep={permissionsSelectStep}
leaveButtonContent={leaveButtonContent}
leaveConfirmationTitle={leaveConfirmationTitle}
leaveConfirmationContent={leaveConfirmationContent}
leaveConfirmationButtonContent={leaveConfirmationButtonContent}
deleteButtonContent={deleteButtonContent}
deleteConfirmationTitle={deleteConfirmationTitle}
deleteConfirmationContent={deleteConfirmationContent}
deleteConfirmationButtonContent={deleteConfirmationButtonContent}
canEdit={canEdit}
canLeave={items.length > 1 || canLeaveIfLast}
onUpdate={onUpdate}
onDelete={onDelete}
>
<Button icon className={styles.addUser}>
+ {remainMembersCount < 99 ? remainMembersCount : 99}
</Button>
</MembershipsPopup>
)}
{canEdit && (
<AddPopup
users={allUsers}
@@ -0,0 +1,107 @@
import React, { useCallback, useState } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import BoardMembershipsStep from '../BoardMembershipsStep/BoardMembershipsStep';
import ActionsStep from './ActionsStep';
import { Popup } from '../../lib/custom-ui';
const MembershipsStep = React.memo(
({
items,
permissionsSelectStep,
leaveButtonContent,
leaveConfirmationTitle,
leaveConfirmationContent,
leaveConfirmationButtonContent,
deleteButtonContent,
deleteConfirmationTitle,
deleteConfirmationContent,
deleteConfirmationButtonContent,
canEdit,
canLeave,
onUpdate,
onDelete,
onClose,
}) => {
const [t] = useTranslation();
const [editingItem, setEditingItem] = useState();
const handleUserClick = useCallback(
(id) => {
setEditingItem(items.find((item) => item.user.id === id));
},
[setEditingItem, items],
);
if (editingItem) {
return (
<>
<Popup.Header onBack={() => setEditingItem(null)}>{t('common.memberInfo')}</Popup.Header>
<Popup.Content>
<ActionsStep
membership={editingItem}
permissionsSelectStep={permissionsSelectStep}
leaveButtonContent={leaveButtonContent}
leaveConfirmationTitle={leaveConfirmationTitle}
leaveConfirmationContent={leaveConfirmationContent}
leaveConfirmationButtonContent={leaveConfirmationButtonContent}
deleteButtonContent={deleteButtonContent}
deleteConfirmationTitle={deleteConfirmationTitle}
deleteConfirmationContent={deleteConfirmationContent}
deleteConfirmationButtonContent={deleteConfirmationButtonContent}
canEdit={canEdit}
canLeave={canLeave}
onUpdate={onUpdate}
onDelete={onDelete}
onClose={onClose}
/>
</Popup.Content>
</>
);
}
return (
<BoardMembershipsStep
items={items}
currentUserIds={[]}
onUserSelect={handleUserClick}
onUserDeselect={() => {}}
/>
);
},
);
MembershipsStep.propTypes = {
items: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types
permissionsSelectStep: PropTypes.elementType,
leaveButtonContent: PropTypes.string,
leaveConfirmationTitle: PropTypes.string,
leaveConfirmationContent: PropTypes.string,
leaveConfirmationButtonContent: PropTypes.string,
deleteButtonContent: PropTypes.string,
deleteConfirmationTitle: PropTypes.string,
deleteConfirmationContent: PropTypes.string,
deleteConfirmationButtonContent: PropTypes.string,
canEdit: PropTypes.bool.isRequired,
canLeave: PropTypes.bool.isRequired,
onUpdate: PropTypes.func,
onDelete: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
};
MembershipsStep.defaultProps = {
permissionsSelectStep: undefined,
leaveButtonContent: 'action.leaveBoard',
leaveConfirmationTitle: 'common.leaveBoard',
leaveConfirmationContent: 'common.areYouSureYouWantToLeaveBoard',
leaveConfirmationButtonContent: 'action.leaveBoard',
deleteButtonContent: 'action.removeFromBoard',
deleteConfirmationTitle: 'common.removeMember',
deleteConfirmationContent: 'common.areYouSureYouWantToRemoveThisMemberFromBoard',
deleteConfirmationButtonContent: 'action.removeMember',
onUpdate: undefined,
};
export default MembershipsStep;
+5
View File
@@ -173,6 +173,10 @@ export default {
LIST_UPDATE__SUCCESS: 'LIST_UPDATE__SUCCESS',
LIST_UPDATE__FAILURE: 'LIST_UPDATE__FAILURE',
LIST_UPDATE_HANDLE: 'LIST_UPDATE_HANDLE',
LIST_SORT: 'LIST_SORT',
LIST_SORT__SUCCESS: 'LIST_SORT__SUCCESS',
LIST_SORT__FAILURE: 'LIST_SORT__FAILURE',
LIST_SORT_HANDLE: 'LIST_SORT_HANDLE',
LIST_DELETE: 'LIST_DELETE',
LIST_DELETE__SUCCESS: 'LIST_DELETE__SUCCESS',
LIST_DELETE__FAILURE: 'LIST_DELETE__FAILURE',
@@ -201,6 +205,7 @@ export default {
CARD_DELETE__SUCCESS: 'CARD_DELETE__SUCCESS',
CARD_DELETE__FAILURE: 'CARD_DELETE__FAILURE',
CARD_DELETE_HANDLE: 'CARD_DELETE_HANDLE',
TEXT_FILTER_IN_CURRENT_BOARD: 'TEXT_FILTER_IN_CURRENT_BOARD',
/* Tasks */
+3
View File
@@ -120,6 +120,8 @@ export default {
LIST_MOVE: `${PREFIX}/LIST_MOVE`,
LIST_DELETE: `${PREFIX}/LIST_DELETE`,
LIST_DELETE_HANDLE: `${PREFIX}/LIST_DELETE_HANDLE`,
LIST_SORT: `${PREFIX}/LIST_SORT`,
LIST_SORT_HANDLE: `${PREFIX}/LIST_SORT_HANDLE`,
/* Cards */
@@ -137,6 +139,7 @@ export default {
CARD_DELETE: `${PREFIX}/CARD_DELETE`,
CURRENT_CARD_DELETE: `${PREFIX}/CURRENT_CARD_DELETE`,
CARD_DELETE_HANDLE: `${PREFIX}/CARD_DELETE_HANDLE`,
TEXT_FILTER_IN_CURRENT_BOARD: `${PREFIX}/FILTER_TEXT_HANDLE`,
/* Tasks */
+7
View File
@@ -8,6 +8,13 @@ export const BoardMembershipRoles = {
VIEWER: 'viewer',
};
export const ListSortTypes = {
NAME_ASC: 'name_asc',
DUE_DATE_ASC: 'dueDate_asc',
CREATED_AT_ASC: 'createdAt_asc',
CREATED_AT_DESC: 'createdAt_desc',
};
export const ActivityTypes = {
CREATE_CARD: 'createCard',
MOVE_CARD: 'moveCard',
@@ -13,6 +13,7 @@ const mapStateToProps = (state) => {
const labels = selectors.selectLabelsForCurrentBoard(state);
const filterUsers = selectors.selectFilterUsersForCurrentBoard(state);
const filterLabels = selectors.selectFilterLabelsForCurrentBoard(state);
const filterText = selectors.selectFilterTextForCurrentBoard(state);
const currentUserMembership = selectors.selectCurrentUserMembershipForCurrentBoard(state);
const isCurrentUserEditor =
@@ -23,6 +24,7 @@ const mapStateToProps = (state) => {
labels,
filterUsers,
filterLabels,
filterText,
allUsers,
canEdit: isCurrentUserEditor,
canEditMemberships: isCurrentUserManager,
@@ -43,6 +45,7 @@ const mapDispatchToProps = (dispatch) =>
onLabelUpdate: entryActions.updateLabel,
onLabelMove: entryActions.moveLabel,
onLabelDelete: entryActions.deleteLabel,
onTextFilterUpdate: entryActions.filterText,
},
dispatch,
);
+1
View File
@@ -33,6 +33,7 @@ const mapDispatchToProps = (dispatch, { id }) =>
bindActionCreators(
{
onUpdate: (data) => entryActions.updateList(id, data),
onSort: (data) => entryActions.sortList(id, data),
onDelete: () => entryActions.deleteList(id),
onCardCreate: (data, autoOpen) => entryActions.createCard(id, data, autoOpen),
},
+8
View File
@@ -105,6 +105,13 @@ const handleCardDelete = (card) => ({
},
});
const filterText = (text) => ({
type: EntryActionTypes.TEXT_FILTER_IN_CURRENT_BOARD,
payload: {
text,
},
});
export default {
createCard,
handleCardCreate,
@@ -120,4 +127,5 @@ export default {
deleteCard,
deleteCurrentCard,
handleCardDelete,
filterText,
};
+20
View File
@@ -37,6 +37,24 @@ const moveList = (id, index) => ({
},
});
const sortList = (id, data) => {
return {
type: EntryActionTypes.LIST_SORT,
payload: {
id,
data,
},
};
};
const handleListSort = (list, cards) => ({
type: EntryActionTypes.LIST_SORT_HANDLE,
payload: {
list,
cards,
},
});
const deleteList = (id) => ({
type: EntryActionTypes.LIST_DELETE,
payload: {
@@ -57,6 +75,8 @@ export default {
updateList,
handleListUpdate,
moveList,
sortList,
handleListSort,
deleteList,
handleListDelete,
};
@@ -9,4 +9,6 @@ export default class Input extends SemanticUIInput {
static Mask = InputMask;
focus = (options) => this.inputRef.current.focus(options);
blur = () => this.inputRef.current.blur();
}
+7
View File
@@ -60,6 +60,7 @@ export default {
currentPassword: 'Current password',
dangerZone_title: 'Danger Zone',
date: 'Date',
dueDate: 'Due date',
dueDate_title: 'Due Date',
deleteAttachment_title: 'Delete Attachment',
deleteBoard_title: 'Delete Board',
@@ -108,10 +109,12 @@ export default {
list: 'List',
listActions_title: 'List Actions',
managers: 'Managers',
memberInfo: 'Member Info',
members: 'Members',
minutes: 'Minutes',
moveCard_title: 'Move Card',
name: 'Name',
newestFirst: 'Newest first',
newEmail: 'New e-mail',
newPassword: 'New password',
newUsername: 'New username',
@@ -121,6 +124,7 @@ export default {
noProjects: 'No projects',
notifications: 'Notifications',
noUnreadNotifications: 'No unread notifications.',
oldestFirst: 'Oldest first',
openBoard_title: 'Open Board',
optional_inline: 'optional',
organization: 'Organization',
@@ -135,12 +139,14 @@ export default {
searchLabels: 'Search labels...',
searchMembers: 'Search members...',
searchUsers: 'Search users...',
searchCards: 'Search cards...',
seconds: 'Seconds',
selectBoard: 'Select board',
selectList: 'Select list',
selectPermissions_title: 'Select Permissions',
selectProject: 'Select project',
settings: 'Settings',
sortList_title: 'Sort List',
stopwatch: 'Stopwatch',
subscribeToMyOwnCardsByDefault: 'Subscribe to my own cards by default',
taskActions_title: 'Task Actions',
@@ -228,6 +234,7 @@ export default {
showAllAttachments: 'Show all attachments ({{hidden}} hidden)',
showDetails: 'Show details',
showFewerAttachments: 'Show fewer attachments',
sortList_title: 'Sort List',
start: 'Start',
stop: 'Stop',
subscribe: 'Subscribe',
+4
View File
@@ -111,6 +111,10 @@ export default {
project: 'Projet',
projectNotFound_title: 'Projet introuvable',
removeMember_title: 'Supprimer le membre',
searchLabels: 'Chercher une étiquette...',
searchMembers: 'Chercher un membre...',
searchUsers: 'Chercher un utilisateur...',
searchCards: 'Chercher une carte...',
seconds: 'Secondes',
selectBoard: 'Sélectionner une carte',
selectList: 'Sélectionner une liste',
+231
View File
@@ -0,0 +1,231 @@
import dateFns from 'date-fns/locale/hu';
export default {
dateFns,
format: {
date: 'yyyy.M.d.',
time: 'p',
dateTime: '$t(format:date) $t(format:time)',
longDate: 'MMM. d.',
longDateTime: 'MMMM. d. p',
fullDate: 'y. MMM d.',
fullDateTime: 'y. MMMM d. p',
},
translation: {
common: {
allChangesWillBeAutomaticallySavedAfterConnectionRestored:
'Az összes változás automatikusan mentésre kerül a kapcsolat helyreállása után.',
areYouSureYouWantToDeleteThisAttachment: 'Biztosan törölni szeretné ezt a mellékletet?',
areYouSureYouWantToDeleteThisBoard: 'Biztosan törölni szeretné ezt a táblát?',
areYouSureYouWantToDeleteThisCard: 'Biztosan törölni szeretné ezt a kártyát?',
areYouSureYouWantToDeleteThisComment: 'Biztosan törölni szeretné ezt a megjegyzést?',
areYouSureYouWantToDeleteThisLabel: 'Biztosan törölni szeretné ezt a címkét?',
areYouSureYouWantToDeleteThisList: 'Biztosan törölni szeretné ezt a listát?',
areYouSureYouWantToDeleteThisProject: 'Biztosan törölni szeretné ezt a projektet?',
areYouSureYouWantToDeleteThisTask: 'Biztosan törölni szeretné ezt a feladatot?',
areYouSureYouWantToDeleteThisUser: 'Biztosan törölni szeretné ezt a felhasználót?',
areYouSureYouWantToLeaveBoard: 'Biztosan el akarja hagyni a táblát?',
areYouSureYouWantToLeaveProject: 'Biztosan el akarja hagyni a projektet?',
areYouSureYouWantToRemoveThisManagerFromProject:
'Biztosan eltávolítja ezt a vezetőt a projektből?',
areYouSureYouWantToRemoveThisMemberFromBoard: 'Biztosan eltávolítja ezt a tagot a tábláról?',
attachment: 'Melléklet',
attachments: 'Mellékletek',
authentication: 'Hitelesítés',
background: 'Háttér',
board: 'Tábla',
boardNotFound_title: 'Tábla nem található',
canComment: 'Hozzászólhat',
canEditContentOfBoard: 'Szerkesztheti a tábla tartalmát.',
canOnlyViewBoard: 'Csak megtekintheti a táblát.',
cardActions_title: 'Kártya műveletek',
cardNotFound_title: 'Kártya nem található',
cardOrActionAreDeleted: 'A kártya vagy művelet törölve lett.',
color: 'Szín',
createBoard_title: 'Tábla létrehozása',
createLabel_title: 'Címke létrehozása',
createNewOneOrSelectExistingOne: 'Hozzon létre egy újat, vagy válasszon ki egy meglévőt.',
createProject_title: 'Projekt létrehozása',
createTextFile_title: 'Szövegfájl létrehozása',
currentPassword: 'Jelenlegi jelszó',
dangerZone_title: 'Veszélyzóna',
date: 'Dátum',
dueDate_title: 'Esedékesség dátuma',
deleteAttachment_title: 'Melléklet törlése',
deleteBoard_title: 'Tábla törlése',
deleteCard_title: 'Kártya törlése',
deleteComment_title: 'Megjegyzés törlése',
deleteLabel_title: 'Címke törlése',
deleteList_title: 'Lista törlése',
deleteProject_title: 'Projekt törlése',
deleteTask_title: 'Feladat törlése',
deleteUser_title: 'Felhasználó törlése',
description: 'Leírás',
detectAutomatically: 'Automatikus érzékelés',
dropFileToUpload: 'Dobja ide a fájlt a feltöltéshez',
editor: 'Szerkesztő',
editAttachment_title: 'Melléklet szerkesztése',
editAvatar_title: 'Avatar szerkesztése',
editBoard_title: 'Tábla szerkesztése',
editDueDate_title: 'Esedékesség dátumának szerkesztése',
editEmail_title: 'E-mail szerkesztése',
editInformation_title: 'Információ szerkesztése',
editLabel_title: 'Címke szerkesztése',
editPassword_title: 'Jelszó szerkesztése',
editPermissions_title: 'Jogosultságok szerkesztése',
editStopwatch_title: 'Stopper szerkesztése',
editUsername_title: 'Felhasználónév szerkesztése',
email: 'E-mail',
emailAlreadyInUse: 'Az e-mail cím már használatban van',
enterCardTitle: 'Adja meg a kártya címét... [Ctrl+Enter] az automatikus megnyitáshoz.',
enterDescription: 'Adja meg a leírást...',
enterFilename: 'Adja meg a fájl nevét',
enterListTitle: 'Adja meg a lista címét...',
enterProjectTitle: 'Adja meg a projekt címét',
enterTaskDescription: 'Adja meg a feladat leírását...',
filterByLabels_title: 'Szűrés címkék alapján',
filterByMembers_title: 'Szűrés tagok alapján',
fromComputer_title: 'Számítógépről',
fromTrello: 'Trello-ról',
general: 'Általános',
hours: 'Órák',
importBoard_title: 'Tábla importálása',
invalidCurrentPassword: 'Érvénytelen jelenlegi jelszó',
labels: 'Címkék',
language: 'Nyelv',
leaveBoard_title: 'Tábla elhagyása',
leaveProject_title: 'Projekt elhagyása',
list: 'Lista',
listActions_title: 'Lista műveletek',
managers: 'Vezetők',
members: 'Tagok',
minutes: 'Percek',
moveCard_title: 'Kártya áthelyezése',
name: 'Név',
newEmail: 'Új e-mail',
newPassword: 'Új jelszó',
newUsername: 'Új felhasználónév',
noConnectionToServer: 'Nincs kapcsolat a szerverrel',
noBoards: 'Nincsenek táblák',
noLists: 'Nincsenek listák',
noProjects: 'Nincsenek projektek',
notifications: 'Értesítések',
noUnreadNotifications: 'Nincsenek olvasatlan értesítések.',
openBoard_title: 'Tábla megnyitása',
optional_inline: 'opcionális',
organization: 'Szervezet',
phone: 'Telefon',
preferences: 'Beállítások',
pressPasteShortcutToAddAttachmentFromClipboard:
'Tipp: nyomja meg a Ctrl-V (Cmd-V a Mac-en) billentyűkombinációt a vágólapról történő melléklet hozzáadásához.',
project: 'Projekt',
projectNotFound_title: 'Projekt nem található',
removeManager_title: 'Vezető eltávolítása',
removeMember_title: 'Tag eltávolítása',
searchLabels: 'Címkék keresése...',
searchMembers: 'Tagok keresése...',
searchUsers: 'Felhasználók keresése...',
seconds: 'Másodpercek',
selectBoard: 'Válassza ki a táblát',
selectList: 'Válassza ki a listát',
selectPermissions_title: 'Jogosultságok kiválasztása',
selectProject: 'Válassza ki a projektet',
settings: 'Beállítások',
stopwatch: 'Stopper',
subscribeToMyOwnCardsByDefault: 'Alapértelmezés szerint feliratkozás a saját kártyáimra',
taskActions_title: 'Feladat műveletek',
tasks: 'Feladatok',
thereIsNoPreviewAvailableForThisAttachment: 'Nincs elérhető előnézet ehhez a melléklethez.',
time: 'Idő',
title: 'Cím',
userActions_title: 'Felhasználói műveletek',
userAddedThisCardToList:
'<0>{{user}}</0><1> hozzáadta ezt a kártyát a következőhöz: {{list}}</1>',
userLeftNewCommentToCard:
'{{user}} új kommentet hagyott itt: «{{comment}}» a következő kártyán: <2>{{card}}</2>',
userMovedCardFromListToList:
'{{user}} áthelyezte ezt a kártyát innen: {{fromList}} ide: {{toList}}',
userMovedThisCardFromListToList:
'<0>{{user}}</0><1> áthelyezte ezt a kártyát innen: {{fromList}} ide: {{toList}}</1>',
username: 'Felhasználónév',
usernameAlreadyInUse: 'A felhasználónév már használatban van',
users: 'Felhasználók',
version: 'Verzió',
viewer: 'Néző',
writeComment: 'Írjon egy megjegyzést...',
},
action: {
addAnotherCard: 'Új kártya hozzáadása',
addAnotherList: 'Új lista hozzáadása',
addAnotherTask: 'Új feladat hozzáadása',
addCard: 'Kártya hozzáadása',
addCard_title: 'Kártya hozzáadása',
addComment: 'Megjegyzés hozzáadása',
addList: 'Lista hozzáadása',
addMember: 'Tag hozzáadása',
addMoreDetailedDescription: 'További részletes leírás hozzáadása',
addTask: 'Feladat hozzáadása',
addToCard: 'Kártyához hozzáadása',
addUser: 'Felhasználó hozzáadása',
createBoard: 'Tábla létrehozása',
createFile: 'Fájl létrehozása',
createLabel: 'Címke létrehozása',
createNewLabel: 'Új címke létrehozása',
createProject: 'Projekt létrehozása',
delete: 'Törlés',
deleteAttachment: 'Melléklet törlése',
deleteAvatar: 'Avatar törlése',
deleteBoard: 'Tábla törlése',
deleteCard: 'Kártya törlése',
deleteCard_title: 'Kártya törlése',
deleteComment: 'Megjegyzés törlése',
deleteImage: 'Kép törlése',
deleteLabel: 'Címke törlése',
deleteList: 'Lista törlése',
deleteList_title: 'Lista törlése',
deleteProject: 'Projekt törlése',
deleteProject_title: 'Projekt törlése',
deleteTask: 'Feladat törlése',
deleteTask_title: 'Feladat törlése',
deleteUser: 'Felhasználó törlése',
edit: 'Szerkesztés',
editDueDate_title: 'Esedékesség dátumának szerkesztése',
editDescription_title: 'Leírás szerkesztése',
editEmail_title: 'E-mail szerkesztése',
editInformation_title: 'Információ szerkesztése',
editPassword_title: 'Jelszó szerkesztése',
editPermissions: 'Jogosultságok szerkesztése',
editStopwatch_title: 'Stopper szerkesztése',
editTitle_title: 'Cím szerkesztése',
editUsername_title: 'Felhasználónév szerkesztése',
hideDetails: 'Részletek elrejtése',
import: 'Importálás',
leaveBoard: 'Tábla elhagyása',
leaveProject: 'Projekt elhagyása',
logOut_title: 'Kijelentkezés',
makeCover_title: 'Borító készítése',
move: 'Áthelyezés',
moveCard_title: 'Kártya áthelyezése',
remove: 'Eltávolítás',
removeBackground: 'Háttér eltávolítása',
removeCover_title: 'Borító eltávolítása',
removeFromBoard: 'Eltávolítás a tábláról',
removeFromProject: 'Eltávolítás a projektről',
removeManager: 'Vezető eltávolítása',
removeMember: 'Tag eltávolítása',
save: 'Mentés',
showAllAttachments: 'Összes melléklet megjelenítése ({{hidden}} rejtve)',
showDetails: 'Részletek megjelenítése',
showFewerAttachments: 'Kevesebb melléklet megjelenítése',
start: 'Indítás',
stop: 'Megállítás',
subscribe: 'Feliratkozás',
unsubscribe: 'Leiratkozás',
uploadNewAvatar: 'Új avatar feltöltése',
uploadNewImage: 'Új kép feltöltése',
},
},
};
+8
View File
@@ -0,0 +1,8 @@
import login from './login';
export default {
language: 'hu',
country: 'hu',
name: 'Magyar',
embeddedLocale: login,
};
+22
View File
@@ -0,0 +1,22 @@
export default {
translation: {
common: {
emailOrUsername: 'E-mail vagy felhasználó',
invalidEmailOrUsername: 'Érvénytelen e-mail vagy felhasználó',
invalidPassword: 'Érvénytelen jelszó',
logInToPlanka: 'Plankába belépés',
noInternetConnection: 'Nincs internet kapcsolat',
pageNotFound_title: 'Az oldal nem található',
password: 'Jelszó',
projectManagement: 'Projektmenedzsment',
serverConnectionFailed: 'A szerverkapcsolat sikertelen',
unknownError: 'Ismeretlen hiba, próbáld meg később újra',
useSingleSignOn: 'Egyszeri bejelentkezés használata',
},
action: {
logIn: 'Belépés',
logInWithSSO: 'Belépés SSO-val',
},
},
};
+2
View File
@@ -4,6 +4,7 @@ import de from './de';
import en from './en';
import es from './es';
import fr from './fr';
import hu from './hu';
import id from './id';
import it from './it';
import ja from './ja';
@@ -27,6 +28,7 @@ const locales = [
en,
es,
fr,
hu,
id,
it,
ja,
+1
View File
@@ -111,6 +111,7 @@ export default {
list: 'Lista',
listActions_title: 'Ações da Lista',
managers: 'Gerentes',
memberInfo: 'Informações do Membro',
members: 'Membros',
minutes: 'Minutos',
moveCard_title: 'Mover Cartão',
+47
View File
@@ -3,6 +3,9 @@ import { attr, fk, many } from 'redux-orm';
import BaseModel from './BaseModel';
import ActionTypes from '../constants/ActionTypes';
import User from './User';
import Label from './Label';
export default class extends BaseModel {
static modelName = 'Board';
@@ -25,6 +28,9 @@ export default class extends BaseModel {
}),
filterUsers: many('User', 'filterBoards'),
filterLabels: many('Label', 'filterBoards'),
filterText: attr({
getDefault: () => '',
}),
};
static reducer({ type, payload }, Board) {
@@ -167,6 +173,47 @@ export default class extends BaseModel {
Board.withId(payload.boardId).filterLabels.remove(payload.id);
break;
case ActionTypes.TEXT_FILTER_IN_CURRENT_BOARD: {
const board = Board.withId(payload.boardId);
let filterText = payload.text;
const posSpace = filterText.indexOf(' ');
// Shortcut to user filters
const posAT = filterText.indexOf('@');
if (posAT >= 0 && posSpace > 0 && posAT < posSpace) {
const userId = User.findUsersFromText(
filterText.substring(posAT + 1, posSpace),
board.memberships.toModelArray().map((membership) => membership.user),
);
if (
userId &&
board.filterUsers.toModelArray().filter((user) => user.id === userId).length === 0
) {
board.filterUsers.add(userId);
filterText = filterText.substring(0, posAT);
}
}
// Shortcut to label filters
const posSharp = filterText.indexOf('#');
if (posSharp >= 0 && posSpace > 0 && posSharp < posSpace) {
const labelId = Label.findLabelsFromText(
filterText.substring(posSharp + 1, posSpace),
board.labels.toModelArray(),
);
if (
labelId &&
board.filterLabels.toModelArray().filter((label) => label.id === labelId).length === 0
) {
board.filterLabels.add(labelId);
filterText = filterText.substring(0, posSharp);
}
}
board.update({ filterText });
break;
}
default:
}
}
+13 -6
View File
@@ -14,6 +14,11 @@ export default class extends BaseModel {
position: attr(),
name: attr(),
description: attr(),
creatorUserId: oneToOne({
to: 'User',
as: 'creatorUser',
relatedName: 'ownCards',
}),
dueDate: attr(),
stopwatch: attr(),
isSubscribed: attr({
@@ -164,6 +169,14 @@ export default class extends BaseModel {
Card.withId(payload.cardLabel.cardId).labels.remove(payload.cardLabel.labelId);
} catch {} // eslint-disable-line no-empty
break;
case ActionTypes.LIST_SORT__SUCCESS:
case ActionTypes.LIST_SORT_HANDLE:
case ActionTypes.NOTIFICATION_CREATE_HANDLE:
payload.cards.forEach((card) => {
Card.upsert(card);
});
break;
case ActionTypes.CARD_CREATE:
case ActionTypes.CARD_UPDATE__SUCCESS:
@@ -282,12 +295,6 @@ export default class extends BaseModel {
break;
}
case ActionTypes.NOTIFICATION_CREATE_HANDLE:
payload.cards.forEach((card) => {
Card.upsert(card);
});
break;
default:
}
}
+12
View File
@@ -80,4 +80,16 @@ export default class extends BaseModel {
default:
}
}
static findLabelsFromText(filterText, labels) {
const selectLabel = filterText.toLocaleLowerCase();
const matchingLabels = labels.filter((label) =>
label.name ? label.name.toLocaleLowerCase().startsWith(selectLabel) : false,
);
if (matchingLabels.length === 1) {
// Appens the user to the filter
return matchingLabels[0].id;
}
return null;
}
}
+34
View File
@@ -1,6 +1,7 @@
import { attr, fk } from 'redux-orm';
import BaseModel from './BaseModel';
import User from './User';
import ActionTypes from '../constants/ActionTypes';
export default class extends BaseModel {
@@ -50,6 +51,8 @@ export default class extends BaseModel {
case ActionTypes.LIST_CREATE_HANDLE:
case ActionTypes.LIST_UPDATE__SUCCESS:
case ActionTypes.LIST_UPDATE_HANDLE:
case ActionTypes.LIST_SORT__SUCCESS:
case ActionTypes.LIST_SORT_HANDLE:
List.upsert(payload.list);
break;
@@ -87,6 +90,37 @@ export default class extends BaseModel {
getFilteredOrderedCardsModelArray() {
let cardModels = this.getOrderedCardsQuerySet().toModelArray();
const { filterText } = this.board;
if (filterText !== '') {
let re = null;
const posSpace = filterText.indexOf(' ');
if (filterText.startsWith('/')) {
re = new RegExp(filterText.substring(1), 'i');
}
let doRegularSearch = true;
if (re) {
cardModels = cardModels.filter((cardModel) => re.test(cardModel.name));
doRegularSearch = false;
} else if (filterText.startsWith('!') && posSpace > 0) {
const creatorUserId = User.findUsersFromText(
filterText.substring(1, posSpace),
this.board.memberships.toModelArray().map((membership) => membership.user),
);
if (creatorUserId != null) {
doRegularSearch = false;
cardModels = cardModels.filter((cardModel) => cardModel.creatorUser.id === creatorUserId);
}
}
if (doRegularSearch) {
const lowerCasedFilter = filterText.toLocaleLowerCase();
cardModels = cardModels.filter(
(cardModel) => cardModel.name.toLocaleLowerCase().indexOf(lowerCasedFilter) >= 0,
);
}
}
const filterUserIds = this.board.filterUsers.toRefArray().map((user) => user.id);
const filterLabelIds = this.board.filterLabels.toRefArray().map((label) => label.id);
+14
View File
@@ -359,4 +359,18 @@ export default class extends BaseModel {
},
);
}
static findUsersFromText(filterText, users) {
const selectUser = filterText.toLocaleLowerCase();
const matchingUsers = users.filter(
(user) =>
user.name.toLocaleLowerCase().startsWith(selectUser) ||
user.username.toLocaleLowerCase().startsWith(selectUser),
);
if (matchingUsers.length === 1) {
// Appens the user to the filter
return matchingUsers[0].id;
}
return null;
}
}
+7
View File
@@ -207,6 +207,12 @@ export function* handleCardDelete(card) {
yield put(actions.handleCardDelete(card));
}
export function* handleTextFilter(text) {
const { boardId } = yield select(selectors.selectPath);
yield put(actions.filterText(boardId, text));
}
export default {
createCard,
handleCardCreate,
@@ -222,4 +228,5 @@ export default {
deleteCard,
deleteCurrentCard,
handleCardDelete,
handleTextFilter,
};
+26
View File
@@ -70,6 +70,30 @@ export function* moveList(id, index) {
});
}
// TODO: sort locally
export function* sortList(id, data) {
yield put(actions.sortList(id, data));
let list;
let cards;
try {
({
item: list,
included: { cards },
} = yield call(request, api.sortList, id, data));
} catch (error) {
yield put(actions.sortList.failure(id, error));
return;
}
yield put(actions.sortList.success(list, cards));
}
export function* handleListSort(list, cards) {
yield put(actions.handleListSort(list, cards));
}
export function* deleteList(id) {
yield put(actions.deleteList(id));
@@ -95,6 +119,8 @@ export default {
updateList,
handleListUpdate,
moveList,
sortList,
handleListSort,
deleteList,
handleListDelete,
};
+3
View File
@@ -39,5 +39,8 @@ export default function* cardsWatchers() {
takeEvery(EntryActionTypes.CARD_DELETE_HANDLE, ({ payload: { card } }) =>
services.handleCardDelete(card),
),
takeEvery(EntryActionTypes.TEXT_FILTER_IN_CURRENT_BOARD, ({ payload: { text } }) =>
services.handleTextFilter(text),
),
]);
}
+6
View File
@@ -20,6 +20,12 @@ export default function* listsWatchers() {
takeEvery(EntryActionTypes.LIST_MOVE, ({ payload: { id, index } }) =>
services.moveList(id, index),
),
takeEvery(EntryActionTypes.LIST_SORT, ({ payload: { id, data } }) =>
services.sortList(id, data),
),
takeEvery(EntryActionTypes.LIST_SORT_HANDLE, ({ payload: { list, cards } }) =>
services.handleListSort(list, cards),
),
takeEvery(EntryActionTypes.LIST_DELETE, ({ payload: { id } }) => services.deleteList(id)),
takeEvery(EntryActionTypes.LIST_DELETE_HANDLE, ({ payload: { list } }) =>
services.handleListDelete(list),
+6
View File
@@ -84,6 +84,10 @@ const createSocketEventsChannel = () =>
emit(entryActions.handleListUpdate(item));
};
const handleListSort = api.makeHandleListSort(({ item, included: { cards } }) => {
emit(entryActions.handleListSort(item, cards));
});
const handleListDelete = ({ item }) => {
emit(entryActions.handleListDelete(item));
};
@@ -198,6 +202,7 @@ const createSocketEventsChannel = () =>
socket.on('listCreate', handleListCreate);
socket.on('listUpdate', handleListUpdate);
socket.on('listSort', handleListSort);
socket.on('listDelete', handleListDelete);
socket.on('labelCreate', handleLabelCreate);
@@ -256,6 +261,7 @@ const createSocketEventsChannel = () =>
socket.off('listCreate', handleListCreate);
socket.off('listUpdate', handleListUpdate);
socket.off('listSort', handleListSort);
socket.off('listDelete', handleListDelete);
socket.off('labelCreate', handleLabelCreate);
+19
View File
@@ -175,6 +175,24 @@ export const selectFilterLabelsForCurrentBoard = createSelector(
},
);
export const selectFilterTextForCurrentBoard = createSelector(
orm,
(state) => selectPath(state).boardId,
({ Board }, id) => {
if (!id) {
return id;
}
const boardModel = Board.withId(id);
if (!boardModel) {
return boardModel;
}
return boardModel.filterText;
},
);
export const selectIsBoardWithIdExists = createSelector(
orm,
(_, id) => id,
@@ -191,5 +209,6 @@ export default {
selectListIdsForCurrentBoard,
selectFilterUsersForCurrentBoard,
selectFilterLabelsForCurrentBoard,
selectFilterTextForCurrentBoard,
selectIsBoardWithIdExists,
};
+18
View File
@@ -0,0 +1,18 @@
FROM node:18-alpine as server-dependencies
RUN apk -U upgrade \
&& apk add build-base python3 \
--no-cache
WORKDIR /app/client
COPY package.json package-lock.json /app/client/
RUN npm install npm@latest --global \
&& npm install pnpm --global \
&& pnpm import \
&& pnpm install
WORKDIR /app/
COPY ../../package.json ../../package-lock.json /app/
RUN pnpm import \
&& pnpm install
+14
View File
@@ -0,0 +1,14 @@
FROM node:18-alpine as server-dependencies
RUN apk -U upgrade \
&& apk add build-base python3 \
--no-cache
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm install npm@latest --global \
&& npm install pnpm --global \
&& pnpm import \
&& pnpm install
+47
View File
@@ -0,0 +1,47 @@
user nginx;
worker_processes 1;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65;
server {
listen 80;
location /api/ {
proxy_pass http://server:1337;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
location /socket.io {
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_http_version 1.1;
proxy_set_header Host $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_pass http://server:1337/socket.io;
}
location / {
proxy_pass http://client:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
}
}
+80 -35
View File
@@ -1,20 +1,18 @@
version: '3'
version: '3.8'
services:
planka:
image: ghcr.io/plankanban/planka:master
restart: on-failure
volumes:
- user-avatars:/app/public/user-avatars
- project-background-images:/app/public/project-background-images
- attachments:/app/private/attachments
ports:
- 3000:1337
environment:
- BASE_URL=http://localhost:3000
- DATABASE_URL=postgresql://postgres@postgres/planka
- SECRET_KEY=notsecretkey
server:
build:
context: ./server
dockerfile: ../config/development/Dockerfile.server
volumes:
- ./server:/app
- /app/node_modules
environment:
- NODE_ENV=development
- DATABASE_URL=postgresql://user:password@postgres:5432/planka_db
- SECRET_KEY=notsecretkey
# - TRUST_PROXY=0
# - TOKEN_EXPIRES_IN=365 # In days
@@ -25,20 +23,6 @@ services:
# Configure knex to accept SSL certificates
# - KNEX_REJECT_UNAUTHORIZED_SSL_CERTIFICATE=false
# - DEFAULT_ADMIN_EMAIL=demo@demo.demo # Do not remove if you want to prevent this user from being edited/deleted
# - DEFAULT_ADMIN_PASSWORD=demo
# - DEFAULT_ADMIN_NAME=Demo Demo
# - DEFAULT_ADMIN_USERNAME=demo
# Email Notifications (https://nodemailer.com/smtp/)
# - SMTP_HOST=
# - SMTP_PORT=587
# - SMTP_SECURE=true
# - SMTP_USER=
# - SMTP_PASSWORD=
# - SMTP_FROM="Demo Demo" <demo@demo.demo>
# - OIDC_ISSUER=
# - OIDC_CLIENT_ID=
# - OIDC_CLIENT_SECRET=
@@ -51,26 +35,87 @@ services:
# - OIDC_IGNORE_USERNAME=true
# - OIDC_IGNORE_ROLES=true
# - OIDC_ENFORCED=true
# Email Notifications (https://nodemailer.com/smtp/)
# - SMTP_HOST=
# - SMTP_PORT=587
# - SMTP_SECURE=true
# - SMTP_USER=
# - SMTP_PASSWORD=
# - SMTP_FROM="Demo Demo" <demo@demo.demo>
# - SLACK_BOT_TOKEN=
# - SLACK_CHANNEL_ID=
working_dir: /app
command: ["sh", "-c", "npm run start"]
depends_on:
postgres:
condition: service_healthy
client:
build:
context: ./client
dockerfile: ../config/development/Dockerfile.client
volumes:
- ./client:/app/client
- /app/client/node_modules
- /app/node_modules
environment:
- NODE_ENV=development
- CHOKIDAR_USEPOLLING=true
- BASE_URL=http://localhost:3000
- REACT_APP_SERVER_BASE_URL=http://localhost:3000
working_dir: /app/client
command: npm start
init-db:
build:
context: ./server
dockerfile: ../config/development/Dockerfile.server
environment:
- DATABASE_URL=postgresql://user:password@postgres:5432/planka_db
# - DEFAULT_ADMIN_EMAIL=demo@demo.demo # Do not remove if you want to prevent this user from being edited/deleted
# - DEFAULT_ADMIN_PASSWORD=demo
# - DEFAULT_ADMIN_NAME=Demo Demo
# - DEFAULT_ADMIN_USERNAME=demo
working_dir: /app
command: ["sh", "-c", "npm run db:init"]
volumes:
- ./server:/app
- /app/node_modules
depends_on:
postgres:
condition: service_healthy
postgres:
image: postgres:14-alpine
restart: on-failure
image: postgres:latest
volumes:
- db-data:/var/lib/postgresql/data
environment:
- POSTGRES_DB=planka
- POSTGRES_HOST_AUTH_METHOD=trust
POSTGRES_DB: planka_db
POSTGRES_USER: user
POSTGRES_PASSWORD: password
ports:
- "5432:5432"
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d planka"]
interval: 10s
timeout: 5s
retries: 5
proxy:
image: nginx:alpine
ports:
- "3000:80"
volumes:
- ./config/development/nginx.conf:/etc/nginx/nginx.conf
depends_on:
- server
- client
volumes:
user-avatars:
project-background-images:
attachments:
db-data:
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "planka",
"version": "1.16.4",
"version": "1.17.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "planka",
"version": "1.16.4",
"version": "1.17.0",
"hasInstallScript": true,
"license": "AGPL-3.0",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "planka",
"version": "1.16.4",
"version": "1.17.0",
"private": true,
"homepage": "https://plankanban.github.io/planka",
"repository": {
+65
View File
@@ -0,0 +1,65 @@
const Errors = {
NOT_ENOUGH_RIGHTS: {
notEnoughRights: 'Not enough rights',
},
LIST_NOT_FOUND: {
listNotFound: 'List not found',
},
};
module.exports = {
inputs: {
id: {
type: 'string',
regex: /^[0-9]+$/,
required: true,
},
type: {
type: 'string',
isIn: Object.values(List.SortTypes),
},
},
exits: {
notEnoughRights: {
responseType: 'forbidden',
},
listNotFound: {
responseType: 'notFound',
},
},
async fn(inputs) {
const { currentUser } = this.req;
const { list } = await sails.helpers.lists
.getProjectPath(inputs.id)
.intercept('pathNotFound', () => Errors.LIST_NOT_FOUND);
const boardMembership = await BoardMembership.findOne({
boardId: list.boardId,
userId: currentUser.id,
});
if (!boardMembership) {
throw Errors.LIST_NOT_FOUND;
}
if (boardMembership.role !== BoardMembership.Roles.EDITOR) {
throw Errors.NOT_ENOUGH_RIGHTS;
}
const cards = await sails.helpers.lists.sortOne.with({
record: list,
type: inputs.type,
request: this.req,
});
return {
item: list,
included: {
cards,
},
};
},
};
+70
View File
@@ -0,0 +1,70 @@
const List = require('../../models/List');
module.exports = {
inputs: {
record: {
type: 'ref',
required: true,
},
type: {
type: 'string',
isIn: Object.values(List.SortTypes),
defaultsTo: List.SortTypes.NAME_ASC,
},
request: {
type: 'ref',
},
},
async fn(inputs) {
let cards = await sails.helpers.lists.getCards(inputs.record.id);
switch (inputs.type) {
case List.SortTypes.NAME_ASC:
cards.sort((a, b) => a.name.localeCompare(b.name));
break;
case List.SortTypes.DUE_DATE_ASC:
cards.sort((a, b) => {
if (a.dueDate === null) return 1;
if (b.dueDate === null) return -1;
return new Date(a.dueDate) - new Date(b.dueDate);
});
break;
case List.SortTypes.CREATED_AT_ASC:
cards.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt));
break;
case List.SortTypes.CREATED_AT_DESC:
cards.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
break;
default:
throw new Error('Invalid sort type specified');
}
const positions = cards.map((c) => c.position).sort((a, b) => a - b);
cards = await Promise.all(
cards.map(({ id }, index) =>
Card.updateOne({
id,
listId: inputs.record.id,
}).set({
position: positions[index],
}),
),
);
sails.sockets.broadcast(
`board:${inputs.record.boardId}`,
'listSort',
{
item: inputs.record,
included: {
cards,
},
},
inputs.request,
);
return cards;
},
};
+9
View File
@@ -5,7 +5,16 @@
* @docs :: https://sailsjs.com/docs/concepts/models-and-orm/models
*/
const SortTypes = {
NAME_ASC: 'name_asc',
DUE_DATE_ASC: 'dueDate_asc',
CREATED_AT_ASC: 'createdAt_asc',
CREATED_AT_DESC: 'createdAt_desc',
};
module.exports = {
SortTypes,
attributes: {
// ╔═╗╦═╗╦╔╦╗╦╔╦╗╦╦ ╦╔═╗╔═╗
// ╠═╝╠╦╝║║║║║ ║ ║╚╗╔╝║╣ ╚═╗
+1
View File
@@ -50,6 +50,7 @@ module.exports.routes = {
'POST /api/boards/:boardId/lists': 'lists/create',
'PATCH /api/lists/:id': 'lists/update',
'POST /api/lists/:id/sort': 'lists/sort',
'DELETE /api/lists/:id': 'lists/delete',
'POST /api/lists/:listId/cards': 'cards/create',