From 0ff82735baeda340543304553e08fa22f6d409b6 Mon Sep 17 00:00:00 2001 From: Matias Arriola Date: Wed, 9 Feb 2022 14:54:48 -0300 Subject: [PATCH] Allow to set and remove labels from maps Allow to create new labels and delete labels --- .../client/cache-decorator-client/index.ts | 12 ++ packages/webapp/src/classes/client/index.ts | 3 + .../src/classes/client/mock-client/index.ts | 39 ++++++- .../src/classes/client/rest-client/index.ts | 54 ++++++++- .../maps-page/action-dispatcher/index.tsx | 2 + .../action-dispatcher/label-dialog/index.tsx | 75 +++++++++++++ .../action-dispatcher/label-dialog/style.ts | 10 ++ .../webapp/src/components/maps-page/index.tsx | 8 +- .../maps-list/add-label-button/index.tsx | 61 ++++++----- .../components/maps-page/maps-list/index.tsx | 63 ++++++++++- .../maps-list/label-selector/index.tsx | 103 +++++++++++++++--- .../maps-list/label-selector/styled.ts | 29 ++++- .../maps-page/maps-list/labels-cell/index.tsx | 31 ++++-- .../maps-page/maps-list/labels-cell/styled.ts | 15 +++ packages/webapp/webpack.dev.js | 1 + yarn.lock | 2 +- 16 files changed, 439 insertions(+), 69 deletions(-) create mode 100644 packages/webapp/src/components/maps-page/action-dispatcher/label-dialog/index.tsx create mode 100644 packages/webapp/src/components/maps-page/action-dispatcher/label-dialog/style.ts create mode 100644 packages/webapp/src/components/maps-page/maps-list/labels-cell/styled.ts diff --git a/packages/webapp/src/classes/client/cache-decorator-client/index.ts b/packages/webapp/src/classes/client/cache-decorator-client/index.ts index 44c9df4d..8c988e4e 100644 --- a/packages/webapp/src/classes/client/cache-decorator-client/index.ts +++ b/packages/webapp/src/classes/client/cache-decorator-client/index.ts @@ -90,10 +90,22 @@ class CacheDecoratorClient implements Client { return this.client.fetchLabels(); } + createLabel(title: string, color: string): Promise { + return this.client.createLabel(title, color); + } + deleteLabel(id: number): Promise { return this.client.deleteLabel(id); } + addLabelToMap(labelId: number, mapId: number): Promise { + return this.client.addLabelToMap(labelId, mapId); + } + + deleteLabelFromMap(labelId: number, mapId: number): Promise { + return this.client.deleteLabelFromMap(labelId, mapId); + } + fetchAccountInfo(): Promise { return this.client.fetchAccountInfo(); } diff --git a/packages/webapp/src/classes/client/index.ts b/packages/webapp/src/classes/client/index.ts index 6a0a3971..34517ba4 100644 --- a/packages/webapp/src/classes/client/index.ts +++ b/packages/webapp/src/classes/client/index.ts @@ -94,8 +94,11 @@ interface Client { updateStarred(id: number, starred: boolean): Promise; updateMapToPublic(id: number, isPublic: boolean): Promise; + createLabel(title: string, color: string): Promise; fetchLabels(): Promise; deleteLabel(id: number): Promise; + addLabelToMap(labelId: number, mapId: number): Promise; + deleteLabelFromMap(labelId: number, mapId: number): Promise; fetchAccountInfo(): Promise; registerNewUser(user: NewUser): Promise; diff --git a/packages/webapp/src/classes/client/mock-client/index.ts b/packages/webapp/src/classes/client/mock-client/index.ts index 1e248360..57376cea 100644 --- a/packages/webapp/src/classes/client/mock-client/index.ts +++ b/packages/webapp/src/classes/client/mock-client/index.ts @@ -327,9 +327,46 @@ class MockClient implements Client { } } + createLabel(title: string, color: string): Promise { + 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 { 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 { + 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 { + 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(); } diff --git a/packages/webapp/src/classes/client/rest-client/index.ts b/packages/webapp/src/classes/client/rest-client/index.ts index 37ab6c85..76b80301 100644 --- a/packages/webapp/src/classes/client/rest-client/index.ts +++ b/packages/webapp/src/classes/client/rest-client/index.ts @@ -510,10 +510,62 @@ export default class RestClient implements Client { return new Promise(handler); } + createLabel(title: string, color: string): Promise { + 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 { 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 { + 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); + } + + // TODO: not working (error 500: missing lid param) + deleteLabelFromMap(labelId: number, mapId: number): Promise { + const handler = (success: () => void, reject: (error: ErrorInfo) => void) => { + axios + .delete(`${this.baseUrl}/c/restful/maps/${mapId}/labels/${labelId}`, { + data: JSON.stringify(labelId) + }) .then(() => { success(); }) diff --git a/packages/webapp/src/components/maps-page/action-dispatcher/index.tsx b/packages/webapp/src/components/maps-page/action-dispatcher/index.tsx index ed3a7b48..c8359ad5 100644 --- a/packages/webapp/src/components/maps-page/action-dispatcher/index.tsx +++ b/packages/webapp/src/components/maps-page/action-dispatcher/index.tsx @@ -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 )} {action === 'share' && } + {action === 'label' && } ); }; diff --git a/packages/webapp/src/components/maps-page/action-dispatcher/label-dialog/index.tsx b/packages/webapp/src/components/maps-page/action-dispatcher/label-dialog/index.tsx new file mode 100644 index 00000000..37fb8759 --- /dev/null +++ b/packages/webapp/src/components/maps-page/action-dispatcher/label-dialog/index.tsx @@ -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('maps', () => { + return client.fetchAllMaps(); + }); + + const map = data.find(m => m.id === mapId); + + const changeLabelMutation = useMutation( + 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 ( +
+ + + +
); +}; + +export default LabelDialog; diff --git a/packages/webapp/src/components/maps-page/action-dispatcher/label-dialog/style.ts b/packages/webapp/src/components/maps-page/action-dispatcher/label-dialog/style.ts new file mode 100644 index 00000000..980cf20e --- /dev/null +++ b/packages/webapp/src/components/maps-page/action-dispatcher/label-dialog/style.ts @@ -0,0 +1,10 @@ +import createStyles from '@mui/styles/createStyles'; +import makeStyles from '@mui/styles/makeStyles'; + +export const useStyles = makeStyles(() => + createStyles({ + paper: { + maxWidth: '420px', + }, + }) +); diff --git a/packages/webapp/src/components/maps-page/index.tsx b/packages/webapp/src/components/maps-page/index.tsx index 8fc8fa73..c1cb9c20 100644 --- a/packages/webapp/src/components/maps-page/index.tsx +++ b/packages/webapp/src/components/maps-page/index.tsx @@ -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'; @@ -77,7 +77,6 @@ const MapsPage = (): ReactElement => { }, cache) useEffect(() => { - document.title = intl.formatMessage({ id: 'maps.page-title', defaultMessage: 'My Maps | WiseMapping', @@ -85,7 +84,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}`); }, diff --git a/packages/webapp/src/components/maps-page/maps-list/add-label-button/index.tsx b/packages/webapp/src/components/maps-page/maps-list/add-label-button/index.tsx index f6ae19ac..0c37dc7f 100644 --- a/packages/webapp/src/components/maps-page/maps-list/add-label-button/index.tsx +++ b/packages/webapp/src/components/maps-page/maps-list/add-label-button/index.tsx @@ -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(null); @@ -29,14 +29,14 @@ export function AddLabelButton({ onChange }: AddLabelButtonTypes): React.ReactEl const id = open ? 'add-label-popover' : undefined; return ( - - <> + <> + - - - - - + + + + + + ); } diff --git a/packages/webapp/src/components/maps-page/maps-list/index.tsx b/packages/webapp/src/components/maps-page/maps-list/index.tsx index 853c42fa..2caf2103 100644 --- a/packages/webapp/src/components/maps-page/maps-list/index.tsx +++ b/packages/webapp/src/components/maps-page/maps-list/index.tsx @@ -331,7 +331,7 @@ export const MapsList = (props: MapsListProps): React.ReactElement => { event.stopPropagation(); }; }; - 9; + const starredMultation = useMutation( (id: number) => { const map = mapsInfo.find((m) => m.id == id); @@ -385,6 +385,58 @@ export const MapsList = (props: MapsListProps): React.ReactElement => { }); }; + const removeLabelMultation = useMutation( + ({ 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( + 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 (
@@ -419,7 +471,10 @@ export const MapsList = (props: MapsListProps): React.ReactElement => { )} - {selected.length > 0 && } + {selected.length > 0 && isSelected(m.id))} + />}
@@ -561,7 +616,9 @@ export const MapsList = (props: MapsListProps): React.ReactElement => { - + { + handleRemoveLabel(row.id, lbl.id); + }} /> diff --git a/packages/webapp/src/components/maps-page/maps-list/label-selector/index.tsx b/packages/webapp/src/components/maps-page/maps-list/label-selector/index.tsx index 1f4947cc..9e2b74c7 100644 --- a/packages/webapp/src/components/maps-page/maps-list/label-selector/index.tsx +++ b/packages/webapp/src/components/maps-page/maps-list/label-selector/index.tsx @@ -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('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) => { - 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={ { + onChange({ id, title, color }, e.target.checked); + }} name={title} color="primary" /> @@ -45,13 +83,42 @@ export function LabelSelector(): React.ReactElement { /> ))} - } - > - {/* i18n */} - Add new label - + + + + + + { + e.stopPropagation(); + setNextLabelColorIndex(); + }} + /> + setNewLabelTitle(e.target.value)} + onKeyPress={(e) => { + if (e.key === 'Enter') { + handleSubmitNew(); + } + }} + value={newLabelTitle} /> + + } + onClick={() => handleSubmitNew()} + disabled={!newLabelTitle.length} + > + + + + ); diff --git a/packages/webapp/src/components/maps-page/maps-list/label-selector/styled.ts b/packages/webapp/src/components/maps-page/maps-list/label-selector/styled.ts index 9c2a4d2a..289353b3 100644 --- a/packages/webapp/src/components/maps-page/maps-list/label-selector/styled.ts +++ b/packages/webapp/src/components/maps-page/maps-list/label-selector/styled.ts @@ -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; +`; diff --git a/packages/webapp/src/components/maps-page/maps-list/labels-cell/index.tsx b/packages/webapp/src/components/maps-page/maps-list/labels-cell/index.tsx index 471a816b..9abcfb6d 100644 --- a/packages/webapp/src/components/maps-page/maps-list/labels-cell/index.tsx +++ b/packages/webapp/src/components/maps-page/maps-list/labels-cell/index.tsx @@ -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 { +export function LabelsCell({ labels, onDelete }: Props): React.ReactElement { return ( <> {labels.map(label => ( - } - label={label.title} - clickable - color="primary" - style={{ backgroundColor: label.color, opacity: '0.75' }} - onDelete={() => { return 1; }} - /> + color={label.color} + > + + { label.title } + { + e.stopPropagation(); + onDelete(label); + }} + > + + + ))} ); diff --git a/packages/webapp/src/components/maps-page/maps-list/labels-cell/styled.ts b/packages/webapp/src/components/maps-page/maps-list/labels-cell/styled.ts new file mode 100644 index 00000000..eab7c0cb --- /dev/null +++ b/packages/webapp/src/components/maps-page/maps-list/labels-cell/styled.ts @@ -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; +`; \ No newline at end of file diff --git a/packages/webapp/webpack.dev.js b/packages/webapp/webpack.dev.js index 986c42bb..006fc763 100644 --- a/packages/webapp/webpack.dev.js +++ b/packages/webapp/webpack.dev.js @@ -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, diff --git a/yarn.lock b/yarn.lock index ae6b6542..f31bcad6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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==