diff --git a/js/components/preview/tags/details/tagDetailRow.js b/js/components/preview/tags/details/tagDetailRow.js index 3ab01febb..fd84c6b64 100644 --- a/js/components/preview/tags/details/tagDetailRow.js +++ b/js/components/preview/tags/details/tagDetailRow.js @@ -162,12 +162,14 @@ const TagDetailRow = memo(({ tag, moleculesToEditIds, moleculesToEdit }) => { isEdit={true} isTagEditor={true} > + {/* category */} {dispatch(getCategoryById(tag.category))?.category} + {/* select hits button */} + {/* discourse button */} {/* Tooltip should not have disabled element as a direct child */} @@ -213,18 +216,23 @@ const TagDetailRow = memo(({ tag, moleculesToEditIds, moleculesToEdit }) => { + {/* user */} {tag.user_id} + {/* date */} {navigator.language ? new Date(tag.create_date).toLocaleDateString(navigator.language) : new Date(tag.create_date).toLocaleDateString()} + {/* */} + {/* edit button */} - + {/* { - + */} ); }); diff --git a/js/components/preview/tags/details/tagDetails.js b/js/components/preview/tags/details/tagDetails.js index 1c7fbdc5e..2f814f0b8 100644 --- a/js/components/preview/tags/details/tagDetails.js +++ b/js/components/preview/tags/details/tagDetails.js @@ -44,6 +44,8 @@ import { import { selectAllTags, clearAllTags } from '../redux/dispatchActions'; import { Button } from '../../../common/Inputs/Button'; import { LoadingContext } from '../../../loading'; +import { EditTagsModal } from '../modal/editTagsModal'; +import { DJANGO_CONTEXT } from '../../../../utils/djangoContext'; export const heightOfBody = '172px'; export const defaultHeaderPadding = 15; @@ -169,6 +171,7 @@ const TagDetails = memo(() => { const [tagList, setTagList] = useState([]); const [selectAll, setSelectAll] = useState(true); + const [showEditTagsModal, setShowEditTagsModal] = useState(false); const [searchString, setSearchString] = useState(null); tagDetailView = tagDetailView?.tagDetailView === undefined ? tagDetailView : tagDetailView.tagDetailView; @@ -337,6 +340,10 @@ const TagDetails = memo(() => { setSelectAll(!selectAll); }; + const handleEditTagsButton = () => { + setShowEditTagsModal(!showEditTagsModal); + }; + return ( { Select all tags + {DJANGO_CONTEXT.pk && ([ + + + , + + ])} -
+
{tagDetailView ? ( <>
@@ -581,7 +604,7 @@ const TagDetails = memo(() => {
-
+
{filteredTagList && filteredTagList.map((tag, idx) => { @@ -604,9 +627,9 @@ const TagDetails = memo(() => {
)}
-
+ {/*
-
+
*/} ); }); diff --git a/js/components/preview/tags/modal/editTagsModal.js b/js/components/preview/tags/modal/editTagsModal.js new file mode 100644 index 000000000..9a9c703e9 --- /dev/null +++ b/js/components/preview/tags/modal/editTagsModal.js @@ -0,0 +1,453 @@ +import React, { useContext, useEffect, useMemo, useState } from "react"; +import { Button, Checkbox, Grid, IconButton, InputLabel, MenuItem, Popper, Select, TextField, Tooltip, makeStyles, withStyles } from "@material-ui/core" +import { Panel } from "../../../common"; +import { Close } from "@material-ui/icons"; +import { useDispatch, useSelector } from "react-redux"; +import { DEFAULT_CATEGORY, DEFAULT_TAG_COLOR, augumentTagObjectWithId, compareTagsAsc, createMoleculeTagObject, getCategoriesToBeRemovedFromTagDetails, getEditNewTagCategories } from "../utils/tagUtils"; +import { ColorPicker } from "../../../common/Components/ColorPicker"; +import { DJANGO_CONTEXT } from "../../../../utils/djangoContext"; +import { createNewTag, deleteExistingTag } from "../api/tagsApi"; +import { appendMoleculeTag, appendTagList, removeFromTagList, setNoTagsReceived, updateMoleculeInMolLists } from "../../../../reducers/api/actions"; +import { removeSelectedTag, updateTagProp } from "../redux/dispatchActions"; +import { getCategoryById } from "../../molecule/redux/dispatchActions"; +import { ToastContext } from "../../../toast"; + +const useStyles = makeStyles(theme => ({ + leftSide: { + textAlign: "right", + paddingRight: 9, + "& > *": { + fontWeight: "bold" + } + }, + row: { + padding: 2 + }, + deleteButton: { + marginRight: 10 + } +})); + +const NEW_TAG = { id: -1, tag: '-- new tag --' }; + +export const EditTagsModal = ({ open, anchorEl, setOpenDialog }) => { + const classes = useStyles(); + const dispatch = useDispatch(); + + const { toastInfo } = useContext(ToastContext); + + const targetName = useSelector(state => state.apiReducers.target_on_name); + const targetId = useSelector(state => state.apiReducers.target_on); + const preTagList = useSelector(state => state.apiReducers.tagList); + const tagCategories = useSelector(state => state.apiReducers.categoryList); + const allMolList = useSelector(state => state.apiReducers.all_mol_lists); + const moleculesToEditIds = useSelector(state => state.selectionReducers.moleculesToEdit); + + const id = open ? 'simple-popover-tags-editor' : undefined; + const [tag, setTag] = useState(null); + const [tags, setTags] = useState([NEW_TAG]); + const [newTagColor, setNewTagColor] = useState(DEFAULT_TAG_COLOR); + const [newTagName, setNewTagName] = useState(''); + const [newTagLink, setNewTagLink] = useState(''); + const [newHidden, setNewHidden] = useState(false); + const [newTagCategory, setNewTagCategory] = useState(DEFAULT_CATEGORY); + + const getTagLabel = tag => { + return tag.tag_prefix ? tag.tag_prefix + " - " + tag.tag : tag.tag; + } + + const getTagCategory = tag => { + const tagCategory = tagCategories.find(category => category.id === tag?.category); + return tagCategory?.category || ''; + } + + useEffect(() => { + const categoriesToRemove = getCategoriesToBeRemovedFromTagDetails(tagCategories); + const newTagList = preTagList.filter(t => { + if (t.additional_info?.downloadName || categoriesToRemove.some(c => c.id === t.category)) { + return false; + } else { + return true; + } + }); + setTags([NEW_TAG, ...newTagList].sort(compareTagsAsc)); + return () => { + setTag(NEW_TAG); + setTags([NEW_TAG]); + }; + }, [preTagList, tagCategories]); + + useEffect(() => { + if (tag) { + setNewTagCategory(tag.category); + if (tag.colour) { + setNewTagColor(tag.colour); + } else { + const category = dispatch(getCategoryById(tag.category)); + if (category) { + setNewTagColor(category.colour ? `#${category.colour}` : DEFAULT_TAG_COLOR); + } else { + setNewTagColor(DEFAULT_TAG_COLOR); + } + } + setNewTagName(tag.tag); + setNewTagLink(tag.discourse_url); + setNewHidden(tag.hidden || false); + } + }, [dispatch, tag]); + + const comboCategories = useMemo(() => { + return getEditNewTagCategories(tagCategories); + }, [tagCategories]); + + const isRestrictedCategory = useMemo(() => { + return tag && tag.hasOwnProperty('category') ? !comboCategories.some(cc => cc.id === tag.category) : false; + }, [comboCategories, tag]); + + const compoundsCount = useMemo(() => { + let count = 0; + if (tag && tag['site_observations']) { + let uniqueCompounds = []; + allMolList.filter(molecule => tag.site_observations.includes(molecule.id)).forEach(observation => { + // we are looking only for unique cmpd and canon_site_conf combination + const uniqueString = observation.cmpd + '-' + observation.canon_site_conf; + if (uniqueCompounds.includes(uniqueString)) { + // ignore - this combination exists already + } else { + uniqueCompounds.push(uniqueString); + } + }); + count = uniqueCompounds.length; + } + return count; + }, [allMolList, tag]); + + const resetTagToEditState = () => { + setNewTagCategory(1); + setNewTagColor(DEFAULT_TAG_COLOR); + setNewTagName(''); + setNewTagLink(''); + setNewHidden(false); + }; + + const onCategoryForNewTagChange = event => { + setNewTagCategory(event.target.value); + // apply also default color for every categpry + let tagColor = DEFAULT_TAG_COLOR; + const category = dispatch(getCategoryById(event.target.value)); + if (category) { + tagColor = `#${category.colour}`; + } + setNewTagColor(tagColor); + }; + + const onNameForNewTagChange = event => { + setNewTagName(event.target.value); + }; + + const onHiddenForNewTagChange = event => { + setNewHidden(event.target.checked); + }; + + const createTag = () => { + if (newTagName && newTagCategory) { + const tagObject = createMoleculeTagObject( + newTagName, + targetId, + newTagCategory, + DJANGO_CONTEXT.pk, + newTagColor, + newTagLink, + [...moleculesToEditIds], + new Date(), + null, + null, + newHidden + ); + createNewTag(tagObject, targetName).then(molTag => { + let augMolTagObject = augumentTagObjectWithId( + { + tag: molTag.tag, + category: molTag.category, + target: molTag.target, + user: molTag.user, + create_date: molTag.create_date, + colour: molTag.colour, + discourse_url: molTag.discourse_url, + help_text: molTag.help_text, + additional_info: molTag.additional_info, + mol_group: molTag.mol_group, + hidden: molTag.hidden + }, + molTag.id + ); + dispatch(appendTagList(augMolTagObject)); + dispatch(appendMoleculeTag(molTag)); + dispatch(setNoTagsReceived(false)); + toastInfo('Tag was created'); + }); + // reset tag/fields after creating new one + resetTagToEditState(); + } + }; + + const updateTag = () => { + if (tag && newTagCategory && newTagName) { + // update all props at once + if (newTagCategory) { + dispatch( + updateTagProp( + Object.assign({}, tag, { + category: newTagCategory, + colour: newTagColor, + tag: newTagName, + discourse_url: newTagLink, + hidden: newHidden + }), + newTagName, + 'tag' + ) + ); + } else { + dispatch( + updateTagProp( + Object.assign({}, tag, { + colour: newTagColor, + tag: newTagName, + discourse_url: newTagLink, + hidden: newHidden + }), + newTagName, + 'tag' + ) + ); + } + toastInfo('Tag was updated'); + // reset tag/fields after updating selected one + resetTagToEditState(); + } + }; + + const deleteTag = () => { + if (confirm('Do wou want to delete "' + tag.tag + '"?')) { + dispatch(removeSelectedTag(tag)); + dispatch(removeFromTagList(tag)); + // remove from all molecules + const molsForTag = allMolList.filter(mol => { + const tags = mol.tags_set.filter(id => id === tag.id); + return tags && tags.length ? true : false; + }); + if (molsForTag && molsForTag.length) { + molsForTag.forEach(m => { + let newMol = { ...m }; + newMol.tags_set = newMol.tags_set.filter(id => id !== tag.id); + dispatch(updateMoleculeInMolLists(newMol)); + }); + } + deleteExistingTag(tag, tag.id); + toastInfo('Tag was deleted'); + // reset tag/fields after removing selected tag + resetTagToEditState(); + } + }; + + const handleCloseModal = () => { + if (open) { + setOpenDialog(false); + } + }; + + const leftSide = text => { + return + {text} + ; + }; + + const rightSide = child => { + return + {child} + ; + }; + + const CreateButton = withStyles(theme => ({ + root: { + color: theme.palette.success.contrastText, + backgroundColor: theme.palette.success.light, + '&:hover': { + backgroundColor: theme.palette.success.main + } + } + }))(Button); + + return + + + + + + ]} + > + + + {leftSide('Choose a tag')} + {rightSide( + + )} + + + {leftSide('Category')} + {isRestrictedCategory ? + rightSide( + + ) + : + rightSide( + + ) + + } + + + {leftSide('Tag prefix')} + {rightSide( + + + + + + Upload name + + + + + + )} + + + {leftSide('Display name')} + {rightSide( + + )} + + + {leftSide('Colour')} + {rightSide( + { + setNewTagColor(value); + }} + disabled={!DJANGO_CONTEXT.pk} + /> + )} + + + {leftSide('Hidden')} + {rightSide( + + )} + + + {leftSide('#Compounds')} + {rightSide( + + )} + + + {leftSide('#Observations')} + {rightSide( + + )} + + {/* Buttons */} + + {tag && tag.id !== -1 ? ([ + , + + ] + ) : ( + createTag()} variant="contained" disabled={!DJANGO_CONTEXT.pk} size="small"> + Create + + )} + + + + ; +} \ No newline at end of file diff --git a/js/components/preview/tags/redux/dispatchActions.js b/js/components/preview/tags/redux/dispatchActions.js index 20763ddd1..ad6706366 100644 --- a/js/components/preview/tags/redux/dispatchActions.js +++ b/js/components/preview/tags/redux/dispatchActions.js @@ -126,7 +126,7 @@ export const storeData = data => (dispatch, getState) => { dispatch(setTagSelectorData(categories, tags)); let allMolecules = []; - data.molecules.forEach(mol => {}); + data.molecules.forEach(mol => { }); }; export const updateTagProp = (tag, value, prop) => (dispatch, getState) => { @@ -148,7 +148,8 @@ export const updateTagProp = (tag, value, prop) => (dispatch, getState) => { [...moleculeTag.site_observations], newTag.create_date, newTag.additional_info, - moleculeTag.mol_group + moleculeTag.mol_group, + newTag.hidden ); let augMolTagObject = augumentTagObjectWithId(newMolTag, tag.id); dispatch(updateMoleculeTag(augMolTagObject)); diff --git a/js/components/preview/tags/utils/tagUtils.js b/js/components/preview/tags/utils/tagUtils.js index 9faa71c41..8d2876ec9 100644 --- a/js/components/preview/tags/utils/tagUtils.js +++ b/js/components/preview/tags/utils/tagUtils.js @@ -20,7 +20,8 @@ export const createMoleculeTagObject = ( molecules, createDate = new Date(), additionalInfo = null, - molGroup = null + molGroup = null, + hidden = false ) => { return { tag: tagName, @@ -33,7 +34,8 @@ export const createMoleculeTagObject = ( create_date: createDate, help_text: tagName, additional_info: additionalInfo, - mol_group: molGroup + mol_group: molGroup, + hidden: hidden }; }; diff --git a/js/components/services/ServiceStatusRow.js b/js/components/services/ServiceStatusRow.js new file mode 100644 index 000000000..976cf7c86 --- /dev/null +++ b/js/components/services/ServiceStatusRow.js @@ -0,0 +1,58 @@ +import { TableCell, TableRow, makeStyles, styled } from "@material-ui/core"; +import React, { memo, useEffect, useRef, useState } from "react"; +import { StatusLight } from "./StatusLight"; + +const useStyles = makeStyles(theme => ({ + cell: { + color: theme.palette.primary.contrastText + } +})); + +export const ServiceStatusRow = memo(({ service }) => { + + const classes = useStyles(); + const interval = useRef(null); + const [uptime, setUptime] = useState(0); + + const StyledTableRow = styled(TableRow)(({ theme }) => ({ + // hide last border + '&:last-child td, &:last-child th': { + border: 0 + } + })); + + useEffect(() => { + if (service.timestamp) { + interval.current = setInterval(() => { + setUptime(Date.now() - service.timestamp); + }, 1000); + } + + return () => { + if (interval) { + clearInterval(interval.current); + } + } + }, [service, interval]); + + const getServiceDowntime = () => { + if (uptime) { + let minutes = Math.floor(uptime / (1000 * 60)); + let seconds = Math.floor(uptime / (1000) % 60); + if (minutes) { + return `${minutes} m ${seconds} s`; + } else { + return `${seconds} s`; + } + } else { + return ''; + } + } + + return + + {service.state} + {service.name} + {getServiceDowntime()} + ; +}); \ No newline at end of file diff --git a/js/components/services/ServicesStatus.js b/js/components/services/ServicesStatus.js index 5a47c4ab3..197ef3774 100644 --- a/js/components/services/ServicesStatus.js +++ b/js/components/services/ServicesStatus.js @@ -1,19 +1,11 @@ -import { Grid, Table, TableBody, TableCell, TableRow, Tooltip, makeStyles, styled } from "@material-ui/core"; +import { Grid, Table, TableBody, Tooltip, styled } from "@material-ui/core"; import React, { memo } from "react"; import { ServiceStatus } from "./ServiceStatus"; -import { StatusLight } from "./StatusLight"; import { tooltipClasses } from "@mui/material"; - -const useStyles = makeStyles(theme => ({ - cell: { - color: theme.palette.primary.contrastText - } -})); +import { ServiceStatusRow } from "./ServiceStatusRow"; export const ServicesStatus = memo(({ services }) => { - const classes = useStyles(); - const NoMaxWidthTooltip = styled(({ className, ...props }) => ( ))({ @@ -22,19 +14,8 @@ export const ServicesStatus = memo(({ services }) => { } }); - const StyledTableRow = styled(TableRow)(({ theme }) => ({ - // hide last border - '&:last-child td, &:last-child th': { - border: 0 - } - })); - return - {services.map((service) => - - {service.state} - {service.name} - )} + {services.map((service) => )} }> {services.map((service) => diff --git a/js/components/services/ServicesStatusWrapper.js b/js/components/services/ServicesStatusWrapper.js index ae5b627af..52fad2f6d 100644 --- a/js/components/services/ServicesStatusWrapper.js +++ b/js/components/services/ServicesStatusWrapper.js @@ -3,23 +3,39 @@ import React, { memo, useCallback, useContext, useEffect, useState } from "react import { ServicesStatus } from "./ServicesStatus"; import { getServicesStatus } from "./api/api"; import { ToastContext } from "../toast"; +import { SERVICE_STATUSES } from "./constants"; export const ServicesStatusWrapper = memo(() => { const [services, setServices] = useState([]); - const { toastError, toastInfo } = useContext(ToastContext); + const { toastError } = useContext(ToastContext); const checkServices = useCallback((previous, current) => { if (previous.length > 0) { - const changedServices = current.filter(newService => { + // update timestamps for services + current.forEach(newService => { const currentService = previous.find(previousService => previousService.id === newService.id); + // remember previous value + newService.timestamp = currentService.timestamp; if (currentService && currentService.state !== newService.state) { - return true; + if (![SERVICE_STATUSES.OK, SERVICE_STATUSES.DEGRADED].includes(newService.state)) { + newService.timestamp = Date.now(); + } else { + // clear timestamp + newService.timestamp = null; + } + } + }); + } else { + // initial set + current.forEach(service => { + if (![SERVICE_STATUSES.OK, SERVICE_STATUSES.DEGRADED].includes(service.state)) { + service.timestamp = Date.now(); + } else { + service.timestamp = null; } - return false; }); - changedServices.forEach(service => toastInfo(`Status of ${service.name} changed to ${service.state}`)); } - }, [toastInfo]); + }, []); const fetchServicesStatus = useCallback(async () => { const temp = await getServicesStatus(); @@ -29,7 +45,7 @@ export const ServicesStatusWrapper = memo(() => { if (!(prevState.length === 1 && prevState[0]?.id === 'services')) { toastError('Status of services is not available'); } - return [{ id: 'services', name: 'Status of services', state: 'NOT_AVAILABLE' }]; + return [{ id: 'services', name: 'Status of services', state: 'NOT_AVAILABLE', timestamp: Date.now() }]; }); } else { setServices((prevState) => { diff --git a/js/components/services/StatusLight.js b/js/components/services/StatusLight.js index c70a48507..3ba936b68 100644 --- a/js/components/services/StatusLight.js +++ b/js/components/services/StatusLight.js @@ -1,5 +1,6 @@ import { makeStyles } from "@material-ui/core"; import React, { memo } from "react"; +import { SERVICE_STATUSES, SERVICE_STATUS_COLORS } from "./constants"; const useStyles = makeStyles(theme => ({ circle: { @@ -8,17 +9,6 @@ const useStyles = makeStyles(theme => ({ } })); -export const SERVICE_STATUSES = { - OK: 'OK', - DEGRADED: 'DEGRADED' -} - -export const SERVICE_STATUS_COLORS = { - OK: 'green', - DEGRADED: 'orange', - OTHER: 'red' -} - export const StatusLight = memo(({ service }) => { const getColor = (status) => { switch (status) { diff --git a/js/components/services/constants.js b/js/components/services/constants.js new file mode 100644 index 000000000..17cbc8b80 --- /dev/null +++ b/js/components/services/constants.js @@ -0,0 +1,10 @@ +export const SERVICE_STATUSES = { + OK: 'OK', + DEGRADED: 'DEGRADED' +} + +export const SERVICE_STATUS_COLORS = { + OK: 'green', + DEGRADED: 'orange', + OTHER: 'red' +} \ No newline at end of file diff --git a/js/components/services/index.js b/js/components/services/index.js index 039373908..8c29f48d4 100644 --- a/js/components/services/index.js +++ b/js/components/services/index.js @@ -2,3 +2,4 @@ export * from './ServicesStatus'; export * from './ServicesStatusWrapper'; export * from './ServiceStatus'; export * from './api/api'; +export * from './constants'; diff --git a/js/reducers/api/apiReducers.js b/js/reducers/api/apiReducers.js index 51fb3ff21..64ea98b25 100644 --- a/js/reducers/api/apiReducers.js +++ b/js/reducers/api/apiReducers.js @@ -364,6 +364,7 @@ export default function apiReducers(state = INITIAL_STATE, action = {}) { foundTag.colour = action.item.colour; foundTag.category = action.item.category; foundTag.discourse_url = action.item.discourse_url; + foundTag.hidden = action.item.hidden; return { ...state, tagList: [...listWithUpdatedTag] }; } else { diff --git a/webpack.config-dev.js b/webpack.config-dev.js index 8d8a381c6..3cf2442f4 100644 --- a/webpack.config-dev.js +++ b/webpack.config-dev.js @@ -1,6 +1,6 @@ const path = require('path'); const webpack = require('webpack'); -// const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin'); +const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin'); const BundleTracker = require('webpack-bundle-tracker'); const ErrorOverlayPlugin = require('error-overlay-webpack-plugin'); const Dotenv = require('dotenv-webpack'); @@ -42,8 +42,8 @@ module.exports = { new webpack.NamedModulesPlugin(), new webpack.HotModuleReplacementPlugin(), new webpack.NoEmitOnErrorsPlugin(), // don't reload if there is an error - new Dotenv() - // new ReactRefreshWebpackPlugin() + new Dotenv(), + new ReactRefreshWebpackPlugin() ], module: { @@ -53,10 +53,10 @@ module.exports = { enforce: 'pre', exclude: /node_modules/, use: { - loader: require.resolve('babel-loader') - // options: { - // plugins: [require.resolve('react-refresh/babel')].filter(Boolean) - // } + loader: require.resolve('babel-loader'), + options: { + plugins: [require.resolve('react-refresh/babel')].filter(Boolean) + } } }, { test: /\.css$/, loader: 'style-loader!css-loader' },