Merge branch 'feature/labels' into develop

This commit is contained in:
Paulo Gustavo Veiga 2022-02-09 16:36:51 -08:00
commit 67fb3bbc35
16 changed files with 436 additions and 69 deletions

View File

@ -90,10 +90,22 @@ class CacheDecoratorClient implements Client {
return this.client.fetchLabels();
}
createLabel(title: string, color: string): Promise<number> {
return this.client.createLabel(title, color);
}
deleteLabel(id: number): Promise<void> {
return this.client.deleteLabel(id);
}
addLabelToMap(labelId: number, mapId: number): Promise<void> {
return this.client.addLabelToMap(labelId, mapId);
}
deleteLabelFromMap(labelId: number, mapId: number): Promise<void> {
return this.client.deleteLabelFromMap(labelId, mapId);
}
fetchAccountInfo(): Promise<AccountInfo> {
return this.client.fetchAccountInfo();
}

View File

@ -94,8 +94,11 @@ interface Client {
updateStarred(id: number, starred: boolean): Promise<void>;
updateMapToPublic(id: number, isPublic: boolean): Promise<void>;
createLabel(title: string, color: string): Promise<number>;
fetchLabels(): Promise<Label[]>;
deleteLabel(id: number): Promise<void>;
addLabelToMap(labelId: number, mapId: number): Promise<void>;
deleteLabelFromMap(labelId: number, mapId: number): Promise<void>;
fetchAccountInfo(): Promise<AccountInfo>;
registerNewUser(user: NewUser): Promise<void>;

View File

@ -327,9 +327,46 @@ class MockClient implements Client {
}
}
createLabel(title: string, color: string): Promise<number> {
const newId = Math.max.apply(Number, this.labels.map(l => l.id)) + 1;
this.labels.push({
id: newId,
title,
color,
});
return newId;
}
deleteLabel(id: number): Promise<void> {
this.labels = this.labels.filter((l) => l.id != id);
console.log('Label delete:' + this.labels);
this.maps = this.maps.map(m => {
return {
...m,
labels: m.labels.filter((l) => l.id != id)
};
});
return Promise.resolve();
}
addLabelToMap(labelId: number, mapId: number): Promise<void> {
const labelToAdd = this.labels.find((l) => l.id === labelId);
if (!labelToAdd) {
return Promise.reject({ msg: `unable to find label with id ${labelId}`});
}
const map = this.maps.find((m) => m.id === mapId);
if (!map) {
return Promise.reject({ msg: `unable to find map with id ${mapId}` });
}
map.labels.push(labelToAdd);
return Promise.resolve();
}
deleteLabelFromMap(labelId: number, mapId: number): Promise<void> {
const map = this.maps.find((m) => m.id === mapId);
if (!map) {
return Promise.reject({ msg: `unable to find map with id ${mapId}` });
}
map.labels = map.labels.filter((l) => l.id !== labelId);
return Promise.resolve();
}

View File

@ -510,10 +510,59 @@ export default class RestClient implements Client {
return new Promise(handler);
}
createLabel(title: string, color: string): Promise<number> {
const handler = (success: (labelId: number) => void, reject: (error: ErrorInfo) => void) => {
axios
.post(`${this.baseUrl}/c/restful/labels`, JSON.stringify({ title, color, iconName: 'smile' }), {
headers: { 'Content-Type': 'application/json' },
})
.then((response) => {
success(response.headers.resourceid);
})
.catch((error) => {
const errorInfo = this.parseResponseOnError(error.response);
reject(errorInfo);
});
};
return new Promise(handler);
}
deleteLabel(id: number): Promise<void> {
const handler = (success: () => void, reject: (error: ErrorInfo) => void) => {
axios
.delete(`${this.baseUrl}/c/restful/label/${id}`)
.delete(`${this.baseUrl}/c/restful/labels/${id}`)
.then(() => {
success();
})
.catch((error) => {
const errorInfo = this.parseResponseOnError(error.response);
reject(errorInfo);
});
};
return new Promise(handler);
}
addLabelToMap(labelId: number, mapId: number): Promise<void> {
const handler = (success: () => void, reject: (error: ErrorInfo) => void) => {
axios
.post(`${this.baseUrl}/c/restful/maps/${mapId}/labels`, JSON.stringify(labelId), {
headers: { 'Content-Type': 'application/json' },
})
.then(() => {
success();
})
.catch((error) => {
const errorInfo = this.parseResponseOnError(error.response);
reject(errorInfo);
});
};
return new Promise(handler);
}
deleteLabelFromMap(labelId: number, mapId: number): Promise<void> {
const handler = (success: () => void, reject: (error: ErrorInfo) => void) => {
axios
.delete(`${this.baseUrl}/c/restful/maps/${mapId}/labels/${labelId}`)
.then(() => {
success();
})

View File

@ -12,6 +12,7 @@ import InfoDialog from './info-dialog';
import DeleteMultiselectDialog from './delete-multiselect-dialog';
import ExportDialog from './export-dialog';
import ShareDialog from './share-dialog';
import LabelDialog from './label-dialog';
export type BasicMapInfo = {
name: string;
@ -61,6 +62,7 @@ const ActionDispatcher = ({ mapsId, action, onClose, fromEditor }: ActionDialogP
<ExportDialog onClose={handleOnClose} mapId={mapsId[0]} enableImgExport={fromEditor} />
)}
{action === 'share' && <ShareDialog onClose={handleOnClose} mapId={mapsId[0]} />}
{action === 'label' && <LabelDialog onClose={handleOnClose} mapId={mapsId[0]} />}
</span>
);
};

