From bc3655b0d183985cdcecafa17a7649627dd4428a Mon Sep 17 00:00:00 2001 From: Jesse Leung Date: Sun, 20 Oct 2024 23:38:45 -0400 Subject: [PATCH 01/18] feat(138.a): add project image tags modal and edit form for existing tags --- src/assets/fontawesome.js | 6 +- src/components/HydratedModal.jsx | 7 + .../projects/ManageTagsModal/EditableTag.jsx | 343 ++++++++++++++++++ .../ManageTagsModal/ManageTagsModal.jsx | 46 +++ src/features/projects/SidebarNav.jsx | 11 + 5 files changed, 411 insertions(+), 2 deletions(-) create mode 100644 src/features/projects/ManageTagsModal/EditableTag.jsx create mode 100644 src/features/projects/ManageTagsModal/ManageTagsModal.jsx diff --git a/src/assets/fontawesome.js b/src/assets/fontawesome.js index 1821266c..7d9536a4 100644 --- a/src/assets/fontawesome.js +++ b/src/assets/fontawesome.js @@ -29,7 +29,8 @@ import { faUpload, faUser, faTag, - faRetweet + faRetweet, + faHighlighter } from '@fortawesome/free-solid-svg-icons'; library.add( @@ -62,5 +63,6 @@ library.add( faUpload, faUser, faTag, - faRetweet + faRetweet, + faHighlighter ); diff --git a/src/components/HydratedModal.jsx b/src/components/HydratedModal.jsx index b2389b09..4ef87a87 100644 --- a/src/components/HydratedModal.jsx +++ b/src/components/HydratedModal.jsx @@ -32,6 +32,7 @@ import { setSelectedCamera, } from '../features/projects/projectsSlice'; import { clearUsers } from '../features/projects/usersSlice.js'; +import { ManageTagsModal } from '../features/projects/ManageTagsModal/ManageTagsModal.jsx'; // Modal populated with content const HydratedModal = () => { @@ -121,6 +122,12 @@ const HydratedModal = () => { dispatch(clearCameraSerialNumberTask()); }, }, + 'manage-tags-form': { + title: 'Manage tags', + size: 'md', + content: , + callBackOnClose: () => true, + } }; const handleModalToggle = (content) => { diff --git a/src/features/projects/ManageTagsModal/EditableTag.jsx b/src/features/projects/ManageTagsModal/EditableTag.jsx new file mode 100644 index 00000000..b10d4abd --- /dev/null +++ b/src/features/projects/ManageTagsModal/EditableTag.jsx @@ -0,0 +1,343 @@ +import React, { useEffect, useState } from 'react'; +import { Pencil1Icon, SymbolIcon, TrashIcon } from '@radix-ui/react-icons'; +import IconButton from '../../../components/IconButton'; +import { styled } from '../../../theme/stitches.config'; +import { ColorPicker } from '../ManageLabelsModal/components'; +import { Tooltip, TooltipArrow, TooltipTrigger, TooltipContent } from '../../../components/Tooltip'; +import { getRandomColor, getTextColor } from '../../../app/utils'; +import * as Yup from 'yup'; +import Button from '../../../components/Button'; + +const defaultColors = [ + '#E54D2E', + '#E5484D', + '#E93D82', + '#D6409F', + '#AB4ABA', + '#8E4EC6', + '#6E56CF', + '#5B5BD6', + '#3E63DD', + '#0090FF', + '#00A2C7', + '#12A594', + '#30A46C', + '#46A758', + '#A18072', + '#F76B15', + '#FFC53D', + '#FFE629', + '#BDEE63', + '#7CE2FE', + '#8D8D8D', + '#F0F0F0', +]; + +const ColorSwatch = styled('button', { + border: 'none', + color: '$backgroundLight', + height: '$4', + width: '$4', + margin: 2, + borderRadius: '$2', + '&:hover': { + cursor: 'pointer', + }, +}); + + +const Container = styled('div', { + paddingTop: '$2', + borderBottom: '1px solid $border' +}); + +const Inner = styled('div', { + marginBottom: '$2', + display: 'flex', +}); + +const TagName = styled('div', { + padding: '$1 $3', + borderRadius: '$2', + border: '1px solid rgba(0,0,0,0)', + color: '$textDark', + fontFamily: '$mono', + fontWeight: 'bold', + fontSize: '$2', + display: 'grid', + placeItems: 'center' +}); + +const Actions = styled('div', { + marginRight: 0, + marginLeft: 'auto', + display: 'flex', + gap: '$3' +}); + +const EditContainer = styled('div', { + display: 'grid', + gridTemplateColumns: '1fr 1fr 1fr', + columnGap: '$3', + rowGap: '$1', + marginBottom: '$3', + marginTop: '$2', + '& > *': { + minWidth: 0 + } +}); + +const EditFieldLabel = styled('label', { + fontWeight: 'bold', + color: '$textDark', + fontSize: '$3' +}); + +const EditFieldInput = styled('input', { + padding: '$2 $3', + color: '$textMedium', + fontFamily: '$sourceSansPro', + border: '1px solid $border', + borderRadius: '$1', + minWidth: 0, + '&:focus': { + transition: 'all 0.2s ease', + outline: 'none', + boxShadow: '0 0 0 3px $gray3', + // borderColor: '$textDark', + '&:hover': { + boxShadow: '0 0 0 3px $blue200', + borderColor: '$blue500', + }, + }, +}); + +const EditFieldError = styled('div', { + color: '$errorText', + fontSize: '$3' +}); + +const EditActionButtonsContainer = styled('div', { + display: 'flex', + margin: 'auto 0', + gap: '$2', + justifyContent: 'flex-end' +}); + +// TODO +// Maybe move this to parent so that new tags can share the same schema +const createTagNameSchema = (currentName, allNames) => { + return Yup.string() + .required('Enter a label name.') + .matches(/^[a-zA-Z0-9_. -]*$/, "Labels can't contain special characters") + .test('unique', 'A label with this name already exists.', (val) => { + if (val?.toLowerCase() === currentName.toLowerCase()) { + // name hasn't changed + return true; + } else if (!allNames.includes(val?.toLowerCase())) { + // name hasn't already been used + return true; + } else { + return false; + } + }); +} + +const tagColorSchema = Yup.string() + .matches(/^#[0-9A-Fa-f]{6}$/, { message: 'Enter a valid color code with 6 digits' }) + .required('Select a color.'); + +export const EditableTag = ({ + id, + currentName, + currentColor, + allTagNames, + onConfirmEdit +}) => { + const [isEditOpen, setIsEditOpen] = useState(false); + + const [name, setName] = useState(currentName); + const [color, setColor] = useState(currentColor); + const [tempColor, setTempColor] = useState(currentColor); + + const tagNameSchema = createTagNameSchema(currentName, allTagNames); + + const [nameError, setNameError] = useState(""); + const [colorError, setColorError] = useState(""); + + const updateColor = (newColor) => { + setTempColor(newColor); + setColor(newColor); + setColorError(""); + } + + useEffect(() => { + if (colorError !== "") { + setColorError(""); + } + }, [tempColor, color]); + + useEffect(() => { + if (nameError !== "") { + setNameError(""); + } + }, [name]); + + const onCancel = () => { + setName(currentName); + setColor(currentColor); + setTempColor(currentColor); + setIsEditOpen(false); + } + + const onConfirm = () => { + let validatedName = ""; + let validatedColor = ""; + // If the user typed in a color, tempColor !== color + const submittedColor = tempColor !== color ? tempColor : color; + try { + validatedColor = tagColorSchema.validateSync(submittedColor); + } catch (err) { + setColorError(err.message); + } + + try { + validatedName = tagNameSchema.validateSync(name); + } catch (err) { + setNameError(err.message); + } + + if (validatedName === "" || validatedColor === "") { + return; + } + + onConfirmEdit(id, validatedName, validatedColor); + } + + return ( + + + + { currentName } + + + setIsEditOpen(true)} + > + + + {console.log('open form')}} + > + + + + + + {/* Tag edit form */} + { isEditOpen && + + {/* Row 1 column 1 */} + Name + + {/* Row 1 column 2 */} + Color + + {/* Row 1 column 3 */} +
+ + {/* Row 2 column 1 */} + setName(e.target.value)} + /> + + {/* Row 2 column 2 */} + + + + updateColor(`#${getRandomColor()}`)} + css={{ + backgroundColor: color, + borderColor: color, + color: getTextColor(color), + '&:hover': { + borderColor: color, + }, + '&:active': { + borderColor: '$border', + }, + }} + > + + + + + Get a new color + + + + + + setTempColor(`${e.target.value}`)}/> + + +
Choose from default colors:
+ {defaultColors.map((color) => ( + updateColor(color)} + /> + ))} + +
+
+
+ + {/* Row 2 column 3 */} + + + + + + {/* Row 3 column 1 */} + {nameError} + + {/* Row 3 column 2 */} + {colorError} + + } + + ); +} diff --git a/src/features/projects/ManageTagsModal/ManageTagsModal.jsx b/src/features/projects/ManageTagsModal/ManageTagsModal.jsx new file mode 100644 index 00000000..dd134745 --- /dev/null +++ b/src/features/projects/ManageTagsModal/ManageTagsModal.jsx @@ -0,0 +1,46 @@ +import { styled } from '../../../theme/stitches.config'; +import { EditableTag } from './EditableTag'; + +const EditableTagsContainer = styled('div', { + overflowY: 'scroll', + padding: '3px' // so that the input boxes' shadow does get cutoff +}) + +export const ManageTagsModal = () => { + const tags = [ + { + id: "1", + name: "example tag", + color: "#E93D82" + }, + { + id: "2", + name: "example tag", + color: "#00A2C7" + }, + { + id: "3", + name: "example tag", + color: "#29A383" + }, + ] + + const onConfirmEdit = (tagId, tagName, tagColor) => { + console.log("edit", tagId, tagName, tagColor); + } + + return ( + + { tags.map(({ id, name, color }) => ( + tag.name)} + onConfirmEdit={onConfirmEdit} + /> + ))} + + ); +} diff --git a/src/features/projects/SidebarNav.jsx b/src/features/projects/SidebarNav.jsx index e45c571b..ab6733be 100644 --- a/src/features/projects/SidebarNav.jsx +++ b/src/features/projects/SidebarNav.jsx @@ -131,6 +131,17 @@ const SidebarNav = ({ toggleFiltersPanel, filtersPanelOpen }) => { tooltipContent="Manage labels" /> )} + + {/* Manage tags view */} + {hasRole(userRoles, WRITE_PROJECT_ROLES) && ( + handleModalToggle('manage-tags-form')} + icon={} + tooltipContent="Manage tags" + /> + )} ); }; From e887cc5276b77f83fc973a6aa027117bee91fd8a Mon Sep 17 00:00:00 2001 From: Jesse Leung Date: Mon, 21 Oct 2024 00:18:22 -0400 Subject: [PATCH 02/18] feat(138.a): refactor and move edit to its own component for reuse --- .../projects/ManageTagsModal/EditTag.jsx | 289 ++++++++++++++++++ .../projects/ManageTagsModal/EditableTag.jsx | 288 +---------------- .../ManageTagsModal/ManageTagsModal.jsx | 2 + 3 files changed, 306 insertions(+), 273 deletions(-) create mode 100644 src/features/projects/ManageTagsModal/EditTag.jsx diff --git a/src/features/projects/ManageTagsModal/EditTag.jsx b/src/features/projects/ManageTagsModal/EditTag.jsx new file mode 100644 index 00000000..1a385765 --- /dev/null +++ b/src/features/projects/ManageTagsModal/EditTag.jsx @@ -0,0 +1,289 @@ +import React, { useEffect, useState } from 'react'; +import { SymbolIcon } from '@radix-ui/react-icons'; +import IconButton from '../../../components/IconButton'; +import { styled } from '../../../theme/stitches.config'; +import { ColorPicker } from '../ManageLabelsModal/components'; +import { Tooltip, TooltipArrow, TooltipTrigger, TooltipContent } from '../../../components/Tooltip'; +import { getRandomColor, getTextColor } from '../../../app/utils'; +import * as Yup from 'yup'; +import Button from '../../../components/Button'; + +const defaultColors = [ + '#E54D2E', + '#E5484D', + '#E93D82', + '#D6409F', + '#AB4ABA', + '#8E4EC6', + '#6E56CF', + '#5B5BD6', + '#3E63DD', + '#0090FF', + '#00A2C7', + '#12A594', + '#30A46C', + '#46A758', + '#A18072', + '#F76B15', + '#FFC53D', + '#FFE629', + '#BDEE63', + '#7CE2FE', + '#8D8D8D', + '#F0F0F0', +]; + +const ColorSwatch = styled('button', { + border: 'none', + color: '$backgroundLight', + height: '$4', + width: '$4', + margin: 2, + borderRadius: '$2', + '&:hover': { + cursor: 'pointer', + }, +}); + +const createTagNameSchema = (currentName, allNames) => { + return Yup.string() + .required('Enter a label name.') + .matches(/^[a-zA-Z0-9_. -]*$/, "Labels can't contain special characters") + .test('unique', 'A label with this name already exists.', (val) => { + if (val?.toLowerCase() === currentName.toLowerCase()) { + // name hasn't changed + return true; + } else if (!allNames.includes(val?.toLowerCase())) { + // name hasn't already been used + return true; + } else { + return false; + } + }); +} + +const tagColorSchema = Yup.string() + .matches(/^#[0-9A-Fa-f]{6}$/, { message: 'Enter a valid color code with 6 digits' }) + .required('Select a color.'); + +const EditContainer = styled('div', { + display: 'grid', + gridTemplateColumns: '1fr 1fr 1fr', + columnGap: '$3', + rowGap: '$1', + marginBottom: '$3', + marginTop: '$2', + '& > *': { + minWidth: 0 + } +}); + +const EditFieldLabel = styled('label', { + fontWeight: 'bold', + color: '$textDark', + fontSize: '$3' +}); + +const EditFieldInput = styled('input', { + padding: '$2 $3', + color: '$textMedium', + fontFamily: '$sourceSansPro', + border: '1px solid $border', + borderRadius: '$1', + minWidth: 0, + '&:focus': { + transition: 'all 0.2s ease', + outline: 'none', + boxShadow: '0 0 0 3px $gray3', + // borderColor: '$textDark', + '&:hover': { + boxShadow: '0 0 0 3px $blue200', + borderColor: '$blue500', + }, + }, +}); + +const EditFieldError = styled('div', { + color: '$errorText', + fontSize: '$3' +}); + +const EditActionButtonsContainer = styled('div', { + display: 'flex', + margin: 'auto 0', + gap: '$2', + justifyContent: 'flex-end' +}); + +export const EditTag = ({ + id, + currentName, + currentColor, + onPreviewColor, + allTagNames, + onSubmit, + onCancel, + isNewLabel, +}) => { + // to get rid of warning for now + console.log(isNewLabel) + const [name, setName] = useState(currentName); + const [color, setColor] = useState(currentColor); + const [tempColor, setTempColor] = useState(currentColor); + + const tagNameSchema = createTagNameSchema(currentName, allTagNames); + + const [nameError, setNameError] = useState(""); + const [colorError, setColorError] = useState(""); + + const updateColor = (newColor) => { + setTempColor(newColor); + setColor(newColor); + if (onPreviewColor) { + onPreviewColor(newColor); + } + setColorError(""); + } + + useEffect(() => { + if (colorError !== "") { + setColorError(""); + } + }, [tempColor, color]); + + useEffect(() => { + if (nameError !== "") { + setNameError(""); + } + }, [name]); + + const onCancelEdit = () => { + setName(currentName); + setColor(currentColor); + setTempColor(currentColor); + onCancel(); + } + + const onConfirmEdit = () => { + let validatedName = ""; + let validatedColor = ""; + // If the user typed in a color, tempColor !== color + const submittedColor = tempColor !== color ? tempColor : color; + try { + validatedColor = tagColorSchema.validateSync(submittedColor); + } catch (err) { + setColorError(err.message); + } + + try { + validatedName = tagNameSchema.validateSync(name); + } catch (err) { + setNameError(err.message); + } + + if (validatedName === "" || validatedColor === "") { + return; + } + + onSubmit(id, validatedName, validatedColor); + } + + return ( + + {/* Row 1 column 1 */} + Name + + {/* Row 1 column 2 */} + Color + + {/* Row 1 column 3 */} +
+ + {/* Row 2 column 1 */} + setName(e.target.value)} + /> + + {/* Row 2 column 2 */} + + + + updateColor(`#${getRandomColor()}`)} + css={{ + backgroundColor: color, + borderColor: color, + color: getTextColor(color), + '&:hover': { + borderColor: color, + }, + '&:active': { + borderColor: '$border', + }, + }} + > + + + + + Get a new color + + + + + + setTempColor(`${e.target.value}`)}/> + + +
Choose from default colors:
+ {defaultColors.map((color) => ( + updateColor(color)} + /> + ))} + +
+
+
+ + {/* Row 2 column 3 */} + + + + + + {/* Row 3 column 1 */} + {nameError} + + {/* Row 3 column 2 */} + {colorError} + + ); +} diff --git a/src/features/projects/ManageTagsModal/EditableTag.jsx b/src/features/projects/ManageTagsModal/EditableTag.jsx index b10d4abd..1fc37853 100644 --- a/src/features/projects/ManageTagsModal/EditableTag.jsx +++ b/src/features/projects/ManageTagsModal/EditableTag.jsx @@ -1,50 +1,8 @@ -import React, { useEffect, useState } from 'react'; -import { Pencil1Icon, SymbolIcon, TrashIcon } from '@radix-ui/react-icons'; +import React, { useState } from 'react'; +import { Pencil1Icon, TrashIcon } from '@radix-ui/react-icons'; import IconButton from '../../../components/IconButton'; import { styled } from '../../../theme/stitches.config'; -import { ColorPicker } from '../ManageLabelsModal/components'; -import { Tooltip, TooltipArrow, TooltipTrigger, TooltipContent } from '../../../components/Tooltip'; -import { getRandomColor, getTextColor } from '../../../app/utils'; -import * as Yup from 'yup'; -import Button from '../../../components/Button'; - -const defaultColors = [ - '#E54D2E', - '#E5484D', - '#E93D82', - '#D6409F', - '#AB4ABA', - '#8E4EC6', - '#6E56CF', - '#5B5BD6', - '#3E63DD', - '#0090FF', - '#00A2C7', - '#12A594', - '#30A46C', - '#46A758', - '#A18072', - '#F76B15', - '#FFC53D', - '#FFE629', - '#BDEE63', - '#7CE2FE', - '#8D8D8D', - '#F0F0F0', -]; - -const ColorSwatch = styled('button', { - border: 'none', - color: '$backgroundLight', - height: '$4', - width: '$4', - margin: 2, - borderRadius: '$2', - '&:hover': { - cursor: 'pointer', - }, -}); - +import { EditTag } from './EditTag'; const Container = styled('div', { paddingTop: '$2', @@ -75,78 +33,6 @@ const Actions = styled('div', { gap: '$3' }); -const EditContainer = styled('div', { - display: 'grid', - gridTemplateColumns: '1fr 1fr 1fr', - columnGap: '$3', - rowGap: '$1', - marginBottom: '$3', - marginTop: '$2', - '& > *': { - minWidth: 0 - } -}); - -const EditFieldLabel = styled('label', { - fontWeight: 'bold', - color: '$textDark', - fontSize: '$3' -}); - -const EditFieldInput = styled('input', { - padding: '$2 $3', - color: '$textMedium', - fontFamily: '$sourceSansPro', - border: '1px solid $border', - borderRadius: '$1', - minWidth: 0, - '&:focus': { - transition: 'all 0.2s ease', - outline: 'none', - boxShadow: '0 0 0 3px $gray3', - // borderColor: '$textDark', - '&:hover': { - boxShadow: '0 0 0 3px $blue200', - borderColor: '$blue500', - }, - }, -}); - -const EditFieldError = styled('div', { - color: '$errorText', - fontSize: '$3' -}); - -const EditActionButtonsContainer = styled('div', { - display: 'flex', - margin: 'auto 0', - gap: '$2', - justifyContent: 'flex-end' -}); - -// TODO -// Maybe move this to parent so that new tags can share the same schema -const createTagNameSchema = (currentName, allNames) => { - return Yup.string() - .required('Enter a label name.') - .matches(/^[a-zA-Z0-9_. -]*$/, "Labels can't contain special characters") - .test('unique', 'A label with this name already exists.', (val) => { - if (val?.toLowerCase() === currentName.toLowerCase()) { - // name hasn't changed - return true; - } else if (!allNames.includes(val?.toLowerCase())) { - // name hasn't already been used - return true; - } else { - return false; - } - }); -} - -const tagColorSchema = Yup.string() - .matches(/^#[0-9A-Fa-f]{6}$/, { message: 'Enter a valid color code with 6 digits' }) - .required('Select a color.'); - export const EditableTag = ({ id, currentName, @@ -155,71 +41,14 @@ export const EditableTag = ({ onConfirmEdit }) => { const [isEditOpen, setIsEditOpen] = useState(false); - - const [name, setName] = useState(currentName); - const [color, setColor] = useState(currentColor); - const [tempColor, setTempColor] = useState(currentColor); - - const tagNameSchema = createTagNameSchema(currentName, allTagNames); - - const [nameError, setNameError] = useState(""); - const [colorError, setColorError] = useState(""); - - const updateColor = (newColor) => { - setTempColor(newColor); - setColor(newColor); - setColorError(""); - } - - useEffect(() => { - if (colorError !== "") { - setColorError(""); - } - }, [tempColor, color]); - - useEffect(() => { - if (nameError !== "") { - setNameError(""); - } - }, [name]); - - const onCancel = () => { - setName(currentName); - setColor(currentColor); - setTempColor(currentColor); - setIsEditOpen(false); - } - - const onConfirm = () => { - let validatedName = ""; - let validatedColor = ""; - // If the user typed in a color, tempColor !== color - const submittedColor = tempColor !== color ? tempColor : color; - try { - validatedColor = tagColorSchema.validateSync(submittedColor); - } catch (err) { - setColorError(err.message); - } - - try { - validatedName = tagNameSchema.validateSync(name); - } catch (err) { - setNameError(err.message); - } - - if (validatedName === "" || validatedColor === "") { - return; - } - - onConfirmEdit(id, validatedName, validatedColor); - } + const [previewColor, setPreviewColor] = useState(currentColor); return ( { currentName } @@ -241,102 +70,15 @@ export const EditableTag = ({ {/* Tag edit form */} { isEditOpen && - - {/* Row 1 column 1 */} - Name - - {/* Row 1 column 2 */} - Color - - {/* Row 1 column 3 */} -
- - {/* Row 2 column 1 */} - setName(e.target.value)} - /> - - {/* Row 2 column 2 */} - - - - updateColor(`#${getRandomColor()}`)} - css={{ - backgroundColor: color, - borderColor: color, - color: getTextColor(color), - '&:hover': { - borderColor: color, - }, - '&:active': { - borderColor: '$border', - }, - }} - > - - - - - Get a new color - - - - - - setTempColor(`${e.target.value}`)}/> - - -
Choose from default colors:
- {defaultColors.map((color) => ( - updateColor(color)} - /> - ))} - -
-
-
- - {/* Row 2 column 3 */} - - - - - - {/* Row 3 column 1 */} - {nameError} - - {/* Row 3 column 2 */} - {colorError} - + setPreviewColor(newColor)} + allTagNames={allTagNames} + onSubmit={onConfirmEdit} + onCancel={() => setIsEditOpen(false)} + /> } ); diff --git a/src/features/projects/ManageTagsModal/ManageTagsModal.jsx b/src/features/projects/ManageTagsModal/ManageTagsModal.jsx index dd134745..b18bc488 100644 --- a/src/features/projects/ManageTagsModal/ManageTagsModal.jsx +++ b/src/features/projects/ManageTagsModal/ManageTagsModal.jsx @@ -1,3 +1,4 @@ +import React from 'react'; import { styled } from '../../../theme/stitches.config'; import { EditableTag } from './EditableTag'; @@ -41,6 +42,7 @@ export const ManageTagsModal = () => { onConfirmEdit={onConfirmEdit} /> ))} + ); } From 5c654dea5b4506df7f43517a9161cff2cbcba91d Mon Sep 17 00:00:00 2001 From: Jesse Leung Date: Tue, 22 Oct 2024 22:27:35 -0400 Subject: [PATCH 03/18] feat(138.a): finish adding project label crud UI --- .../ManageTagsModal/DeleteTagAlert.jsx | 94 ++++++++ .../projects/ManageTagsModal/EditTag.jsx | 216 +++++++++++------- .../projects/ManageTagsModal/EditableTag.jsx | 5 +- .../ManageTagsModal/ManageTagsModal.jsx | 111 +++++++-- 4 files changed, 320 insertions(+), 106 deletions(-) create mode 100644 src/features/projects/ManageTagsModal/DeleteTagAlert.jsx diff --git a/src/features/projects/ManageTagsModal/DeleteTagAlert.jsx b/src/features/projects/ManageTagsModal/DeleteTagAlert.jsx new file mode 100644 index 00000000..0b2f6362 --- /dev/null +++ b/src/features/projects/ManageTagsModal/DeleteTagAlert.jsx @@ -0,0 +1,94 @@ +import React from 'react'; +import { Alert, AlertPortal, AlertOverlay, AlertContent, AlertTitle } from '../../../components/AlertDialog.jsx'; +import Button from '../../../components/Button.jsx'; +import { red } from '@radix-ui/colors'; +import { styled } from '../../../theme/stitches.config.js'; + +const PreviewTag = styled('div', { + padding: '$1 $3', + borderRadius: '$2', + border: '1px solid rgba(0,0,0,0)', + color: '$textDark', + fontFamily: '$mono', + fontWeight: 'bold', + fontSize: '$2', + display: 'grid', + placeItems: 'center', + margin: 'auto $1', + height: '$5' +}); + +export const DeleteTagAlert = ({ + open, + tag, + onConfirm, + onCancel +}) => { + return ( + + + + + + Are you sure you'd like to delete the{' '} + {tag && + + { tag.name } + + } tag? + +
+ Deleting this tag will: +
    +
  • + remove it as an option to apply to your images ( + + Note: if this is your only goal, this can also be accomplished by "disabling", rather than + deleting, the tag. + + ) +
  • +
  • remove all instances of it from your existing images
  • +
  • + if the tag has been validated as the correct, accurate tag on objects, deleting it will remove the + tag and unlock those objects, which will revert all affected images to a "not-reviewed" + state +
  • +
+ This action can not be undone. +
+
+ + +
+
+
+
+ ); +}; diff --git a/src/features/projects/ManageTagsModal/EditTag.jsx b/src/features/projects/ManageTagsModal/EditTag.jsx index 1a385765..53fbb1e6 100644 --- a/src/features/projects/ManageTagsModal/EditTag.jsx +++ b/src/features/projects/ManageTagsModal/EditTag.jsx @@ -115,6 +115,26 @@ const EditActionButtonsContainer = styled('div', { justifyContent: 'flex-end' }); +const PreviewTagContainer = styled('div', { + display: 'flex', + marginTop: '$2' +}); + +const PreviewTag = styled('div', { + padding: '$1 $3', + borderRadius: '$2', + border: '1px solid rgba(0,0,0,0)', + color: '$textDark', + fontFamily: '$mono', + fontWeight: 'bold', + fontSize: '$2', + display: 'grid', + placeItems: 'center', + marginLeft: '0', + marginRight: 'auto', + height: '$5' +}); + export const EditTag = ({ id, currentName, @@ -125,8 +145,12 @@ export const EditTag = ({ onCancel, isNewLabel, }) => { + if (isNewLabel) { + currentColor = `#${getRandomColor()}` + currentName = '' + } + // to get rid of warning for now - console.log(isNewLabel) const [name, setName] = useState(currentName); const [color, setColor] = useState(currentColor); const [tempColor, setTempColor] = useState(currentColor); @@ -185,105 +209,121 @@ export const EditTag = ({ return; } - onSubmit(id, validatedName, validatedColor); + if (isNewLabel) { + onSubmit(validatedName, validatedColor); + } else { + onSubmit(id, validatedName, validatedColor); + } } return ( - - {/* Row 1 column 1 */} - Name + <> + { isNewLabel && + + + { !name ? 'new tag' : name } + + + } + + {/* Row 1 column 1 */} + Name - {/* Row 1 column 2 */} - Color + {/* Row 1 column 2 */} + Color - {/* Row 1 column 3 */} -
+ {/* Row 1 column 3 */} +
- {/* Row 2 column 1 */} - setName(e.target.value)} - /> + {/* Row 2 column 1 */} + setName(e.target.value)} + /> - {/* Row 2 column 2 */} - - - - updateColor(`#${getRandomColor()}`)} - css={{ - backgroundColor: color, - borderColor: color, - color: getTextColor(color), - '&:hover': { + {/* Row 2 column 2 */} + + + + updateColor(`#${getRandomColor()}`)} + css={{ + backgroundColor: color, borderColor: color, - }, - '&:active': { - borderColor: '$border', - }, + color: getTextColor(color), + '&:hover': { + borderColor: color, + }, + '&:active': { + borderColor: '$border', + }, + }} + > + + + + + Get a new color + + + + + + setTempColor(`${e.target.value}`)}/> + + - - - - - Get a new color - - - - - - setTempColor(`${e.target.value}`)}/> - - -
Choose from default colors:
- {defaultColors.map((color) => ( - updateColor(color)} - /> - ))} - -
-
-
+
Choose from default colors:
+ {defaultColors.map((color) => ( + updateColor(color)} + /> + ))} + + + + - {/* Row 2 column 3 */} - - - - + {/* Row 2 column 3 */} + + + + - {/* Row 3 column 1 */} - {nameError} + {/* Row 3 column 1 */} + {nameError} - {/* Row 3 column 2 */} - {colorError} - + {/* Row 3 column 2 */} + {colorError} + + ); } diff --git a/src/features/projects/ManageTagsModal/EditableTag.jsx b/src/features/projects/ManageTagsModal/EditableTag.jsx index 1fc37853..1ced96a8 100644 --- a/src/features/projects/ManageTagsModal/EditableTag.jsx +++ b/src/features/projects/ManageTagsModal/EditableTag.jsx @@ -38,7 +38,8 @@ export const EditableTag = ({ currentName, currentColor, allTagNames, - onConfirmEdit + onConfirmEdit, + onDelete }) => { const [isEditOpen, setIsEditOpen] = useState(false); const [previewColor, setPreviewColor] = useState(currentColor); @@ -61,7 +62,7 @@ export const EditableTag = ({ {console.log('open form')}} + onClick={() => onDelete(id)} > diff --git a/src/features/projects/ManageTagsModal/ManageTagsModal.jsx b/src/features/projects/ManageTagsModal/ManageTagsModal.jsx index b18bc488..b09f06d3 100644 --- a/src/features/projects/ManageTagsModal/ManageTagsModal.jsx +++ b/src/features/projects/ManageTagsModal/ManageTagsModal.jsx @@ -1,13 +1,39 @@ -import React from 'react'; +import React, { useState } from 'react'; import { styled } from '../../../theme/stitches.config'; import { EditableTag } from './EditableTag'; +import { EditTag } from './EditTag'; +import Button from '../../../components/Button'; +import { SimpleSpinner, SpinnerOverlay } from '../../../components/Spinner'; +import { DeleteTagAlert } from './DeleteTagAlert'; const EditableTagsContainer = styled('div', { overflowY: 'scroll', - padding: '3px' // so that the input boxes' shadow does get cutoff -}) + padding: '3px', // so that the input boxes' shadow does get cutoff + maxHeight: '500px' +}); + +const AddNewTagButtonContainer = styled('div', { + display: 'flex', +}); + +const AddNewTagButton = styled(Button, { + marginRight: 0, + marginLeft: 'auto', + marginTop: '$3' +}); + +const EditTagContainer = styled('div', { + marginLeft: '3px' +}); + export const ManageTagsModal = () => { + const [isLoading, setIsLoading] = useState(false); + const [isNewTagOpen, setIsNewTagOpen] = useState(false); + + const [isAlertOpen, setIsAlertOpen] = useState(false); + const [tagToDelete, setTagToDelete] = useState(''); + const tags = [ { id: "1", @@ -30,19 +56,72 @@ export const ManageTagsModal = () => { console.log("edit", tagId, tagName, tagColor); } + const onConfirmAdd = (tagName, tagColor) => { + console.log("add", tagName, tagColor); + } + + const onConfirmDelete = (tagid) => { + console.log('delete', tagid); + } + + const onStartDelete = (id) => { + setTagToDelete(id); + setIsAlertOpen(true); + } + + const onCancelDelete = () => { + console.log("what") + setIsAlertOpen(false); + setTagToDelete(''); + } + return ( - - { tags.map(({ id, name, color }) => ( - tag.name)} - onConfirmEdit={onConfirmEdit} - /> - ))} - - + <> + {isLoading && ( + + + + )} + + { tags.map(({ id, name, color }) => ( + tag.name)} + onConfirmEdit={onConfirmEdit} + onDelete={(id) => onStartDelete(id)} + /> + ))} + + { !isNewTagOpen && + + setIsNewTagOpen(true)} + > + New tag + + + } + { isNewTagOpen && + + t.name)} + onSubmit={onConfirmAdd} + onCancel={() => setIsNewTagOpen(false)} + isNewLabel={true} + /> + + } + tag.id === tagToDelete)} + onConfirm={onConfirmDelete} + onCancel={onCancelDelete} + /> + ); } From 0ed9339a654b179d0defb153e799f11d7f61a6a1 Mon Sep 17 00:00:00 2001 From: Jesse Leung Date: Thu, 24 Oct 2024 23:32:30 -0400 Subject: [PATCH 04/18] feat(138.d): add frontend api connections and reducers to create, edit, and delete project tags --- src/api/buildQuery.js | 48 ++++++ .../ManageTagsModal/DeleteTagAlert.jsx | 2 +- .../projects/ManageTagsModal/EditableTag.jsx | 7 +- .../ManageTagsModal/ManageTagsModal.jsx | 47 +++-- src/features/projects/projectsSlice.js | 163 ++++++++++++++++++ 5 files changed, 239 insertions(+), 28 deletions(-) diff --git a/src/api/buildQuery.js b/src/api/buildQuery.js index 1c01ef2c..3ac3e678 100644 --- a/src/api/buildQuery.js +++ b/src/api/buildQuery.js @@ -152,6 +152,12 @@ const projectLabelFields = ` ml `; +const projectTagFields = ` + _id + name + color +`; + const projectFields = ` _id name @@ -169,6 +175,9 @@ const projectFields = ` labels { ${projectLabelFields} } + tags { + ${projectTagFields} + } availableMLModels `; @@ -512,6 +521,45 @@ const queries = { variables: { input: input }, }), + createProjectTag: (input) => ({ + template: ` + mutation CreateProjectTag($input: CreateProjectTagInput!) { + createProjectTag(input: $input) { + tags { + ${projectTagFields} + } + } + } + `, + variables: { input: input }, + }), + + deleteProjectTag: (input) => ({ + template: ` + mutation DeleteProjectTag($input: DeleteProjectTagInput!) { + deleteProjectTag(input: $input) { + tags { + ${projectTagFields} + } + } + } + `, + variables: { input: input }, + }), + + updateProjectTag: (input) => ({ + template: ` + mutation UpdateProjectTag($input: UpdateProjectTagInput!) { + updateProjectTag(input: $input) { + tags { + ${projectTagFields} + } + } + } + `, + variables: { input: input }, + }), + createProjectLabel: (input) => ({ template: ` mutation CreateProjectLabel($input: CreateProjectLabelInput!) { diff --git a/src/features/projects/ManageTagsModal/DeleteTagAlert.jsx b/src/features/projects/ManageTagsModal/DeleteTagAlert.jsx index 0b2f6362..aa18e1ae 100644 --- a/src/features/projects/ManageTagsModal/DeleteTagAlert.jsx +++ b/src/features/projects/ManageTagsModal/DeleteTagAlert.jsx @@ -82,7 +82,7 @@ export const DeleteTagAlert = ({ border: 'none', '&:hover': { color: red.red11, backgroundColor: red.red5 }, }} - onClick={() => onConfirm(tag.id)} + onClick={() => onConfirm(tag._id)} > Yes, delete diff --git a/src/features/projects/ManageTagsModal/EditableTag.jsx b/src/features/projects/ManageTagsModal/EditableTag.jsx index 1ced96a8..a06419a6 100644 --- a/src/features/projects/ManageTagsModal/EditableTag.jsx +++ b/src/features/projects/ManageTagsModal/EditableTag.jsx @@ -44,6 +44,11 @@ export const EditableTag = ({ const [isEditOpen, setIsEditOpen] = useState(false); const [previewColor, setPreviewColor] = useState(currentColor); + const onEdit = (newName, newColor) => { + onConfirmEdit(id, newName, newColor); + setIsEditOpen(false); + } + return ( @@ -77,7 +82,7 @@ export const EditableTag = ({ currentColor={currentColor} onPreviewColor={(newColor) => setPreviewColor(newColor)} allTagNames={allTagNames} - onSubmit={onConfirmEdit} + onSubmit={(_id, newName, newColor) => onEdit(newName, newColor)} onCancel={() => setIsEditOpen(false)} /> } diff --git a/src/features/projects/ManageTagsModal/ManageTagsModal.jsx b/src/features/projects/ManageTagsModal/ManageTagsModal.jsx index b09f06d3..5df437e1 100644 --- a/src/features/projects/ManageTagsModal/ManageTagsModal.jsx +++ b/src/features/projects/ManageTagsModal/ManageTagsModal.jsx @@ -1,10 +1,12 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { styled } from '../../../theme/stitches.config'; import { EditableTag } from './EditableTag'; import { EditTag } from './EditTag'; import Button from '../../../components/Button'; import { SimpleSpinner, SpinnerOverlay } from '../../../components/Spinner'; import { DeleteTagAlert } from './DeleteTagAlert'; +import { useDispatch, useSelector } from 'react-redux'; +import { createProjectTag, deleteProjectTag, selectTags, updateProjectTag } from '../projectsSlice'; const EditableTagsContainer = styled('div', { overflowY: 'scroll', @@ -28,40 +30,34 @@ const EditTagContainer = styled('div', { export const ManageTagsModal = () => { + const dispatch = useDispatch(); + const tags = useSelector(selectTags); + const [isLoading, setIsLoading] = useState(false); const [isNewTagOpen, setIsNewTagOpen] = useState(false); const [isAlertOpen, setIsAlertOpen] = useState(false); const [tagToDelete, setTagToDelete] = useState(''); - const tags = [ - { - id: "1", - name: "example tag", - color: "#E93D82" - }, - { - id: "2", - name: "example tag", - color: "#00A2C7" - }, - { - id: "3", - name: "example tag", - color: "#29A383" - }, - ] + // TODO + // to avoid lint error + useEffect(() => { + setIsLoading(false) + }) const onConfirmEdit = (tagId, tagName, tagColor) => { console.log("edit", tagId, tagName, tagColor); + dispatch(updateProjectTag({ _id: tagId, name: tagName, color: tagColor })); } const onConfirmAdd = (tagName, tagColor) => { - console.log("add", tagName, tagColor); + dispatch(createProjectTag({ name: tagName, color: tagColor })); + setIsNewTagOpen(false); } - const onConfirmDelete = (tagid) => { - console.log('delete', tagid); + const onConfirmDelete = (tagId) => { + dispatch(deleteProjectTag({ _id: tagId })); + setIsAlertOpen(false); } const onStartDelete = (id) => { @@ -70,7 +66,6 @@ export const ManageTagsModal = () => { } const onCancelDelete = () => { - console.log("what") setIsAlertOpen(false); setTagToDelete(''); } @@ -83,10 +78,10 @@ export const ManageTagsModal = () => { )} - { tags.map(({ id, name, color }) => ( + { tags.map(({ _id, name, color }) => ( tag.name)} @@ -118,7 +113,7 @@ export const ManageTagsModal = () => { } tag.id === tagToDelete)} + tag={tags.find((tag) => tag._id === tagToDelete)} onConfirm={onConfirmDelete} onCancel={onCancelDelete} /> diff --git a/src/features/projects/projectsSlice.js b/src/features/projects/projectsSlice.js index 63cc83a6..4cf3b331 100644 --- a/src/features/projects/projectsSlice.js +++ b/src/features/projects/projectsSlice.js @@ -53,6 +53,11 @@ const initialState = { operation: null, errors: null, }, + projectTags: { + isLoading: false, + operation: null, + errors: null + }, }, unsavedViewChanges: false, modalOpen: false, @@ -361,6 +366,72 @@ export const projectsSlice = createSlice({ state.loadingStates.projectLabels.errors.splice(index, 1); }, + /* + * Project Tags CRUD + */ + + createProjectTagStart: (state) => { + const ls = { isLoading: true, operation: 'creating', errors: null }; + state.loadingStates.projectTags = ls; + }, + + createProjectTagSuccess: (state, { payload }) => { + const ls = { + isLoading: false, + operation: null, + errors: null, + }; + state.loadingStates.projectTags = ls; + + const proj = state.projects.find((p) => p._id === payload.projId); + proj.tags = payload.tags; + }, + + createProjectTagFailure: (state, { payload }) => { + const ls = { isLoading: false, operation: null, errors: payload }; + state.loadingStates.projectTags = ls; + }, + + deleteProjectTagStart: (state) => { + const ls = { isLoading: true, operation: 'deleting', errors: null }; + state.loadingStates.projectTags = ls; + }, + + deleteProjectTagSuccess: (state, { payload }) => { + const ls = { isLoading: false, operation: null, errors: null }; + state.loadingStates.projectTags = ls; + + const proj = state.projects.find((p) => p._id === payload.projId); + proj.tags = payload.tags + }, + + deleteProjectTagFailure: (state, { payload }) => { + const ls = { isLoading: false, operation: null, errors: payload }; + state.loadingStates.projectTags = ls; + }, + + updateProjectTagStart: (state) => { + const ls = { isLoading: true, operation: 'updating', errors: null }; + state.loadingStates.projectTags = ls; + }, + + updateProjectTagSuccess: (state, { payload }) => { + const ls = { + isLoading: false, + operation: null, + errors: null, + }; + state.loadingStates.projectTags = ls; + + const proj = state.projects.find((p) => p._id === payload.projId); + proj.tags = payload.tags + }, + + updateProjectTagFailure: (state, { payload }) => { + const ls = { isLoading: false, operation: null, errors: payload }; + state.loadingStates.projectTags = ls; + }, + setModalOpen: (state, { payload }) => { state.modalOpen = payload; }, @@ -452,6 +523,16 @@ export const { deleteProjectLabelFailure, dismissManageLabelsError, + createProjectTagStart, + createProjectTagFailure, + createProjectTagSuccess, + deleteProjectTagStart, + deleteProjectTagFailure, + deleteProjectTagSuccess, + updateProjectTagStart, + updateProjectTagFailure, + updateProjectTagSuccess, + setModalOpen, setModalContent, setSelectedCamera, @@ -631,6 +712,85 @@ export const fetchModelOptions = () => { }; }; +// Project Tags thunks +export const createProjectTag = (payload) => { + return async (dispatch, getState) => { + try { + dispatch(createProjectTagStart()); + const currentUser = await Auth.currentAuthenticatedUser(); + const token = currentUser.getSignInUserSession().getIdToken().getJwtToken(); + const projects = getState().projects.projects; + const selectedProj = projects.find((proj) => proj.selected); + const projId = selectedProj._id; + + if (token && selectedProj) { + const res = await call({ + projId, + request: 'createProjectTag', + input: payload, + }); + dispatch(createProjectTagSuccess({ projId, tags: res.createProjectTag.tags })); + } + } catch (err) { + console.log(`error attempting to create tag: `, err); + dispatch(createProjectTagFailure(err)); + } + }; +}; + +export const deleteProjectTag = (payload) => { + return async (dispatch, getState) => { + try { + dispatch(deleteProjectTagStart()); + const currentUser = await Auth.currentAuthenticatedUser(); + const token = currentUser.getSignInUserSession().getIdToken().getJwtToken(); + const projects = getState().projects.projects; + const selectedProj = projects.find((proj) => proj.selected); + const projId = selectedProj._id; + + if (token && selectedProj) { + const res = await call({ + projId, + request: 'deleteProjectTag', + input: payload, + }); + dispatch(deleteProjectTagSuccess({ projId, tags: res.deleteProjectTag.tags })); + // TODO waterfall delete + // dispatch(clearImages()); + // dispatch(fetchProjects({ _ids: [projId] })); + } + } catch (err) { + console.log(`error attempting to delete tag: `, err); + dispatch(deleteProjectTagFailure(err)); + } + }; +}; + +export const updateProjectTag = (payload) => { + return async (dispatch, getState) => { + try { + dispatch(updateProjectTagStart()); + const currentUser = await Auth.currentAuthenticatedUser(); + const token = currentUser.getSignInUserSession().getIdToken().getJwtToken(); + const projects = getState().projects.projects; + const selectedProj = projects.find((proj) => proj.selected); + const projId = selectedProj._id; + + if (token && selectedProj) { + const res = await call({ + projId, + request: 'updateProjectTag', + input: payload, + }); + dispatch(updateProjectTagSuccess({ projId, tags: res.updateProjectTag.tags })); + } + } catch (err) { + console.log(`error attempting to update tag: `, err); + dispatch(updateProjectTagFailure(err)); + } + }; +}; + // Project Labels thunks export const createProjectLabel = (payload) => { return async (dispatch, getState) => { @@ -729,6 +889,9 @@ export const selectMLModels = createSelector([selectSelectedProject], (proj) => export const selectLabels = createSelector([selectSelectedProject], (proj) => proj ? proj.labels : [], ); +export const selectTags = createSelector([selectSelectedProject], (proj) => + proj ? proj.tags : [], +); export const selectProjectsLoading = (state) => state.projects.loadingStates.projects; export const selectViewsLoading = (state) => state.projects.loadingStates.views; export const selectAutomationRulesLoading = (state) => state.projects.loadingStates.automationRules; From 266df78c730c132142b27b8a05926cbfb0b83439 Mon Sep 17 00:00:00 2001 From: Jesse Leung Date: Sun, 27 Oct 2024 23:58:18 -0400 Subject: [PATCH 05/18] feat(138.d): start adding image tag UI --- src/features/loupe/ImageTag.jsx | 95 +++++++++++++++++++++ src/features/loupe/ImageTagsToolbar.jsx | 108 ++++++++++++++++++++++++ src/features/loupe/Loupe.jsx | 28 +++--- 3 files changed, 219 insertions(+), 12 deletions(-) create mode 100644 src/features/loupe/ImageTag.jsx create mode 100644 src/features/loupe/ImageTagsToolbar.jsx diff --git a/src/features/loupe/ImageTag.jsx b/src/features/loupe/ImageTag.jsx new file mode 100644 index 00000000..2913be4d --- /dev/null +++ b/src/features/loupe/ImageTag.jsx @@ -0,0 +1,95 @@ +import React from 'react'; +import { styled } from '../../theme/stitches.config'; +import { + Tooltip, + TooltipContent, + TooltipArrow, + TooltipTrigger, +} from '../../components/Tooltip.jsx'; +import { Cross2Icon } from '@radix-ui/react-icons'; +import Button from '../../components/Button.jsx'; +import { violet, mauve } from '@radix-ui/colors'; + +export const itemStyles = { + all: 'unset', + flex: '0 0 auto', + color: mauve.mauve11, + height: 30, + padding: '0 5px', + borderRadius: '0 4px 4px 0', + display: 'flex', + fontSize: 13, + lineHeight: 1, + alignItems: 'center', + justifyContent: 'center', + '&:hover': { + backgroundColor: violet.violet3, + color: violet.violet11, + cursor: 'pointer', + }, + '&:focus': { position: 'relative', boxShadow: `0 0 0 2px ${violet.violet7}` }, +}; + +const ToolbarIconButton = styled(Button, { + ...itemStyles, + backgroundColor: 'white', + '&:first-child': { marginLeft: 0 }, + '&[data-state=on]': { + backgroundColor: violet.violet5, + color: violet.violet11, + }, + svg: { + marginRight: '$1', + marginLeft: '$1', + }, +}); + +const TagContainer = styled('div', { + display: 'flex', + border: '1px solid rgba(0,0,0,0)', + borderRadius: 4, + height: 32, +}); + +const TagName = styled('div', { + padding: '$1 $3', + color: '$textDark', + fontFamily: '$mono', + fontWeight: 'bold', + fontSize: '$2', + display: 'grid', + placeItems: 'center', + marginLeft: '0', + marginRight: 'auto', + height: 30, + borderRadius: '4px 0 0 4px' +}); + +export const ImageTag = ({ + id, + name, + color, + onDelete +}) => { + return ( + + + { name } + + + + onDelete(id)}> + + + + + Delete tag + + + + + ); +} diff --git a/src/features/loupe/ImageTagsToolbar.jsx b/src/features/loupe/ImageTagsToolbar.jsx new file mode 100644 index 00000000..3ae65e58 --- /dev/null +++ b/src/features/loupe/ImageTagsToolbar.jsx @@ -0,0 +1,108 @@ +import React from 'react'; +import { styled } from "../../theme/stitches.config"; +import CategorySelector from '../../components/CategorySelector.jsx'; +import { mauve } from '@radix-ui/colors'; +import { ImageTag } from "./ImageTag.jsx"; + +const Toolbar = styled('div', { + display: 'flex', + height: 'calc(32px + $2 + $2)', + width: '100%', + borderBottom: '1px solid $border', + position: 'relative' +}); + +const TagsContainer = styled('div', { + position: 'relative', + width: '100%' +}); + +const TagSelectorContainer = styled('div', { + margin: '$2', + marginRight: '0', + display: 'grid', + placeItems: 'center' +}); + +const ScrollContainer = styled('div', { + position: 'absolute', + width: '100%', + height: '48px', + display: 'flex', + gap: '$2', + overflowX: 'scroll', + whiteSpace: 'nowrap', + left: 0, + top: 0, + padding: '$2 0', + scrollbarWidth: 'none', +}); + +const Separator = styled('div', { + width: '1px', + backgroundColor: mauve.mauve6, + margin: '$2 10px' +}); + +export const ImageTagsToolbar = () => { + const tags = [ + { + id: `${Math.random()}`, + name: 'example', + color: '#B06D2F', + onDelete: () => {console.log("hello")} + }, + { + id: `${Math.random()}`, + name: 'example 2', + color: '#3C6DDE', + onDelete: () => {console.log("hello")} + }, + { + id: `${Math.random()}`, + name: 'example 3', + color: '#08C04C', + onDelete: () => {console.log("hello")} + }, + { + id: `${Math.random()}`, + name: 'example', + color: '#EEBC03', + onDelete: () => {console.log("hello")} + }, + { + id: `${Math.random()}`, + name: 'example 2', + color: '#3CE26E', + onDelete: () => {console.log("hello")} + }, + { + id: `${Math.random()}`, + name: 'example 3', + color: '#C31C76', + onDelete: () => {console.log("hello")} + }, + ] + + return ( + + + console.log("cat chagen")} /> + + + + + { tags.map(({ id, name, color, onDelete }) => ( + + ))} + + + + ); +} diff --git a/src/features/loupe/Loupe.jsx b/src/features/loupe/Loupe.jsx index 26772e0a..162742d3 100644 --- a/src/features/loupe/Loupe.jsx +++ b/src/features/loupe/Loupe.jsx @@ -24,6 +24,7 @@ import FullSizeImage from './FullSizeImage.jsx'; import ImageReviewToolbar from './ImageReviewToolbar.jsx'; import ShareImageButton from './ShareImageButton'; import LoupeDropdown from './LoupeDropdown.jsx'; +import { ImageTagsToolbar } from './ImageTagsToolbar.jsx'; const ItemValue = styled('div', { fontSize: '$3', @@ -79,7 +80,7 @@ const LoupeBody = styled('div', { // $7 - height of panel header // $8 - height of nav bar // 98px - height of toolbar plus height of 2 borders - height: 'calc(100vh - $7 - $8 - 98px)', + height: 'calc(100vh - $7 - $8 - 145px)', backgroundColor: '$hiContrast', }); @@ -99,7 +100,7 @@ const StyledLoupe = styled('div', { }); const ToolbarContainer = styled('div', { - height: '97px', + height: '145px', }); const ShareImage = styled('div', { @@ -338,16 +339,19 @@ const Loupe = () => { {/**/} {image && hasRole(userRoles, WRITE_OBJECTS_ROLES) && ( - + <> + + + )} From d4a4ab35079ab9518d25a4487144dcaf32ba355b Mon Sep 17 00:00:00 2001 From: Jesse Leung Date: Thu, 31 Oct 2024 08:50:34 -0400 Subject: [PATCH 06/18] feat(138): add UI and start adding functiionality for image tags --- src/api/buildQuery.js | 12 +++ src/components/TagSelector.jsx | 97 +++++++++++++++++++ src/features/loupe/ImageTagsToolbar.jsx | 93 +++++++++--------- src/features/loupe/Loupe.jsx | 4 +- .../ManageTagsModal/ManageTagsModal.jsx | 4 +- src/features/projects/projectsSlice.js | 3 +- src/features/review/reviewSlice.js | 67 +++++++++++++ 7 files changed, 232 insertions(+), 48 deletions(-) create mode 100644 src/components/TagSelector.jsx diff --git a/src/api/buildQuery.js b/src/api/buildQuery.js index 3ac3e678..f75fc375 100644 --- a/src/api/buildQuery.js +++ b/src/api/buildQuery.js @@ -60,6 +60,7 @@ const imageFields = ` comments { ${imageCommentFields} } + tags reviewed `; @@ -635,6 +636,17 @@ const queries = { `, variables: { input: input }, }), + + createImageTag: (input) => ({ + template: ` + mutation CreateImageTag($input: CreateImageTagInput!){ + createImageTag(input: $input) { + tags + } + } + `, + variables: { input: input }, + }), createDeployment: (input) => ({ template: ` diff --git a/src/components/TagSelector.jsx b/src/components/TagSelector.jsx new file mode 100644 index 00000000..c910d531 --- /dev/null +++ b/src/components/TagSelector.jsx @@ -0,0 +1,97 @@ +import React from 'react'; +import { styled } from '../theme/stitches.config.js'; +import { useSelector, useDispatch } from 'react-redux'; +import Select, { createFilter } from 'react-select'; +import { + selectProjectTags, + selectTagsLoading +} from '../features/projects/projectsSlice.js'; +import { addLabelEnd } from '../features/loupe/loupeSlice.js'; + +const StyledTagSelector = styled(Select, { + width: '155px', + fontFamily: '$mono', + fontSize: '$2', + fontWeight: '$1', + zIndex: '$5', + '.react-select__control': { + boxSizing: 'border-box', + // height: '24px', + minHeight: 'unset', + border: '1px solid', + borderColor: '$border', + borderRadius: '$2', + cursor: 'pointer', + }, + '.react-select__single-value': { + // position: 'relative', + }, + '.react-select__indicator-separator': { + display: 'none', + }, + '.react-select__dropdown-indicator': { + paddingTop: '0', + paddingBottom: '0', + }, + '.react-select__control--is-focused': { + transition: 'all 0.2s ease', + boxShadow: '0 0 0 3px $blue200', + borderColor: '$blue500', + '&:hover': { + boxShadow: '0 0 0 3px $blue200', + borderColor: '$blue500', + }, + }, + '.react-select__menu': { + color: '$textDark', + fontSize: '$3', + '.react-select__option': { + cursor: 'pointer', + }, + '.react-select__option--is-selected': { + color: '$blue500', + backgroundColor: '$blue200', + }, + '.react-select__option--is-focused': { + backgroundColor: '$gray3', + }, + }, +}); + +export const TagSelector = ({ + css, + handleTagChange, + handleTagChangeBlur, + menuPlacement = 'top', +}) => { + const tagsLoading = useSelector(selectTagsLoading); + const tags = useSelector(selectProjectTags); + const options = tags.map((tag) => { + return { + value: tag._id, + label: tag.name + } + }); + const dispatch = useDispatch(); + const defaultHandleBlur = () => dispatch(addLabelEnd()); + + return ( + + ); +}; diff --git a/src/features/loupe/ImageTagsToolbar.jsx b/src/features/loupe/ImageTagsToolbar.jsx index 3ae65e58..ee3d0b31 100644 --- a/src/features/loupe/ImageTagsToolbar.jsx +++ b/src/features/loupe/ImageTagsToolbar.jsx @@ -1,8 +1,11 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { styled } from "../../theme/stitches.config"; -import CategorySelector from '../../components/CategorySelector.jsx'; import { mauve } from '@radix-ui/colors'; import { ImageTag } from "./ImageTag.jsx"; +import { TagSelector } from '../../components/TagSelector.jsx'; +import { editTag } from '../review/reviewSlice.js'; +import { useDispatch, useSelector } from 'react-redux'; +import { selectProjectTags } from '../projects/projectsSlice.js'; const Toolbar = styled('div', { display: 'flex', @@ -44,61 +47,63 @@ const Separator = styled('div', { margin: '$2 10px' }); -export const ImageTagsToolbar = () => { - const tags = [ - { - id: `${Math.random()}`, - name: 'example', - color: '#B06D2F', - onDelete: () => {console.log("hello")} - }, - { - id: `${Math.random()}`, - name: 'example 2', - color: '#3C6DDE', - onDelete: () => {console.log("hello")} - }, - { - id: `${Math.random()}`, - name: 'example 3', - color: '#08C04C', - onDelete: () => {console.log("hello")} - }, - { - id: `${Math.random()}`, - name: 'example', - color: '#EEBC03', - onDelete: () => {console.log("hello")} - }, - { - id: `${Math.random()}`, - name: 'example 2', - color: '#3CE26E', - onDelete: () => {console.log("hello")} - }, - { - id: `${Math.random()}`, - name: 'example 3', - color: '#C31C76', - onDelete: () => {console.log("hello")} - }, - ] +const getImageTagInfo = (imageTags, projectTags) => { + console.log("img", imageTags) + console.log("proj", projectTags) + return projectTags.filter((t) => { + return imageTags.find((it) => it === t._id) !== undefined + }); +} + +export const ImageTagsToolbar = ({ + image +}) => { + const dispatch = useDispatch(); + const projectTags = useSelector(selectProjectTags); + const [imageTags, setImageTags] = useState(image.tags ?? []); + + console.log("tg", image.tags) + + useEffect(() => { + const imageTagInfo = getImageTagInfo(image.tags ?? [], projectTags); + setImageTags(imageTagInfo); + }, [image, projectTags]) + + useEffect(() => { + const imageTagInfo = getImageTagInfo(image.tags ?? [], projectTags); + console.log(imageTagInfo) + setImageTags(imageTagInfo); + }, []); + + const onDeleteTag = (tagId) => { + console.log(`delete tag: ${tagId}`) + } + + const onAddTag = ({ value }) => { + const addTagDto = { + tagId: value, + imageId: image._id + }; + dispatch(editTag('create', addTagDto)); + } return ( - console.log("cat chagen")} /> + onAddTag(tag)} + /> - { tags.map(({ id, name, color, onDelete }) => ( + { imageTags.map(({ id, name, color }) => ( ))} diff --git a/src/features/loupe/Loupe.jsx b/src/features/loupe/Loupe.jsx index 162742d3..2b9013fe 100644 --- a/src/features/loupe/Loupe.jsx +++ b/src/features/loupe/Loupe.jsx @@ -340,7 +340,9 @@ const Loupe = () => { {image && hasRole(userRoles, WRITE_OBJECTS_ROLES) && ( <> - + { const dispatch = useDispatch(); - const tags = useSelector(selectTags); + const tags = useSelector(selectProjectTags); const [isLoading, setIsLoading] = useState(false); const [isNewTagOpen, setIsNewTagOpen] = useState(false); diff --git a/src/features/projects/projectsSlice.js b/src/features/projects/projectsSlice.js index 4cf3b331..36229fc6 100644 --- a/src/features/projects/projectsSlice.js +++ b/src/features/projects/projectsSlice.js @@ -889,9 +889,10 @@ export const selectMLModels = createSelector([selectSelectedProject], (proj) => export const selectLabels = createSelector([selectSelectedProject], (proj) => proj ? proj.labels : [], ); -export const selectTags = createSelector([selectSelectedProject], (proj) => +export const selectProjectTags = createSelector([selectSelectedProject], (proj) => proj ? proj.tags : [], ); +export const selectTagsLoading = (state) => state.projects.loadingStates.projectTags.isLoading; export const selectProjectsLoading = (state) => state.projects.loadingStates.projects; export const selectViewsLoading = (state) => state.projects.loadingStates.views; export const selectAutomationRulesLoading = (state) => state.projects.loadingStates.automationRules; diff --git a/src/features/review/reviewSlice.js b/src/features/review/reviewSlice.js index 5b95ca62..7b8ada72 100644 --- a/src/features/review/reviewSlice.js +++ b/src/features/review/reviewSlice.js @@ -25,6 +25,11 @@ const initialState = { operation: null /* 'fetching', 'updating', 'deleting' */, errors: null, }, + tags: { + isLoading: false, + operation: null /* 'fetching', 'deleting' */, + errors: null, + }, }, lastAction: null, lastCategoryApplied: null, @@ -177,6 +182,25 @@ export const reviewSlice = createSlice({ image.comments = payload.comments; }, + editTagStart: (state, { payload }) => { + state.loadingStates.tags.isLoading = true; + state.loadingStates.tags.operation = payload; + }, + + editTagFailure: (state, { payload }) => { + state.loadingStates.tags.isLoading = false; + state.loadingStates.tags.operation = null; + state.loadingStates.tags.errors = payload; + }, + + editTagSuccess: (state, { payload }) => { + state.loadingStates.tags.isLoading = false; + state.loadingStates.tags.operation = null; + state.loadingStates.tags.errors = null; + const image = findImage(state.workingImages, payload.imageId); + image.tags = payload.tags; + }, + dismissLabelsError: (state, { payload }) => { const index = payload; state.loadingStates.labels.errors.splice(index, 1); @@ -228,6 +252,9 @@ export const { editCommentStart, editCommentFailure, editCommentSuccess, + editTagStart, + editTagFailure, + editTagSuccess, dismissLabelsError, dismissCommentsError, } = reviewSlice.actions; @@ -311,6 +338,44 @@ export const editComment = (operation, payload) => { }; }; +export const editTag = (operation, payload) => { + return async (dispatch, getState) => { + try { + console.log('editTag - operation: ', operation); + console.log('editTag - payload: ', payload); + + if (!operation || !payload) { + const msg = `An operation (create or delete) and payload is required`; + throw new Error(msg); + } + + dispatch(editTagStart(operation)); + const currentUser = await Auth.currentAuthenticatedUser(); + const token = currentUser.getSignInUserSession().getIdToken().getJwtToken(); + const projects = getState().projects.projects; + const selectedProj = projects.find((proj) => proj.selected); + + if (token && selectedProj) { + const req = `${operation}ImageTag`; + console.log('req:',req); + + const res = await call({ + projId: selectedProj._id, + request: req, + input: payload, + }); + console.log('editTag - res: ', res); + const mutation = Object.keys(res)[0]; + const tags = res[mutation].tags; + dispatch(editTagSuccess({ imageId: payload.imageId, tags })); + } + } catch (err) { + console.log(`error attempting to ${operation}ImageTag: `, err); + dispatch(editTagFailure(err)); + } + }; +}; + // Actions only used in middlewares: export const incrementFocusIndex = createAction('review/incrementFocusIndex'); export const incrementImage = createAction('review/incrementImage'); @@ -324,6 +389,8 @@ export const selectFocusChangeType = (state) => state.review.focusChangeType; export const selectLabelsErrors = (state) => state.review.loadingStates.labels.errors; export const selectCommentsErrors = (state) => state.review.loadingStates.comments.errors; export const selectCommentsLoading = (state) => state.review.loadingStates.comments.isLoading; +export const selectTagsErrors = (state) => state.review.loadingStates.tags.errors; +export const selectTagsLoading = (state) => state.review.loadingStates.comments.isLoading; export const selectLastAction = (state) => state.review.lastAction; export const selectLastCategoryApplied = (state) => state.review.lastCategoryApplied; export const selectSelectedImages = createSelector( From 066fee6a296b536fd2fce7fc2752d44bbeafebba Mon Sep 17 00:00:00 2001 From: Jesse Leung Date: Fri, 1 Nov 2024 14:20:24 -0400 Subject: [PATCH 07/18] fix: remove unneeded import --- src/api/buildQuery.js | 13 ++++++++- src/components/TagSelector.jsx | 4 +-- src/features/loupe/ImageTagsToolbar.jsx | 27 ++++++++++++------- .../ManageTagsModal/ManageTagsModal.jsx | 11 +++----- 4 files changed, 34 insertions(+), 21 deletions(-) diff --git a/src/api/buildQuery.js b/src/api/buildQuery.js index f75fc375..4832e515 100644 --- a/src/api/buildQuery.js +++ b/src/api/buildQuery.js @@ -639,7 +639,7 @@ const queries = { createImageTag: (input) => ({ template: ` - mutation CreateImageTag($input: CreateImageTagInput!){ + mutation CreateImageTag($input: CreateImageTagInput!) { createImageTag(input: $input) { tags } @@ -648,6 +648,17 @@ const queries = { variables: { input: input }, }), + deleteImageTag: (input) => ({ + template: ` + mutation DeleteImageTag($input: DeleteImageTagInput!) { + deleteImageTag(input: $input) { + tags + } + } + `, + variables: { input: input }, + }), + createDeployment: (input) => ({ template: ` mutation CreateDeployment($input: CreateDeploymentInput!) { diff --git a/src/components/TagSelector.jsx b/src/components/TagSelector.jsx index c910d531..612ddeb9 100644 --- a/src/components/TagSelector.jsx +++ b/src/components/TagSelector.jsx @@ -3,7 +3,6 @@ import { styled } from '../theme/stitches.config.js'; import { useSelector, useDispatch } from 'react-redux'; import Select, { createFilter } from 'react-select'; import { - selectProjectTags, selectTagsLoading } from '../features/projects/projectsSlice.js'; import { addLabelEnd } from '../features/loupe/loupeSlice.js'; @@ -60,12 +59,12 @@ const StyledTagSelector = styled(Select, { export const TagSelector = ({ css, + tags, handleTagChange, handleTagChangeBlur, menuPlacement = 'top', }) => { const tagsLoading = useSelector(selectTagsLoading); - const tags = useSelector(selectProjectTags); const options = tags.map((tag) => { return { value: tag._id, @@ -80,6 +79,7 @@ export const TagSelector = ({ value={""} css={css} autoFocus + closeMenuOnSelect={options.length <= 1} isClearable isSearchable openMenuOnClick diff --git a/src/features/loupe/ImageTagsToolbar.jsx b/src/features/loupe/ImageTagsToolbar.jsx index ee3d0b31..0fd6fb32 100644 --- a/src/features/loupe/ImageTagsToolbar.jsx +++ b/src/features/loupe/ImageTagsToolbar.jsx @@ -48,35 +48,41 @@ const Separator = styled('div', { }); const getImageTagInfo = (imageTags, projectTags) => { - console.log("img", imageTags) - console.log("proj", projectTags) return projectTags.filter((t) => { return imageTags.find((it) => it === t._id) !== undefined }); } +const getUnaddedTags = (imageTags, projectTags) => { + return projectTags.filter((t) => imageTags.indexOf(t._id) === -1) +} + export const ImageTagsToolbar = ({ image }) => { const dispatch = useDispatch(); const projectTags = useSelector(selectProjectTags); const [imageTags, setImageTags] = useState(image.tags ?? []); - - console.log("tg", image.tags) + const [unaddedTags, setUnaddedTags] = useState([]); useEffect(() => { const imageTagInfo = getImageTagInfo(image.tags ?? [], projectTags); setImageTags(imageTagInfo); + setUnaddedTags(getUnaddedTags(image.tags ?? [], projectTags)); }, [image, projectTags]) useEffect(() => { const imageTagInfo = getImageTagInfo(image.tags ?? [], projectTags); - console.log(imageTagInfo) setImageTags(imageTagInfo); + setUnaddedTags(getUnaddedTags(image.tags ?? [], projectTags)); }, []); const onDeleteTag = (tagId) => { - console.log(`delete tag: ${tagId}`) + const deleteTagDto = { + tagId: tagId, + imageId: image._id + }; + dispatch(editTag('delete', deleteTagDto)); } const onAddTag = ({ value }) => { @@ -91,19 +97,20 @@ export const ImageTagsToolbar = ({ onAddTag(tag)} /> - { imageTags.map(({ id, name, color }) => ( + { imageTags.map(({ _id, name, color }) => ( onDeleteTag(tagId)} /> ))} diff --git a/src/features/projects/ManageTagsModal/ManageTagsModal.jsx b/src/features/projects/ManageTagsModal/ManageTagsModal.jsx index 74b5a33f..9774d501 100644 --- a/src/features/projects/ManageTagsModal/ManageTagsModal.jsx +++ b/src/features/projects/ManageTagsModal/ManageTagsModal.jsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useState } from 'react'; import { styled } from '../../../theme/stitches.config'; import { EditableTag } from './EditableTag'; import { EditTag } from './EditTag'; @@ -6,7 +6,7 @@ import Button from '../../../components/Button'; import { SimpleSpinner, SpinnerOverlay } from '../../../components/Spinner'; import { DeleteTagAlert } from './DeleteTagAlert'; import { useDispatch, useSelector } from 'react-redux'; -import { createProjectTag, deleteProjectTag, selectProjectTags, updateProjectTag } from '../projectsSlice'; +import { createProjectTag, deleteProjectTag, selectProjectTags, selectTagsLoading, updateProjectTag } from '../projectsSlice'; const EditableTagsContainer = styled('div', { overflowY: 'scroll', @@ -33,17 +33,12 @@ export const ManageTagsModal = () => { const dispatch = useDispatch(); const tags = useSelector(selectProjectTags); - const [isLoading, setIsLoading] = useState(false); const [isNewTagOpen, setIsNewTagOpen] = useState(false); const [isAlertOpen, setIsAlertOpen] = useState(false); const [tagToDelete, setTagToDelete] = useState(''); - // TODO - // to avoid lint error - useEffect(() => { - setIsLoading(false) - }) + const isLoading = useSelector(selectTagsLoading); const onConfirmEdit = (tagId, tagName, tagColor) => { console.log("edit", tagId, tagName, tagColor); From ff9cc46102a634136cc2eed2432e27918c5e05f5 Mon Sep 17 00:00:00 2001 From: Jesse Leung Date: Tue, 5 Nov 2024 21:21:19 -0500 Subject: [PATCH 08/18] feat(138): add errors to error toast --- src/components/ErrorToast.jsx | 10 ++++++++++ src/features/projects/ManageTagsModal/EditTag.jsx | 13 ++++++++----- src/features/projects/projectsSlice.js | 7 +++++++ src/features/review/reviewSlice.js | 6 ++++++ 4 files changed, 31 insertions(+), 5 deletions(-) diff --git a/src/components/ErrorToast.jsx b/src/components/ErrorToast.jsx index f1983c79..cf769a7b 100644 --- a/src/components/ErrorToast.jsx +++ b/src/components/ErrorToast.jsx @@ -8,6 +8,8 @@ import { dismissLabelsError, selectCommentsErrors, dismissCommentsError, + selectTagsErrors, + dismissTagsError, } from '../features/review/reviewSlice'; import { selectProjectsErrors, @@ -20,6 +22,8 @@ import { dismissCreateProjectError, selectManageLabelsErrors, dismissManageLabelsError, + selectProjectTagErrors, + dismissProjectTagErrors } from '../features/projects/projectsSlice'; import { selectWirelessCamerasErrors, @@ -57,6 +61,7 @@ import { selectManageUserErrors, dismissManageUsersError } from '../features/pro const ErrorToast = () => { const dispatch = useDispatch(); const labelsErrors = useSelector(selectLabelsErrors); + const tagsErrors = useSelector(selectTagsErrors); const commentsErrors = useSelector(selectCommentsErrors); const projectsErrors = useSelector(selectProjectsErrors); const viewsErrors = useSelector(selectViewsErrors); @@ -74,9 +79,12 @@ const ErrorToast = () => { const manageLabelsErrors = useSelector(selectManageLabelsErrors); const uploadErrors = useSelector(selectUploadErrors); const cameraSerialNumberErrors = useSelector(selectCameraSerialNumberErrors); + const projectTagErrors = useSelector(selectProjectTagErrors); const enrichedErrors = [ enrichErrors(labelsErrors, 'Label Error', 'labels'), + enrichErrors(tagsErrors, 'Tag Error', 'tags'), + enrichErrors(projectTagErrors, 'Tag Error', 'projectTags'), enrichErrors(commentsErrors, 'Comment Error', 'comments'), enrichErrors(projectsErrors, 'Project Error', 'projects'), enrichErrors(viewsErrors, 'View Error', 'views'), @@ -144,6 +152,8 @@ const ErrorToast = () => { const dismissErrorActions = { labels: (i) => dismissLabelsError(i), + tags: (i) => dismissTagsError(i), + projectTags: (i) => dismissProjectTagErrors(i), comments: (i) => dismissCommentsError(i), projects: (i) => dismissProjectsError(i), createProject: (i) => dismissCreateProjectError(i), diff --git a/src/features/projects/ManageTagsModal/EditTag.jsx b/src/features/projects/ManageTagsModal/EditTag.jsx index 53fbb1e6..41d7bcc1 100644 --- a/src/features/projects/ManageTagsModal/EditTag.jsx +++ b/src/features/projects/ManageTagsModal/EditTag.jsx @@ -47,13 +47,14 @@ const ColorSwatch = styled('button', { const createTagNameSchema = (currentName, allNames) => { return Yup.string() - .required('Enter a label name.') - .matches(/^[a-zA-Z0-9_. -]*$/, "Labels can't contain special characters") - .test('unique', 'A label with this name already exists.', (val) => { + .required('Enter a tag name.') + .matches(/^[a-zA-Z0-9_. -]*$/, "Tags can't contain special characters") + .test('unique', 'A tag with this name already exists.', (val) => { + const allNamesLowerCase = allNames.map((n) => n.toLowerCase()) if (val?.toLowerCase() === currentName.toLowerCase()) { // name hasn't changed return true; - } else if (!allNames.includes(val?.toLowerCase())) { + } else if (!allNamesLowerCase.includes(val?.toLowerCase())) { // name hasn't already been used return true; } else { @@ -155,7 +156,6 @@ export const EditTag = ({ const [color, setColor] = useState(currentColor); const [tempColor, setTempColor] = useState(currentColor); - const tagNameSchema = createTagNameSchema(currentName, allTagNames); const [nameError, setNameError] = useState(""); const [colorError, setColorError] = useState(""); @@ -191,6 +191,9 @@ export const EditTag = ({ const onConfirmEdit = () => { let validatedName = ""; let validatedColor = ""; + + const tagNameSchema = createTagNameSchema(currentName, allTagNames); + // If the user typed in a color, tempColor !== color const submittedColor = tempColor !== color ? tempColor : color; try { diff --git a/src/features/projects/projectsSlice.js b/src/features/projects/projectsSlice.js index 36229fc6..b99d9965 100644 --- a/src/features/projects/projectsSlice.js +++ b/src/features/projects/projectsSlice.js @@ -432,6 +432,11 @@ export const projectsSlice = createSlice({ state.loadingStates.projectTags = ls; }, + dismissProjectTagErrors: (state, { payload }) => { + const index = payload; + state.loadingStates.projectTags.errors.splice(index, 1); + }, + setModalOpen: (state, { payload }) => { state.modalOpen = payload; }, @@ -487,6 +492,7 @@ export const { setSelectedProjAndView, setUnsavedViewChanges, dismissProjectsError, + dismissProjectTagErrors, createProjectStart, createProjectSuccess, createProjectFailure, @@ -915,5 +921,6 @@ export const selectModelOptionsLoading = (state) => export const selectProjectLabelsLoading = (state) => state.projects.loadingStates.projectLabels; export const selectManageLabelsErrors = (state) => state.projects.loadingStates.projectLabels.errors; +export const selectProjectTagErrors = (state) => state.projects.loadingStates.projectTags.errors; export default projectsSlice.reducer; diff --git a/src/features/review/reviewSlice.js b/src/features/review/reviewSlice.js index 7b8ada72..2b5362cd 100644 --- a/src/features/review/reviewSlice.js +++ b/src/features/review/reviewSlice.js @@ -206,6 +206,11 @@ export const reviewSlice = createSlice({ state.loadingStates.labels.errors.splice(index, 1); }, + dismissTagsError: (state, { payload }) => { + const index = payload; + state.loadingStates.tags.errors.splice(index, 1); + }, + dismissCommentsError: (state, { payload }) => { const index = payload; state.loadingStates.comments.errors.splice(index, 1); @@ -256,6 +261,7 @@ export const { editTagFailure, editTagSuccess, dismissLabelsError, + dismissTagsError, dismissCommentsError, } = reviewSlice.actions; From 67e947f8f3f44f4d1e4563d13e91c63e6db37873 Mon Sep 17 00:00:00 2001 From: Jesse Leung Date: Sat, 16 Nov 2024 22:16:51 -0500 Subject: [PATCH 09/18] feat(138): add manage tags and manage labels to same modal feat(138): put tags bar below image review fix: fix wording in tag modal --- package-lock.json | 408 +++++++++++++----- src/components/HydratedModal.jsx | 19 +- src/components/PanelHeader.jsx | 1 + src/features/loupe/Loupe.jsx | 6 +- .../projects/ManageTagsAndLabelsModal.jsx | 76 ++++ .../ManageTagsModal/DeleteTagAlert.jsx | 12 +- src/features/projects/SidebarNav.jsx | 18 +- 7 files changed, 394 insertions(+), 146 deletions(-) create mode 100644 src/features/projects/ManageTagsAndLabelsModal.jsx diff --git a/package-lock.json b/package-lock.json index 7782eacf..c95fa9c1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1625,6 +1625,27 @@ "react": "^16.8 || ^17.0 || ^18.0" } }, + "node_modules/@aws-amplify/ui-react/node_modules/@radix-ui/react-tabs": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.0.0.tgz", + "integrity": "sha512-oKUwEDsySVC0uuSEH7SHCVt1+ijmiDFAI9p+fHCtuZdqrRDKIFs09zp5nrmu4ggP6xqSx9lj1VSblnDH+n3IBA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.0", + "@radix-ui/react-context": "1.0.0", + "@radix-ui/react-direction": "1.0.0", + "@radix-ui/react-id": "1.0.0", + "@radix-ui/react-presence": "1.0.0", + "@radix-ui/react-primitive": "1.0.0", + "@radix-ui/react-roving-focus": "1.0.0", + "@radix-ui/react-use-controllable-state": "1.0.0" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + } + }, "node_modules/@aws-amplify/ui-react/node_modules/@radix-ui/react-use-callback-ref": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.0.tgz", @@ -13855,55 +13876,302 @@ } }, "node_modules/@radix-ui/react-tabs": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.0.0.tgz", - "integrity": "sha512-oKUwEDsySVC0uuSEH7SHCVt1+ijmiDFAI9p+fHCtuZdqrRDKIFs09zp5nrmu4ggP6xqSx9lj1VSblnDH+n3IBA==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.1.tgz", + "integrity": "sha512-3GBUDmP2DvzmtYLMsHmpA1GtR46ZDZ+OreXM/N+kkQJOPIgytFWWTfDQmBQKBvaFS0Vno0FktdbVzN28KGrMdw==", + "license": "MIT", "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "1.0.0", - "@radix-ui/react-context": "1.0.0", - "@radix-ui/react-direction": "1.0.0", - "@radix-ui/react-id": "1.0.0", - "@radix-ui/react-presence": "1.0.0", - "@radix-ui/react-primitive": "1.0.0", - "@radix-ui/react-roving-focus": "1.0.0", - "@radix-ui/react-use-controllable-state": "1.0.0" + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-presence": "1.1.1", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-roving-focus": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0" }, "peerDependencies": { - "react": "^16.8 || ^17.0 || ^18.0", - "react-dom": "^16.8 || ^17.0 || ^18.0" + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.0.tgz", + "integrity": "sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-collection": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.0.tgz", + "integrity": "sha512-GZsZslMJEyo1VKm5L1ZJY8tGDxZNPAoUeQUIbKeJfoi7Q4kmig5AsgLMYYuyYbfjd8fBmFORAIwYAkXMnXZgZw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.0", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-slot": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-context": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.0.tgz", + "integrity": "sha512-OKrckBy+sMEgYM/sMmqmErVn0kZqrHPJze+Ql3DzYsDDp0hl0L62nx/2122/Bvps1qz645jlcu2tD9lrRSdf8A==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.0.tgz", + "integrity": "sha512-b4inOtiaOnYf9KWyO3jAeeCG6FeyfY6ldiEPanbUjWd+xIk5wZeHa8yVwmrJ2vderhu/BQvzCrJI0lHd+wIiqw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-context": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz", + "integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-direction": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.0.tgz", + "integrity": "sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-id": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.0.tgz", + "integrity": "sha512-EJUrI8yYh7WOjNOqpoJaf1jlFIH2LvtgAl+YcFqNCa+4hj64ZXmPkAKOFs/ukjz3byN6bdb/AVUqHkI8/uWWMA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-presence": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.1.tgz", + "integrity": "sha512-IeFXVi4YS1K0wVZzXNrbaaUvIJ3qdY+/Ih4eHFhWA9SwGR9UDX7Ck8abvL57C4cv3wwMvUE0OG69Qc3NCcTe/A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-primitive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.0.tgz", + "integrity": "sha512-ZSpFm0/uHa8zTvKBDjLFWLo8dkr4MBsiDLz0g3gMUwqgLHz9rTaRRGYDgvZPtBJgYCBKXkS9fzmoySgr8CO6Cw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } } }, "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-roving-focus": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.0.0.tgz", - "integrity": "sha512-lHvO4MhvoWpeNbiJAoyDsEtbKqP2jkkdwsMVJ3kfqbkC71J/aXE6Th6gkZA1xHEqSku+t+UgoDjvE7Z3gsBpcg==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.0.tgz", + "integrity": "sha512-EA6AMGeq9AEeQDeSH0aZgG198qkfHSbvWTf1HvoDmOB5bBG/qTxjYMWUKMnYiV6J/iP/J8MEFSuB2zRU2n7ODA==", + "license": "MIT", "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "1.0.0", - "@radix-ui/react-collection": "1.0.0", - "@radix-ui/react-compose-refs": "1.0.0", - "@radix-ui/react-context": "1.0.0", - "@radix-ui/react-direction": "1.0.0", - "@radix-ui/react-id": "1.0.0", - "@radix-ui/react-primitive": "1.0.0", - "@radix-ui/react-use-callback-ref": "1.0.0", - "@radix-ui/react-use-controllable-state": "1.0.0" + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-collection": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.0", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0" }, "peerDependencies": { - "react": "^16.8 || ^17.0 || ^18.0", - "react-dom": "^16.8 || ^17.0 || ^18.0" + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-context": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.0.tgz", + "integrity": "sha512-OKrckBy+sMEgYM/sMmqmErVn0kZqrHPJze+Ql3DzYsDDp0hl0L62nx/2122/Bvps1qz645jlcu2tD9lrRSdf8A==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz", + "integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-use-callback-ref": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.0.tgz", - "integrity": "sha512-GZtyzoHz95Rhs6S63D2t/eqvdFCm7I+yHMLVQheKM7nBD8mbZIt+ct1jz4536MDnaOGKIxynJ8eHTkVGVVkoTg==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz", + "integrity": "sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.1.0.tgz", + "integrity": "sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw==", + "license": "MIT", "dependencies": { - "@babel/runtime": "^7.13.10" + "@radix-ui/react-use-callback-ref": "1.1.0" }, "peerDependencies": { - "react": "^16.8 || ^17.0 || ^18.0" + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.0.tgz", + "integrity": "sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, "node_modules/@radix-ui/react-toast": { @@ -14782,48 +15050,6 @@ } } }, - "node_modules/@radix-ui/themes/node_modules/@radix-ui/react-id": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.0.1.tgz", - "integrity": "sha512-tI7sT/kqYp8p96yGWY1OAnLHrqDgzHefRBKQ2YAkBS5ja7QLcZ9Z/uY7bEjPUatf8RomoXM8/1sMj1IJaE5UzQ==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-layout-effect": "1.0.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/themes/node_modules/@radix-ui/react-presence": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.0.1.tgz", - "integrity": "sha512-UXLW4UAbIY5ZjcvzjfRFo5gxva8QirC9hF7wRE4U5gz+TP0DbRk+//qyuAQ1McDxBt1xNMBTaciFGvEmJvAZCg==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "1.0.1", - "@radix-ui/react-use-layout-effect": "1.0.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0", - "react-dom": "^16.8 || ^17.0 || ^18.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, "node_modules/@radix-ui/themes/node_modules/@radix-ui/react-primitive": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-1.0.3.tgz", @@ -14880,36 +15106,6 @@ } } }, - "node_modules/@radix-ui/themes/node_modules/@radix-ui/react-tabs": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.0.4.tgz", - "integrity": "sha512-egZfYY/+wRNCflXNHx+dePvnz9FbmssDTJBtgRfDY7e8SE5oIo3Py2eCB1ckAbh1Q7cQ/6yJZThJ++sgbxibog==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "1.0.1", - "@radix-ui/react-context": "1.0.1", - "@radix-ui/react-direction": "1.0.1", - "@radix-ui/react-id": "1.0.1", - "@radix-ui/react-presence": "1.0.1", - "@radix-ui/react-primitive": "1.0.3", - "@radix-ui/react-roving-focus": "1.0.4", - "@radix-ui/react-use-controllable-state": "1.0.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0", - "react-dom": "^16.8 || ^17.0 || ^18.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, "node_modules/@radix-ui/themes/node_modules/@radix-ui/react-use-controllable-state": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.0.1.tgz", diff --git a/src/components/HydratedModal.jsx b/src/components/HydratedModal.jsx index 8790951b..50c72ebf 100644 --- a/src/components/HydratedModal.jsx +++ b/src/components/HydratedModal.jsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useState } from 'react'; import { useSelector, useDispatch } from 'react-redux'; import moment from 'moment-timezone'; import { Modal } from './Modal.jsx'; @@ -9,7 +9,6 @@ import AutomationRulesForm from '../features/projects/AutomationRulesForm.jsx'; import SaveViewForm from '../features/projects/SaveViewForm.jsx'; import DeleteViewForm from '../features/projects/DeleteViewForm.jsx'; import ManageUsersModal from '../features/projects/ManageUsersModal.jsx'; -import ManageLabelsModal from '../features/projects/ManageLabelsModal/index.jsx'; import BulkUploadForm from '../features/upload/BulkUploadForm.jsx'; import UpdateCameraSerialNumberForm from '../features/cameras/UpdateCameraSerialNumberForm.jsx'; import { @@ -33,7 +32,7 @@ import { setSelectedCamera, } from '../features/projects/projectsSlice'; import { clearUsers } from '../features/projects/usersSlice.js'; -import { ManageTagsModal } from '../features/projects/ManageTagsModal/ManageTagsModal.jsx'; +import { ManageLabelsAndTagsModal, ManageLabelsAndTagsModalTitle } from '../features/projects/ManageTagsAndLabelsModal.jsx'; // Modal populated with content const HydratedModal = () => { @@ -56,6 +55,8 @@ const HydratedModal = () => { cameraSerialNumberLoading.isLoading || deleteImagesLoading.isLoading; + const [manageTagsAndLabelsTab, setManageTagsAndLabelsTab] = useState("labels"); + const modalContentMap = { 'stats-modal': { title: 'Stats', @@ -110,12 +111,6 @@ const HydratedModal = () => { content: , callBackOnClose: () => dispatch(clearUsers()), }, - 'manage-labels-form': { - title: 'Manage labels', - size: 'md', - content: , - callBackOnClose: () => true, - }, 'update-serial-number-form': { title: 'Edit Camera Serial Number', size: 'md', @@ -125,10 +120,10 @@ const HydratedModal = () => { dispatch(clearCameraSerialNumberTask()); }, }, - 'manage-tags-form': { - title: 'Manage tags', + 'manage-tags-and-labels-form': { + title: , size: 'md', - content: , + content: , callBackOnClose: () => true, } }; diff --git a/src/components/PanelHeader.jsx b/src/components/PanelHeader.jsx index b6a635dd..7a5cfba8 100644 --- a/src/components/PanelHeader.jsx +++ b/src/components/PanelHeader.jsx @@ -5,6 +5,7 @@ import IconButton from './IconButton'; const PanelTitle = styled('span', { // marginLeft: '$2', + flex: '1' }); const ClosePanelButton = styled(IconButton, { diff --git a/src/features/loupe/Loupe.jsx b/src/features/loupe/Loupe.jsx index 2b9013fe..0d4176a2 100644 --- a/src/features/loupe/Loupe.jsx +++ b/src/features/loupe/Loupe.jsx @@ -340,9 +340,6 @@ const Loupe = () => { {image && hasRole(userRoles, WRITE_OBJECTS_ROLES) && ( <> - { handleUnlockAllButtonClick={handleUnlockAllButtonClick} handleIncrementClick={handleIncrementClick} /> + )} diff --git a/src/features/projects/ManageTagsAndLabelsModal.jsx b/src/features/projects/ManageTagsAndLabelsModal.jsx new file mode 100644 index 00000000..e428f832 --- /dev/null +++ b/src/features/projects/ManageTagsAndLabelsModal.jsx @@ -0,0 +1,76 @@ +import { styled } from "../../theme/stitches.config"; +import ManageLabelsModal from "./ManageLabelsModal"; +import { ManageTagsModal } from "./ManageTagsModal/ManageTagsModal"; + +export const ManageLabelsAndTagsModal = ({ + tab = "labels" +}) => { + return ( + <> + { tab === "labels" && + + } + { tab === "tags" && + + } + + ); +} + +const TitleContainer = styled('div', { + display: 'flex', + justifyContent: 'center', + gap: '$2', +}); + +const TabTitle = styled('div', { + width: 'fit-content', + padding: '$1 $3', + borderRadius: '$2', + transition: 'all 40ms linear', + '&:hover': { + background: '$gray4', + cursor: 'pointer' + }, + '&:focus': { + background: '$gray4', + color: '$blue500' + }, + variants: { + active: { + true: { + background: '$gray4', + } + } + } +}); + +const ModalTitle = styled('div', { + position: 'absolute', + left: '$3', + paddingTop: '$1', + paddingBottom: '$1' +}); + +export const ManageLabelsAndTagsModalTitle = ({ + tab, + setTab +}) => { + return ( + + {`Manage ${tab}`} + setTab("labels")} + > + Labels + + setTab("tags")} + > + Tags + + + ); +} diff --git a/src/features/projects/ManageTagsModal/DeleteTagAlert.jsx b/src/features/projects/ManageTagsModal/DeleteTagAlert.jsx index aa18e1ae..bf8ae159 100644 --- a/src/features/projects/ManageTagsModal/DeleteTagAlert.jsx +++ b/src/features/projects/ManageTagsModal/DeleteTagAlert.jsx @@ -48,19 +48,9 @@ export const DeleteTagAlert = ({ Deleting this tag will:
  • - remove it as an option to apply to your images ( - - Note: if this is your only goal, this can also be accomplished by "disabling", rather than - deleting, the tag. - - ) + remove it as an option to apply to your images
  • remove all instances of it from your existing images
  • -
  • - if the tag has been validated as the correct, accurate tag on objects, deleting it will remove the - tag and unlock those objects, which will revert all affected images to a "not-reviewed" - state -
This action can not be undone.
diff --git a/src/features/projects/SidebarNav.jsx b/src/features/projects/SidebarNav.jsx index ab6733be..21013e52 100644 --- a/src/features/projects/SidebarNav.jsx +++ b/src/features/projects/SidebarNav.jsx @@ -121,27 +121,17 @@ const SidebarNav = ({ toggleFiltersPanel, filtersPanelOpen }) => { /> )} - {/* Manage label view */} + {/* Manage label and tag view */} {hasRole(userRoles, WRITE_PROJECT_ROLES) && ( handleModalToggle('manage-labels-form')} + handleClick={() => handleModalToggle('manage-tags-and-labels-form')} icon={} - tooltipContent="Manage labels" + tooltipContent="Manage labels and tags" /> )} - {/* Manage tags view */} - {hasRole(userRoles, WRITE_PROJECT_ROLES) && ( - handleModalToggle('manage-tags-form')} - icon={} - tooltipContent="Manage tags" - /> - )} ); }; From 386e0dc24f3d055e064a1359ad246ecac97300dd Mon Sep 17 00:00:00 2001 From: Jesse Leung Date: Sun, 17 Nov 2024 19:13:29 -0500 Subject: [PATCH 10/18] feat(138): change selector to match shadcn --- src/components/HydratedModal.jsx | 5 +- src/components/TagSelector.jsx | 241 ++++++++++++------ src/features/loupe/ImageTagsToolbar.jsx | 8 +- .../projects/ManageTagsAndLabelsModal.jsx | 1 + 4 files changed, 166 insertions(+), 89 deletions(-) diff --git a/src/components/HydratedModal.jsx b/src/components/HydratedModal.jsx index 50c72ebf..900a9c8c 100644 --- a/src/components/HydratedModal.jsx +++ b/src/components/HydratedModal.jsx @@ -124,7 +124,10 @@ const HydratedModal = () => { title: , size: 'md', content: , - callBackOnClose: () => true, + callBackOnClose: () => { + setManageTagsAndLabelsTab("labels"); + return true; + }, } }; diff --git a/src/components/TagSelector.jsx b/src/components/TagSelector.jsx index 612ddeb9..92b333cd 100644 --- a/src/components/TagSelector.jsx +++ b/src/components/TagSelector.jsx @@ -1,97 +1,170 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { styled } from '../theme/stitches.config.js'; -import { useSelector, useDispatch } from 'react-redux'; -import Select, { createFilter } from 'react-select'; -import { - selectTagsLoading -} from '../features/projects/projectsSlice.js'; -import { addLabelEnd } from '../features/loupe/loupeSlice.js'; +import { Cross2Icon, MagnifyingGlassIcon, PlusCircledIcon } from '@radix-ui/react-icons'; +import { Root as PopoverRoot, PopoverTrigger, PopoverContent, PopoverPortal } from '@radix-ui/react-popover'; +import { mauve, slate, violet } from '@radix-ui/colors'; -const StyledTagSelector = styled(Select, { - width: '155px', - fontFamily: '$mono', +const Selector = styled('div', { + padding: '$1 $3', fontSize: '$2', + color: mauve.mauve11, + fontWeight: 'bold', + display: 'grid', + placeItems: 'center', + height: 32, + borderRadius: '$2', + borderColor: '$gray10', + borderStyle: 'dashed', + borderWidth: '1px', + '&:hover': { + cursor: 'pointer', + background: violet.violet3 + } +}); + +const SelectorTitle = styled('div', { + display: 'flex', + gap: '$2' +}); + +const TagSelectorContent = styled('div', { + background: 'White', + border: '1px solid $border', + borderRadius: '$2', + boxShadow: 'hsl(206 22% 7% / 35%) 0px 10px 38px -10px, hsl(206 22% 7% / 20%) 0px 10px 20px -15px', + overflow: 'hidden', + fontFamily: '$mono', + fontSize: '$3', fontWeight: '$1', - zIndex: '$5', - '.react-select__control': { - boxSizing: 'border-box', - // height: '24px', - minHeight: 'unset', - border: '1px solid', - borderColor: '$border', - borderRadius: '$2', +}); + +const TagSearchContainer = styled('div', { + display: 'flex', + borderTop: '1px solid $border', + paddingBottom: '$1', + paddingTop: '$1', +}); + +const TagSearchIcon = styled('div', { + width: 32, + display: 'grid', + placeItems: 'center' +}); + +const TagSearch = styled('input', { + all: 'unset', + padding: '$1 $3', + paddingLeft: 'unset' +}); + +const TagOptionsContainer = styled('div', { + maxHeight: '50vh', + overflowY: 'scroll', + maxWidth: 450 +}); + +const TagOption = styled('div', { + padding: '$1 $3', + '&:hover': { + background: '$gray3', cursor: 'pointer', - }, - '.react-select__single-value': { - // position: 'relative', - }, - '.react-select__indicator-separator': { - display: 'none', - }, - '.react-select__dropdown-indicator': { - paddingTop: '0', - paddingBottom: '0', - }, - '.react-select__control--is-focused': { - transition: 'all 0.2s ease', - boxShadow: '0 0 0 3px $blue200', - borderColor: '$blue500', - '&:hover': { - boxShadow: '0 0 0 3px $blue200', - borderColor: '$blue500', - }, - }, - '.react-select__menu': { - color: '$textDark', - fontSize: '$3', - '.react-select__option': { - cursor: 'pointer', - }, - '.react-select__option--is-selected': { - color: '$blue500', - backgroundColor: '$blue200', - }, - '.react-select__option--is-focused': { - backgroundColor: '$gray3', - }, - }, + } +}); + +const AllTagsAdded = styled('div', { + padding: '$2 $3', + color: '$gray10' }); +const filterList = (tags, searchTerm) => { + return tags.filter(({ name }) => { + const lower = name.toLowerCase(); + const searchLower = searchTerm.toLowerCase(); + return lower.startsWith(searchLower) + }); +} + export const TagSelector = ({ - css, - tags, - handleTagChange, - handleTagChangeBlur, - menuPlacement = 'top', + tagList, + onAddTag, }) => { - const tagsLoading = useSelector(selectTagsLoading); - const options = tags.map((tag) => { - return { - value: tag._id, - label: tag.name + const [tagOptions, setTagOptions] = useState(tagList); + const [searchValue, setSearchValue] = useState(""); + + const onInput = (e) => { + e.stopPropagation(); + e.preventDefault(); + if (e.target.value !== undefined) { + setSearchValue(e.target.value); } - }); - const dispatch = useDispatch(); - const defaultHandleBlur = () => dispatch(addLabelEnd()); + } + + useEffect(() => { + const options = filterList(tagList, searchValue); + setTagOptions(options); + }, [searchValue]); + + const onClickTag = (tagId) => { + const options = tagOptions.filter(({ _id }) => _id !== tagId ); + setTagOptions(options); + onAddTag(tagId); + } return ( - + + setTagOptions(tagList)}> + + + + Tag + + + + + + + + { tagOptions.length === 0 && + + All tags added + + } + { tagOptions.map(({ _id, name }) => ( + onClickTag(_id)} + > + { name } + + ))} + + + + { searchValue !== "" && + setSearchValue("")} + height={18} + width={18} + color={slate.slate9} + /> + } + { searchValue === "" && + + } + + onInput(e)} + /> + + + + + ); -}; +} diff --git a/src/features/loupe/ImageTagsToolbar.jsx b/src/features/loupe/ImageTagsToolbar.jsx index 0fd6fb32..973f9315 100644 --- a/src/features/loupe/ImageTagsToolbar.jsx +++ b/src/features/loupe/ImageTagsToolbar.jsx @@ -85,9 +85,9 @@ export const ImageTagsToolbar = ({ dispatch(editTag('delete', deleteTagDto)); } - const onAddTag = ({ value }) => { + const onAddTag = (tagId) => { const addTagDto = { - tagId: value, + tagId: tagId, imageId: image._id }; dispatch(editTag('create', addTagDto)); @@ -97,8 +97,8 @@ export const ImageTagsToolbar = ({ onAddTag(tag)} + tagList={unaddedTags} + onAddTag={onAddTag} /> diff --git a/src/features/projects/ManageTagsAndLabelsModal.jsx b/src/features/projects/ManageTagsAndLabelsModal.jsx index e428f832..c4c19c99 100644 --- a/src/features/projects/ManageTagsAndLabelsModal.jsx +++ b/src/features/projects/ManageTagsAndLabelsModal.jsx @@ -1,3 +1,4 @@ +import React from "react"; import { styled } from "../../theme/stitches.config"; import ManageLabelsModal from "./ManageLabelsModal"; import { ManageTagsModal } from "./ManageTagsModal/ManageTagsModal"; From 028cb7e6e12e059307382736d4bb7b86dab1f901 Mon Sep 17 00:00:00 2001 From: Jesse Leung Date: Tue, 19 Nov 2024 23:26:35 -0500 Subject: [PATCH 11/18] feat(138): update UI before api call finishes --- src/components/TagSelector.jsx | 14 +++++++------- src/features/loupe/ImageTagsToolbar.jsx | 21 +++++++++++++++------ 2 files changed, 22 insertions(+), 13 deletions(-) diff --git a/src/components/TagSelector.jsx b/src/components/TagSelector.jsx index 92b333cd..7716ea26 100644 --- a/src/components/TagSelector.jsx +++ b/src/components/TagSelector.jsx @@ -104,10 +104,10 @@ export const TagSelector = ({ setTagOptions(options); }, [searchValue]); - const onClickTag = (tagId) => { - const options = tagOptions.filter(({ _id }) => _id !== tagId ); + const onClickTag = (tag) => { + const options = tagOptions.filter(({ _id }) => _id !== tag._id ); setTagOptions(options); - onAddTag(tagId); + onAddTag(tag); } return ( @@ -129,12 +129,12 @@ export const TagSelector = ({ All tags added } - { tagOptions.map(({ _id, name }) => ( + { tagOptions.map((tag) => ( onClickTag(_id)} + key={tag._id} + onClick={() => onClickTag(tag)} > - { name } + { tag.name } ))} diff --git a/src/features/loupe/ImageTagsToolbar.jsx b/src/features/loupe/ImageTagsToolbar.jsx index 973f9315..9070851f 100644 --- a/src/features/loupe/ImageTagsToolbar.jsx +++ b/src/features/loupe/ImageTagsToolbar.jsx @@ -54,7 +54,7 @@ const getImageTagInfo = (imageTags, projectTags) => { } const getUnaddedTags = (imageTags, projectTags) => { - return projectTags.filter((t) => imageTags.indexOf(t._id) === -1) + return projectTags.filter((t) => imageTags.findIndex((i) => i._id === t._id) === -1) } export const ImageTagsToolbar = ({ @@ -62,14 +62,13 @@ export const ImageTagsToolbar = ({ }) => { const dispatch = useDispatch(); const projectTags = useSelector(selectProjectTags); - const [imageTags, setImageTags] = useState(image.tags ?? []); + const [imageTags, setImageTags] = useState([]); const [unaddedTags, setUnaddedTags] = useState([]); useEffect(() => { const imageTagInfo = getImageTagInfo(image.tags ?? [], projectTags); setImageTags(imageTagInfo); - setUnaddedTags(getUnaddedTags(image.tags ?? [], projectTags)); - }, [image, projectTags]) + }, [image._id, projectTags]) useEffect(() => { const imageTagInfo = getImageTagInfo(image.tags ?? [], projectTags); @@ -77,19 +76,29 @@ export const ImageTagsToolbar = ({ setUnaddedTags(getUnaddedTags(image.tags ?? [], projectTags)); }, []); + useEffect(() => { + setUnaddedTags(getUnaddedTags(imageTags, projectTags)) + }, [imageTags, projectTags]) + const onDeleteTag = (tagId) => { const deleteTagDto = { tagId: tagId, imageId: image._id }; + const idx = imageTags.findIndex((t) => t._id === tagId) + if (idx >= 0) { + imageTags.splice(idx, 1) + setImageTags([...imageTags]) + } dispatch(editTag('delete', deleteTagDto)); } - const onAddTag = (tagId) => { + const onAddTag = (tag) => { const addTagDto = { - tagId: tagId, + tagId: tag._id, imageId: image._id }; + setImageTags([...imageTags, tag]) dispatch(editTag('create', addTagDto)); } From e7c5e4d421ca6a4d5bbb7d1387374132e1a0b6b6 Mon Sep 17 00:00:00 2001 From: Jesse Leung Date: Tue, 19 Nov 2024 23:41:35 -0500 Subject: [PATCH 12/18] fix: prevent keyboard nav feat(138): add hover cursor and text effects --- src/components/TagSelector.jsx | 31 +++++++++++++++++++++++++------ 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/src/components/TagSelector.jsx b/src/components/TagSelector.jsx index 7716ea26..531c6f34 100644 --- a/src/components/TagSelector.jsx +++ b/src/components/TagSelector.jsx @@ -48,7 +48,13 @@ const TagSearchContainer = styled('div', { const TagSearchIcon = styled('div', { width: 32, display: 'grid', - placeItems: 'center' + placeItems: 'center', +}); + +const CrossIcon = styled(Cross2Icon, { + '&:hover': { + cursor: 'pointer' + } }); const TagSearch = styled('input', { @@ -84,16 +90,18 @@ const filterList = (tags, searchTerm) => { }); } +const allTagsAdded = "All tags added" +const noMatches = "No matches" + export const TagSelector = ({ tagList, onAddTag, }) => { const [tagOptions, setTagOptions] = useState(tagList); const [searchValue, setSearchValue] = useState(""); + const [errMessage, setErrMessage] = useState(allTagsAdded); const onInput = (e) => { - e.stopPropagation(); - e.preventDefault(); if (e.target.value !== undefined) { setSearchValue(e.target.value); } @@ -101,9 +109,19 @@ export const TagSelector = ({ useEffect(() => { const options = filterList(tagList, searchValue); + if (searchValue === "") { + setErrMessage(allTagsAdded) + } else if (options.length === 0) { + setErrMessage(noMatches); + } setTagOptions(options); }, [searchValue]); + const onClearSearch = () => { + setSearchValue(""); + setErrMessage(allTagsAdded) + } + const onClickTag = (tag) => { const options = tagOptions.filter(({ _id }) => _id !== tag._id ); setTagOptions(options); @@ -126,7 +144,7 @@ export const TagSelector = ({ { tagOptions.length === 0 && - All tags added + { errMessage } } { tagOptions.map((tag) => ( @@ -141,8 +159,8 @@ export const TagSelector = ({ { searchValue !== "" && - setSearchValue("")} + onClearSearch()} height={18} width={18} color={slate.slate9} @@ -159,6 +177,7 @@ export const TagSelector = ({ { e.stopPropagation(); }} onChange={(e) => onInput(e)} /> From efec5e9b46cfaf11f94c4d94b6ccf30e01efcbd2 Mon Sep 17 00:00:00 2001 From: Jesse Leung Date: Thu, 21 Nov 2024 23:21:50 -0500 Subject: [PATCH 13/18] fix: use fewer use effects fix: close tag menu when changing images fix: order unadded tags fix: remove search bar from tag selector --- src/components/TagSelector.jsx | 139 +++++++++++++----------- src/features/loupe/ImageTagsToolbar.jsx | 46 ++++---- src/features/loupe/Loupe.jsx | 4 +- 3 files changed, 106 insertions(+), 83 deletions(-) diff --git a/src/components/TagSelector.jsx b/src/components/TagSelector.jsx index 531c6f34..5c0e5125 100644 --- a/src/components/TagSelector.jsx +++ b/src/components/TagSelector.jsx @@ -1,8 +1,10 @@ import React, { useEffect, useState } from 'react'; import { styled } from '../theme/stitches.config.js'; -import { Cross2Icon, MagnifyingGlassIcon, PlusCircledIcon } from '@radix-ui/react-icons'; +// [FUTURE FEATURE] +// import { Cross2Icon, MagnifyingGlassIcon } from '@radix-ui/react-icons'; +import { PlusCircledIcon } from '@radix-ui/react-icons'; import { Root as PopoverRoot, PopoverTrigger, PopoverContent, PopoverPortal } from '@radix-ui/react-popover'; -import { mauve, slate, violet } from '@radix-ui/colors'; +import { mauve, violet } from '@radix-ui/colors'; const Selector = styled('div', { padding: '$1 $3', @@ -38,34 +40,35 @@ const TagSelectorContent = styled('div', { fontWeight: '$1', }); -const TagSearchContainer = styled('div', { - display: 'flex', - borderTop: '1px solid $border', - paddingBottom: '$1', - paddingTop: '$1', -}); - -const TagSearchIcon = styled('div', { - width: 32, - display: 'grid', - placeItems: 'center', -}); - -const CrossIcon = styled(Cross2Icon, { - '&:hover': { - cursor: 'pointer' - } -}); - -const TagSearch = styled('input', { - all: 'unset', - padding: '$1 $3', - paddingLeft: 'unset' -}); +// [FUTURE FEATURE] +// const TagSearchContainer = styled('div', { +// display: 'flex', +// borderTop: '1px solid $border', +// paddingBottom: '$1', +// paddingTop: '$1', +// }); +// +// const TagSearchIcon = styled('div', { +// width: 32, +// display: 'grid', +// placeItems: 'center', +// }); +// +// const CrossIcon = styled(Cross2Icon, { +// '&:hover': { +// cursor: 'pointer' +// } +// }); +// +// const TagSearch = styled('input', { +// all: 'unset', +// padding: '$1 $3', +// paddingLeft: 'unset' +// }); const TagOptionsContainer = styled('div', { maxHeight: '50vh', - overflowY: 'scroll', + overflowY: 'auto', maxWidth: 450 }); @@ -82,45 +85,52 @@ const AllTagsAdded = styled('div', { color: '$gray10' }); -const filterList = (tags, searchTerm) => { - return tags.filter(({ name }) => { - const lower = name.toLowerCase(); - const searchLower = searchTerm.toLowerCase(); - return lower.startsWith(searchLower) - }); -} - -const allTagsAdded = "All tags added" -const noMatches = "No matches" +// [FUTURE FEATURE] +// const filterList = (tags, searchTerm) => { +// return tags.filter(({ name }) => { +// const lower = name.toLowerCase(); +// const searchLower = searchTerm.toLowerCase(); +// return lower.startsWith(searchLower) +// }); +// } +// +// const allTagsAdded = "All tags added" +// const noMatches = "No matches" export const TagSelector = ({ tagList, onAddTag, + imageId, }) => { + const [isOpen, setIsOpen] = useState(false); const [tagOptions, setTagOptions] = useState(tagList); - const [searchValue, setSearchValue] = useState(""); - const [errMessage, setErrMessage] = useState(allTagsAdded); - - const onInput = (e) => { - if (e.target.value !== undefined) { - setSearchValue(e.target.value); - } - } - - useEffect(() => { - const options = filterList(tagList, searchValue); - if (searchValue === "") { - setErrMessage(allTagsAdded) - } else if (options.length === 0) { - setErrMessage(noMatches); - } - setTagOptions(options); - }, [searchValue]); - - const onClearSearch = () => { - setSearchValue(""); - setErrMessage(allTagsAdded) - } + const errMessage = "All tags added"; + // [FUTURE FEATURE] + // const [searchValue, setSearchValue] = useState(""); + // const [errMessage, setErrMessage] = useState(allTagsAdded); + + // [FUTURE FEATURE] + // const onInput = (e) => { + // if (e.target.value !== undefined) { + // setSearchValue(e.target.value); + // } + // } + // + // useEffect(() => { + // const options = filterList(tagList, searchValue); + // if (searchValue === "") { + // setErrMessage(allTagsAdded) + // } else if (options.length === 0) { + // setErrMessage(noMatches); + // } + // setTagOptions(options); + // }, [searchValue]); + // + // [FUTURE FEATURE] Leave search commented for now + // const onClearSearch = () => { + // setSearchValue(""); + // setErrMessage(allTagsAdded) + // } const onClickTag = (tag) => { const options = tagOptions.filter(({ _id }) => _id !== tag._id ); @@ -128,8 +138,13 @@ export const TagSelector = ({ onAddTag(tag); } + // Close when changing image using keyboard nav + useEffect(() => { + setIsOpen(false); + }, [imageId]); + return ( - + setTagOptions(tagList)}> @@ -156,6 +171,7 @@ export const TagSelector = ({ ))} + {/* [FUTURE FEATURE] { searchValue !== "" && @@ -181,6 +197,7 @@ export const TagSelector = ({ onChange={(e) => onInput(e)} /> + */} diff --git a/src/features/loupe/ImageTagsToolbar.jsx b/src/features/loupe/ImageTagsToolbar.jsx index 9070851f..ab0f57a2 100644 --- a/src/features/loupe/ImageTagsToolbar.jsx +++ b/src/features/loupe/ImageTagsToolbar.jsx @@ -4,8 +4,7 @@ import { mauve } from '@radix-ui/colors'; import { ImageTag } from "./ImageTag.jsx"; import { TagSelector } from '../../components/TagSelector.jsx'; import { editTag } from '../review/reviewSlice.js'; -import { useDispatch, useSelector } from 'react-redux'; -import { selectProjectTags } from '../projects/projectsSlice.js'; +import { useDispatch } from 'react-redux'; const Toolbar = styled('div', { display: 'flex', @@ -54,31 +53,29 @@ const getImageTagInfo = (imageTags, projectTags) => { } const getUnaddedTags = (imageTags, projectTags) => { - return projectTags.filter((t) => imageTags.findIndex((i) => i._id === t._id) === -1) + return projectTags.filter((t) => imageTags.findIndex((it) => it === t._id) === -1) +} + +// Sort alphabetically with JS magic +const orderUnaddedTags = (unaddedTags) => { + return unaddedTags.sort((a, b) => a.name.localeCompare(b.name)); } export const ImageTagsToolbar = ({ - image + image, + projectTags }) => { const dispatch = useDispatch(); - const projectTags = useSelector(selectProjectTags); - const [imageTags, setImageTags] = useState([]); - const [unaddedTags, setUnaddedTags] = useState([]); - useEffect(() => { - const imageTagInfo = getImageTagInfo(image.tags ?? [], projectTags); - setImageTags(imageTagInfo); - }, [image._id, projectTags]) + const [imageTags, setImageTags] = useState(getImageTagInfo(image.tags, projectTags)); + const [unaddedTags, setUnaddedTags] = useState(orderUnaddedTags(getUnaddedTags(image.tags, projectTags))); + // image._id -> when the enlarged image changes + // projectTags -> so that newly added project tags show up without refreshing useEffect(() => { - const imageTagInfo = getImageTagInfo(image.tags ?? [], projectTags); - setImageTags(imageTagInfo); - setUnaddedTags(getUnaddedTags(image.tags ?? [], projectTags)); - }, []); - - useEffect(() => { - setUnaddedTags(getUnaddedTags(imageTags, projectTags)) - }, [imageTags, projectTags]) + setImageTags(getImageTagInfo(image.tags, projectTags)); + setUnaddedTags(orderUnaddedTags(getUnaddedTags(image.tags, projectTags))); + }, [image._id, projectTags]); const onDeleteTag = (tagId) => { const deleteTagDto = { @@ -87,8 +84,9 @@ export const ImageTagsToolbar = ({ }; const idx = imageTags.findIndex((t) => t._id === tagId) if (idx >= 0) { - imageTags.splice(idx, 1) + const removed = imageTags.splice(idx, 1) setImageTags([...imageTags]) + setUnaddedTags(orderUnaddedTags([...unaddedTags, ...removed])) } dispatch(editTag('delete', deleteTagDto)); } @@ -98,7 +96,12 @@ export const ImageTagsToolbar = ({ tagId: tag._id, imageId: image._id }; - setImageTags([...imageTags, tag]) + const idx = unaddedTags.findIndex((t) => t._id === tag._id) + if (idx >= 0) { + setImageTags([...imageTags, tag]); + unaddedTags.splice(idx, 1); + setUnaddedTags(orderUnaddedTags([...unaddedTags])); + } dispatch(editTag('create', addTagDto)); } @@ -108,6 +111,7 @@ export const ImageTagsToolbar = ({ diff --git a/src/features/loupe/Loupe.jsx b/src/features/loupe/Loupe.jsx index 0d4176a2..71e8f57f 100644 --- a/src/features/loupe/Loupe.jsx +++ b/src/features/loupe/Loupe.jsx @@ -15,7 +15,7 @@ import { incrementImage, incrementFocusIndex, } from '../review/reviewSlice.js'; -import { selectModalOpen } from '../projects/projectsSlice.js'; +import { selectModalOpen, selectProjectTags } from '../projects/projectsSlice.js'; import { toggleOpenLoupe, selectReviewMode, selectIsAddingLabel, drawBboxStart, addLabelStart } from './loupeSlice.js'; import { selectUserUsername, selectUserCurrentRoles } from '../auth/authSlice'; import { hasRole, WRITE_OBJECTS_ROLES } from '../auth/roles.js'; @@ -119,6 +119,7 @@ const Loupe = () => { const focusIndex = useSelector(selectFocusIndex); const image = workingImages[focusIndex.image]; const dispatch = useDispatch(); + const projectTags = useSelector(selectProjectTags); // // track reivew mode // const reviewMode = useSelector(selectReviewMode); @@ -352,6 +353,7 @@ const Loupe = () => { /> )} From 133a690af3f6354f0963a7cf950aa32c334c6804 Mon Sep 17 00:00:00 2001 From: Nathaniel Rindlaub Date: Mon, 2 Dec 2024 14:39:43 -0800 Subject: [PATCH 14/18] feat(138): add info icon and tooltip to manage labels and tags modal --- .../projects/ManageTagsAndLabelsModal.jsx | 82 +++++++++++-------- 1 file changed, 49 insertions(+), 33 deletions(-) diff --git a/src/features/projects/ManageTagsAndLabelsModal.jsx b/src/features/projects/ManageTagsAndLabelsModal.jsx index c4c19c99..4c7463d6 100644 --- a/src/features/projects/ManageTagsAndLabelsModal.jsx +++ b/src/features/projects/ManageTagsAndLabelsModal.jsx @@ -1,26 +1,22 @@ -import React from "react"; -import { styled } from "../../theme/stitches.config"; -import ManageLabelsModal from "./ManageLabelsModal"; -import { ManageTagsModal } from "./ManageTagsModal/ManageTagsModal"; +import React from 'react'; +import { styled } from '../../theme/stitches.config'; +import ManageLabelsModal from './ManageLabelsModal'; +import { ManageTagsModal } from './ManageTagsModal/ManageTagsModal'; +import InfoIcon from '../../components/InfoIcon'; -export const ManageLabelsAndTagsModal = ({ - tab = "labels" -}) => { +export const ManageLabelsAndTagsModal = ({ tab = 'labels' }) => { return ( <> - { tab === "labels" && - - } - { tab === "tags" && - - } + {tab === 'labels' && } + {tab === 'tags' && } ); -} +}; const TitleContainer = styled('div', { display: 'flex', justifyContent: 'center', + alignItems: 'center', gap: '$2', }); @@ -31,47 +27,67 @@ const TabTitle = styled('div', { transition: 'all 40ms linear', '&:hover': { background: '$gray4', - cursor: 'pointer' + cursor: 'pointer', }, '&:focus': { background: '$gray4', - color: '$blue500' + color: '$blue500', }, variants: { active: { true: { background: '$gray4', - } - } - } + }, + }, + }, }); const ModalTitle = styled('div', { position: 'absolute', left: '$3', paddingTop: '$1', - paddingBottom: '$1' + paddingBottom: '$1', +}); + +const TagsVsLabelsContent = styled('div', { + maxWidth: '300px', }); -export const ManageLabelsAndTagsModalTitle = ({ - tab, - setTab -}) => { +const TagsVsLabelsHelp = () => ( + +

+ Labels describe an Object within an image (e.g., “animal”, “rodent”, “sasquatch“) and can be + applied by either AI or humans. +

+

+ Tags are used to describe the image as a whole (e.g., “favorite”, “seen”, “predation event”), + and can only be applied to an image by a human reviewers. +

+

+ See the{' '} + + documentation + {' '} + for more information. +

+
+); + +export const ManageLabelsAndTagsModalTitle = ({ tab, setTab }) => { return ( {`Manage ${tab}`} - setTab("labels")} - > + setTab('labels')}> Labels - setTab("tags")} - > + setTab('tags')}> Tags + } /> ); -} +}; From ffcccdf03f1a085715cfaa63affe95986d00416c Mon Sep 17 00:00:00 2001 From: Nathaniel Rindlaub Date: Mon, 2 Dec 2024 14:58:31 -0800 Subject: [PATCH 15/18] feat(138): update label/tag toggle styling --- .../projects/ManageTagsAndLabelsModal.jsx | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/features/projects/ManageTagsAndLabelsModal.jsx b/src/features/projects/ManageTagsAndLabelsModal.jsx index 4c7463d6..12ee7e16 100644 --- a/src/features/projects/ManageTagsAndLabelsModal.jsx +++ b/src/features/projects/ManageTagsAndLabelsModal.jsx @@ -23,7 +23,8 @@ const TitleContainer = styled('div', { const TabTitle = styled('div', { width: 'fit-content', padding: '$1 $3', - borderRadius: '$2', + borderRadius: '$4', + fontSize: '$3', transition: 'all 40ms linear', '&:hover': { background: '$gray4', @@ -36,7 +37,12 @@ const TabTitle = styled('div', { variants: { active: { true: { - background: '$gray4', + background: '$hiContrast', + color: '$loContrast', + '&:hover': { + background: '$hiContrast', + cursor: 'pointer', + }, }, }, }, @@ -56,12 +62,12 @@ const TagsVsLabelsContent = styled('div', { const TagsVsLabelsHelp = () => (

- Labels describe an Object within an image (e.g., “animal”, “rodent”, “sasquatch“) and can be - applied by either AI or humans. + Labels are used to describe an Object within an image (e.g., “animal”, “rodent”, + “sasquatch“) and can be applied by either AI or humans.

- Tags are used to describe the image as a whole (e.g., “favorite”, “seen”, “predation event”), - and can only be applied to an image by a human reviewers. + Tags are used to annotate the image as a whole (e.g., “favorite”, “seen”, “predation + event”), and can only be applied to an image by human reviewers.

See the{' '} From 8d5799a13ae9e82468f1453e0b6fca7ed86217e6 Mon Sep 17 00:00:00 2001 From: Nathaniel Rindlaub Date: Mon, 2 Dec 2024 15:28:02 -0800 Subject: [PATCH 16/18] feat(138): update tag selector fallback text --- src/components/TagSelector.jsx | 62 ++++++++++++------------- src/features/loupe/ImageTagsToolbar.jsx | 58 +++++++++++------------ 2 files changed, 59 insertions(+), 61 deletions(-) diff --git a/src/components/TagSelector.jsx b/src/components/TagSelector.jsx index 5c0e5125..6e1f8bd8 100644 --- a/src/components/TagSelector.jsx +++ b/src/components/TagSelector.jsx @@ -3,7 +3,12 @@ import { styled } from '../theme/stitches.config.js'; // [FUTURE FEATURE] // import { Cross2Icon, MagnifyingGlassIcon } from '@radix-ui/react-icons'; import { PlusCircledIcon } from '@radix-ui/react-icons'; -import { Root as PopoverRoot, PopoverTrigger, PopoverContent, PopoverPortal } from '@radix-ui/react-popover'; +import { + Root as PopoverRoot, + PopoverTrigger, + PopoverContent, + PopoverPortal, +} from '@radix-ui/react-popover'; import { mauve, violet } from '@radix-ui/colors'; const Selector = styled('div', { @@ -20,13 +25,13 @@ const Selector = styled('div', { borderWidth: '1px', '&:hover': { cursor: 'pointer', - background: violet.violet3 - } + background: violet.violet3, + }, }); const SelectorTitle = styled('div', { display: 'flex', - gap: '$2' + gap: '$2', }); const TagSelectorContent = styled('div', { @@ -69,7 +74,7 @@ const TagSelectorContent = styled('div', { const TagOptionsContainer = styled('div', { maxHeight: '50vh', overflowY: 'auto', - maxWidth: 450 + maxWidth: 450, }); const TagOption = styled('div', { @@ -77,12 +82,12 @@ const TagOption = styled('div', { '&:hover': { background: '$gray3', cursor: 'pointer', - } + }, }); -const AllTagsAdded = styled('div', { +const DefaultTagMessage = styled('div', { padding: '$2 $3', - color: '$gray10' + color: '$gray10', }); // [FUTURE FEATURE] @@ -97,15 +102,12 @@ const AllTagsAdded = styled('div', { // const allTagsAdded = "All tags added" // const noMatches = "No matches" -export const TagSelector = ({ - tagList, - onAddTag, - imageId, -}) => { +export const TagSelector = ({ projectTags, unaddedTags, onAddTag, imageId }) => { const [isOpen, setIsOpen] = useState(false); - const [tagOptions, setTagOptions] = useState(tagList); - const errMessage = "All tags added"; + const [tagOptions, setTagOptions] = useState(unaddedTags); + // [FUTURE FEATURE] + // const errMessage = 'All tags added'; // const [searchValue, setSearchValue] = useState(""); // const [errMessage, setErrMessage] = useState(allTagsAdded); @@ -133,10 +135,10 @@ export const TagSelector = ({ // } const onClickTag = (tag) => { - const options = tagOptions.filter(({ _id }) => _id !== tag._id ); + const options = tagOptions.filter(({ _id }) => _id !== tag._id); setTagOptions(options); onAddTag(tag); - } + }; // Close when changing image using keyboard nav useEffect(() => { @@ -145,29 +147,25 @@ export const TagSelector = ({ return ( - setTagOptions(tagList)}> + setTagOptions(unaddedTags)}> - + Tag - + - { tagOptions.length === 0 && - - { errMessage } - - } - { tagOptions.map((tag) => ( - onClickTag(tag)} - > - { tag.name } + {projectTags.length === 0 && No tags available} + {projectTags.length > 0 && tagOptions.length === 0 && ( + All tags added + )} + {tagOptions.map((tag) => ( + onClickTag(tag)}> + {tag.name} ))} @@ -203,4 +201,4 @@ export const TagSelector = ({ ); -} +}; diff --git a/src/features/loupe/ImageTagsToolbar.jsx b/src/features/loupe/ImageTagsToolbar.jsx index ab0f57a2..5a6f2921 100644 --- a/src/features/loupe/ImageTagsToolbar.jsx +++ b/src/features/loupe/ImageTagsToolbar.jsx @@ -1,7 +1,7 @@ import React, { useEffect, useState } from 'react'; -import { styled } from "../../theme/stitches.config"; +import { styled } from '../../theme/stitches.config'; import { mauve } from '@radix-ui/colors'; -import { ImageTag } from "./ImageTag.jsx"; +import { ImageTag } from './ImageTag.jsx'; import { TagSelector } from '../../components/TagSelector.jsx'; import { editTag } from '../review/reviewSlice.js'; import { useDispatch } from 'react-redux'; @@ -11,19 +11,19 @@ const Toolbar = styled('div', { height: 'calc(32px + $2 + $2)', width: '100%', borderBottom: '1px solid $border', - position: 'relative' + position: 'relative', }); const TagsContainer = styled('div', { position: 'relative', - width: '100%' + width: '100%', }); const TagSelectorContainer = styled('div', { margin: '$2', marginRight: '0', display: 'grid', - placeItems: 'center' + placeItems: 'center', }); const ScrollContainer = styled('div', { @@ -43,32 +43,31 @@ const ScrollContainer = styled('div', { const Separator = styled('div', { width: '1px', backgroundColor: mauve.mauve6, - margin: '$2 10px' + margin: '$2 10px', }); const getImageTagInfo = (imageTags, projectTags) => { return projectTags.filter((t) => { - return imageTags.find((it) => it === t._id) !== undefined + return imageTags.find((it) => it === t._id) !== undefined; }); -} +}; const getUnaddedTags = (imageTags, projectTags) => { - return projectTags.filter((t) => imageTags.findIndex((it) => it === t._id) === -1) -} + return projectTags.filter((t) => imageTags.findIndex((it) => it === t._id) === -1); +}; // Sort alphabetically with JS magic const orderUnaddedTags = (unaddedTags) => { return unaddedTags.sort((a, b) => a.name.localeCompare(b.name)); -} +}; -export const ImageTagsToolbar = ({ - image, - projectTags -}) => { +export const ImageTagsToolbar = ({ image, projectTags }) => { const dispatch = useDispatch(); const [imageTags, setImageTags] = useState(getImageTagInfo(image.tags, projectTags)); - const [unaddedTags, setUnaddedTags] = useState(orderUnaddedTags(getUnaddedTags(image.tags, projectTags))); + const [unaddedTags, setUnaddedTags] = useState( + orderUnaddedTags(getUnaddedTags(image.tags, projectTags)), + ); // image._id -> when the enlarged image changes // projectTags -> so that newly added project tags show up without refreshing @@ -80,36 +79,37 @@ export const ImageTagsToolbar = ({ const onDeleteTag = (tagId) => { const deleteTagDto = { tagId: tagId, - imageId: image._id + imageId: image._id, }; - const idx = imageTags.findIndex((t) => t._id === tagId) + const idx = imageTags.findIndex((t) => t._id === tagId); if (idx >= 0) { - const removed = imageTags.splice(idx, 1) - setImageTags([...imageTags]) - setUnaddedTags(orderUnaddedTags([...unaddedTags, ...removed])) + const removed = imageTags.splice(idx, 1); + setImageTags([...imageTags]); + setUnaddedTags(orderUnaddedTags([...unaddedTags, ...removed])); } dispatch(editTag('delete', deleteTagDto)); - } + }; const onAddTag = (tag) => { const addTagDto = { tagId: tag._id, - imageId: image._id + imageId: image._id, }; - const idx = unaddedTags.findIndex((t) => t._id === tag._id) + const idx = unaddedTags.findIndex((t) => t._id === tag._id); if (idx >= 0) { setImageTags([...imageTags, tag]); unaddedTags.splice(idx, 1); setUnaddedTags(orderUnaddedTags([...unaddedTags])); } dispatch(editTag('create', addTagDto)); - } + }; return ( - @@ -117,7 +117,7 @@ export const ImageTagsToolbar = ({ - { imageTags.map(({ _id, name, color }) => ( + {imageTags.map(({ _id, name, color }) => ( ); -} +}; From 5900c0898a99baf6ba2edda84894e9e4a4167f75 Mon Sep 17 00:00:00 2001 From: Nathaniel Rindlaub Date: Mon, 2 Dec 2024 15:55:53 -0800 Subject: [PATCH 17/18] feat(138): add link to docs in tag selector fallback text --- src/components/TagSelector.jsx | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/components/TagSelector.jsx b/src/components/TagSelector.jsx index 6e1f8bd8..a23252f4 100644 --- a/src/components/TagSelector.jsx +++ b/src/components/TagSelector.jsx @@ -159,7 +159,19 @@ export const TagSelector = ({ projectTags, unaddedTags, onAddTag, imageId }) => - {projectTags.length === 0 && No tags available} + {projectTags.length === 0 && ( + + No tags{' '} + + tags + {' '} + available + + )} {projectTags.length > 0 && tagOptions.length === 0 && ( All tags added )} From 0fc4d386885c38f397cf486caab0139b59c95dd9 Mon Sep 17 00:00:00 2001 From: Nathaniel Rindlaub Date: Mon, 2 Dec 2024 19:30:43 -0800 Subject: [PATCH 18/18] feat(138): update helper text --- src/components/TagSelector.jsx | 5 +++-- .../projects/ManageTagsAndLabelsModal.jsx | 19 +++++++++++-------- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/src/components/TagSelector.jsx b/src/components/TagSelector.jsx index a23252f4..318ac0da 100644 --- a/src/components/TagSelector.jsx +++ b/src/components/TagSelector.jsx @@ -161,7 +161,7 @@ export const TagSelector = ({ projectTags, unaddedTags, onAddTag, imageId }) => {projectTags.length === 0 && ( - No tags{' '} + No{' '} > tags {' '} - available + available. To add tags to your Project, select the "Manage labels and + tags" button in the sidebar. )} {projectTags.length > 0 && tagOptions.length === 0 && ( diff --git a/src/features/projects/ManageTagsAndLabelsModal.jsx b/src/features/projects/ManageTagsAndLabelsModal.jsx index 12ee7e16..b58f53bc 100644 --- a/src/features/projects/ManageTagsAndLabelsModal.jsx +++ b/src/features/projects/ManageTagsAndLabelsModal.jsx @@ -62,23 +62,26 @@ const TagsVsLabelsContent = styled('div', { const TagsVsLabelsHelp = () => (

- Labels are used to describe an Object within an image (e.g., “animal”, “rodent”, + + Labels + {' '} + are used to describe an Object within an image (e.g., “animal”, “rodent”, “sasquatch“) and can be applied by either AI or humans.

- Tags are used to annotate the image as a whole (e.g., “favorite”, “seen”, “predation - event”), and can only be applied to an image by human reviewers. -

-

- See the{' '} - documentation + Tags {' '} - for more information. + are used to annotate the image as a whole (e.g., “favorite”, “seen”, “predation + event”), and can only be applied to an image by human reviewers.

);