1
0
Fork 0
mirror of https://github.com/plankanban/planka.git synced 2025-07-26 00:29:48 +02:00

Project managers, board members, auto-update after reconnection, refactoring

This commit is contained in:
Maksim Eltyshev 2021-06-24 01:05:22 +05:00
parent 7956503a46
commit fe91b5241e
478 changed files with 21226 additions and 19495 deletions

View file

@ -0,0 +1,168 @@
import { dequal } from 'dequal';
import upperFirst from 'lodash/upperFirst';
import camelCase from 'lodash/camelCase';
import React, { useCallback, useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useTranslation } from 'react-i18next';
import { Button, Image } from 'semantic-ui-react';
import { FilePicker } from '../../lib/custom-ui';
import ProjectBackgroundGradients from '../../constants/ProjectBackgroundGradients';
import { ProjectBackgroundTypes } from '../../constants/Enums';
import styles from './BackgroundPane.module.scss';
import globalStyles from '../../styles.module.scss';
const BackgroundPane = React.memo(
({ item, imageCoverUrl, isImageUpdating, onUpdate, onImageUpdate, onImageDelete }) => {
const [t] = useTranslation();
const field = useRef(null);
const handleGradientClick = useCallback(
(_, { value }) => {
const background = {
type: ProjectBackgroundTypes.GRADIENT,
name: value,
};
if (!dequal(background, item)) {
onUpdate(background);
}
},
[item, onUpdate],
);
const handleImageClick = useCallback(() => {
const background = {
type: ProjectBackgroundTypes.IMAGE,
};
if (!dequal(background, item)) {
onUpdate(background);
}
}, [item, onUpdate]);
const handleFileSelect = useCallback(
(file) => {
onImageUpdate({
file,
});
},
[onImageUpdate],
);
const handleDeleteImageClick = useCallback(() => {
onImageDelete();
}, [onImageDelete]);
const handleRemoveClick = useCallback(() => {
onUpdate(null);
}, [onUpdate]);
useEffect(() => {
field.current.focus();
}, []);
return (
<>
<div className={styles.gradientButtons}>
{ProjectBackgroundGradients.map((gradient) => (
<Button
key={gradient}
type="button"
name="gradient"
value={gradient}
className={classNames(
styles.gradientButton,
item &&
item.type === ProjectBackgroundTypes.GRADIENT &&
gradient === item.name &&
styles.gradientButtonActive,
globalStyles[`background${upperFirst(camelCase(gradient))}`],
)}
onClick={handleGradientClick}
/>
))}
</div>
{imageCoverUrl && (
// TODO: wrap in button
<Image
src={imageCoverUrl}
label={
item &&
item.type === 'image' && {
corner: 'left',
size: 'small',
icon: {
name: 'star',
color: 'grey',
inverted: true,
},
className: styles.imageLabel,
}
}
className={styles.image}
onClick={handleImageClick}
/>
)}
<div className={styles.actions}>
<div className={styles.action}>
<FilePicker accept="image/*" onSelect={handleFileSelect}>
<Button
ref={field}
content={t('action.uploadNewImage', {
context: 'title',
})}
loading={isImageUpdating}
disabled={isImageUpdating}
className={styles.actionButton}
/>
</FilePicker>
</div>
{imageCoverUrl && (
<div className={styles.action}>
<Button
content={t('action.deleteImage', {
context: 'title',
})}
disabled={isImageUpdating}
className={styles.actionButton}
onClick={handleDeleteImageClick}
/>
</div>
)}
{item && (
<div className={styles.action}>
<Button
content={t('action.removeBackground', {
context: 'title',
})}
disabled={isImageUpdating}
className={styles.actionButton}
onClick={handleRemoveClick}
/>
</div>
)}
</div>
</>
);
},
);
BackgroundPane.propTypes = {
item: PropTypes.object, // eslint-disable-line react/forbid-prop-types
imageCoverUrl: PropTypes.string,
isImageUpdating: PropTypes.bool.isRequired,
onUpdate: PropTypes.func.isRequired,
onImageUpdate: PropTypes.func.isRequired,
onImageDelete: PropTypes.func.isRequired,
};
BackgroundPane.defaultProps = {
item: undefined,
imageCoverUrl: undefined,
};
export default BackgroundPane;

View file