View File

@ -0,0 +1,75 @@
import React from 'react';
import BaseDialog from '../base-dialog';
import { SimpleDialogProps } from '..';
import { useIntl } from 'react-intl';
import Client, { ErrorInfo, Label, MapInfo } from '../../../../classes/client';
import { useStyles } from './style';
import { LabelSelector } from '../../maps-list/label-selector';
import { useMutation, useQuery, useQueryClient } from 'react-query';
import { useSelector } from 'react-redux';
import { activeInstance } from '../../../../redux/clientSlice';
const LabelDialog = ({ mapId, onClose }: SimpleDialogProps): React.ReactElement => {
const intl = useIntl();
const classes = useStyles();
const client: Client = useSelector(activeInstance);
const queryClient = useQueryClient();
// TODO: pass down map data instead of using query?
const { data } = useQuery<unknown, ErrorInfo, MapInfo[]>('maps', () => {
return client.fetchAllMaps();
});
const map = data.find(m => m.id === mapId);
const changeLabelMutation = useMutation<void, ErrorInfo, { label: Label, checked: boolean }, number>(
async ({ label, checked }) => {
if (!label.id) {
label.id = await client.createLabel(label.title, label.color);
}
if (checked){
return client.addLabelToMap(label.id, mapId);
} else {
return client.deleteLabelFromMap(label.id, mapId);
}
},
{
onSuccess: () => {
queryClient.invalidateQueries('maps');
queryClient.invalidateQueries('labels');
},
onError: (error) => {
console.error(error);
}
}
);
const handleChangesInLabels = (label: Label, checked: boolean) => {
changeLabelMutation.mutate({
label,
checked
});
};
return (
<div>
<BaseDialog
onClose={onClose}
title={intl.formatMessage({
id: 'label.title',
defaultMessage: 'Add a label',
})}
description={intl.formatMessage({
id: 'label.description',
defaultMessage:
'Use labels to organize your maps',
})}
PaperProps={{ classes: { root: classes.paper } }}
>
<LabelSelector onChange={handleChangesInLabels} maps={[map]} />
</BaseDialog>
</div>);
};
export default LabelDialog;

View File

@ -0,0 +1,10 @@
import createStyles from '@mui/styles/createStyles';
import makeStyles from '@mui/styles/makeStyles';
export const useStyles = makeStyles(() =>
createStyles({
paper: {
maxWidth: '420px',
},
})
);

View File

