1
0
Fork 0
mirror of https://github.com/plankanban/planka.git synced 2025-07-24 15:49:46 +02:00

feat: Toggle actions details, little redesign

This commit is contained in:
Maksim Eltyshev 2022-07-29 17:40:18 +02:00
parent 82e4f73c1f
commit 34db8947b6
25 changed files with 301 additions and 81 deletions

View file

@ -1,6 +1,5 @@
import ActionTypes from '../constants/ActionTypes';
// eslint-disable-next-line import/prefer-default-export
export const fetchActions = (cardId) => ({
type: ActionTypes.ACTIONS_FETCH,
payload: {
@ -24,3 +23,28 @@ fetchActions.failure = (cardId, error) => ({
error,
},
});
export const toggleActionsDetails = (cardId, isVisible) => ({
type: ActionTypes.ACTIONS_DETAILS_TOGGLE,
payload: {
cardId,
isVisible,
},
});
toggleActionsDetails.success = (cardId, actions, users) => ({
type: ActionTypes.ACTIONS_DETAILS_TOGGLE__SUCCESS,
payload: {
cardId,
actions,
users,
},
});
toggleActionsDetails.failure = (cardId, error) => ({
type: ActionTypes.ACTIONS_DETAILS_TOGGLE__FAILURE,
payload: {
cardId,
error,
},
});

View file

@ -1,7 +1,13 @@
import EntryActionTypes from '../../constants/EntryActionTypes';
// eslint-disable-next-line import/prefer-default-export
export const fetchActionsInCurrentCard = () => ({
type: EntryActionTypes.ACTIONS_IN_CURRENT_CARD_FETCH,
payload: {},
});
export const toggleActionsDetailsInCurrentCard = (isVisible) => ({
type: EntryActionTypes.ACTIONS_DETAILS_IN_CURRENT_CARD_TOGGLE,
payload: {
isVisible,
},
});

View file

@ -1,7 +1,7 @@
import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { Comment, Icon, Loader, Visibility } from 'semantic-ui-react';
import { Button, Comment, Icon, Loader, Visibility } from 'semantic-ui-react';
import { ActionTypes } from '../../../constants/Enums';
import CommentAdd from './CommentAdd';
@ -14,15 +14,22 @@ const Actions = React.memo(
items,
isFetching,
isAllFetched,
isDetailsVisible,
isDetailsFetching,
canEdit,
canEditAllComments,
onFetch,
onDetailsToggle,
onCommentCreate,
onCommentUpdate,
onCommentDelete,
}) => {
const [t] = useTranslation();
const handleToggleDetailsClick = useCallback(() => {
onDetailsToggle(!isDetailsVisible);
}, [isDetailsVisible, onDetailsToggle]);
const handleCommentUpdate = useCallback(
(id, data) => {
onCommentUpdate(id, data);
@ -38,20 +45,18 @@ const Actions = React.memo(
);
return (
<>
{canEdit && (
<div className={styles.contentModule}>
<div className={styles.moduleWrapper}>
<Icon name="comment outline" className={styles.moduleIcon} />
<div className={styles.moduleHeader}>{t('common.addComment')}</div>
<CommentAdd onCreate={onCommentCreate} />
</div>
</div>
)}
<div className={styles.contentModule}>
<div className={styles.moduleWrapper}>
<Icon name="list ul" className={styles.moduleIcon} />
<div className={styles.moduleHeader}>{t('common.actions')}</div>
<div className={styles.moduleHeader}>
{t('common.actions')}
<Button
content={isDetailsVisible ? t('action.hideDetails') : t('action.showDetails')}
className={styles.toggleButton}
onClick={handleToggleDetailsClick}
/>
</div>
{canEdit && <CommentAdd onCreate={onCommentCreate} />}
<div className={styles.wrapper}>
<Comment.Group>
{items.map((item) =>
@ -78,14 +83,13 @@ const Actions = React.memo(
)}
</Comment.Group>
</div>
{isFetching ? (
{isFetching || isDetailsFetching ? (
<Loader active inverted inline="centered" size="small" className={styles.loader} />
) : (
!isAllFetched && <Visibility fireOnMount onOnScreen={onFetch} />
)}
</div>
</div>
</>
);
},
);
@ -94,9 +98,12 @@ Actions.propTypes = {
items: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types
isFetching: PropTypes.bool.isRequired,
isAllFetched: PropTypes.bool.isRequired,
isDetailsVisible: PropTypes.bool.isRequired,
isDetailsFetching: PropTypes.bool.isRequired,
canEdit: PropTypes.bool.isRequired,
canEditAllComments: PropTypes.bool.isRequired,
onFetch: PropTypes.func.isRequired,
onDetailsToggle: PropTypes.func.isRequired,
onCommentCreate: PropTypes.func.isRequired,
onCommentUpdate: PropTypes.func.isRequired,
onCommentDelete: PropTypes.func.isRequired,

View file

@ -8,12 +8,14 @@
}
.moduleHeader {
align-items: center;
color: #17394d;
display: flex;
font-size: 16px;
font-weight: bold;
line-height: 20px;
height: 36px;
justify-content: space-between;
margin: 0 0 4px;
padding: 8px 0;
}
.moduleIcon {
@ -33,6 +35,24 @@
position: relative;
}
.toggleButton {
background: transparent;
box-shadow: none;
color: #6b808c;
float: right;
font-weight: normal;
margin-right: 0;
padding: 6px 11px;
text-align: left;
text-decoration: underline;
transition: none;
&:hover {
background: rgba(9, 30, 66, 0.08);
color: #092d42;
}
}
.wrapper {
margin-left: -40px;
margin-top: 12px;

View file

@ -1,10 +1,11 @@
import React, { useCallback, useRef } from 'react';
import React, { useCallback, useRef, useState } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import TextareaAutosize from 'react-textarea-autosize';
import { Button, Form, TextArea } from 'semantic-ui-react';
import { useDidUpdate, useToggle } from '../../../lib/hooks';
import { useForm } from '../../../hooks';
import { useClosableForm, useForm } from '../../../hooks';
import styles from './CommentAdd.module.scss';
@ -14,10 +15,16 @@ const DEFAULT_DATA = {
const CommentAdd = React.memo(({ onCreate }) => {
const [t] = useTranslation();
const [isOpened, setIsOpened] = useState(false);
const [data, handleFieldChange, setData] = useForm(DEFAULT_DATA);
const [selectTextFieldState, selectTextField] = useToggle();
const textField = useRef(null);
const close = useCallback(() => {
setIsOpened(false);
}, []);
const submit = useCallback(() => {
const cleanData = {
...data,
@ -32,7 +39,12 @@ const CommentAdd = React.memo(({ onCreate }) => {
onCreate(cleanData);
setData(DEFAULT_DATA);
}, [onCreate, data, setData]);
selectTextField();
}, [onCreate, data, setData, selectTextField]);
const handleFieldFocus = useCallback(() => {
setIsOpened(true);
}, []);
const handleFieldKeyDown = useCallback(
(event) => {
@ -43,10 +55,16 @@ const CommentAdd = React.memo(({ onCreate }) => {
[submit],
);
const [handleFieldBlur, handleControlMouseOver, handleControlMouseOut] = useClosableForm(close);
const handleSubmit = useCallback(() => {
submit();
}, [submit]);
useDidUpdate(() => {
textField.current.ref.current.focus();
}, [selectTextFieldState]);
return (
<Form onSubmit={handleSubmit}>
<TextArea
@ -55,15 +73,24 @@ const CommentAdd = React.memo(({ onCreate }) => {
name="text"
value={data.text}
placeholder={t('common.writeComment')}
minRows={3}
minRows={isOpened ? 3 : 1}
spellCheck={false}
className={styles.field}
onFocus={handleFieldFocus}
onKeyDown={handleFieldKeyDown}
onChange={handleFieldChange}
onBlur={handleFieldBlur}
/>
{isOpened && (
<div className={styles.controls}>
<Button positive content={t('action.addComment')} disabled={!data.text} />
<Button
positive
content={t('action.addComment')}
onMouseOver={handleControlMouseOver}
onMouseOut={handleControlMouseOut}
/>
</div>
)}
</Form>
);
});

View file

@ -34,6 +34,8 @@ const CardModal = React.memo(
isSubscribed,
isActionsFetching,
isAllActionsFetched,
isActionsDetailsVisible,
isActionsDetailsFetching,
listId,
boardId,
projectId,
@ -67,6 +69,7 @@ const CardModal = React.memo(
onAttachmentUpdate,
onAttachmentDelete,
onActionsFetch,
onActionsDetailsToggle,
onCommentActionCreate,
onCommentActionUpdate,
onCommentActionDelete,
@ -358,9 +361,12 @@ const CardModal = React.memo(
items={actions}
isFetching={isActionsFetching}
isAllFetched={isAllActionsFetched}
isDetailsVisible={isActionsDetailsVisible}
isDetailsFetching={isActionsDetailsFetching}
canEdit={canEdit}
canEditAllComments={canEditAllCommentActions}
onFetch={onActionsFetch}
onDetailsToggle={onActionsDetailsToggle}
onCommentCreate={onCommentActionCreate}
onCommentUpdate={onCommentActionUpdate}
onCommentDelete={onCommentActionDelete}
@ -486,6 +492,8 @@ CardModal.propTypes = {
isSubscribed: PropTypes.bool.isRequired,
isActionsFetching: PropTypes.bool.isRequired,
isAllActionsFetched: PropTypes.bool.isRequired,
isActionsDetailsVisible: PropTypes.bool.isRequired,
isActionsDetailsFetching: PropTypes.bool.isRequired,
listId: PropTypes.string.isRequired,
boardId: PropTypes.string.isRequired,
projectId: PropTypes.string.isRequired,
@ -521,6 +529,7 @@ CardModal.propTypes = {
onAttachmentUpdate: PropTypes.func.isRequired,
onAttachmentDelete: PropTypes.func.isRequired,
onActionsFetch: PropTypes.func.isRequired,
onActionsDetailsToggle: PropTypes.func.isRequired,
onCommentActionCreate: PropTypes.func.isRequired,
onCommentActionUpdate: PropTypes.func.isRequired,
onCommentActionDelete: PropTypes.func.isRequired,

View file

@ -225,6 +225,9 @@ export default {
ACTIONS_FETCH: 'ACTIONS_FETCH',
ACTIONS_FETCH__SUCCESS: 'ACTIONS_FETCH__SUCCESS',
ACTIONS_FETCH__FAILURE: 'ACTIONS_FETCH__FAILURE',
ACTIONS_DETAILS_TOGGLE: 'ACTIONS_DETAILS_TOGGLE',
ACTIONS_DETAILS_TOGGLE__SUCCESS: 'ACTIONS_DETAILS_TOGGLE__SUCCESS',
ACTIONS_DETAILS_TOGGLE__FAILURE: 'ACTIONS_DETAILS_TOGGLE__FAILURE',
/* Action */

View file

@ -13,7 +13,7 @@ const ACCESS_TOKEN_KEY = 'accessToken';
const ACCESS_TOKEN_EXPIRES = 365;
const POSITION_GAP = 65535;
const ACTIONS_LIMIT = 10;
const ACTIONS_LIMIT = 50;
export default {
SERVER_BASE_URL,

View file

@ -155,6 +155,7 @@ export default {
/* Actions */
ACTIONS_IN_CURRENT_CARD_FETCH: `${PREFIX}/ACTIONS_IN_CURRENT_CARD_FETCH`,
ACTIONS_DETAILS_IN_CURRENT_CARD_TOGGLE: `${PREFIX}/ACTIONS_DETAILS_IN_CURRENT_CARD_TOGGLE`,
/* Action */

View file

@ -35,6 +35,7 @@ import {
moveTask,
removeLabelFromCurrentCard,
removeUserFromCurrentCard,
toggleActionsDetailsInCurrentCard,
transferCurrentCard,
updateAttachment,
updateCommentAction,
@ -60,6 +61,8 @@ const mapStateToProps = (state) => {
timer,
isSubscribed,
isActionsFetching,
isActionsDetailsVisible,
isActionsDetailsFetching,
isAllActionsFetched,
boardId,
listId,
@ -79,6 +82,8 @@ const mapStateToProps = (state) => {
isSubscribed,
isActionsFetching,
isAllActionsFetched,
isActionsDetailsVisible,
isActionsDetailsFetching,
listId,
boardId,
projectId,
@ -118,6 +123,7 @@ const mapDispatchToProps = (dispatch) =>
onAttachmentUpdate: updateAttachment,
onAttachmentDelete: deleteAttachment,
onActionsFetch: fetchActionsInCurrentCard,
onActionsDetailsToggle: toggleActionsDetailsInCurrentCard,
onCommentActionCreate: createCommentActionInCurrentCard,
onCommentActionUpdate: updateCommentAction,
onCommentActionDelete: deleteCommentAction,

View file

@ -195,6 +195,7 @@ export default {
editTimer_title: 'Timer bearbeiten',
editTitle_title: 'Titel bearbeiten',
editUsername_title: 'Benutzername ändern',
hideDetails: 'Details ausblenden',
leaveBoard: 'Board verlassen',
leaveProject: 'Projekt verlassen',
logOut_title: 'Ausloggen',
@ -210,6 +211,7 @@ export default {
removeMember: 'Mitglied entfernen',
save: 'Speichern',
showAllAttachments: 'Alle Anhänge anzeigen ({{hidden}} versteckt)',
showDetails: 'Details anzeigen',
showFewerAttachments: 'Weniger Anhänge anzeigen',
start: 'Start',
stop: 'Stopp',

View file

@ -188,6 +188,7 @@ export default {
editTimer_title: 'Edit Timer',
editTitle_title: 'Edit Title',
editUsername_title: 'Edit Username',
hideDetails: 'Hide details',
leaveBoard: 'Leave board',
leaveProject: 'Leave project',
logOut_title: 'Log Out',
@ -203,6 +204,7 @@ export default {
removeMember: 'Remove member',
save: 'Save',
showAllAttachments: 'Show all attachments ({{hidden}} hidden)',
showDetails: 'Show details',
showFewerAttachments: 'Show fewer attachments',
start: 'Start',
stop: 'Stop',

View file

@ -178,7 +178,8 @@ export default {
editTask: 'Изменить задачу',
editTimer: 'Изменить таймер',
editTitle: 'Изменить название',
editUsername_title: 'Изменить имя пользователя',
editUsername: 'Изменить имя пользователя',
hideDetails: 'Скрыть подробности',
logOut: 'Выйти',
makeCover: 'Сделать обложкой',
move: 'Переместить',
@ -190,6 +191,7 @@ export default {
removeMember: 'Удалить участника',
save: 'Сохранить',
showAllAttachments: 'Показать все вложения ({{hidden}} скрыто)',
showDetails: 'Показать подробности',
showFewerAttachments: 'Показать меньше вложений',
start: 'Начать',
stop: 'Остановить',

View file

@ -50,6 +50,7 @@ export default class extends Model {
break;
case ActionTypes.ACTIONS_FETCH__SUCCESS:
case ActionTypes.ACTIONS_DETAILS_TOGGLE__SUCCESS:
case ActionTypes.NOTIFICATION_CREATE_HANDLE:
payload.actions.forEach((action) => {
Action.upsert(action);

View file

@ -2,6 +2,7 @@ import { Model, attr, fk, many, oneToOne } from 'redux-orm';
import ActionTypes from '../constants/ActionTypes';
import Config from '../constants/Config';
import { ActionTypes as ActionTypesEnum } from '../constants/Enums';
export default class extends Model {
static modelName = 'Card';
@ -22,6 +23,12 @@ export default class extends Model {
isAllActionsFetched: attr({
getDefault: () => false,
}),
isActionsDetailsVisible: attr({
getDefault: () => false,
}),
isActionsDetailsFetching: attr({
getDefault: () => false,
}),
boardId: fk({
to: 'Board',
as: 'board',
@ -195,6 +202,36 @@ export default class extends Model {
});
break;
case ActionTypes.ACTIONS_DETAILS_TOGGLE: {
const cardModel = Card.withId(payload.cardId);
cardModel.isActionsDetailsVisible = payload.isVisible;
if (payload.isVisible) {
cardModel.isActionsDetailsFetching = true;
}
break;
}
case ActionTypes.ACTIONS_DETAILS_TOGGLE__SUCCESS: {
const cardModel = Card.withId(payload.cardId);
cardModel.update({
isAllActionsFetched: payload.actions.length < Config.ACTIONS_LIMIT,
isActionsDetailsFetching: false,
});
cardModel.actions.toModelArray().forEach((actionModel) => {
if (actionModel.notification) {
actionModel.update({
isInCard: false,
});
} else {
actionModel.delete();
}
});
break;
}
case ActionTypes.NOTIFICATION_CREATE_HANDLE:
payload.cards.forEach((card) => {
Card.upsert(card);
@ -213,8 +250,16 @@ export default class extends Model {
return this.attachments.orderBy('id', false);
}
getOrderedInCardActionsQuerySet() {
return this.actions.orderBy('id', false);
getFilteredOrderedInCardActionsQuerySet() {
const filter = {
isInCard: true,
};
if (!this.isActionsDetailsVisible) {
filter.type = ActionTypesEnum.COMMENT_CARD;
}
return this.actions.filter(filter).orderBy('id', false);
}
getUnreadNotificationsQuerySet() {

View file

@ -83,7 +83,7 @@ export default class extends Model {
return this.cards.orderBy('position');
}
getOrderedFilteredCardsModelArray() {
getFilteredOrderedCardsModelArray() {
let cardModels = this.getOrderedCardsQuerySet().toModelArray();
const filterUserIds = this.board.filterUsers.toRefArray().map((user) => user.id);

View file

@ -1,4 +1,4 @@
import { Model, attr, fk } from 'redux-orm';
import { Model, attr, fk, oneToOne } from 'redux-orm';
import ActionTypes from '../constants/ActionTypes';
@ -15,16 +15,15 @@ export default class extends Model {
as: 'user',
relatedName: 'notifications',
}),
actionId: fk({
to: 'Action',
as: 'action',
relatedName: 'notifications',
}),
cardId: fk({
to: 'Card',
as: 'card',
relatedName: 'notifications',
}),
actionId: oneToOne({
to: 'Action',
as: 'action',
}),
};
static reducer({ type, payload }, Notification) {

View file

@ -1,11 +1,12 @@
import { call, put, select } from 'redux-saga/effects';
import request from '../request';
import { lastActionIdByCardIdSelector, pathSelector } from '../../../selectors';
import { fetchActions } from '../../../actions';
import { cardByIdSelector, lastActionIdByCardIdSelector, pathSelector } from '../../../selectors';
import { fetchActions, toggleActionsDetails } from '../../../actions';
import api from '../../../api';
export function* fetchActionsService(cardId) {
const { isActionsDetailsVisible } = yield select(cardByIdSelector, cardId);
const lastId = yield select(lastActionIdByCardIdSelector, cardId);
yield put(fetchActions(cardId));
@ -19,6 +20,7 @@ export function* fetchActionsService(cardId) {
included: { users },
} = yield call(request, api.getActions, cardId, {
beforeId: lastId,
withDetails: isActionsDetailsVisible,
}));
} catch (error) {
yield put(fetchActions.failure(cardId, error));
@ -33,3 +35,32 @@ export function* fetchActionsInCurrentCardService() {
yield call(fetchActionsService, cardId);
}
export function* toggleActionsDetailsService(cardId, isVisible) {
yield put(toggleActionsDetails(cardId, isVisible));
if (isVisible) {
let actions;
let users;
try {
({
items: actions,
included: { users },
} = yield call(request, api.getActions, cardId, {
withDetails: isVisible,
}));
} catch (error) {
yield put(toggleActionsDetails.failure(cardId, error));
return;
}
yield put(toggleActionsDetails.success(cardId, actions, users));
}
}
export function* toggleActionsDetailsInCurrentCardService(isVisible) {
const { cardId } = yield select(pathSelector);
yield call(toggleActionsDetailsService, cardId, isVisible);
}

View file

@ -1,10 +1,19 @@
import { takeEvery } from 'redux-saga/effects';
import { all, takeEvery } from 'redux-saga/effects';
import { fetchActionsInCurrentCardService } from '../services';
import {
fetchActionsInCurrentCardService,
toggleActionsDetailsInCurrentCardService,
} from '../services';
import EntryActionTypes from '../../../constants/EntryActionTypes';
export default function* actionsWatchers() {
yield takeEvery(EntryActionTypes.ACTIONS_IN_CURRENT_CARD_FETCH, () =>
yield all([
takeEvery(EntryActionTypes.ACTIONS_IN_CURRENT_CARD_FETCH, () =>
fetchActionsInCurrentCardService(),
);
),
takeEvery(
EntryActionTypes.ACTIONS_DETAILS_IN_CURRENT_CARD_TOGGLE,
({ payload: { isVisible } }) => toggleActionsDetailsInCurrentCardService(isVisible),
),
]);
}

View file

@ -88,7 +88,7 @@ export const makeLastActionIdByCardIdSelector = () =>
return cardModel;
}
const lastActionModel = cardModel.getOrderedInCardActionsQuerySet().last();
const lastActionModel = cardModel.getFilteredOrderedInCardActionsQuerySet().last();
return lastActionModel && lastActionModel.id;
},
@ -249,7 +249,7 @@ export const actionsForCurrentCardSelector = createSelector(
}
return cardModel
.getOrderedInCardActionsQuerySet()
.getFilteredOrderedInCardActionsQuerySet()
.toModelArray()
.map((actionModel) => ({
...actionModel.ref,

View file

@ -73,7 +73,7 @@ export const nextCardPositionSelector = createSelector(
return listModel;
}
return nextPosition(listModel.getOrderedFilteredCardsModelArray(), index, excludedId);
return nextPosition(listModel.getFilteredOrderedCardsModelArray(), index, excludedId);
},
);

View file

@ -34,7 +34,7 @@ export const makeCardIdsByListIdSelector = () =>
return listModel;
}
return listModel.getOrderedFilteredCardsModelArray().map((cardModel) => cardModel.id);
return listModel.getFilteredOrderedCardsModelArray().map((cardModel) => cardModel.id);
},
);

View file

@ -15,6 +15,9 @@ module.exports = {
type: 'string',
regex: /^[0-9]+$/,
},
withDetails: {
type: 'boolean',
},
},
exits: {
@ -43,7 +46,11 @@ module.exports = {
}
}
const actions = await sails.helpers.cards.getActions(card.id, inputs.beforeId);
const actions = await sails.helpers.cards.getActions(
card.id,
inputs.beforeId,
inputs.withDetails,
);
const userIds = sails.helpers.utils.mapRecords(actions, 'userId', true);
const users = await sails.helpers.users.getMany(userIds, true);

View file

@ -1,4 +1,4 @@
const LIMIT = 10;
const LIMIT = 50;
module.exports = {
inputs: {
@ -10,6 +10,10 @@ module.exports = {
beforeId: {
type: 'string',
},
withDetails: {
type: 'boolean',
defaultsTo: false,
},
},
async fn(inputs) {
@ -23,6 +27,10 @@ module.exports = {
};
}
if (!inputs.withDetails) {
criteria.type = Action.Types.COMMENT_CARD;
}
return sails.helpers.actions.getMany(criteria, LIMIT);
},
};

View file

@ -0,0 +1,11 @@
module.exports.up = (knex) =>
knex.schema.table('action', (table) => {
/* Indexes */
table.index('type');
});
module.exports.down = (knex) =>
knex.schema.table('action', (table) => {
table.dropIndex('type');
});