@ -0,0 +1,82 @@
:global(#app) {
.action {
border: none;
display: inline-block;
height: 36px;
overflow: hidden;
position: relative;
transition: background 0.3s ease;
width: 100%;
&:hover {
background: #e9e9e9;
}
}
.actions {
margin-top: 20px;
}
.actionButton {
background: transparent;
color: #6b808c;
font-weight: normal;
height: 36px;
line-height: 24px;
padding: 6px 12px;
text-align: left;
text-decoration: underline;
width: 100%;
}
.gradientButton {
float: left;
height: 40px;
margin: 4px;
padding: 0;
position: relative;
width: 49.6px;
&:hover {
opacity: 0.9;
}
}
.gradientButtonActive:before {
bottom: 3px;
color: #ffffff;
content: "Г";
font-size: 18px;
line-height: 36px;
position: absolute;
right: 6px;
text-align: center;
text-shadow: 1px 1px 0 rgba(0, 0, 0, 0.2);
top: 0;
transform: rotate(-135deg);
width: 36px;
}
.gradientButtons {
margin-left: -4px;
&:after {
content: "";
display: table;
clear: both;
}
}
.image {
cursor: pointer;
margin-top: 10px;
&:hover {
opacity: 0.9;
}
}
.imageLabel {
border-color: rgba(29, 46, 63, 0.8);
}
}

View file

@ -0,0 +1,55 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { Button, Divider, Header, Tab } from 'semantic-ui-react';
import InformationEdit from './InformationEdit';
import DeletePopup from '../../DeletePopup';
import styles from './GeneralPane.module.scss';
const GeneralPane = React.memo(({ name, onUpdate, onDelete }) => {
const [t] = useTranslation();
return (
<Tab.Pane attached={false} className={styles.wrapper}>
<InformationEdit
defaultData={{
name,
}}
onUpdate={onUpdate}
/>
<Divider horizontal section>
<Header as="h4">
{t('common.dangerZone', {
context: 'title',
})}
</Header>
</Divider>
<div className={styles.action}>
<DeletePopup
title={t('common.deleteProject', {
context: 'title',
})}
content={t('common.areYouSureYouWantToDeleteThisProject')}
buttonContent={t('action.deleteProject')}
onConfirm={onDelete}
>
<Button className={styles.actionButton}>
{t('action.deleteProject', {
context: 'title',
})}
</Button>
</DeletePopup>
</div>
</Tab.Pane>
);
});
GeneralPane.propTypes = {
name: PropTypes.string.isRequired,
onUpdate: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
};
export default GeneralPane;

View file

@ -0,0 +1,32 @@
:global(#app) {
.action {
border: none;
display: inline-block;
height: 36px;
overflow: hidden;
position: relative;
transition: background 0.3s ease;
width: 100%;
&:hover {
background: #e9e9e9;
}
}
.actionButton {
background: transparent;
color: #6b808c;
font-weight: normal;
height: 36px;
line-height: 24px;
padding: 6px 12px;
text-align: left;
text-decoration: underline;
width: 100%;
}
.wrapper {
border: none;
box-shadow: none;
}
}

View file

@ -0,0 +1,60 @@
import { dequal } from 'dequal';
import pickBy from 'lodash/pickBy';
import React, { useCallback, useMemo, useRef } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { Button, Form, Input } from 'semantic-ui-react';
import { useForm } from '../../../hooks';
import styles from './InformationEdit.module.scss';
const InformationEdit = React.memo(({ defaultData, onUpdate }) => {
const [t] = useTranslation();
const [data, handleFieldChange] = useForm(() => ({
name: '',
...pickBy(defaultData),
}));
const cleanData = useMemo(
() => ({
...data,
name: data.name.trim(),
}),
[data],
);
const nameField = useRef(null);
const handleSubmit = useCallback(() => {
if (!cleanData.name) {
nameField.current.select();
return;
}
onUpdate(cleanData);
}, [onUpdate, cleanData]);
return (
<Form onSubmit={handleSubmit}>
<div className={styles.text}>{t('common.title')}</div>
<Input
fluid
ref={nameField}
name="name"
value={data.name}
className={styles.field}
onChange={handleFieldChange}
/>
<Button positive disabled={dequal(cleanData, defaultData)} content={t('action.save')} />
</Form>
);
});
InformationEdit.propTypes = {
defaultData: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
onUpdate: PropTypes.func.isRequired,
};
export default InformationEdit;

View file

