diff --git a/src/UI/components/shared/textEditor/RichTextEditor.tsx b/src/UI/components/shared/textEditor/RichTextEditor.tsx index e79b8efca..0ddbe564c 100644 --- a/src/UI/components/shared/textEditor/RichTextEditor.tsx +++ b/src/UI/components/shared/textEditor/RichTextEditor.tsx @@ -56,11 +56,13 @@ const ReactStatePlugin = ({ description }: { description: string }) => { }; export const TextEditor = (props: { + label?: string; description: string; initialDescription: string; setDescription: (d: string) => void; error?: boolean; helperText?: string; + hideBlockTypeSelector?: boolean; }) => { const editorConfig = { namespace: 'speciesInformation', @@ -69,6 +71,15 @@ export const TextEditor = (props: { onError(error: Error) { throw error; }, + // Underline (unlike bold/italic) has no native HTML tag Lexical applies + // automatically — it needs a theme class name to attach the formatting to. + // The matching CSS rule (.editor-text-underline) lives in the global stylesheet + // alongside the other .editor-* classes. + theme: { + text: { + underline: 'editor-text-underline', + }, + }, nodes: [ HeadingNode, ListNode, @@ -87,7 +98,12 @@ export const TextEditor = (props: { return (
- + {props.label ? ( + + {props.label} + + ) : null} +
{ +const EditorToolbar = ({ + hideBlockTypeSelector, +}: { + hideBlockTypeSelector?: boolean; +}) => { const t = useTranslations('RichTextEditor'); const [editor] = useLexicalComposerContext(); const [blockType, setBlockType] = useState('paragraph'); @@ -139,19 +143,21 @@ const EditorToolbar = () => { return ( - - - + {!hideBlockTypeSelector && ( + + + + )} { diff --git a/src/UI/components/sources/source_filters.tsx b/src/UI/components/sources/source_filters.tsx index 8e2245d3f..531028bc6 100644 --- a/src/UI/components/sources/source_filters.tsx +++ b/src/UI/components/sources/source_filters.tsx @@ -1,4 +1,10 @@ -import { Box, TextField } from '@mui/material'; +import { + Box, + MenuItem, + Select, + SelectChangeEvent, + TextField, +} from '@mui/material'; import { debounce } from 'lodash'; import { useCallback, useState } from 'react'; import { useAppDispatch } from '../../state/hooks'; @@ -6,6 +12,7 @@ import { getSourceInfo } from '../../state/source/actions/getSourceInfo'; import { changeFilterId, changeFilterText, + changeFilterField, } from '../../state/source/sourceSlice'; import { useTranslations } from 'next-intl'; import { useRouter } from 'next/router'; @@ -17,6 +24,14 @@ const getEndId = (range: string) => { return parseInt(range.substring(range.indexOf('-') + 1, range.length)); }; +// The value each option maps to must match a real column name that +// reference.service.ts's findReferences can filter against. +const FILTER_FIELD_OPTIONS = [ + { value: 'article_title', labelKey: 'filters.fieldTitle' }, + { value: 'author', labelKey: 'filters.fieldAuthor' }, + { value: 'journal_title', labelKey: 'filters.fieldJournalTitle' }, +]; + export default function SourceFilters(): JSX.Element { const t = useTranslations('SourcesPage'); const dispatch = useAppDispatch(); @@ -25,6 +40,7 @@ export default function SourceFilters(): JSX.Element { const hasNumIds = typeof router.query.num_ids === 'string'; const [idError, setIdError] = useState(false); + const [filterField, setFilterField] = useState('article_title'); const idHandler = useCallback( debounce((value: string) => { @@ -70,22 +86,57 @@ export default function SourceFilters(): JSX.Element { textHandler(event.target.value); }; + const handleFieldChange = (event: SelectChangeEvent) => { + const value = event.target.value; + setFilterField(value); + dispatch(changeFilterField(value)); + dispatch(getSourceInfo()); + }; + return ( + diff --git a/src/UI/components/sources/source_form.tsx b/src/UI/components/sources/source_form.tsx index 84091872b..93c20bdd9 100644 --- a/src/UI/components/sources/source_form.tsx +++ b/src/UI/components/sources/source_form.tsx @@ -2,14 +2,16 @@ import { Paper, Box, Button, Typography, Switch } from '@mui/material'; import { useForm, Controller } from 'react-hook-form'; import { yupResolver } from '@hookform/resolvers/yup'; import { FormControlLabel, TextField } from '@mui/material'; -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import * as yup from 'yup'; import { useDispatch } from 'react-redux'; import { AppDispatch } from '../../state/store'; import { DatePicker, LocalizationProvider } from '@mui/x-date-pickers'; import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns'; import { postNewSource } from '../../state/source/actions/postNewSource'; +import { updateSource } from '../../state/source/actions/updateSource'; import { useTranslations } from 'next-intl'; +import { TextEditor } from '../shared/textEditor/RichTextEditor'; export interface NewSource { author: string; @@ -31,35 +33,82 @@ const schema = yup journal_title: yup.string().required(), year: yup.string().required(), published: yup.boolean().required(), - report_type: yup.string().required(), + report_type: yup.string().notRequired(), v_data: yup.boolean().required(), + num_id: yup.number().notRequired(), }) .required(); -export default function SourceForm() { +interface SourceFormProps { + existingSource?: NewSource | null; +} + +const DEFAULT_REPORT_TYPE = 'Journal Article'; + +export default function SourceForm({ existingSource }: SourceFormProps) { const t = useTranslations('NewSourcePage'); + const isEditMode = !!existingSource; - const { register, reset, control, handleSubmit } = useForm({ + const { reset, control, handleSubmit } = useForm({ resolver: yupResolver(schema), defaultValues: { v_data: false, published: false, + report_type: DEFAULT_REPORT_TYPE, }, }); const [year, setYear] = useState(null); + + // Tracks whether the form's values are safe to render into the editors. + // If there's no existingSource (new-source mode), we're ready immediately. + // If there IS an existingSource, we must wait until reset(existingSource) + // has actually run — otherwise TextEditor mounts once with empty content + // and never picks up the real data (Lexical only reads initial content once). + const [formReady, setFormReady] = useState(!existingSource); + const onKeyDown = (e: { preventDefault: () => void }) => { e.preventDefault(); }; const dispatch = useDispatch(); + + useEffect(() => { + if (existingSource) { + reset(existingSource); + setYear(new Date(existingSource.year, 0, 1)); + setFormReady(true); + } + }, [existingSource, reset]); + const onSubmit = async (data: NewSource) => { - console.log(data); - const success = await dispatch(postNewSource(data)); - if (success) { - reset(); + if (isEditMode) { + // updateSource already shows a success/error toast internally + await dispatch(updateSource(data)); + } else { + const resultAction = await dispatch(postNewSource(data)); + // postNewSource already shows a success/error toast internally; + // we just check the real return value to decide whether to clear the form + if ( + postNewSource.fulfilled.match(resultAction) && + resultAction.payload === true + ) { + reset({ + v_data: false, + published: false, + report_type: DEFAULT_REPORT_TYPE, + }); + setYear(null); + } } }; + // Don't render the form (and its TextEditors) until we know Controller's + // values actually reflect existingSource. Briefly returning null avoids + // a flash of empty editors that never get corrected. + if (existingSource && !formReady) { + return null; + } + return ( onSubmit(d))}>
- {t('title')} + {isEditMode ? t('editTitle') : t('title')}
+
( - + helperText={error ? t('authorHelperText') : undefined} + hideBlockTypeSelector + /> )} rules={{ required: 'Author required' }} />
-
+
( - + helperText={error ? t('articleTitleHelperText') : undefined} + hideBlockTypeSelector + /> )} rules={{ required: 'Article Title required' }} /> @@ -129,18 +179,18 @@ export default function SourceForm() { name="journal_title" control={control} render={({ - field: { onChange, value }, + field: { value, onChange }, fieldState: { error }, }) => ( - + helperText={error ? t('journalTitleHelperText') : undefined} + hideBlockTypeSelector + /> )} rules={{ required: 'Journal Title required' }} /> @@ -157,11 +207,11 @@ export default function SourceForm() { }) => ( + /> )} rules={{ required: 'Citation required' }} /> @@ -217,6 +267,7 @@ export default function SourceForm() { }) => ( + /> )} - rules={{ required: 'Report Type required' }} />

@@ -241,9 +290,9 @@ export default function SourceForm() { control={ onChange(e.target.checked)} color="primary" size="medium" - {...register('published')} /> } label={t('published')} @@ -263,9 +312,9 @@ export default function SourceForm() { control={ onChange(e.target.checked)} color="primary" size="medium" - {...register('v_data')} /> } label={t('vectorData')} @@ -276,7 +325,7 @@ export default function SourceForm() {

+ - ))} + )} ))} @@ -155,6 +239,33 @@ export default function SourceTable(): JSX.Element { onRowsPerPageChange={handleChangeRowsPerPage} /> )} + + {canEdit && ( + + {t('deleteConfirm.title')} + + {t('deleteConfirm.message')} + + + + + + + )} ); } diff --git a/src/UI/pages/edit_source.tsx b/src/UI/pages/edit_source.tsx new file mode 100644 index 000000000..f8f23b200 --- /dev/null +++ b/src/UI/pages/edit_source.tsx @@ -0,0 +1,94 @@ +import { Container, Button, Box } from '@mui/material'; +import ArrowBackIcon from '@mui/icons-material/ArrowBack'; +import React, { useEffect } from 'react'; +import { useRouter } from 'next/router'; +import { useDispatch } from 'react-redux'; +import { useTranslations } from 'next-intl'; +import AuthWrapper from '../components/shared/AuthWrapper'; +import SourceForm, { NewSource } from '../components/sources/source_form'; +import { getMessages } from '../utils/localization'; +import { GetServerSidePropsContext } from 'next'; +import { AppDispatch } from '../state/store'; +import { useAppSelector } from '../state/hooks'; +import { getSourceById } from '../state/source/actions/getSourceById'; +import { clearSourceEdit } from '../state/source/sourceSlice'; + +function EditSource(): JSX.Element { + const router = useRouter(); + const dispatch = useDispatch(); + const t = useTranslations('NewSourcePage'); + + const idParam = router.query.id as string | undefined; + + const source_edit = useAppSelector((state) => state.source.source_edit); + const source_edit_status = useAppSelector( + (state) => state.source.source_edit_status + ); + + useEffect(() => { + if (idParam) { + const num_id = parseInt(idParam, 10); + if (!isNaN(num_id)) { + dispatch(getSourceById(num_id)); + } + } + + return () => { + dispatch(clearSourceEdit()); + }; + }, [idParam, dispatch]); + + const handleBack = () => { + if (window.history.length > 1) { + router.back(); + } else { + router.push('/sources'); + } + }; + + return ( + <> +
+
+ + + + +
+ + {source_edit_status === 'success' && source_edit ? ( + + ) : source_edit_status === 'error' ? ( +
+ Could not load this source. It may have been deleted, or the + link is invalid. +
+ ) : ( +
Loading....
+ )} +
+
+
+
+
+ + ); +} + +export async function getServerSideProps(context: GetServerSidePropsContext) { + return await getMessages(context); +} + +export default EditSource; diff --git a/src/UI/public/messages/en.json b/src/UI/public/messages/en.json index f5d3e9eb5..5f32e5554 100644 --- a/src/UI/public/messages/en.json +++ b/src/UI/public/messages/en.json @@ -13,7 +13,7 @@ "species": "Species List", "source": "Source List", "catalogue": "Species Catalogue", - "catalogue": "Species Catalogue", + "addSource": "Add Source", "datasets": "Datasets", "editPointData": "Edit Point Data", @@ -515,7 +515,7 @@ "paragraph1": "Maps are powerful tools. They can illustrate the distribution of mosquito vector species known to transmit some of the world's most debilitating diseases and highlight where these species are no longer susceptible to the insecticides used as their primary method of control.", "paragraph2": "All evidence-based maps rely on field data collected in a myriad of different ways by multiple data collectors for a wide variety of purposes. In isolation, these data are able to answer the questions they were collected to address, but when combined, their value multiplies.", "paragraph3": "The Vector Atlas is an international project dedicated to synthesising complex vector data into intuitive mapped surfaces to support vector control decision making. At its core is the Vector Atlas Data Base (VADB), built over the past three years and continuing to expand. The VADB combines African vector occurrence, bionomics and insecticide resistance data alongside human behaviour, local environment and community information.", - "paragraph4": "It provides a 'one stop shop' of relatable and cross-referenced data access and underpins a comprehensive suite of vector maps, including species suitability, phenotypic insecticide resistance and relative abundance. All curated data and modelled surfaces are available for download via the map page on this platform. Working closely with expert vector teams in Burkina Faso, Côte d’Ivoire, the Democratic Republic of the Congo, Nigeria, Senegal and Uganda, we are bringing spatial modelling to the core of malaria vector control decisions.", + "paragraph4": "It provides a 'one stop shop' of relatable and cross-referenced data access and underpins a comprehensive suite of vector maps, including species suitability, phenotypic insecticide resistance and relative abundance. All curated data and modelled surfaces are available for download via the map page on this platform. Working closely with expert vector teams in Burkina Faso, Côte d'Ivoire, the Democratic Republic of the Congo, Nigeria, Senegal and Uganda, we are bringing spatial modelling to the core of malaria vector control decisions.", "paragraph5": "The Vector Atlas is a University of Oxford, International Centre of Insect Physiology and Ecology (icipe) and The Kids (Australia) initiative, working alongside the Malaria Atlas Project and funded by the Gates Foundation.", "paragraph6": "We are always interested in receiving feedback to continue developing the Vector Atlas resources shared via this platform. If you have any comments, questions or suggestions, please contact us via email." }, @@ -570,16 +570,27 @@ } }, "SpeciesPage": { - "title": "Species List", - "confirmDeleteTitle": "Confirm Delete", + "title": "Species list", + "confirmDeleteTitle": "Confirm deletion", "confirmDeleteMessage": "Are you sure you want to delete this species information? This action cannot be undone.", "buttons": { - "create": "Create New Species", - "edit": "Edit Item", - "deleteItem": "Delete Item", - "more": "See more details", + "create": "Create new species", + "edit": "Edit item", + "deleteItem": "Delete item", + "more": "View more details", "cancel": "Cancel", - "delete": "Delete" + "delete": "Delete", + "preview": "Preview", + "download": "Download", + "downloadFull": "Download full image", + "close": "Close", + "back": "Back to Species List" + }, + "tooltips": { + "zoomOut": "Zoom out", + "zoomIn": "Zoom in", + "resetZoom": "Reset zoom", + "closePreview": "Close preview" }, "countryTable": { "title": "Country Registry Catalog", @@ -634,30 +645,44 @@ } } }, - "SourcesPage": { - "title": "Source List", - "grid": { - "id": "ID", - "author": "Author", - "title": "Title", - "journalTitle": "Journal Title", - "year": "Year", - "published": "Published", - "vectorData": "Vector Data" - }, - "filters": { - "errorMsg": "Please enter range (e.g. 100-200)", - "idFilter": "Filter by id (e.g. 100-200)", - "titleFilter": "Filter by Title" - } + + +"SourcesPage": { + "title": "Source List", + "edit": "Edit", + "delete": "Delete", + "deleteConfirm": { + "title": "Delete this source?", + "message": "This action cannot be undone. Are you sure you want to delete this source?", + "confirm": "Delete", + "cancel": "Cancel" }, + "filters": { + "titleFilter": "Filter by Title", + "fieldTitle": "Title", + "fieldAuthor": "Author", + "fieldJournalTitle": "Journal Title", + "searchPlaceholder": "Search..." + }, + "grid": { + "author": "Author", + "title": "Title", + "journalTitle": "Journal Title", + "year": "Year", + "actions": "Actions" + } +}, "NewSourcePage": { "title": "Add a new reference source", + "editTitle": "Edit reference source", "author": "Author", "authorHelperText": "Author is a required field", "articleTitle": "Article Title", "articleTitleHelperText": "Article Title is a required field", - "citation": "Citation", + "journalTitle": "Journal Title", + + "journalTitleHelperText": "Journal Title is a required field", + "citation": " DOI Citation", "citationHelperText": "Article Title is a required field", "year": "Year", "yearHelperText": "Year is a required field", @@ -666,10 +691,13 @@ "published": "Published", "vectorData": "Vector Data", "buttons": { - "submit": "Submit", - "reset": "Reset" - } - }, + "update": "Update", + "submit": "Submit", + "reset": "Reset", + "back": "Back to Source List" + } + }, + "AdminPage": { "title": "Administration", "datasets": "Datasets", @@ -884,7 +912,9 @@ "datasetLogsLoadError": "Something went wrong when retrieved dataset logs. Please try again", "reuploadRequestError": "Something went wrong with requesting dataset re-upload. Please try again", "reuploadError": "Something went wrong with dataset re-upload. Please try again", - "deleteError": "Error deleting dataset" + "deleteError": "Error deleting dataset", + "updateError": "Unknown error updating reference. Please try again.", + "updateSuccess": "Reference {id} updated successfully" } }, "UploadedModel": { @@ -976,11 +1006,14 @@ } }, "Source": { - "createSuccess": "Reference created with id {id}", - "errors": { - "createError": "Unknown error in creating new reference. Please try again.", - "duplicateSource": "Reference with title {article_title} already exists" - } + "createSuccess": "Reference created with id {id}", + "updateSuccess": "Reference {id} updated successfully", + "errors": { + "createError": "Unknown error in creating new reference. Please try again.", + "duplicateSource": "Reference with title {article_title} already exists", + "updateError": "Unknown error updating reference. Please try again." + } + }, "SpeciesInformation": { "updateSuccess": "Updated species information with id {id}", diff --git a/src/UI/public/messages/fr.json b/src/UI/public/messages/fr.json index 23f9c7d73..a73526160 100644 --- a/src/UI/public/messages/fr.json +++ b/src/UI/public/messages/fr.json @@ -564,60 +564,85 @@ "email": "E-mail" } }, + "SpeciesPage": { "title": "Liste des espèces", "confirmDeleteTitle": "Confirmer la suppression", - "confirmDeleteMessage": "Êtes-vous sûr de vouloir supprimer les informations de cette espèce? ", + "confirmDeleteMessage": "Êtes-vous sûr de vouloir supprimer ces informations sur l'espèce ? Cette action est irréversible.", "buttons": { - "create": "Créer de nouvelles espèces", - "edit": "Modifier", - "deleteItem": "Supprimer", + "create": "Créer une nouvelle espèce", + "edit": "Modifier l'élément", + "deleteItem": "Supprimer l'élément", "more": "Voir plus de détails", "cancel": "Annuler", - "delete": "Supprimer" + "delete": "Supprimer", + "preview": "Aperçu", + "download": "Télécharger", + "downloadFull": "Télécharger l'image complète", + "close": "Fermer", + "back": "Retour à la liste des espèces" }, - "speciesInformationEditor": { - "create": "Créer des informations sur les espèces", - "edit": "Mettre à jour les informations sur les espèces", - "name": "Nom", - "nameHelperText": "Le nom ne peut pas être vide", - "shortDescription": "Brève description", - "shortDescriptionHelperText": "La description courte ne peut pas être vide", - "fullDescription": "Description complète", - "image": "Image de l'espèce", - "uploadImageFile": "Télécharger le fichier d'image des espèces", - "uploadImageFileHelperText": "Fichiers doivent être plus petites que {maxSize} kb", - "distributionMapImage": "Image de la carte de distribution", - "buttons": { - "create": "Créer", - "update": "Mise à jour" - } - } - }, - "SourcesPage": { - "title": "Liste de sources", - "grid": { - "id": "IDENTIFIANT", - "author": "Auteur", - "title": "Titre", - "journalTitle": "Titre de la revue", - "year": "Année", - "published": "Publié", - "vectorData": "Données vectorielles" + "tooltips": { + "zoomOut": "Zoom arrière", + "zoomIn": "Zoom avant", + "resetZoom": "Réinitialiser le zoom", + "closePreview": "Fermer l'aperçu" }, - "filters": { - "errorMsg": "Veuillez saisir la gamme (par exemple 100-200)", - "idFilter": "Filtre par ID (par exemple 100-200)", - "titleFilter": "Filtre par titre" + "speciesInformationEditor": { + "create": "Créer les informations de l'espèce", + "edit": "Modifier les informations de l'espèce", + "name": "Nom", + "nameHelperText": "Le nom ne peut pas être vide", + "shortDescription": "Description courte", + "shortDescriptionHelperText": "La description courte ne peut pas être vide", + "fullDescription": "Description complète", + "image": "Image de l'espèce", + "uploadImageFile": "Téléverser le fichier image de l'espèce", + "uploadImageFileHelperText": "Les fichiers doivent être inférieurs à {maxSize} Ko", + "distributionMapImage": "Carte de répartition", + "citation": "Citations", + "buttons": { + "create": "Créer", + "update": "Mettre à jour" + } } }, + +"SourcesPage": { + "title": "Liste des sources", + "filters": { + "titleFilter": "Filtrer par titre", + "fieldTitle": "Titre", + "fieldAuthor": "Auteur", + "fieldJournalTitle": "Titre du journal", + "searchPlaceholder": "Rechercher..." + }, + "grid": { + "author": "Auteur", + "title": "Titre", + "journalTitle": "Titre du journal", + "year": "Année", + "actions": "Actions" + }, + "edit": "Modifier", + "delete": "Supprimer", + "deleteConfirm": { + "title": "Supprimer cette source ?", + "message": "Cette action est irréversible. Êtes-vous sûr de vouloir supprimer cette source ?", + "confirm": "Supprimer", + "cancel": "Annuler" + } +}, "NewSourcePage": { "title": "Ajouter une nouvelle source de référence", + "editTitle": "Modifier la source de référence", "author": "Auteur", "authorHelperText": "L'auteur est un champ obligatoire", "articleTitle": "Titre d'article", "articleTitleHelperText": "Le titre de l'article est un champ requis", - "citation": "Citation", + "journalTitle": "Titre du journal", + "journalTitleHelperText": "Le titre du journal est un champ requis", + "citation": " DOI Citation", "citationHelperText": "Le titre de l'article est un champ requis", "year": "Année", "yearHelperText": "L'année est un champ obligatoire", @@ -627,7 +652,9 @@ "vectorData": "Données vectorielles", "buttons": { "submit": "Soumettre", - "reset": "Réinitialiser" + "reset": "Réinitialiser", + "update": "Mettre à jour", + "back": "Retour à la liste des sources" } }, "AdminPage": { @@ -837,7 +864,9 @@ "datasetLogsLoadError": "Quelque chose s'est mal passé lors des journaux de jeu de données récupérés. ", "reuploadRequestError": "Quelque chose a mal tourné avec la demande de re-téléchargement de l'ensemble de données. ", "reuploadError": "Quelque chose a mal tourné avec le re-téléchargement de l'ensemble de données. ", - "deleteError": "Erreur lors de la suppression de l'ensemble de données" + "deleteError": "Erreur lors de la suppression de l'ensemble de données", + "updateError": "Erreur inconnue lors de la mise à jour de la référence. Veuillez réessayer.", + "updateSuccess": "Référence {id} mise à jour avec succès" } }, "UploadedModel": { @@ -928,13 +957,15 @@ "approveError": "Quelque chose a mal tourné avec l'approbation de l'ensemble de données. " } }, - "Source": { - "createSuccess": "Référence créée avec id {id}", - "errors": { - "createError": "Erreur inconnue dans la création de nouvelles références. ", - "duplicateSource": "Référence avec le titre {article_title} existe déjà" - } - }, + "Source": { + "createSuccess": "Référence créée avec id {id}", + "updateSuccess": "Référence {id} mise à jour avec succès", + "errors": { + "createError": "Erreur inconnue dans la création de nouvelles références. ", + "duplicateSource": "Référence avec le titre {article_title} existe déjà", + "updateError": "Erreur inconnue lors de la mise à jour de la référence. Veuillez réessayer." + } +}, "SpeciesInformation": { "updateSuccess": "Informations sur les espèces mises à jour avec id {id}", "createSuccess": "Informations sur les nouvelles espèces créées avec id {id}", diff --git a/src/UI/public/messages/pt.json b/src/UI/public/messages/pt.json index bfb814def..9f0e8c446 100644 --- a/src/UI/public/messages/pt.json +++ b/src/UI/public/messages/pt.json @@ -564,58 +564,81 @@ }, "SpeciesPage": { "title": "Lista de espécies", - "confirmDeleteTitle": "Confirme excluir", - "confirmDeleteMessage": "Tem certeza de que deseja excluir informações sobre esta espécie? ", + "confirmDeleteTitle": "Confirmar exclusão", + "confirmDeleteMessage": "Tem certeza de que deseja excluir estas informações da espécie? Esta ação não pode ser desfeita.", "buttons": { - "create": "Crie novas espécies", - "edit": "Item de edição", + "create": "Criar nova espécie", + "edit": "Editar item", "deleteItem": "Excluir item", - "more": "Veja mais detalhes", + "more": "Ver mais detalhes", "cancel": "Cancelar", - "delete": "Excluir" + "delete": "Excluir", + "preview": "Visualizar", + "download": "Baixar", + "downloadFull": "Baixar imagem completa", + "close": "Fechar", + "back": "Voltar à lista de espécies" }, - "speciesInformationEditor": { - "create": "Crie informações sobre espécies", - "edit": "Atualize as informações das espécies", - "name": "Nome", - "nameHelperText": "Nome não pode estar vazio", - "shortDescription": "Breve descrição", - "shortDescriptionHelperText": "Breve descrição não pode estar vazia", - "fullDescription": "Descrição completa", - "image": "Imagem da espécie", - "uploadImageFile": "Faça o upload do arquivo de imagem da espécie", - "uploadImageFileHelperText": "Os arquivos devem ser menores que {maxSize} kb", - "distributionMapImage": "Imagem do mapa de distribuição", - "buttons": { - "create": "Criar", - "update": "Atualizar" - } + "tooltips": { + "zoomOut": "Diminuir zoom", + "zoomIn": "Aumentar zoom", + "resetZoom": "Redefinir zoom", + "closePreview": "Fechar visualização" + }, + "speciesInformationEditor": { + "create": "Criar informações da espécie", + "edit": "Editar informações da espécie", + "name": "Nome", + "nameHelperText": "O nome não pode estar vazio", + "shortDescription": "Descrição curta", + "shortDescriptionHelperText": "A descrição curta não pode estar vazia", + "fullDescription": "Descrição completa", + "image": "Imagem da espécie", + "uploadImageFile": "Carregar imagem da espécie", + "uploadImageFileHelperText": "Os arquivos devem ser menores que {maxSize} KB", + "distributionMapImage": "Mapa de distribuição", + "citation": "Citações", + "buttons": { + "create": "Criar", + "update": "Atualizar" + } } }, "SourcesPage": { - "title": "Lista de origem", - "grid": { - "id": "EU IA", - "author": "Autor", - "title": "Título", - "journalTitle": "Título do diário", - "year": "Ano", - "published": "Publicado", - "vectorData": "Dados vetoriais" - }, - "filters": { - "errorMsg": "Por favor, insira o intervalo (por exemplo, 100-200)", - "idFilter": "Filtro por id (por exemplo, 100-200)", - "titleFilter": "Filtro por título" - } + "title": "Lista de fontes", + "filters": { + "titleFilter": "Filtrar por título", + "fieldTitle": "Título", + "fieldAuthor": "Autor", + "fieldJournalTitle": "Título do periódico", + "searchPlaceholder": "Pesquisar..." + }, + "grid": { + "author": "Autor", + "title": "Título", + "journalTitle": "Título do periódico", + "year": "Ano", + "actions": "Ações" }, + "edit": "Editar", + "delete": "Excluir", + "deleteConfirm": { + "title": "Excluir esta fonte?", + "message": "Esta ação não pode ser desfeita. Tem certeza de que deseja excluir esta fonte?", + "confirm": "Excluir", + "cancel": "Cancelar" + } +}, "NewSourcePage": { "title": "Adicione uma nova fonte de referência", + "editTitle": "Editar fonte de referência", "author": "Autor", "authorHelperText": "Autor é um campo necessário", "articleTitle": "Título do artigo", "articleTitleHelperText": "O título do artigo é um campo necessário", - "citation": "Citação", + "journalTitle": "Título do periódico", + "journalTitleHelperText": "Título do periódico é um campo necessário", + "citation": " DOI Citação", "citationHelperText": "O título do artigo é um campo necessário", "year": "Ano", "yearHelperText": "Ano é um campo necessário", @@ -625,7 +648,10 @@ "vectorData": "Dados vetoriais", "buttons": { "submit": "Enviar", - "reset": "Reiniciar" + "reset": "Reiniciar", + "update": "Atualizar", + "back": "Voltar à lista de fontes" + } }, "AdminPage": { @@ -835,7 +861,9 @@ "datasetLogsLoadError": "Algo deu errado ao recuperar logs do conjunto de dados. ", "reuploadRequestError": "Algo deu errado em solicitar novamente o conjunto de dados. ", "reuploadError": "Algo deu errado com o conjunto de dados novamente. ", - "deleteError": "Erro excluindo o conjunto de dados" + "deleteError": "Erro excluindo o conjunto de dados", + "updateError": "Erro desconhecido ao atualizar a referência. Tente novamente.", + "updateSuccess": "Referência {id} atualizada com sucesso" } }, "UploadedModel": { @@ -927,12 +955,14 @@ } }, "Source": { - "createSuccess": "Referência criada com id {id}", - "errors": { - "createError": "Erro desconhecido na criação de uma nova referência. ", - "duplicateSource": "Referência com o título {artigo_title} já existe" - } - }, + "createSuccess": "Referência criada com id {id}", + "updateSuccess": "Referência {id} atualizada com sucesso", + "errors": { + "createError": "Erro desconhecido na criação de uma nova referência. ", + "duplicateSource": "Referência com o título {article_title} já existe", + "updateError": "Erro desconhecido ao atualizar a referência. Tente novamente." + } +}, "SpeciesInformation": { "updateSuccess": "Informações sobre espécies atualizadas com id {id}", "createSuccess": "Informações de novas espécies criadas com id {id}", diff --git a/src/UI/state/source/actions/deleteSource.ts b/src/UI/state/source/actions/deleteSource.ts new file mode 100644 index 000000000..32f755ef6 --- /dev/null +++ b/src/UI/state/source/actions/deleteSource.ts @@ -0,0 +1,31 @@ +import { createAsyncThunk } from '@reduxjs/toolkit'; +import { toast } from 'react-toastify'; +import { fetchGraphQlDataAuthenticated } from '../../../api/api'; +import { deleteSourceQuery } from '../../../api/queries'; +import { AppState } from '../../store'; +import { getTranslation } from '../../../utils/localization'; + +export const deleteSource = createAsyncThunk( + 'source/deleteSource', + async (num_id: number, { getState }) => { + const query = deleteSourceQuery(num_id); + const token = (getState() as AppState).auth.token; + const result = await fetchGraphQlDataAuthenticated(query, token); + + if (result.errors) { + toast.error( + await getTranslation('ReduxActions.Source.errors.deleteError') + // 'Unknown error in deleting reference. Please try again.' + ); + return false; + } else if (result.data) { + toast.success( + await getTranslation('ReduxActions.Source.deleteSuccess', { + id: num_id, + }) + // `Reference ${num_id} deleted successfully` + ); + return true; + } + } +); diff --git a/src/UI/state/source/actions/getSourceById.ts b/src/UI/state/source/actions/getSourceById.ts new file mode 100644 index 000000000..d519da764 --- /dev/null +++ b/src/UI/state/source/actions/getSourceById.ts @@ -0,0 +1,14 @@ +import { createAsyncThunk } from '@reduxjs/toolkit'; +import { fetchGraphQlData } from '../../../api/api'; +import { referenceQuery } from '../../../api/queries'; + +export const getSourceById = createAsyncThunk( + 'source/getSourceById', + async (num_id: number) => { + const result = await fetchGraphQlData( + referenceQuery(0, 1, 'num_id', 'ASC', num_id, num_id, '') + ); + const items = result.data.allReferenceData.items; + return items.length > 0 ? items[0] : null; + } +); diff --git a/src/UI/state/source/actions/getSourceInfo.ts b/src/UI/state/source/actions/getSourceInfo.ts index 8a7814c3c..872e70244 100644 --- a/src/UI/state/source/actions/getSourceInfo.ts +++ b/src/UI/state/source/actions/getSourceInfo.ts @@ -6,9 +6,16 @@ import { AppState } from '../../store'; export const getSourceInfo = createAsyncThunk( 'source/getSourceInfo', async (_, { getState }) => { - const { page, rowsPerPage, orderBy, order, startId, endId, textFilter } = ( - getState() as AppState - ).source.source_table_options; + const { + page, + rowsPerPage, + orderBy, + order, + startId, + endId, + textFilter, + filterField, + } = (getState() as AppState).source.source_table_options; const skip = page * rowsPerPage; const sourceInfo = await fetchGraphQlData( referenceQuery( @@ -18,7 +25,8 @@ export const getSourceInfo = createAsyncThunk( order.toLocaleUpperCase(), startId, endId, - textFilter + textFilter, + filterField ) ); return sourceInfo.data.allReferenceData; diff --git a/src/UI/state/source/actions/updateSource.ts b/src/UI/state/source/actions/updateSource.ts new file mode 100644 index 000000000..788ba0714 --- /dev/null +++ b/src/UI/state/source/actions/updateSource.ts @@ -0,0 +1,31 @@ +import { createAsyncThunk } from '@reduxjs/toolkit'; +import { toast } from 'react-toastify'; +import { fetchGraphQlDataAuthenticated } from '../../../api/api'; +import { updateSourceQuery } from '../../../api/queries'; +import { NewSource } from '../../../components/sources/source_form'; +import { AppState } from '../../store'; +import { getTranslation } from '../../../utils/localization'; + +export const updateSource = createAsyncThunk( + 'source/updateSource', + async (source: NewSource, { getState }) => { + const query = updateSourceQuery(source); + const token = (getState() as AppState).auth.token; + const result = await fetchGraphQlDataAuthenticated(query, token); + if (result.errors) { + toast.error( + await getTranslation('ReduxActions.Source.errors.updateError') + // 'Unknown error updating reference. Please try again.' + ); + return false; + } else if (result.data) { + toast.success( + await getTranslation('ReduxActions.Source.updateSuccess', { + id: result.data.updateReference.num_id, + }) + // `Reference ${num_id} updated successfully` + ); + return true; + } + } +); diff --git a/src/UI/state/source/sourceSlice.ts b/src/UI/state/source/sourceSlice.ts index ddb4ef391..230811b41 100644 --- a/src/UI/state/source/sourceSlice.ts +++ b/src/UI/state/source/sourceSlice.ts @@ -1,6 +1,8 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { FilterSort } from '../state.types'; import { getSourceInfo } from './actions/getSourceInfo'; +import { deleteSource } from './actions/deleteSource'; +import { getSourceById } from './actions/getSourceById'; export interface Source { [index: string]: any; @@ -9,10 +11,7 @@ export interface Source { journal_title: string; citation: string; year: number; - //published: boolean; report_type: string; - //v_data: boolean; - //num_id: number; } export interface SourceState { @@ -21,6 +20,9 @@ export interface SourceState { total: number; }; source_info_status: string; + source_delete_status: string; + source_edit: Source | null; + source_edit_status: string; source_table_options: FilterSort; } @@ -30,6 +32,9 @@ export const initialState: SourceState = { total: 0, }, source_info_status: '', + source_delete_status: '', + source_edit: null, + source_edit_status: '', source_table_options: { page: 0, rowsPerPage: 10, @@ -38,6 +43,8 @@ export const initialState: SourceState = { startId: 0, endId: null, textFilter: '', + + filterField: 'article_title', }, }; @@ -68,18 +75,44 @@ export const sourceSlice = createSlice({ changeFilterText(state, action: PayloadAction) { state.source_table_options.textFilter = action.payload; }, + changeFilterField(state, action: PayloadAction) { + state.source_table_options.filterField = action.payload; + }, + clearSourceEdit(state) { + state.source_edit = null; + state.source_edit_status = ''; + }, }, extraReducers: (builder) => { builder .addCase(getSourceInfo.pending, (state) => { state.source_info_status = 'loading'; }) - .addCase(getSourceInfo.rejected, (state, action) => { + .addCase(getSourceInfo.rejected, (state) => { state.source_info_status = 'error'; }) .addCase(getSourceInfo.fulfilled, (state, action) => { state.source_info = action.payload; state.source_info_status = 'success'; + }) + .addCase(deleteSource.pending, (state) => { + state.source_delete_status = 'loading'; + }) + .addCase(deleteSource.rejected, (state) => { + state.source_delete_status = 'error'; + }) + .addCase(deleteSource.fulfilled, (state, action) => { + state.source_delete_status = action.payload ? 'success' : 'error'; + }) + .addCase(getSourceById.pending, (state) => { + state.source_edit_status = 'loading'; + }) + .addCase(getSourceById.rejected, (state) => { + state.source_edit_status = 'error'; + }) + .addCase(getSourceById.fulfilled, (state, action) => { + state.source_edit = action.payload; + state.source_edit_status = action.payload ? 'success' : 'error'; }); }, }); @@ -90,5 +123,7 @@ export const { changeSort, changeFilterId, changeFilterText, + changeFilterField, + clearSourceEdit, } = sourceSlice.actions; export default sourceSlice.reducer; diff --git a/src/UI/styles/globals.css b/src/UI/styles/globals.css index 4f2580089..90d4c3278 100644 --- a/src/UI/styles/globals.css +++ b/src/UI/styles/globals.css @@ -21,6 +21,7 @@ a { input[type='range'] { -webkit-appearance: none; + appearance: none; height: 7px; background: #ebbd40; border: #038543; @@ -55,8 +56,12 @@ input[type='range']::-webkit-slider-thumb { border: 1px solid #ff1744; } +.editor-text-underline { + text-decoration: underline; +} + .ql-editor { height: 150px !important; max-height: 150px; overflow: auto; -} +} \ No newline at end of file