@ -7,7 +7,7 @@ import List from '@mui/material/List';
import IconButton from '@mui/material/IconButton';
import { useStyles } from './style';
import { MapsList } from './maps-list';
import { createIntl, createIntlCache, FormattedMessage, IntlProvider, IntlShape, useIntl } from 'react-intl';
import { createIntl, createIntlCache, FormattedMessage, IntlProvider } from 'react-intl';
import { useQuery, useMutation, useQueryClient } from 'react-query';
import { activeInstance } from '../../redux/clientSlice';
import { useSelector } from 'react-redux';
@ -76,7 +76,6 @@ const MapsPage = (): ReactElement => {
}, cache)
useEffect(() => {
document.title = intl.formatMessage({
id: 'maps.page-title',
defaultMessage: 'My Maps | WiseMapping',
@ -84,7 +83,10 @@ const MapsPage = (): ReactElement => {
}, []);
const mutation = useMutation((id: number) => client.deleteLabel(id), {
onSuccess: () => queryClient.invalidateQueries('labels'),
onSuccess: () => {
queryClient.invalidateQueries('labels');
queryClient.invalidateQueries('maps');
},
onError: (error) => {
console.error(`Unexpected error ${error}`);
},

View File

@ -4,15 +4,15 @@ import Button from '@mui/material/Button';
import Tooltip from '@mui/material/Tooltip';
import LabelTwoTone from '@mui/icons-material/LabelTwoTone';
import { FormattedMessage, useIntl } from 'react-intl';
import { Label } from '../../../../classes/client';
import { Label, MapInfo } from '../../../../classes/client';
import { LabelSelector } from '../label-selector';
type AddLabelButtonTypes = {
onChange?: (label: Label) => void;
maps: MapInfo[];
onChange: (label: Label, checked: boolean) => void;
};
export function AddLabelButton({ onChange }: AddLabelButtonTypes): React.ReactElement {
console.log(onChange);
export function AddLabelButton({ onChange, maps }: AddLabelButtonTypes): React.ReactElement {
const intl = useIntl();
const [anchorEl, setAnchorEl] = React.useState<HTMLButtonElement | null>(null);
@ -29,6 +29,7 @@ export function AddLabelButton({ onChange }: AddLabelButtonTypes): React.ReactEl
const id = open ? 'add-label-popover' : undefined;
return (
<>
<Tooltip
arrow={true}
title={intl.formatMessage({
@ -36,7 +37,6 @@ export function AddLabelButton({ onChange }: AddLabelButtonTypes): React.ReactEl
defaultMessage: 'Add label to selected',
})}
>
<>
<Button
color="primary"
size="medium"
@ -49,6 +49,8 @@ export function AddLabelButton({ onChange }: AddLabelButtonTypes): React.ReactEl
>
<FormattedMessage id="action.label" defaultMessage="Add Label" />
</Button>
</Tooltip>
<Popover
id={id}
open={open}
@ -63,9 +65,8 @@ export function AddLabelButton({ onChange }: AddLabelButtonTypes): React.ReactEl
horizontal: 'center',
}}
>
<LabelSelector />
<LabelSelector onChange={onChange} maps={maps} />
</Popover>
</>
</Tooltip>
);
}

View File

@ -331,7 +331,7 @@ export const MapsList = (props: MapsListProps): React.ReactElement => {
event.stopPropagation();
};
};
9;
const starredMultation = useMutation<void, ErrorInfo, number>(
(id: number) => {
const map = mapsInfo.find((m) => m.id == id);
@ -385,6 +385,58 @@ export const MapsList = (props: MapsListProps): React.ReactElement => {
});
};
const removeLabelMultation = useMutation<void, ErrorInfo, { mapId: number, labelId: number}, number>(
({ mapId, labelId }) => {
return client.deleteLabelFromMap(labelId, mapId);
},
{
onSuccess: () => {
queryClient.invalidateQueries('maps');
},
onError: (error) => {
console.error(error);
},
}
);
const handleRemoveLabel = (mapId: number, labelId: number) => {
removeLabelMultation.mutate({ mapId, labelId });
};
const changeLabelMutation = useMutation<void, ErrorInfo, { mapIds: number[], label: Label, checked: boolean }, number>(
async ({ mapIds, label, checked }) => {
const selectedMaps: MapInfo[] = mapsInfo.filter((m) => mapIds.includes(m.id));
if (!label.id) {
label.id = await client.createLabel(label.title, label.color);
}
if (checked){
const toAdd = selectedMaps.filter((m) => !m.labels.find((l) => l.id === label.id));
await Promise.all(toAdd.map((m) => client.addLabelToMap(label.id, m.id)));
} else {
const toRemove = selectedMaps.filter((m) => m.labels.find((l) => l.id === label.id));
await Promise.all(toRemove.map((m) => client.deleteLabelFromMap(label.id, m.id)));
}
return Promise.resolve();
},
{
onSuccess: () => {
queryClient.invalidateQueries('maps');
queryClient.invalidateQueries('labels');
},
onError: (error) => {
console.error(error);
}
}
);
const handleChangesInLabels = (label: Label, checked: boolean) => {
changeLabelMutation.mutate({
mapIds: selected,
label,
checked
});
};
const isSelected = (id: number) => selected.indexOf(id) !== -1;
return (
<div className={classes.root}>
@ -419,7 +471,10 @@ export const MapsList = (props: MapsListProps): React.ReactElement => {
</Tooltip>
)}
{selected.length > 0 && <AddLabelButton />}
{selected.length > 0 && <AddLabelButton
onChange={handleChangesInLabels}
maps={mapsInfo.filter(m => isSelected(m.id))}
/>}
</div>
<div className={classes.toolbarListActions}>
@ -561,7 +616,9 @@ export const MapsList = (props: MapsListProps): React.ReactElement => {
</TableCell>
<TableCell className={classes.bodyCell}>
<LabelsCell labels={row.labels} />
<LabelsCell labels={row.labels} onDelete={(lbl) => {
handleRemoveLabel(row.id, lbl.id);
}} />
</TableCell>
<TableCell className={classes.bodyCell}>

View File

@ -6,24 +6,60 @@ import AddIcon from '@mui/icons-material/Add';
import Checkbox from '@mui/material/Checkbox';
import Container from '@mui/material/Container';
import { Label as LabelComponent } from '../label';
import Client, { Label, ErrorInfo } from '../../../../classes/client';
import Client, { Label, ErrorInfo, MapInfo } from '../../../../classes/client';
import { useQuery } from 'react-query';
import { useSelector } from 'react-redux';
import { activeInstance } from '../../../../redux/clientSlice';
import { StyledButton } from './styled';
import { StyledButton, NewLabelContainer, NewLabelColor, CreateLabel } from './styled';
import { TextField } from '@mui/material';
import { FormattedMessage, useIntl } from 'react-intl';
import Typography from '@mui/material/Typography';
export function LabelSelector(): React.ReactElement {
const labelColors = [
'#00b327',
'#0565ff',
'#2d2dd6',
'#6a00ba',
'#ad1599',
'#ff1e35',
'#ff6600',
'#ffff47',
];
export type LabelSelectorProps = {
maps: MapInfo[];
onChange: (label: Label, checked: boolean) => void;
};
export function LabelSelector({ onChange, maps }: LabelSelectorProps): React.ReactElement {
const client: Client = useSelector(activeInstance);
const intl = useIntl();
const { data: labels = [] } = useQuery<unknown, ErrorInfo, Label[]>('labels', async () => client.fetchLabels());
const [state, setState] = React.useState(labels.reduce((acc, label) => {
acc[label.id] = false //label.checked;
return acc;
}, {}),);
const checkedLabelIds = labels.map(l => l.id).filter(labelId => maps.every(m => m.labels.find(l => l.id === labelId)));
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setState({ ...state, [event.target.id]: event.target.checked });
const [createLabelColorIndex, setCreateLabelColorIndex] = React.useState(Math.floor(Math.random() * labelColors.length));
const [newLabelTitle, setNewLabelTitle] = React.useState('');
const newLabelColor = labelColors[createLabelColorIndex];
const setNextLabelColorIndex = () => {
const nextIndex = labelColors[createLabelColorIndex + 1] ?
createLabelColorIndex + 1 :
0;
setCreateLabelColorIndex(nextIndex);
};
const handleSubmitNew = () => {
onChange({
title: newLabelTitle,
color: newLabelColor,
id: 0,
}, true);
setNewLabelTitle('');
setNextLabelColorIndex();
};
return (
@ -35,8 +71,10 @@ export function LabelSelector(): React.ReactElement {
control={
<Checkbox
id={`${id}`}
checked={state[id]}
onChange={handleChange}
checked={checkedLabelIds.includes(id)}
onChange={(e) => {
onChange({ id, title, color }, e.target.checked);
}}
name={title}
color="primary"
/>
@ -45,13 +83,42 @@ export function LabelSelector(): React.ReactElement {
/>
))}
<Divider />
<CreateLabel>
<Typography variant="h4" component="h4" fontSize={14} >
<FormattedMessage id="label.create-new" defaultMessage={
intl.formatMessage({
id: 'label.add-placeholder',
defaultMessage: 'Label title',
})
} />
</Typography>
<NewLabelContainer>
<NewLabelColor
color={newLabelColor}
onClick={(e) => {
e.stopPropagation();
setNextLabelColorIndex();
}}
/>
<TextField variant='outlined' label="Label title"
onChange={(e) => setNewLabelTitle(e.target.value)}
onKeyPress={(e) => {
if (e.key === 'Enter') {
handleSubmitNew();
}
}}
value={newLabelTitle} />
</NewLabelContainer>
<StyledButton
color="primary"
startIcon={<AddIcon />}
onClick={() => handleSubmitNew()}
disabled={!newLabelTitle.length}
>
{/* i18n */}
Add new label
<FormattedMessage id="label.add-button" defaultMessage="Add label" />
</StyledButton>
</CreateLabel>
</FormGroup>
</Container>
);

View File

@ -1,6 +1,33 @@
import styled from 'styled-components';
import styled, { css } from 'styled-components';
import Button from '@mui/material/Button';
export const StyledButton = styled(Button)`
margin: 4px;
`;
export const NewLabelContainer = styled.div`
display: flex;
flex-direction: row;
align-items: center;
padding: 15px 0 5px 0 ;
`;
const SIZE = 25;
export const NewLabelColor = styled.div`
width: ${SIZE}px;
height: ${SIZE}px;
border-radius: ${SIZE * 0.25}px;
border: 1px solid black;
margin: 1px ${SIZE * 0.5}px 1px 0px;
${props => props.color && css`
background-color: ${props.color};
`}
cursor: pointer;
`;
export const CreateLabel = styled.div`
padding-top: 10px;
display: flex;
flex-direction: column;
justify-content: flex-end;
`;

View File

@ -1,26 +1,35 @@
import React from 'react';
import Chip from '@mui/material/Chip';
import { LabelContainer, LabelText } from './styled';
import { Label } from '../../../../classes/client';
import LabelTwoTone from '@mui/icons-material/LabelTwoTone';
import DeleteIcon from '@mui/icons-material/Clear';
import IconButton from '@mui/material/IconButton';
type Props = {
labels: Label[],
onDelete: (label: Label) => void,
};
export function LabelsCell({ labels }: Props): React.ReactElement<Props> {
export function LabelsCell({ labels, onDelete }: Props): React.ReactElement<Props> {
return (
<>
{labels.map(label => (
<Chip
<LabelContainer
key={label.id}
size="small"
icon={<LabelTwoTone />}
label={label.title}
clickable
color="primary"
style={{ backgroundColor: label.color, opacity: '0.75' }}
onDelete={() => { return 1; }}
/>
color={label.color}
>
<LabelTwoTone htmlColor={label.color} style={{ height: '0.6em', width: '0.6em' }} />
<LabelText>{ label.title }</LabelText>
<IconButton color="default" size='small' aria-label="delete tag" component="span"
onClick={(e) => {
e.stopPropagation();
onDelete(label);
}}
>
<DeleteIcon style={{ height: '0.6em', width: '0.6em' }} />
</IconButton>
</LabelContainer>
))}
</>
);

View File

@ -0,0 +1,15 @@
import styled from 'styled-components';
export const LabelContainer = styled.div`
display: inline-flex;
flex-direction: row;
margin: 4px;
padding: 4px;
align-items: center;
font-size: smaller;
`;
export const LabelText = styled.span`
margin-left: 4px;
margin-right: 2px;
`;

View File

@ -7,6 +7,7 @@ const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = merge(common, {
mode: 'development',
devtool: 'source-map',
watch: true,
devServer: {
contentBase: path.join(__dirname, 'dist'),
port: 3000,

View File

@ -5661,7 +5661,7 @@ dateformat@^3.0.0:
resolved "https://registry.yarnpkg.com/dateformat/-/dateformat-3.0.3.tgz#a6e37499a4d9a9cf85ef5872044d62901c9889ae"
integrity sha512-jyCETtSl3VMZMWeRo7iY1FL19ges1t55hMo5yaam4Jrsm5EPL89UQkoQRyiI+Yf4k8r2ZpdngkV8hr1lIdjb3Q==
dayjs@^1.10.4:
dayjs@^1.10.4, dayjs@^1.10.7:
version "1.10.7"
resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.10.7.tgz#2cf5f91add28116748440866a0a1d26f3a6ce468"
integrity sha512-P6twpd70BcPK34K26uJ1KT3wlhpuOAPoMwJzpsIWUxHZ7wpmbdZL/hQqBDfz7hGurYSa5PhzdhDHtt319hL3ig==