@ -0,0 +1,12 @@
:global(#app) {
.field {
margin-bottom: 8px;
}
.text {
color: #444444;
font-size: 12px;
font-weight: bold;
padding-bottom: 6px;
}
}

View file

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

View file

@ -0,0 +1,41 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Tab } from 'semantic-ui-react';
import Memberships from '../Memberships';
import styles from './ManagersPane.module.scss';
const ManagersPane = React.memo(({ items, allUsers, onCreate, onDelete }) => {
return (
<Tab.Pane attached={false} className={styles.wrapper}>
<Memberships
items={items}
allUsers={allUsers}
addTitle="common.addManager"
leaveButtonContent="action.leaveProject"
leaveConfirmationTitle="common.leaveProject"
leaveConfirmationContent="common.areYouSureYouWantToLeaveProject"
leaveConfirmationButtonContent="action.leaveProject"
deleteButtonContent="action.removeFromProject"
deleteConfirmationTitle="common.removeManager"
deleteConfirmationContent="common.areYouSureYouWantToRemoveThisManagerFromProject"
deleteConfirmationButtonContent="action.removeManager"
canLeaveIfLast={false}
onCreate={onCreate}
onDelete={onDelete}
/>
</Tab.Pane>
);
});
ManagersPane.propTypes = {
/* eslint-disable react/forbid-prop-types */
items: PropTypes.array.isRequired,
allUsers: PropTypes.array.isRequired,
/* eslint-enable react/forbid-prop-types */
onCreate: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
};
export default ManagersPane;

View file

@ -0,0 +1,6 @@
:global(#app) {
.wrapper {
border: none;
box-shadow: none;
}
}

View file

@ -0,0 +1,119 @@
import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { Modal, Tab } from 'semantic-ui-react';
import ManagersPane from './ManagersPane';
import BackgroundPane from './BackgroundPane';
import GeneralPane from './GeneralPane';
const ProjectSettingsModal = React.memo(
({
name,
background,
backgroundImage,
isBackgroundImageUpdating,
managers,
allUsers,
onUpdate,
onBackgroundImageUpdate,
onDelete,
onManagerCreate,
onManagerDelete,
onClose,
}) => {
const [t] = useTranslation();
const handleBackgroundUpdate = useCallback(
(newBackground) => {
onUpdate({
background: newBackground,
});
},
[onUpdate],
);
const handleBackgroundImageDelete = useCallback(() => {
onUpdate({
backgroundImage: null,
});
}, [onUpdate]);
const panes = [
{
menuItem: t('common.general', {
context: 'title',
}),
render: () => <GeneralPane name={name} onUpdate={onUpdate} onDelete={onDelete} />,
},
{
menuItem: t('common.managers', {
context: 'title',
}),
render: () => (
<ManagersPane
items={managers}
allUsers={allUsers}
onCreate={onManagerCreate}
onDelete={onManagerDelete}
/>
),
},
{
menuItem: t('common.background', {
context: 'title',
}),
render: () => (
<BackgroundPane
item={background}
imageCoverUrl={backgroundImage && backgroundImage.coverUrl}
isImageUpdating={isBackgroundImageUpdating}
onUpdate={handleBackgroundUpdate}
onImageUpdate={onBackgroundImageUpdate}
onImageDelete={handleBackgroundImageDelete}
/>
),
},
];
return (
<Modal open closeIcon size="small" centered={false} onClose={onClose}>
<Modal.Content>
<Tab
menu={{
secondary: true,
pointing: true,
}}
panes={panes}
/>
</Modal.Content>
</Modal>
);
},
);
ProjectSettingsModal.propTypes = {
name: PropTypes.string.isRequired,
/* eslint-disable react/forbid-prop-types */
background: PropTypes.object,
backgroundImage: PropTypes.object,
/* eslint-enable react/forbid-prop-types */
isBackgroundImageUpdating: PropTypes.bool.isRequired,
/* eslint-disable react/forbid-prop-types */
managers: PropTypes.array.isRequired,
allUsers: PropTypes.array.isRequired,
/* eslint-enable react/forbid-prop-types */
onUpdate: PropTypes.func.isRequired,
onBackgroundImageUpdate: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
onManagerCreate: PropTypes.func.isRequired,
onManagerDelete: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
};
ProjectSettingsModal.defaultProps = {
background: undefined,
backgroundImage: undefined,
};
export default ProjectSettingsModal;

View file

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