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:
parent
7956503a46
commit
fe91b5241e
478 changed files with 21226 additions and 19495 deletions
168
client/src/components/ProjectSettingsModal/BackgroundPane.jsx
Normal file
168
client/src/components/ProjectSettingsModal/BackgroundPane.jsx
Normal 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;
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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;
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
|
@ -0,0 +1,12 @@
|
|||
:global(#app) {
|
||||
.field {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.text {
|
||||
color: #444444;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
import GeneralPane from './GeneralPane';
|
||||
|
||||
export default GeneralPane;
|
41
client/src/components/ProjectSettingsModal/ManagersPane.jsx
Normal file
41
client/src/components/ProjectSettingsModal/ManagersPane.jsx
Normal 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;
|
|
@ -0,0 +1,6 @@
|
|||
:global(#app) {
|
||||
.wrapper {
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
|
@ -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;
|
3
client/src/components/ProjectSettingsModal/index.js
Normal file
3
client/src/components/ProjectSettingsModal/index.js
Normal file
|
@ -0,0 +1,3 @@
|
|||
import ProjectSettingsModal from './ProjectSettingsModal';
|
||||
|
||||
export default ProjectSettingsModal;
|
Loading…
Add table
Add a link
Reference in a new issue