mirror of
https://github.com/nowcommunity/nowdatabase.git
synced 2026-10-12 19:13:52 +03:00
Merge branch 'main' into copy-species-taxonomy
This commit is contained in:
@@ -2,6 +2,7 @@ import 'express-async-errors'
|
||||
import express from 'express'
|
||||
import cors from 'cors'
|
||||
import crossSearchRouter from './routes/crossSearch'
|
||||
import statisticsRouter from './routes/statistics'
|
||||
import localityRouter from './routes/locality'
|
||||
import museumRouter from './routes/museum'
|
||||
import referenceRouter from './routes/reference'
|
||||
@@ -52,6 +53,7 @@ app.use('/crosssearch', crossSearchRouter)
|
||||
app.use('/locality', localityRouter)
|
||||
app.use('/locality-species', localitySpeciesRouter)
|
||||
app.use('/species', speciesRouter)
|
||||
app.use('/statistics', statisticsRouter)
|
||||
app.use('/reference', referenceRouter)
|
||||
app.use('/time-unit', timeUnitRouter)
|
||||
app.use('/time-bound', requireOneOf([Role.Admin, Role.EditUnrestricted]), timeBoundRouter)
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { Router, Request } from 'express'
|
||||
import { getAllPersons, getPersonDetails, validateEntirePerson } from '../services/person'
|
||||
import { Role, PersonDetailsType, EditDataType, EditMetaData } from '../../../frontend/src/shared/types'
|
||||
import {
|
||||
Role,
|
||||
PersonDetailsType,
|
||||
EditDataType,
|
||||
EditMetaData,
|
||||
userGroups,
|
||||
UserGroup,
|
||||
} from '../../../frontend/src/shared/types'
|
||||
import { requireOneOf } from '../middlewares/authorizer'
|
||||
import { writePerson } from '../services/write/person'
|
||||
import { UserGroup, validUserGroups, writeUserGroup } from '../services/write/user'
|
||||
import { writeUserGroup } from '../services/write/user'
|
||||
|
||||
const router = Router()
|
||||
|
||||
@@ -34,11 +41,9 @@ router.put(
|
||||
'/',
|
||||
async (req: Request<object, object, { person: EditDataType<PersonDetailsType> & EditMetaData }>, res) => {
|
||||
const { ...editedPerson } = req.body.person
|
||||
|
||||
if (!editedPerson.initials) {
|
||||
return res.status(403).send({ error: 'Missing initials, creating new persons is not yet implemented' })
|
||||
}
|
||||
|
||||
/* Access checking happens differently for this route, since we want to allow users to modify their own data */
|
||||
if (!req.user)
|
||||
return res.status(401).send({
|
||||
@@ -60,7 +65,7 @@ router.put(
|
||||
req.user.role === Role.Admin &&
|
||||
editedPerson.user_id &&
|
||||
editedPerson.now_user_group &&
|
||||
validUserGroups.includes(editedPerson.now_user_group)
|
||||
userGroups.includes(editedPerson.now_user_group)
|
||||
) {
|
||||
await writeUserGroup(editedPerson.user_id, editedPerson.now_user_group as UserGroup)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Router } from 'express'
|
||||
import { getLocalityStatistics, getSpeciesStatistics, getStatistics } from '../services/statistics'
|
||||
import { fixBigInt } from '../utils/common'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.get('/', async (_req, res) => {
|
||||
const statistics = await getStatistics()
|
||||
return res.status(200).send(fixBigInt(statistics))
|
||||
})
|
||||
|
||||
router.get('/locality', async (_req, res) => {
|
||||
const statistics = await getLocalityStatistics()
|
||||
return res.status(200).send(fixBigInt(statistics))
|
||||
})
|
||||
|
||||
router.get('/species', async (_req, res) => {
|
||||
const statistics = await getSpeciesStatistics()
|
||||
return res.status(200).send(fixBigInt(statistics))
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -1,14 +1,17 @@
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
import { Router } from 'express'
|
||||
import { Request, Router } from 'express'
|
||||
import { sign } from 'jsonwebtoken'
|
||||
import { SECRET, LOGIN_VALID_SECONDS } from '../utils/config'
|
||||
import * as bcrypt from 'bcrypt'
|
||||
import { nowDb } from '../utils/db'
|
||||
import { getRole } from '../middlewares/authenticator'
|
||||
import { AccessError } from '../middlewares/authorizer'
|
||||
import { Role } from '../../../frontend/src/shared/types'
|
||||
import { Role, UserDetailsType } from '../../../frontend/src/shared/types'
|
||||
import md5 from 'md5'
|
||||
import { validatePassword } from '../utils/validatePassword'
|
||||
import { createPasswordHash } from '../utils/createPasswordHash'
|
||||
import { writeUser } from '../services/write/user'
|
||||
import { validateUser } from '../utils/validateUser'
|
||||
|
||||
const router = Router()
|
||||
|
||||
@@ -75,11 +78,7 @@ router.post('/login', async (req, res) => {
|
||||
})
|
||||
})
|
||||
|
||||
const createPasswordHash = async (password: string) => {
|
||||
const saltRounds = 10
|
||||
return await bcrypt.hash(password, saltRounds)
|
||||
}
|
||||
|
||||
// TODO: Remove if not needed for anything
|
||||
router.post('/create', async (req, res) => {
|
||||
const { username, password, initials, role, secret } = req.body
|
||||
|
||||
@@ -137,6 +136,33 @@ router.put('/password', async (req, res) => {
|
||||
return res.status(200).send()
|
||||
})
|
||||
|
||||
router.post('/create')
|
||||
router.post('/new', async (req: Request<UserDetailsType>, res) => {
|
||||
const user: UserDetailsType = req.body
|
||||
if (!user) return res.status(400).send({ message: 'Missing user' })
|
||||
|
||||
if (!user.initials) {
|
||||
return res.status(403).send({ message: 'Missing initials' })
|
||||
}
|
||||
|
||||
if (!req.user || req.user.role !== Role.Admin)
|
||||
return res.status(401).send({
|
||||
message: 'User not authorized for the requested resource or action.',
|
||||
})
|
||||
|
||||
const error = await validateUser(user)
|
||||
if (error)
|
||||
return res.status(400).send({
|
||||
message: error,
|
||||
})
|
||||
|
||||
// All good, write user.
|
||||
try {
|
||||
await writeUser(user)
|
||||
} catch (e) {
|
||||
return res.status(500).send({ message: 'Unknown server error occurred' })
|
||||
}
|
||||
|
||||
return res.status(200).send({ message: '' })
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -59,7 +59,7 @@ export const getAllLocalities = async (user?: User) => {
|
||||
return rest
|
||||
}
|
||||
|
||||
const result = (await nowDb.now_loc.findMany({
|
||||
const localityResult = (await nowDb.now_loc.findMany({
|
||||
select: {
|
||||
lid: true,
|
||||
loc_name: true,
|
||||
@@ -117,6 +117,17 @@ export const getAllLocalities = async (user?: User) => {
|
||||
},
|
||||
})) as LocalityPreFilter[]
|
||||
|
||||
const synonyms: { lid: number }[] = await nowDb.now_syn_loc.findMany({
|
||||
select: { lid: true },
|
||||
distinct: ['lid'],
|
||||
})
|
||||
|
||||
const synonymIdSet = new Set(synonyms.map(s => s.lid))
|
||||
const result = localityResult.map(loc => ({
|
||||
...loc,
|
||||
has_synonym: synonymIdSet.has(loc.lid),
|
||||
}))
|
||||
|
||||
if (showAll) return result.map(removeProjects)
|
||||
|
||||
if (!user) return result.filter(loc => loc.loc_status === false).map(removeProjects)
|
||||
|
||||
@@ -7,7 +7,7 @@ import { logDb, nowDb } from '../utils/db'
|
||||
import { getReferenceDetails } from './reference'
|
||||
|
||||
export const getAllSpecies = async () => {
|
||||
const result = await nowDb.com_species.findMany({
|
||||
const speciesResult = await nowDb.com_species.findMany({
|
||||
select: {
|
||||
species_id: true,
|
||||
order_name: true,
|
||||
@@ -54,6 +54,31 @@ export const getAllSpecies = async () => {
|
||||
},
|
||||
})
|
||||
|
||||
const speciesWithLocality: { species_id: number }[] = await nowDb.now_ls.findMany({
|
||||
where: {
|
||||
now_loc: {
|
||||
NOT: undefined,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
species_id: true,
|
||||
},
|
||||
distinct: ['species_id'],
|
||||
})
|
||||
|
||||
const synonyms: { species_id: number }[] = await nowDb.com_taxa_synonym.findMany({
|
||||
select: { species_id: true },
|
||||
distinct: ['species_id'],
|
||||
})
|
||||
|
||||
const speciesWithLocalitySet = new Set(speciesWithLocality.map(s => s.species_id))
|
||||
const synonymIdSet = new Set(synonyms.map(s => s.species_id))
|
||||
const result = speciesResult.map(sp => ({
|
||||
...sp,
|
||||
has_synonym: synonymIdSet.has(sp.species_id),
|
||||
has_no_locality: !speciesWithLocalitySet.has(sp.species_id),
|
||||
}))
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -69,13 +94,14 @@ export const getSpeciesDetails = async (id: number) => {
|
||||
now_sau: {
|
||||
include: {
|
||||
now_sr: {
|
||||
include: {
|
||||
select: {
|
||||
ref_ref: {
|
||||
include: {
|
||||
select: {
|
||||
ref_authors: true,
|
||||
ref_journal: true,
|
||||
},
|
||||
},
|
||||
rid: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { ActivityStatistic, Statistics } from '../../../frontend/src/shared/types'
|
||||
import { Prisma } from '../../prisma/generated/now_test_client'
|
||||
import { nowDb } from '../utils/db'
|
||||
|
||||
export const getStatistics = async (): Promise<Statistics> => {
|
||||
const localityAggregate = await nowDb.now_loc.aggregate({
|
||||
_count: {
|
||||
lid: true,
|
||||
},
|
||||
})
|
||||
const localityCount = localityAggregate._count.lid
|
||||
|
||||
const speciesAggregate = await nowDb.com_species.aggregate({
|
||||
_count: {
|
||||
species_id: true,
|
||||
},
|
||||
})
|
||||
const speciesCount = speciesAggregate._count.species_id
|
||||
|
||||
const localitySpeciesQuery = Prisma.sql`
|
||||
SELECT COUNT(now_ls.species_id) as count
|
||||
FROM now_loc
|
||||
LEFT JOIN now_ls ON now_ls.lid = now_loc.lid
|
||||
`
|
||||
|
||||
const localitySpeciesAggregate: { count: number }[] = await nowDb.$queryRaw(localitySpeciesQuery)
|
||||
let localitySpeciesCount = 0
|
||||
|
||||
if (localitySpeciesAggregate.length > 0) {
|
||||
localitySpeciesCount = localitySpeciesAggregate[0].count
|
||||
}
|
||||
|
||||
return {
|
||||
localityCount,
|
||||
speciesCount,
|
||||
localitySpeciesCount,
|
||||
}
|
||||
}
|
||||
|
||||
export const getLocalityStatistics = async (): Promise<ActivityStatistic[]> => {
|
||||
const query = Prisma.sql`SELECT * FROM now_v_locality_statistics LIMIT 10`
|
||||
return await nowDb.$queryRaw(query)
|
||||
}
|
||||
|
||||
export const getSpeciesStatistics = async (): Promise<ActivityStatistic[]> => {
|
||||
const query = Prisma.sql`SELECT * FROM now_v_species_statistics LIMIT 10`
|
||||
return await nowDb.$queryRaw(query)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { EditDataType, PersonDetailsType } from '../../../../frontend/src/shared/types'
|
||||
import Prisma from '../../../prisma/generated/now_test_client'
|
||||
import { nowDb, getFieldsOfTables } from '../../utils/db'
|
||||
import { getPersonDetails } from '../person'
|
||||
import { filterAllowedKeys } from './writeOperations/utils'
|
||||
|
||||
export const writePerson = async (person: EditDataType<PersonDetailsType>) => {
|
||||
@@ -8,12 +9,24 @@ export const writePerson = async (person: EditDataType<PersonDetailsType>) => {
|
||||
const filteredPerson = filterAllowedKeys(person, allowedColumns) as Prisma.com_people
|
||||
let personId: string
|
||||
|
||||
if (!filteredPerson.initials) {
|
||||
throw new Error('Missing initials, creating new persons is not yet implemented')
|
||||
const isPerson = await getPersonDetails(filteredPerson.initials)
|
||||
|
||||
if (!isPerson) {
|
||||
const newPerson = await nowDb.com_people.create({
|
||||
data: { ...filteredPerson, full_name: `${filteredPerson.first_name} ${filteredPerson.surname}` },
|
||||
})
|
||||
personId = newPerson.initials
|
||||
} else {
|
||||
await nowDb.com_people.update({
|
||||
where: { initials: person.initials },
|
||||
data: { ...filteredPerson, full_name: `${filteredPerson.first_name} ${filteredPerson.surname}` },
|
||||
data: {
|
||||
first_name: filteredPerson.first_name,
|
||||
surname: filteredPerson.surname,
|
||||
email: filteredPerson.email,
|
||||
organization: filteredPerson.organization,
|
||||
country: filteredPerson.country,
|
||||
full_name: `${filteredPerson.first_name} ${filteredPerson.surname}`,
|
||||
},
|
||||
})
|
||||
personId = filteredPerson.initials
|
||||
}
|
||||
|
||||
@@ -1,11 +1,30 @@
|
||||
import { UserDetailsType, UserGroup } from '../../../../frontend/src/shared/types'
|
||||
import { createPasswordHash } from '../../utils/createPasswordHash'
|
||||
import { nowDb } from '../../utils/db'
|
||||
|
||||
export const validUserGroups = ['su', 'eu', 'er', 'pl', 'plp', 'no', 'ro']
|
||||
export type UserGroup = 'su' | 'eu' | 'er' | 'pl' | 'plp' | 'no' | 'ro'
|
||||
|
||||
export const writeUserGroup = async (user_id: number, group: UserGroup) => {
|
||||
await nowDb.com_users.update({
|
||||
where: { user_id: user_id },
|
||||
data: { now_user_group: group },
|
||||
data: { now_user_group: group.split(' ')[0] },
|
||||
})
|
||||
}
|
||||
|
||||
export const writeUser = async (user: UserDetailsType) => {
|
||||
const passwordHash = await createPasswordHash(user.password!)
|
||||
const createdUser = await nowDb.com_users.create({
|
||||
data: {
|
||||
user_name: user.user_name,
|
||||
newpassword: passwordHash,
|
||||
now_user_group: user.now_user_group?.split(' ')[0],
|
||||
},
|
||||
})
|
||||
|
||||
if (!createdUser || !createdUser.user_id) throw Error('Could not create user.')
|
||||
|
||||
const createdPerson = await nowDb.com_people.update({
|
||||
where: { initials: user.initials },
|
||||
data: { user_id: createdUser.user_id },
|
||||
})
|
||||
|
||||
if (!createdPerson) throw Error("Could not update person's user ID.")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import * as bcrypt from 'bcrypt'
|
||||
|
||||
export const createPasswordHash = async (password: string) => {
|
||||
const saltRounds = 10
|
||||
return await bcrypt.hash(password, saltRounds)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { UserDetailsType } from '../../../frontend/src/shared/types'
|
||||
import { nowDb } from './db'
|
||||
import { validatePassword } from './validatePassword'
|
||||
|
||||
const isUsernameTaken = async (username: string): Promise<boolean> =>
|
||||
!!(await nowDb.com_users.findFirst({ where: { user_name: username } }))
|
||||
|
||||
const personWithoutUserExists = async (initials: string): Promise<boolean> => {
|
||||
const person = await nowDb.com_people.findFirst({ where: { initials: initials } })
|
||||
if (!person) return false
|
||||
if (person.user_id) {
|
||||
const user = await nowDb.com_users.findFirst({ where: { user_id: person.user_id } })
|
||||
if (user) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export const validateUser = async (user: UserDetailsType): Promise<string | null> => {
|
||||
const usernameMinLength = 3
|
||||
const usernameMaxLength = 100
|
||||
|
||||
if (!user.user_name) return 'Username is missing.'
|
||||
if (!user.password) return 'Password is missing.'
|
||||
if (!user.now_user_group) return 'User group is missing.'
|
||||
|
||||
if (user.user_name?.length < usernameMinLength || user.user_name?.length > usernameMaxLength)
|
||||
return `Username must be between ${usernameMinLength} and ${usernameMaxLength} characters.`
|
||||
|
||||
const res = validatePassword(user.password)
|
||||
if (!res.isValid) return res.error ?? 'Password is invalid.'
|
||||
|
||||
if (await isUsernameTaken(user.user_name)) return 'Username is already in use.'
|
||||
if (!(await personWithoutUserExists(user.initials))) return "Person doesn't exist or person already has a user."
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -295,6 +295,8 @@ describe("Locality's coordinate selection map works", () => {
|
||||
it('Map view and location search work', () => {
|
||||
cy.visit(`/locality/20920?tab=1`)
|
||||
cy.get('[id=edit-button]').click()
|
||||
cy.contains('Choose a country').parent().type('Finland')
|
||||
cy.contains('Finland').click()
|
||||
cy.contains('Get Coordinates').click()
|
||||
cy.contains('OpenStreetMap')
|
||||
cy.contains('Leaflet')
|
||||
|
||||
@@ -35,11 +35,11 @@ describe('Editing a person', () => {
|
||||
|
||||
cy.get('[id=edit-button]').click()
|
||||
|
||||
cy.contains('Choose user group').parent().type('n')
|
||||
cy.contains('no').click()
|
||||
cy.contains('Choose user group').parent().type('e')
|
||||
cy.contains('er (edit restricted)').click()
|
||||
cy.get('[id=write-button]').click()
|
||||
|
||||
cy.contains('no')
|
||||
cy.contains('er')
|
||||
})
|
||||
|
||||
it('with any missing fields does not work', () => {
|
||||
|
||||
@@ -14,7 +14,7 @@ describe('Button Tests', () => {
|
||||
cy.contains('References').should('be.visible')
|
||||
cy.contains('Time Units').should('be.visible')
|
||||
|
||||
cy.contains('Cross-Search').should('be.visible')
|
||||
cy.contains('Locality-Species').should('be.visible')
|
||||
cy.contains('Localities').should('be.visible')
|
||||
cy.contains('Species').should('be.visible')
|
||||
})
|
||||
@@ -49,7 +49,7 @@ describe('Button Tests', () => {
|
||||
|
||||
it('Cross Search button works', () => {
|
||||
cy.visit('/')
|
||||
cy.contains('Cross-Search').click()
|
||||
cy.contains('Locality-Species').click()
|
||||
cy.url().should('include', '/crosssearch')
|
||||
cy.contains('Locality name').should('be.visible')
|
||||
cy.contains('Country').should('be.visible')
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Architecture picture, stack
|
||||
|
||||
To do any updates to this picture, update documentation/images/architecture.drawio.svg in drawio/diagrams.net
|
||||
|
||||

|
||||
@@ -25,8 +25,9 @@ A normal street map as well as a simplified map with only country borders visibl
|
||||
|
||||
## Country border view
|
||||
|
||||
The simplified country border map view is achieved through a hardcoded array of latitude-longitude polygons that represent country borders, imported from [`country_borders_WGS84.ts`](../../../../frontend/src/components/Map/country_borders_WGS84.ts).
|
||||
|
||||
The simplified country border map view is achieved through an array of latitude-longitude polygons that represent country borders, imported from [`countryPolygons.ts`](../../../../frontend/src/country_data/countryPolygons.ts).
|
||||
This means that the country borders are fixed in time to the moment of when that file was last updated.
|
||||
Detailed instructions on where to get the data and how to convert it to this desired format can be found at (TBD).
|
||||
Detailed instructions on where to get the data and how to convert it to this desired format can be found [here](../../../../frontend/src/country_data/README.md).
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Map features
|
||||
|
||||
See component-specific docs at [CoordinateSelectionMap](./CoordinateSelectionMap.md) and [LocalityMap](./LocalityMap.md)
|
||||
See component-specific docs at [CoordinateSelectionMap](./CoordinateSelectionMap.md) and [LocalitiesMap](./LocalitiesMap.md)
|
||||
|
||||
The map features use the React Leaflet package and OpenStreetMap.
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Single locality map
|
||||
|
||||
A component for visualizing the location of a single latitude and longitude coordinate, used for the locality detail page as well as in a country bounding box check warning modal to illustrate the location with the box it missed.
|
||||
|
||||
The component takes in decimal-form latitude and longitude coordinates, along with an optional array of bounding boxes to display on the map.
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 119 KiB |
@@ -0,0 +1,62 @@
|
||||
## User Testing Instructions
|
||||
|
||||
To participate in user testing, please follow the steps below:
|
||||
|
||||
1. Use the latest version of the application:
|
||||
[Test Application](https://nowdb-frontend-nowdatabase.ext.ocp-test-0.k8s.it.helsinki.fi/)
|
||||
2. Perform the listed tasks under typical usage conditions.
|
||||
3. Note any bugs, usability issues, or unexpected behavior.
|
||||
4. Submit your feedback via the provided feedback form or issue tracker.
|
||||
|
||||
Your feedback is essential to improving the user experience—thank you for participating!
|
||||
|
||||
---
|
||||
|
||||
## User Testing Tasks
|
||||
|
||||
### Cold Tests (No Instructions for Testing)
|
||||
|
||||
|
||||
| Test Case ID | Description | Preconditions | Steps to Execute | Expected Result |
|
||||
|--------------|-------------|---------------|------------------|------------------|
|
||||
| TC01 | Find all localities for species Primus | User is not logged in | No instructions (tests intuitivity) | See a list of Primus with their localities (no map) |
|
||||
| TC02 | Find all genus that includes "Alopex" | User is not logged in | No instructions | See 9 rows with "Alopex" |
|
||||
| TC03 | See localities on a map filtered by country | User is not logged in | No instructions | See filtered localities on a map |
|
||||
| TC04 | Take KML export of localities from map | User is not logged in | No instructions | Get KML file downloaded |
|
||||
| TC05 | View base map with country boundaries | User is not logged in | No instructions | View simple colored boundary map |
|
||||
| TC06 | Info for locality on map | User is not logged in | No instructions | Click marker to view info |
|
||||
| TC07 | SVG export of map localities | User is not logged in | No instructions | Download SVG file (opens in browser) |
|
||||
|
||||
---
|
||||
|
||||
### Instructed Tests
|
||||
|
||||
|
||||
| Test Case ID | Description | Preconditions | Steps to Execute | Expected Result |
|
||||
|--------------|-------------|---------------|------------------|------------------|
|
||||
| TC08 | View localities for species "lynx" | User is not logged in | 1. Go to Cross-search 2. Select species: lynx | See list of lynx localities (2 pages) |
|
||||
| TC09 | Full lynx localities listed | User is not logged in | Check that list matches expected | All localities shown accurately |
|
||||
| TC10 | View single locality on map via alternative path | User is not logged in | Go to localities page → choose → tab → map | See map with one locality |
|
||||
| TC11 | Find localities for Cormohipparion | User is not logged in | Cross-search → filter Genus: "Cormohipparion" | See 3 pages of results |
|
||||
|
||||
---
|
||||
|
||||
## Additional Testing Comments (test results can be added here)
|
||||
|
||||
|
||||
| Test Case ID | Pass/Fail if one fails | Comments (1st) 10.6.2025 | Comments (2nd) 10.6.2025 |
|
||||
|--------------|------------------|----------------------|------------------|
|
||||
| TC01 | Fail | Meni lokaliteetteihin, ei löydä, yrittää etsiä | Ei avaa Cross-search-hakua |
|
||||
| TC02 | Pass | | |
|
||||
| TC03 | Fail | Ei löydä karttaa heti, ei tarpeeksi näkyvästi karttapampula | Ei ollut helposti kartta löydettävissä |
|
||||
| TC04 | Pass | | |
|
||||
| TC05 | Pass | Etsi controlleria, löytää lopulta | |
|
||||
| TC06 | Fail | not ready for testing | |
|
||||
| TC07 | Pass | Yritti tehdä exportin kartalla, ei mennyt ilman apua | Helppo toiselle |
|
||||
| TC08 | Pass | | |
|
||||
| TC09 | — | | |
|
||||
| TC10 | Fail | Ei tajua painaa search-merkkiä, kesti liian kauan | Symbolin painallus ei riittänyt |
|
||||
| TC11 | Fail | Haku tyhjensi vastaukset, vaikka käyttö oikein | |
|
||||
|
||||
---
|
||||
|
||||
@@ -31,7 +31,7 @@ export default [
|
||||
'**/jest.config.ts',
|
||||
'**/coverage',
|
||||
'src/components/Map/*.js',
|
||||
'src/components/Map/country_borders_WGS84.ts',
|
||||
'src/country_data/**',
|
||||
],
|
||||
},
|
||||
...fixupConfigRules(
|
||||
|
||||
+28
-24
@@ -1,10 +1,10 @@
|
||||
import { Container, Grid } from '@mui/material'
|
||||
import { FrontPage } from './components/FrontPage'
|
||||
import { NavBar } from './components/NavBar'
|
||||
import { BrowserRouter, Route, Routes } from 'react-router-dom'
|
||||
import { Login } from './components/Login'
|
||||
import {
|
||||
crossSearchPage,
|
||||
frontPage,
|
||||
localityPage,
|
||||
personPage,
|
||||
projectPage,
|
||||
@@ -16,36 +16,40 @@ import {
|
||||
} from './components/pages'
|
||||
import { Notification, NotificationContextProvider } from './components/Notification'
|
||||
import { EmailPage } from './components/EmailPage'
|
||||
import { Footer } from './Footer'
|
||||
|
||||
const App = () => {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<NotificationContextProvider>
|
||||
<Notification />
|
||||
<Grid container>
|
||||
<Grid item xs={12}>
|
||||
<NavBar />
|
||||
</Grid>
|
||||
<Container maxWidth="xl" fixed style={{ marginTop: '2em', marginBottom: '2em' }}>
|
||||
<Grid item>
|
||||
<Routes>
|
||||
<Route element={crossSearchPage} path="/crosssearch/:id?" />
|
||||
<Route element={localityPage} path="/locality/:id?" />
|
||||
<Route element={speciesPage} path="/species/:id?" />
|
||||
<Route element={referencePage} path="/reference/:id?" />
|
||||
<Route element={timeUnitPage} path="/time-unit/:id?" />
|
||||
<Route element={timeBoundPage} path="/time-bound/:id?" />
|
||||
<Route element={regionPage} path="/region/:id?" />
|
||||
<Route element={personPage} path="/person/:id?" />
|
||||
<Route element={projectPage} path="/project/:id?" />
|
||||
<Route element={<EmailPage />} path="/email/" />
|
||||
<Route element={<Login />} path="/login" />
|
||||
<Route element={<FrontPage />} path="/" />
|
||||
<Route element={<div>Page not found.</div>} path="*" />
|
||||
</Routes>
|
||||
<div id="wrapper">
|
||||
<Grid container>
|
||||
<Grid item xs={12}>
|
||||
<NavBar />
|
||||
</Grid>
|
||||
</Container>
|
||||
</Grid>
|
||||
<Container maxWidth="xl" fixed style={{ marginTop: '2em', marginBottom: '2em' }}>
|
||||
<Grid item>
|
||||
<Routes>
|
||||
<Route element={crossSearchPage} path="/crosssearch/:id?" />
|
||||
<Route element={localityPage} path="/locality/:id?" />
|
||||
<Route element={speciesPage} path="/species/:id?" />
|
||||
<Route element={referencePage} path="/reference/:id?" />
|
||||
<Route element={timeUnitPage} path="/time-unit/:id?" />
|
||||
<Route element={timeBoundPage} path="/time-bound/:id?" />
|
||||
<Route element={regionPage} path="/region/:id?" />
|
||||
<Route element={personPage} path="/person/:id?" />
|
||||
<Route element={projectPage} path="/project/:id?" />
|
||||
<Route element={<EmailPage />} path="/email/" />
|
||||
<Route element={<Login />} path="/login" />
|
||||
<Route element={frontPage} path="/" />
|
||||
<Route element={<div>Page not found.</div>} path="*" />
|
||||
</Routes>
|
||||
</Grid>
|
||||
</Container>
|
||||
</Grid>
|
||||
<Footer />
|
||||
</div>
|
||||
</NotificationContextProvider>
|
||||
</BrowserRouter>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import './styles/Footer.css'
|
||||
|
||||
export const Footer = () => {
|
||||
return <footer>© The NOW Database {new Date().getFullYear()}</footer>
|
||||
}
|
||||
@@ -15,7 +15,7 @@ export const DetailBrowser = <T extends object>() => {
|
||||
const nextIndex = currentIndex + 1
|
||||
const previousIndex = currentIndex - 1
|
||||
const style = {
|
||||
width: '50em',
|
||||
width: '100%',
|
||||
marginLeft: '0em',
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'pre-wrap',
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Button, Modal } from '@mui/material'
|
||||
import { SingleLocalityMap } from '../Map/SingleLocalityMap'
|
||||
import { CountryBoundingBox } from '@/country_data/countryBoundingBoxes'
|
||||
|
||||
export interface OutOfBoundsWarningModalState {
|
||||
decLong: number
|
||||
decLat: number
|
||||
boxes?: CountryBoundingBox[]
|
||||
}
|
||||
|
||||
interface Props {
|
||||
onAnswer: (answer: boolean) => void
|
||||
state: OutOfBoundsWarningModalState
|
||||
isOpen: boolean
|
||||
}
|
||||
|
||||
export const OutOfBoundsWarningModal = ({ isOpen, state, onAnswer }: Props) => {
|
||||
return (
|
||||
<Modal open={isOpen} className={`modal ${isOpen ? 'open' : ''}`} onClick={() => onAnswer(false)}>
|
||||
<div className="modal-content" onClick={e => e.stopPropagation()}>
|
||||
<h3>Coordinate warning</h3>
|
||||
<p style={{ marginBottom: '2em' }}>
|
||||
The coordinate selected is outside of the locality country. Do you still want to proceed?
|
||||
</p>
|
||||
<SingleLocalityMap decLat={state.decLat} decLong={state.decLong} boxes={state.boxes} />
|
||||
<div style={{ marginTop: '1em' }}>
|
||||
<Button variant="contained" onClick={() => onAnswer(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button sx={{ marginLeft: '0.6em' }} variant="contained" onClick={() => onAnswer(true)}>
|
||||
Proceed
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
// NOTE: Contact button is disabled until we get the email server access working
|
||||
|
||||
import { Box, Button, Modal } from '@mui/material'
|
||||
import { ReactNode, useState } from 'react'
|
||||
import { modalStyle } from './misc'
|
||||
@@ -20,6 +22,7 @@ export const ContactModal = ({
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return <></>
|
||||
return (
|
||||
<Box>
|
||||
<Button onClick={() => setOpen(true)} variant="outlined" startIcon={<EmailIcon />} className="button">
|
||||
|
||||
@@ -5,7 +5,9 @@ import {
|
||||
SpeciesDetailsType,
|
||||
TimeBoundDetailsType,
|
||||
TimeUnitDetailsType,
|
||||
PersonDetailsType,
|
||||
} from '@/shared/types'
|
||||
|
||||
import { MRT_PaginationState } from 'material-react-table'
|
||||
|
||||
export const emptyLocality = {
|
||||
@@ -246,5 +248,14 @@ export const emptyRegion = {
|
||||
now_reg_coord_country: [],
|
||||
} as unknown as RegionDetails
|
||||
|
||||
export const emptyPerson = {
|
||||
initials: '',
|
||||
first_name: '',
|
||||
surname: '',
|
||||
email: '',
|
||||
organization: '',
|
||||
country: '',
|
||||
} as unknown as PersonDetailsType
|
||||
|
||||
export const defaultPagination: MRT_PaginationState = { pageIndex: 0, pageSize: 15 }
|
||||
export const defaultPaginationSmall: MRT_PaginationState = { pageIndex: 0, pageSize: 10 }
|
||||
|
||||
@@ -11,6 +11,9 @@ import { referenceValidator } from '@/shared/validators/validator'
|
||||
import { checkTaxonomy, convertTaxonomyFields } from '../../util/taxonomyFunctions'
|
||||
import { useLazyGetAllSpeciesQuery, useLazyGetAllSynonymsQuery } from '@/redux/speciesReducer'
|
||||
import { useNotify } from '@/hooks/notification'
|
||||
import { countryBoundingBoxes } from '@/country_data/countryBoundingBoxes'
|
||||
import { boundingBoxSplit, isPointInBoxes } from '@/util/isPointInBox'
|
||||
import { OutOfBoundsWarningModal, OutOfBoundsWarningModalState } from './OutOfBoundsWarningModal'
|
||||
|
||||
export const WriteButton = <T,>({
|
||||
onWrite,
|
||||
@@ -27,11 +30,26 @@ export const WriteButton = <T,>({
|
||||
const notify = useNotify()
|
||||
const [getSpeciesData] = useLazyGetAllSpeciesQuery()
|
||||
const [getSynonyms] = useLazyGetAllSynonymsQuery()
|
||||
|
||||
// For coordinate out of bounds warning
|
||||
const [warningModalState, setWarningModalState] = useState<OutOfBoundsWarningModalState>({
|
||||
decLong: 0,
|
||||
decLat: 0,
|
||||
boxes: undefined,
|
||||
})
|
||||
const [warningModalOpen, setWarningModalOpen] = useState(false)
|
||||
|
||||
const getButtonText = () => {
|
||||
if (!mode.staging) return hasStagingMode ? 'Finalize entry' : 'Save changes'
|
||||
return 'Complete and save'
|
||||
}
|
||||
|
||||
// Checks if given coordinate is outside of country bounding box
|
||||
const isCoordinateOutOfBounds = (dec_lat: number, dec_long: number, country: string): boolean => {
|
||||
if (!(country in countryBoundingBoxes)) return false
|
||||
return !isPointInBoxes(dec_lat, dec_long, boundingBoxSplit(countryBoundingBoxes[country]))
|
||||
}
|
||||
|
||||
/* validates all fields when entering new/editing mode
|
||||
to disable write button when unvisited tabs have
|
||||
validation errors (e.g. missing required fields) */
|
||||
@@ -147,20 +165,56 @@ export const WriteButton = <T,>({
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
disabled={Object.keys(fieldsWithErrors).length > 0}
|
||||
id="write-button"
|
||||
sx={{ width: '20em' }}
|
||||
onClick={() => void handleWriteButtonClick()}
|
||||
variant="contained"
|
||||
>
|
||||
{loading ? (
|
||||
<CircularProgress size="1.2em" sx={{ color: 'white', marginRight: '1em' }} />
|
||||
) : (
|
||||
<SaveIcon style={{ marginRight: '0.5em' }} />
|
||||
)}
|
||||
{getButtonText()}
|
||||
</Button>
|
||||
<>
|
||||
<OutOfBoundsWarningModal
|
||||
isOpen={warningModalOpen}
|
||||
onAnswer={isOkay => {
|
||||
setWarningModalOpen(false)
|
||||
if (isOkay) save()
|
||||
}}
|
||||
state={warningModalState}
|
||||
/>
|
||||
<Button
|
||||
disabled={Object.keys(fieldsWithErrors).length > 0}
|
||||
id="write-button"
|
||||
sx={{ width: '20em' }}
|
||||
onClick={() => {
|
||||
// Check for out-of-boundness before saving
|
||||
|
||||
if (
|
||||
!('dec_lat' in (editData as object)) ||
|
||||
!('dec_long' in (editData as object)) ||
|
||||
!('country' in (editData as object))
|
||||
) {
|
||||
void handleWriteButtonClick()
|
||||
return
|
||||
}
|
||||
|
||||
const localityObject = editData as unknown as { dec_lat: number; dec_long: number; country: string }
|
||||
if (!isCoordinateOutOfBounds(localityObject.dec_lat, localityObject.dec_long, localityObject.country)) {
|
||||
void handleWriteButtonClick()
|
||||
return
|
||||
}
|
||||
|
||||
setWarningModalOpen(true)
|
||||
setWarningModalState({
|
||||
decLat: localityObject.dec_lat,
|
||||
decLong: localityObject.dec_long,
|
||||
// Guaranteed to exist by the isCoordinateOutOfBounds check above,
|
||||
// as it will return false and return on no key found
|
||||
boxes: boundingBoxSplit(countryBoundingBoxes[localityObject.country]),
|
||||
})
|
||||
}}
|
||||
variant="contained"
|
||||
>
|
||||
{loading ? (
|
||||
<CircularProgress size="1.2em" sx={{ color: 'white', marginRight: '1em' }} />
|
||||
) : (
|
||||
<SaveIcon style={{ marginRight: '0.5em' }} />
|
||||
)}
|
||||
{getButtonText()}
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,30 +1,35 @@
|
||||
import {
|
||||
useGetLocalityStatisticsQuery,
|
||||
useGetSpeciesStatisticsQuery,
|
||||
useGetStatisticsQuery,
|
||||
} from '../redux/statisticsReducer'
|
||||
import { ENV } from '@/util/config'
|
||||
import { Divider } from '@mui/material'
|
||||
import Box from '@mui/material/Box'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
const dateWhenStarted = new Date(Date.now())
|
||||
import mapSvg from '../resource/map.svg'
|
||||
import logo from '../resource/nowlogo.jpg'
|
||||
import '../styles/FrontPage.css'
|
||||
|
||||
export const FrontPage = () => {
|
||||
const [dateString, setDateString] = useState('')
|
||||
const mapContainerRef = useRef<HTMLDivElement | null>(null)
|
||||
const { data: statisticsQueryData } = useGetStatisticsQuery()
|
||||
const { data: speciesStatisticsQueryData, isFetching: speciesStatisticsQueryIsFetching } =
|
||||
useGetSpeciesStatisticsQuery()
|
||||
const { data: localityStatisticsQueryData, isFetching: localityStatisticsQueryIsFetching } =
|
||||
useGetLocalityStatisticsQuery()
|
||||
|
||||
// Map hover effect
|
||||
useEffect(() => {
|
||||
const currentLocalStorage = localStorage.getItem('dateWhenStarted')
|
||||
if (!currentLocalStorage) {
|
||||
localStorage.setItem('dateWhenStarted', dateWhenStarted.toString())
|
||||
return
|
||||
}
|
||||
if (!mapContainerRef.current) return
|
||||
mapContainerRef.current.onmousemove = (e: MouseEvent) => {
|
||||
if (!mapContainerRef.current) return
|
||||
const rect = mapContainerRef.current.getBoundingClientRect()
|
||||
const height = rect.bottom - rect.top
|
||||
const width = rect.right - rect.left
|
||||
|
||||
const currentLocalStorageDate = new Date(currentLocalStorage)
|
||||
const optionsForDateFormatting: Intl.DateTimeFormatOptions = {
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
second: 'numeric',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
mapContainerRef.current?.style.setProperty('--x', `${((e.clientX - rect.left) / width) * 100}%`)
|
||||
mapContainerRef.current?.style.setProperty('--y', `${((e.clientY - rect.top) / height) * 100}%`)
|
||||
}
|
||||
setDateString(currentLocalStorageDate.toLocaleDateString('fi-FI', optionsForDateFormatting))
|
||||
}, [])
|
||||
|
||||
if (ENV === 'dev') {
|
||||
@@ -32,51 +37,118 @@ export const FrontPage = () => {
|
||||
} else {
|
||||
document.title = 'NOW Database'
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ fontSize: 20, maxWidth: '40em' }}>
|
||||
<h1>Welcome</h1>
|
||||
{ENV !== 'prod' && (
|
||||
<p>
|
||||
<b style={{ backgroundColor: 'orange' }}>
|
||||
This version is running on test data. May contain incorrect and outdated information.
|
||||
</b>
|
||||
</p>
|
||||
)}
|
||||
<p>
|
||||
This is a new version of the NOW (New And Old Worlds) Fossil Mammal Database. It displays tables and details of
|
||||
localities, species, time units and references.
|
||||
</p>
|
||||
<p>
|
||||
<b>To navigate this application, use the bar at the top of the website.</b> Click on the links there to view
|
||||
different data tables, such as localities or species. Then, to view details of an individual item, click the
|
||||
magnifying glass icon on the left side of the row.
|
||||
</p>
|
||||
<p>
|
||||
To log in, click the Login-link at the top-right corner of the screen. A logged in user will see more links in
|
||||
the navigation bar, depending on their user rights, as well as the User-link that will take the user to their
|
||||
own user-page, where you can view your own information and change your password.
|
||||
</p>
|
||||
<p style={{ fontSize: 26 }}>
|
||||
<b>
|
||||
<a href="https://forms.gle/Aj392QWov5QXBJfo7">Click here to provide feedback!</a>
|
||||
</b>
|
||||
</p>
|
||||
<Divider />
|
||||
<h2>External links</h2>
|
||||
<ul>
|
||||
<li>
|
||||
Information about the database and NOW community:{' '}
|
||||
<a href="https://nowdatabase.org/">https://nowdatabase.org/</a>
|
||||
</li>
|
||||
<li>
|
||||
The old application: <a href="https://nowdatabase.luomus.fi/">https://nowdatabase.luomus.fi/</a>
|
||||
</li>
|
||||
<li>
|
||||
Code repository of the project:{' '}
|
||||
<a href="https://github.com/nowcommunity/nowdatabase">https://github.com/nowcommunity/nowdatabase</a>
|
||||
</li>
|
||||
</ul>
|
||||
{ENV !== 'prod' ? <p style={{ opacity: 0.25 }}>{dateString}</p> : ''}
|
||||
</Box>
|
||||
<main id="front-page">
|
||||
<header className="top-info">
|
||||
<div className="left">
|
||||
<h1>Welcome to NOW database</h1>
|
||||
<p>
|
||||
The NOW (New and Old Worlds) fossil mammal database contains information about Cenozoic land mammal taxa and
|
||||
localities. The emphasis of the database has been on the European Miocene and Pliocene but North American
|
||||
localities, covering the whole Cenozoic, were added to the public database in 2016. African localities are
|
||||
currently being added and updated, and the temporal scale is also becoming wider for both Eurasia and
|
||||
Africa, with Pleistocene and Paleogene localities being added into the database. The NOW database is
|
||||
maintained and coordinated by <a href="mailto:indre-dot-zliobaite-at-helsinki-dot-fi">Indrė Žliobaitė</a> in
|
||||
collaboration with associate coordinators, a steering group and an international{' '}
|
||||
<a href="https://nowdatabase.org/now/board/">advisory board</a>.
|
||||
</p>
|
||||
<p>
|
||||
<a href="https://nowdatabase.org/" target="_blank" rel="noreferrer">
|
||||
See more information at nowdatabase.org
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<div className="image-container">
|
||||
<img src={logo} alt="NOW database logo" />
|
||||
<span className="copyright">© Noira Martiskainen</span>
|
||||
</div>
|
||||
</header>
|
||||
<div className="stats">
|
||||
<span>
|
||||
<b>Localities</b> {statisticsQueryData && statisticsQueryData.localityCount}
|
||||
</span>
|
||||
<span>
|
||||
<b>Species</b> {statisticsQueryData && statisticsQueryData.speciesCount}
|
||||
</span>
|
||||
<span>
|
||||
<b>Locality-species</b> {statisticsQueryData && statisticsQueryData.localitySpeciesCount}
|
||||
</span>
|
||||
</div>
|
||||
<div className="map-container" ref={mapContainerRef}>
|
||||
<div className="map">
|
||||
<img src={mapSvg} />
|
||||
</div>
|
||||
</div>
|
||||
<section className="additional-info">
|
||||
<div>
|
||||
<h2>Statistics</h2>
|
||||
<div className="activity-stats">
|
||||
<div className="stat-table">
|
||||
<h3>Most localities</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Year</th>
|
||||
<th>Month</th>
|
||||
<th>Surname</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{!localityStatisticsQueryIsFetching &&
|
||||
localityStatisticsQueryData &&
|
||||
localityStatisticsQueryData.map((stat, i) => (
|
||||
<tr key={i}>
|
||||
<td>{stat.year}</td>
|
||||
<td>{stat.month}</td>
|
||||
<td>{stat.surname}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="stat-table">
|
||||
<h3>Most species</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Year</th>
|
||||
<th>Month</th>
|
||||
<th>Surname</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{!speciesStatisticsQueryIsFetching &&
|
||||
speciesStatisticsQueryData &&
|
||||
speciesStatisticsQueryData.map((stat, i) => (
|
||||
<tr key={i}>
|
||||
<td>{stat.year}</td>
|
||||
<td>{stat.month}</td>
|
||||
<td>{stat.surname}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Citation</h2>
|
||||
<h3>Minimum required attribution</h3>
|
||||
<p>
|
||||
Data (<a href="https://nowdatabase.org/now/database/">https://nowdatabase.org/now/database/</a>) by The NOW
|
||||
Community / CC BY 4.0.
|
||||
</p>
|
||||
<h3>Suggested citation or attribution</h3>
|
||||
<p>
|
||||
The NOW Community [year]. New and Old Worlds Database of Fossil Mammals (NOW). Licensed under CC BY 4.0.
|
||||
Retrieved [download date] from{' '}
|
||||
<a href="https://nowdatabase.org/now/database/">https://nowdatabase.org/now/database/</a>.
|
||||
</p>
|
||||
<h3>DOI</h3>
|
||||
<a href="http://doi.org/10.5281/zenodo.4268068">doi:10.5281/zenodo.4268068</a>.
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Box, Modal, Alert, Typography, IconButton, Table, TableBody, TableRow, TableCell } from '@mui/material'
|
||||
import { useGetLocalityDetailsQuery } from '../../redux/localityReducer'
|
||||
import { skipToken } from '@reduxjs/toolkit/query/react'
|
||||
import CloseIcon from '@mui/icons-material/Close'
|
||||
import '../../styles/species/SynonymsModal.css'
|
||||
|
||||
export const LocalitySynonymsModal = ({
|
||||
open,
|
||||
onClose,
|
||||
selectedLocality,
|
||||
}: {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
selectedLocality: string | undefined
|
||||
}) => {
|
||||
const { isError, isFetching, data } = useGetLocalityDetailsQuery(selectedLocality ? selectedLocality : skipToken)
|
||||
|
||||
if (isFetching) {
|
||||
return (
|
||||
<Modal open={open} onClose={onClose}>
|
||||
<Box className="modal-content"></Box>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} className="synonyms-modal modal">
|
||||
<Box className="modal-content">
|
||||
<Box className="modal-header">
|
||||
<Typography variant="h6" component="h2">
|
||||
{`Synonyms ${isError ? '' : `- ${data?.loc_name}`}`}
|
||||
</Typography>
|
||||
|
||||
<IconButton sx={{ color: 'white' }} onClick={onClose} aria-label="close" className="close-button">
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
{isError ? (
|
||||
<Alert severity="error" sx={{ m: 2 }}>
|
||||
Failed to load locality details. Please try again later.
|
||||
</Alert>
|
||||
) : (
|
||||
<Table className="modal-table">
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<strong>Locality synonyms</strong>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{data?.now_syn_loc?.map((synonym, index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell>{synonym.synonym}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</Box>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { LocalitiesMap } from '../Map/LocalitiesMap'
|
||||
import { generateKml } from '@/util/kml'
|
||||
import { generateSvg } from '../Map/generateSvg'
|
||||
import { usePageContext } from '../Page'
|
||||
import { LocalitySynonymsModal } from './LocalitySynonymsModal'
|
||||
|
||||
const decimalCount = (num: number) => {
|
||||
const numAsString = num.toString()
|
||||
@@ -18,18 +19,27 @@ const decimalCount = (num: number) => {
|
||||
}
|
||||
|
||||
export const LocalityTable = ({ selectorFn }: { selectorFn?: (newObject: Locality) => void }) => {
|
||||
const [selectedLocality, setSelectedLocality] = useState<string | undefined>()
|
||||
const [modalOpen, setModalOpen] = useState<boolean>(false)
|
||||
const { data: localitiesQueryData, isFetching: localitiesQueryIsFetching } = useGetAllLocalitiesQuery()
|
||||
const [getLocalitySpeciesList, { isLoading }] = useGetLocalitySpeciesListMutation()
|
||||
const [filteredLocalities, setFilteredLocalities] = useState<SimplifiedLocality[]>()
|
||||
const columnFilters = usePageContext()
|
||||
const notify = useNotify()
|
||||
|
||||
const handleLocalityRowActionClick = (row: Locality) => {
|
||||
setSelectedLocality(row.lid.toString())
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// Filter localities for the map component
|
||||
const localityIds = columnFilters.idList as unknown as number[]
|
||||
const validIds = localityIds.filter(id => typeof id === 'number')
|
||||
const localities = localitiesQueryData?.filter(locality => validIds.includes(locality.lid))
|
||||
setFilteredLocalities(localities as SimplifiedLocality[])
|
||||
|
||||
if (validIds.length > 0)
|
||||
setFilteredLocalities(localitiesQueryData?.filter(locality => validIds.includes(locality.lid)))
|
||||
else setFilteredLocalities(localitiesQueryData as SimplifiedLocality[])
|
||||
}, [columnFilters, localitiesQueryData])
|
||||
|
||||
const columns = useMemo<MRT_ColumnDef<Locality>[]>(
|
||||
@@ -427,9 +437,7 @@ export const LocalityTable = ({ selectorFn }: { selectorFn?: (newObject: Localit
|
||||
|
||||
return (
|
||||
<>
|
||||
{filteredLocalities && filteredLocalities.length !== 0 && (
|
||||
<LocalitiesMap localities={filteredLocalities} isFetching={localitiesQueryIsFetching} />
|
||||
)}
|
||||
<LocalitiesMap localities={filteredLocalities} isFetching={localitiesQueryIsFetching} />
|
||||
<TableView<Locality>
|
||||
title="Localities"
|
||||
selectorFn={selectorFn}
|
||||
@@ -445,7 +453,9 @@ export const LocalityTable = ({ selectorFn }: { selectorFn?: (newObject: Localit
|
||||
svgExport={svgExport}
|
||||
exportIsLoading={isLoading}
|
||||
enableColumnFilterModes={true}
|
||||
tableRowAction={handleLocalityRowActionClick}
|
||||
/>
|
||||
<LocalitySynonymsModal open={modalOpen} onClose={() => setModalOpen(false)} selectedLocality={selectedLocality} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -192,7 +192,7 @@ export const LocalityTab = () => {
|
||||
>
|
||||
<ArrayFrame array={latlong} title="Latitude & Longitude" />
|
||||
<Box sx={{ width: '50%' }}>
|
||||
{locality && <SingleLocalityMap dec_lat={editData.dec_lat} dec_long={editData.dec_long} />}
|
||||
{locality && <SingleLocalityMap decLat={editData.dec_lat} decLong={editData.dec_long} />}
|
||||
</Box>
|
||||
</Box>
|
||||
{!mode.read && coordinateButton}
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { useGetLocalityDetailsQuery } from '../../redux/localityReducer'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import 'leaflet/dist/leaflet.css'
|
||||
import { borders } from './country_borders_WGS84'
|
||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'
|
||||
import { countryPolygons } from '../../country_data/countryPolygons.ts'
|
||||
import L, { LatLngExpression } from 'leaflet'
|
||||
import { SimplifiedLocality } from '@/shared/types/data.js'
|
||||
import { usePageContext } from '../Page'
|
||||
import { SimplifiedLocality } from '../../shared/types/data.js'
|
||||
import { skipToken } from '@reduxjs/toolkit/query'
|
||||
import './leaflet.markercluster.js'
|
||||
import './MarkerCluster.css'
|
||||
@@ -29,12 +27,12 @@ export const LocalitiesMap = ({ localities, isFetching }: Props) => {
|
||||
const [map, setMap] = useState<L.Map | null>(null)
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [localityDetailsIsOpen, setLocalityDetailsIsOpen] = useState(false)
|
||||
const columnFilters = usePageContext()
|
||||
const [cluster, setCluster] = useState(true)
|
||||
const [clusteringEnabled, setClusteringEnabled] = useState(true)
|
||||
|
||||
// Use skipToken to skip query when state is null
|
||||
const { data: localityDetailsQueryData, isFetching: detailsLoading } = useGetLocalityDetailsQuery(
|
||||
selectedLocality ?? skipToken
|
||||
) // use skipToken to skip query when state is null
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!mapRef.current) return
|
||||
@@ -45,29 +43,28 @@ export const LocalitiesMap = ({ localities, isFetching }: Props) => {
|
||||
const coords: LatLngExpression = [15, 13]
|
||||
mapInstance.setView(coords, 3)
|
||||
|
||||
// BASE MAPS
|
||||
//// OpenTopoMap
|
||||
// ---- Base maps ----
|
||||
|
||||
// OpenTopoMap
|
||||
const topomap = L.tileLayer('https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png', {
|
||||
attribution: 'Map data: © <a href="https://opentopomap.org">OpenTopoMap</a> (CC-BY-SA)',
|
||||
noWrap: true,
|
||||
}).addTo(mapInstance)
|
||||
|
||||
//// OpenStreetMap
|
||||
// OpenStreetMap
|
||||
const osm = L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||||
noWrap: true,
|
||||
})
|
||||
|
||||
// LAYERS ON TOP OF BASE MAPS
|
||||
//// Add borders to the map
|
||||
borders.forEach(poly => {
|
||||
L.polygon(poly as LatLngExpression[], { color: 'gray', weight: 1 }).addTo(mapInstance)
|
||||
})
|
||||
// ---- Layers on top of base maps ----
|
||||
|
||||
// create a polygon layer for the country borders that is in layer control panel..
|
||||
// Create a polygon layer for the country borders that is in layer control panel.
|
||||
const borderLayer = L.layerGroup()
|
||||
borders.forEach(country_border => {
|
||||
const polygon = L.polygon(country_border as LatLngExpression[], {
|
||||
countryPolygons.forEach(countryBorder => {
|
||||
L.polygon(countryBorder as LatLngExpression[], { color: 'gray', weight: 1 }).addTo(mapInstance)
|
||||
|
||||
const polygon = L.polygon(countryBorder as LatLngExpression[], {
|
||||
color: '#136f94',
|
||||
fillOpacity: 0.3,
|
||||
weight: 1,
|
||||
@@ -81,13 +78,13 @@ export const LocalitiesMap = ({ localities, isFetching }: Props) => {
|
||||
Countries: borderLayer,
|
||||
}
|
||||
|
||||
// Add a scale bar to the map
|
||||
// Scale bar
|
||||
L.control.scale({ position: 'bottomright' }).addTo(mapInstance)
|
||||
|
||||
//Add a layer control to the map
|
||||
// Layer control
|
||||
L.control.layers(baseMaps, {}, { position: 'topright' }).addTo(mapInstance)
|
||||
|
||||
// Add north-arrow to the map
|
||||
// North-arrow
|
||||
const northArrowControl = L.Control.extend({
|
||||
onAdd: function () {
|
||||
const img = L.DomUtil.create('img')
|
||||
@@ -96,7 +93,6 @@ export const LocalitiesMap = ({ localities, isFetching }: Props) => {
|
||||
return img
|
||||
},
|
||||
})
|
||||
|
||||
const northArrow = new northArrowControl({ position: 'bottomright' })
|
||||
northArrow.addTo(mapInstance)
|
||||
|
||||
@@ -116,7 +112,7 @@ export const LocalitiesMap = ({ localities, isFetching }: Props) => {
|
||||
|
||||
// @ts-expect-error The marker cluster library is a plain javascript library
|
||||
// with no module exports that extends 'L' when imported
|
||||
const newMarkers: Layer = cluster ? L.markerClusterGroup() : L.layerGroup()
|
||||
const newMarkers: Layer = clusteringEnabled ? L.markerClusterGroup() : L.layerGroup()
|
||||
|
||||
localities?.forEach(locality => {
|
||||
const options: CustomCircleMarkerOptions = {
|
||||
@@ -149,28 +145,29 @@ export const LocalitiesMap = ({ localities, isFetching }: Props) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
map.removeLayer(newMarkers)
|
||||
}
|
||||
}, [localities, isFetching, columnFilters, map, cluster])
|
||||
}, [localities, isFetching, map, clusteringEnabled])
|
||||
|
||||
document.title = 'Map'
|
||||
|
||||
return (
|
||||
<>
|
||||
<article id="localities-map">
|
||||
<div id="map-container" className={isOpen ? 'open' : ''}>
|
||||
<div className={'map'} ref={mapRef} style={{ flex: 1 }} />
|
||||
<button className="cluster-btn" onClick={() => setCluster(cluster => !cluster)}>
|
||||
{cluster ? 'Show individual' : 'Show cluster'}
|
||||
<article id="localities-map" className={isOpen ? 'open' : ''}>
|
||||
<div className="map-container">
|
||||
<div className="map" ref={mapRef} />
|
||||
<button className="cluster" onClick={() => setClusteringEnabled(cluster => !cluster)}>
|
||||
{clusteringEnabled ? 'Show individual' : 'Show cluster'}
|
||||
</button>
|
||||
<div id="blur-container" className={isOpen ? 'open' : ''}></div>
|
||||
<div className="gradient" onClick={() => setIsOpen(v => !v)}></div>
|
||||
</div>
|
||||
<div className="button-row">
|
||||
<KeyboardArrowDownIcon
|
||||
className={isOpen ? 'map-btn-up' : 'map-btn-down'}
|
||||
onClick={() => setIsOpen(v => !v)}
|
||||
/>
|
||||
<div className="open-button" onClick={() => setIsOpen(v => !v)} title="Close map">
|
||||
<span className="downarrow"></span>
|
||||
</div>
|
||||
</article>
|
||||
<SlidingModal isOpen={localityDetailsIsOpen} onClose={() => setLocalityDetailsIsOpen(false)}>
|
||||
<SlidingModal
|
||||
isOpen={localityDetailsIsOpen}
|
||||
onClose={() => setLocalityDetailsIsOpen(false)}
|
||||
returnButtonText="Return to map"
|
||||
>
|
||||
<LocalityInfo
|
||||
localityDetailsQueryData={localityDetailsQueryData}
|
||||
detailsLoading={detailsLoading}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useState, useEffect } from 'react'
|
||||
import { LocalityDetailsType } from '@/shared/types/data.js'
|
||||
import { SpeciesTable } from './SpeciesTable'
|
||||
import '../../styles/LocalityDetailsPanel.css'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
interface Props {
|
||||
localityDetailsQueryData: LocalityDetailsType | undefined
|
||||
@@ -37,7 +38,9 @@ export const LocalityInfo = ({ localityDetailsQueryData, selectedLocality, detai
|
||||
<p>Loading...</p>
|
||||
) : (
|
||||
<div>
|
||||
<h2>{localityDetailsQueryData.loc_name}</h2>
|
||||
<Link to={`/locality/${localityDetailsQueryData.lid}`}>
|
||||
<h2>{localityDetailsQueryData.loc_name}</h2>
|
||||
</Link>
|
||||
<p>
|
||||
<strong>ID:</strong> {localityDetailsQueryData.lid}
|
||||
</p>
|
||||
|
||||
@@ -1,51 +1,52 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import 'leaflet/dist/leaflet.css'
|
||||
import { borders } from './country_borders_WGS84'
|
||||
import { countryPolygons } from '../../country_data/countryPolygons.ts'
|
||||
import L, { LatLngExpression } from 'leaflet'
|
||||
import '../../styles/SingleLocalityMap.css'
|
||||
import northarrow from './images/north-arrow.png'
|
||||
import { CountryBoundingBox } from '@/country_data/countryBoundingBoxes.ts'
|
||||
|
||||
interface Props {
|
||||
dec_lat: number | undefined
|
||||
dec_long: number | undefined
|
||||
decLat?: number
|
||||
decLong?: number
|
||||
boxes?: CountryBoundingBox[]
|
||||
}
|
||||
|
||||
export const SingleLocalityMap = ({ dec_lat, dec_long }: Props) => {
|
||||
export const SingleLocalityMap = ({ decLat, decLong, boxes }: Props) => {
|
||||
const mapRef = useRef<HTMLDivElement | null>(null)
|
||||
const [map, setMap] = useState<L.Map | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!mapRef.current || !dec_lat || !dec_long) return
|
||||
if (!mapRef.current || !decLat || !decLong) return
|
||||
|
||||
const mapInstance = L.map(mapRef.current, { maxZoom: 16 })
|
||||
setMap(mapInstance)
|
||||
|
||||
const coords: LatLngExpression = [dec_lat, dec_long]
|
||||
const coords: LatLngExpression = [decLat, decLong]
|
||||
mapInstance.setView(coords, 4)
|
||||
|
||||
// BASE MAPS
|
||||
//// OpenTopoMap
|
||||
// ---- Base maps ----
|
||||
|
||||
// OpenTopoMap
|
||||
const topomap = L.tileLayer('https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png', {
|
||||
attribution: 'Map data: © <a href="https://opentopomap.org">OpenTopoMap</a> (CC-BY-SA)',
|
||||
noWrap: true,
|
||||
}).addTo(mapInstance)
|
||||
|
||||
//// OpenStreetMap
|
||||
// OpenStreetMap
|
||||
const osm = L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||||
noWrap: true,
|
||||
})
|
||||
|
||||
// LAYERS ON TOP OF BASE MAPS
|
||||
//// Add borders to the map
|
||||
borders.forEach(poly => {
|
||||
L.polygon(poly as LatLngExpression[], { color: 'gray', weight: 1 }).addTo(mapInstance)
|
||||
})
|
||||
// ---- Layers on top of base maps ----
|
||||
|
||||
// create a polygon layer for the country borders that is in layer control panel..
|
||||
// Create a polygon layer for the country borders that is in layer control panel..
|
||||
const borderLayer = L.layerGroup()
|
||||
borders.forEach(country_border => {
|
||||
const polygon = L.polygon(country_border as LatLngExpression[], {
|
||||
countryPolygons.forEach(countryBorder => {
|
||||
L.polygon(countryBorder as LatLngExpression[], { color: 'gray', weight: 1 }).addTo(mapInstance)
|
||||
|
||||
const polygon = L.polygon(countryBorder as LatLngExpression[], {
|
||||
color: '#136f94',
|
||||
fillOpacity: 0.3,
|
||||
weight: 1,
|
||||
@@ -62,7 +63,7 @@ export const SingleLocalityMap = ({ dec_lat, dec_long }: Props) => {
|
||||
// Add a scale bar to the map
|
||||
L.control.scale({ position: 'bottomright' }).addTo(mapInstance)
|
||||
|
||||
//Add a layer control to the map
|
||||
// Add a layer control to the map
|
||||
L.control.layers(baseMaps, {}, { position: 'topright' }).addTo(mapInstance)
|
||||
|
||||
// Add north-arrow to the map
|
||||
@@ -81,15 +82,29 @@ export const SingleLocalityMap = ({ dec_lat, dec_long }: Props) => {
|
||||
return () => {
|
||||
mapInstance.remove()
|
||||
}
|
||||
}, [dec_lat, dec_long])
|
||||
}, [decLat, decLong])
|
||||
|
||||
useEffect(() => {
|
||||
if (!map || !dec_lat || !dec_long) return
|
||||
if (!map || !decLat || !decLong) return
|
||||
|
||||
if (boxes) {
|
||||
boxes.forEach(box => {
|
||||
L.polygon(
|
||||
[
|
||||
[box.top, box.left],
|
||||
[box.top, box.right],
|
||||
[box.bottom, box.right],
|
||||
[box.bottom, box.left],
|
||||
] as LatLngExpression[],
|
||||
{ color: 'blue' }
|
||||
).addTo(map)
|
||||
})
|
||||
}
|
||||
|
||||
const newMarker = L.layerGroup()
|
||||
|
||||
newMarker.addLayer(
|
||||
L.circleMarker([dec_lat, dec_long], {
|
||||
L.circleMarker([decLat, decLong], {
|
||||
radius: 4,
|
||||
color: '#db2c2c',
|
||||
fillColor: '#d95050',
|
||||
@@ -106,7 +121,7 @@ export const SingleLocalityMap = ({ dec_lat, dec_long }: Props) => {
|
||||
return () => {
|
||||
map.removeLayer(newMarker)
|
||||
}
|
||||
}, [map, dec_lat, dec_long])
|
||||
}, [map, decLat, decLong, boxes])
|
||||
|
||||
document.title = 'Map'
|
||||
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import { Button } from '@mui/material'
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack'
|
||||
import '../../styles/SlidingModal.css'
|
||||
|
||||
interface Props {
|
||||
children: JSX.Element
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
returnButtonText?: string
|
||||
id?: string
|
||||
}
|
||||
|
||||
export const SlidingModal = ({ children, isOpen, onClose }: Props) => {
|
||||
export const SlidingModal = ({ children, isOpen, onClose, returnButtonText = 'Return', id }: Props) => {
|
||||
return (
|
||||
<div className={`sliding-modal-container ${isOpen ? 'open' : ''}`}>
|
||||
<div className={`sliding-modal-container ${isOpen ? 'open' : ''}`} id={id}>
|
||||
<div className="background" onClick={onClose}></div>
|
||||
<div
|
||||
className="content"
|
||||
@@ -16,6 +20,10 @@ export const SlidingModal = ({ children, isOpen, onClose }: Props) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
>
|
||||
<Button sx={{ marginTop: '2.5em', marginLeft: '1em' }} onClick={onClose}>
|
||||
<ArrowBackIcon color="primary" style={{ marginRight: '0.2em' }} />
|
||||
{returnButtonText}
|
||||
</Button>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,5 +1,5 @@
|
||||
import { SVG } from '@svgdotjs/svg.js'
|
||||
import { borders } from './country_borders_WGS84'
|
||||
import { countryPolygons } from '../../country_data/countryPolygons.ts'
|
||||
import { Locality } from '@/shared/types'
|
||||
|
||||
interface PixelCoordinates {
|
||||
@@ -36,10 +36,10 @@ export const generateSvg = (
|
||||
|
||||
if (!transparent) draw.polygon(`0,0 ${width},0 ${width},${width} 0,${width}`).fill('#ffffff')
|
||||
|
||||
borders.forEach(country_border => {
|
||||
countryPolygons.forEach(countryBorder => {
|
||||
const points: string[] = []
|
||||
|
||||
country_border.forEach(coordinate => {
|
||||
countryBorder.forEach(coordinate => {
|
||||
const pixelCoords = webMercatorConvert(coordinate[0], coordinate[1], width)
|
||||
points.push(`${pixelCoords.x},${pixelCoords.y}`)
|
||||
})
|
||||
|
||||
@@ -5,12 +5,13 @@ import { clearUser } from '../redux/userReducer'
|
||||
import { api } from '../redux/api'
|
||||
import { Role } from '@/shared/types'
|
||||
import { useUser } from '@/hooks/user'
|
||||
import { ENV } from '@/util/config'
|
||||
import { useNotify } from '@/hooks/notification'
|
||||
import PersonIcon from '@mui/icons-material/Person'
|
||||
import '../styles/NavBar.css'
|
||||
import { LinkDefinition, NavBarLink } from './NavBarLink'
|
||||
|
||||
import logo from '../resource/nowlogo.jpg'
|
||||
|
||||
export const NavBar = () => {
|
||||
const user = useUser()
|
||||
const navigate = useNavigate()
|
||||
@@ -19,7 +20,7 @@ export const NavBar = () => {
|
||||
const pages: LinkDefinition[] = [
|
||||
{ title: 'Localities', url: '/locality' },
|
||||
{ title: 'Species', url: '/species' },
|
||||
{ title: 'Cross-Search', url: '/crosssearch' },
|
||||
{ title: 'Locality-Species', url: '/crosssearch' },
|
||||
{ title: 'References', url: '/reference' },
|
||||
{ title: 'Time Units', url: '/time-unit' },
|
||||
{ title: 'Time Bounds', url: '/time-bound', allowedRoles: [Role.Admin, Role.EditUnrestricted] },
|
||||
@@ -53,16 +54,11 @@ export const NavBar = () => {
|
||||
</MenuItem>
|
||||
)
|
||||
|
||||
const getModeText = () => {
|
||||
if (ENV === 'staging') return '(test)'
|
||||
if (ENV === 'dev') return '(dev)'
|
||||
return ''
|
||||
}
|
||||
return (
|
||||
<AppBar position="static" id="main-navbar">
|
||||
<Stack spacing={10} direction="row" marginLeft={2} justifyContent="flex-start">
|
||||
<MenuItem component={Link} to="/">
|
||||
<Typography fontSize={'1.4em'}>NOW Database {getModeText()}</Typography>
|
||||
<div className="container">
|
||||
<MenuItem component={Link} to="/" className="logo-link">
|
||||
<img src={logo} title="Logo © Noira Martiskainen" />
|
||||
</MenuItem>
|
||||
<MenuList className="menu-list">
|
||||
{pages
|
||||
@@ -86,7 +82,7 @@ export const NavBar = () => {
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
</div>
|
||||
</AppBar>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { useEditPersonMutation, useGetPersonDetailsQuery } from '../../redux/personReducer'
|
||||
import {
|
||||
useEditPersonMutation,
|
||||
useGetPersonDetailsIdMutation,
|
||||
useGetPersonDetailsQuery,
|
||||
} from '../../redux/personReducer'
|
||||
import { CircularProgress } from '@mui/material'
|
||||
import { DetailView, TabType } from '../DetailView/DetailView'
|
||||
import { PersonTab } from './Tabs/PersonTab'
|
||||
@@ -8,6 +12,7 @@ import { EditDataType, PersonDetailsType, Role, ValidationErrors } from '@/share
|
||||
import { validatePerson } from '@/shared/validators/person'
|
||||
import { useNotify } from '@/hooks/notification'
|
||||
import { useEffect } from 'react'
|
||||
import { emptyPerson } from '../DetailView/common/defaultValues'
|
||||
|
||||
export const PersonDetails = () => {
|
||||
const { id: idFromUrl } = useParams()
|
||||
@@ -21,8 +26,10 @@ export const PersonDetails = () => {
|
||||
// We designate special id 'user-page' instead of normal initials to mean current user's own page.
|
||||
const isUserPage = idFromUrl === 'user-page'
|
||||
const id = isUserPage ? user.initials : idFromUrl
|
||||
const isNew = idFromUrl === 'new'
|
||||
|
||||
const { isLoading, isError, data } = useGetPersonDetailsQuery(id!)
|
||||
const { isLoading, isError, data } = useGetPersonDetailsQuery(id!, { skip: isNew })
|
||||
const [getPersonDetailsId] = useGetPersonDetailsIdMutation()
|
||||
|
||||
useEffect(() => {
|
||||
if (!isUserPage && user.role !== Role.Admin) {
|
||||
@@ -32,8 +39,23 @@ export const PersonDetails = () => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const onWrite = async (editData: EditDataType<PersonDetailsType>) => {
|
||||
const personExists = async (initials: string) => {
|
||||
try {
|
||||
const isPerson = await getPersonDetailsId(initials).unwrap()
|
||||
if (isPerson) return true
|
||||
return false
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const onWrite = async (editData: EditDataType<PersonDetailsType>) => {
|
||||
if (!editData.initials) return
|
||||
try {
|
||||
if (isNew && (await personExists(editData.initials))) {
|
||||
notify('Initials already exists. Select Edit.', 'error')
|
||||
return
|
||||
}
|
||||
const { initials } = await editPersonRequest(editData).unwrap()
|
||||
notify('Saved person successfully.')
|
||||
if (isUserPage) {
|
||||
@@ -48,9 +70,9 @@ export const PersonDetails = () => {
|
||||
}
|
||||
|
||||
if (isError) return <div>Error loading data</div>
|
||||
if (isLoading || !data || mutationLoading) return <CircularProgress />
|
||||
if (isLoading || (!data && !isNew) || mutationLoading) return <CircularProgress />
|
||||
|
||||
document.title = `User - ${data.user?.user_name}`
|
||||
document.title = isNew ? 'New person' : `User - ${data!.user?.user_name}`
|
||||
|
||||
const tabs: TabType[] = [
|
||||
{
|
||||
@@ -62,10 +84,11 @@ export const PersonDetails = () => {
|
||||
return (
|
||||
<DetailView
|
||||
onWrite={onWrite}
|
||||
isNew={isNew}
|
||||
isUserPage={isUserPage}
|
||||
isPersonPage={true}
|
||||
tabs={tabs}
|
||||
data={data}
|
||||
data={isNew ? emptyPerson : data!}
|
||||
validator={validatePerson}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import { SlidingModal } from '@/components/Map/SlidingModal'
|
||||
import { useNotify } from '@/hooks/notification'
|
||||
import '../../../styles/AddUserModal.css'
|
||||
import { Button, IconButton, MenuItem, Select, SelectChangeEvent, TextField } from '@mui/material'
|
||||
import { ChangeEventHandler, useState } from 'react'
|
||||
import SaveIcon from '@mui/icons-material/Save'
|
||||
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff'
|
||||
import VisibilityIcon from '@mui/icons-material/Visibility'
|
||||
import CasinoIcon from '@mui/icons-material/Casino'
|
||||
import { UserDetailsType, userGroups } from '@/shared/types'
|
||||
import { useCreateUserMutation } from '@/redux/userReducer'
|
||||
|
||||
const UserFieldInput = ({
|
||||
field,
|
||||
label,
|
||||
onChange,
|
||||
value,
|
||||
password,
|
||||
}: {
|
||||
field: string
|
||||
label?: string
|
||||
onChange: ChangeEventHandler<HTMLInputElement>
|
||||
value: string
|
||||
password?: boolean
|
||||
}) => {
|
||||
const capitalize = (str: string) => str.charAt(0).toUpperCase() + str.slice(1)
|
||||
|
||||
return (
|
||||
<>
|
||||
<label htmlFor={`user-form-${field}`}>{label ? label : capitalize(field)}:</label>
|
||||
<TextField
|
||||
variant="outlined"
|
||||
size="small"
|
||||
className="field"
|
||||
type={password ? 'password' : 'text'}
|
||||
id={`user-form-${field}`}
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const UserPasswordInput = ({ onChange, value }: { onChange: (value: string) => void; value: string }) => {
|
||||
const [hidePassword, setHidePassword] = useState(false)
|
||||
|
||||
const randomPassword = () => {
|
||||
const passwordLength = 14
|
||||
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789^?$%&~!'
|
||||
let result: string = ''
|
||||
|
||||
for (let i = 0; i < passwordLength; i++) {
|
||||
result += characters.charAt(Math.floor(Math.random() * characters.length))
|
||||
}
|
||||
onChange(result)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<label htmlFor="user-form-password">Password:</label>
|
||||
<div className="password-input-container">
|
||||
<TextField
|
||||
variant="outlined"
|
||||
size="small"
|
||||
className="field"
|
||||
type={hidePassword ? 'password' : 'text'}
|
||||
id="user-form-password"
|
||||
onChange={e => onChange(e.target.value)}
|
||||
value={value}
|
||||
/>
|
||||
<IconButton onClick={() => setHidePassword(v => !v)}>
|
||||
{hidePassword ? <VisibilityIcon /> : <VisibilityOffIcon />}
|
||||
</IconButton>
|
||||
<IconButton onClick={randomPassword}>
|
||||
<CasinoIcon />
|
||||
</IconButton>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const UserGroupSelect = ({
|
||||
value,
|
||||
onChange,
|
||||
id,
|
||||
}: {
|
||||
value: string
|
||||
onChange: (e: SelectChangeEvent) => void
|
||||
id?: string
|
||||
}) => {
|
||||
return (
|
||||
<Select label={'User group'} value={value} onChange={onChange} size="small" id={id}>
|
||||
{userGroups.map(group => (
|
||||
<MenuItem key={group} value={group} style={{ height: '2em' }}>
|
||||
{group}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
interface UserFieldValues {
|
||||
username: string
|
||||
password: string
|
||||
userGroup: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onSave: () => void
|
||||
personInitials: string
|
||||
}
|
||||
|
||||
export const AddUserModal = ({ isOpen, onClose, onSave, personInitials }: Props) => {
|
||||
const [fieldValues, setFieldValues] = useState<UserFieldValues>({
|
||||
username: '',
|
||||
password: '',
|
||||
userGroup: userGroups[0],
|
||||
})
|
||||
const [createUser, { isLoading }] = useCreateUserMutation()
|
||||
const notify = useNotify()
|
||||
|
||||
const save = () => {
|
||||
const userDetails: UserDetailsType = {
|
||||
initials: personInitials,
|
||||
password: fieldValues.password,
|
||||
user_name: fieldValues.username,
|
||||
now_user_group: fieldValues.userGroup,
|
||||
}
|
||||
|
||||
createUser(userDetails)
|
||||
.unwrap()
|
||||
.then(() => {
|
||||
notify('User saved successfully.')
|
||||
onClose()
|
||||
onSave()
|
||||
})
|
||||
.catch(e => {
|
||||
notify((e as { data: { message: string } }).data.message, 'error')
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<SlidingModal isOpen={isOpen} onClose={onClose} id="add-user-modal">
|
||||
<div className="inputs">
|
||||
<UserFieldInput
|
||||
field="username"
|
||||
value={fieldValues.username}
|
||||
onChange={e => setFieldValues({ ...fieldValues, username: e.target.value })}
|
||||
/>
|
||||
<UserPasswordInput
|
||||
value={fieldValues.password}
|
||||
onChange={value => setFieldValues({ ...fieldValues, password: value })}
|
||||
/>
|
||||
<label htmlFor="user-form-usergroup">User group:</label>
|
||||
<UserGroupSelect
|
||||
id="user-form-usergroup"
|
||||
value={fieldValues.userGroup}
|
||||
onChange={e => setFieldValues({ ...fieldValues, userGroup: e.target.value })}
|
||||
/>
|
||||
<div></div>
|
||||
<div className="save-btn-container">
|
||||
<Button variant="contained" className="save-btn" startIcon={<SaveIcon />} onClick={save} disabled={isLoading}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SlidingModal>
|
||||
)
|
||||
}
|
||||
@@ -1,18 +1,26 @@
|
||||
import { PersonDetailsType } from '@/shared/types'
|
||||
import { PersonDetailsType, Role, userGroups } from '@/shared/types'
|
||||
import { ArrayFrame, Grouped } from '../../DetailView/common/tabLayoutHelpers'
|
||||
import { useDetailContext } from '@/components/DetailView/Context/DetailContext'
|
||||
import { formatLastLoginDate } from '@/common'
|
||||
import { useUser } from '@/hooks/user'
|
||||
import { ChangePasswordForm } from './ChangePasswordForm'
|
||||
import { Box } from '@mui/material'
|
||||
import { useEffect } from 'react'
|
||||
import { Box, Button } from '@mui/material'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNotify } from '@/hooks/notification'
|
||||
import { validCountries } from '@/shared/validators/countryList'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import PersonAddIcon from '@mui/icons-material/PersonAdd'
|
||||
import { AddUserModal } from './AddUserModal'
|
||||
|
||||
export const PersonTab = () => {
|
||||
const { textField, dropdownWithSearch, data } = useDetailContext<PersonDetailsType>()
|
||||
const { textField, dropdownWithSearch, data, mode } = useDetailContext<PersonDetailsType>()
|
||||
const currentUser = useUser()
|
||||
const notify = useNotify()
|
||||
const { id: idFromUrl } = useParams()
|
||||
const isNew = idFromUrl === 'new'
|
||||
const isAdmin = currentUser.role == Role.Admin
|
||||
const [isAddUserModalOpen, setIsAddUserModalOpen] = useState(false)
|
||||
const [disableAddUserButton, setDisableAddUserButton] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentUser?.isFirstLogin) return
|
||||
@@ -22,10 +30,9 @@ export const PersonTab = () => {
|
||||
}, [currentUser])
|
||||
|
||||
const countryOptions = ['', ...validCountries]
|
||||
const userGroupOptions = ['su', 'eu', 'er', 'pl', 'plp', 'no', 'ro']
|
||||
|
||||
const person = [
|
||||
['Initials', textField('initials', { type: 'text', disabled: true })],
|
||||
['Initials', textField('initials', { type: 'text', disabled: !isNew })],
|
||||
['First Name', textField('first_name')],
|
||||
['Surname', textField('surname')],
|
||||
['Email', textField('email')],
|
||||
@@ -34,21 +41,36 @@ export const PersonTab = () => {
|
||||
]
|
||||
const lastLogin = data.user?.last_login
|
||||
|
||||
const user = data.user
|
||||
? [
|
||||
['User Name', data.user?.user_name ?? ''],
|
||||
['Last log in', lastLogin ? formatLastLoginDate(lastLogin) : 'No data'],
|
||||
[
|
||||
'User Group',
|
||||
dropdownWithSearch('now_user_group', userGroupOptions, 'User Group', false, 'Choose user group'),
|
||||
],
|
||||
]
|
||||
: [['Not a user']]
|
||||
const user = [
|
||||
['User Name', data.user?.user_name ?? ''],
|
||||
['Last log in', lastLogin ? formatLastLoginDate(lastLogin) : 'No data'],
|
||||
['User Group', dropdownWithSearch('now_user_group', userGroups, 'User Group', !isAdmin, 'Choose user group')],
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<AddUserModal
|
||||
isOpen={isAddUserModalOpen}
|
||||
onClose={() => {
|
||||
setIsAddUserModalOpen(false)
|
||||
}}
|
||||
onSave={() => setDisableAddUserButton(true)}
|
||||
personInitials={data.initials}
|
||||
/>
|
||||
<ArrayFrame array={person} title="Person" />
|
||||
<ArrayFrame array={user} title="User" />
|
||||
{data.user && <ArrayFrame array={user} title="User" />}
|
||||
|
||||
{isAdmin && !data.user && !disableAddUserButton && mode.option === 'read' && (
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<PersonAddIcon />}
|
||||
onClick={() => setIsAddUserModalOpen(true)}
|
||||
sx={{ alignSelf: 'flex-start' }}
|
||||
>
|
||||
Add user
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{currentUser.initials === data.initials && (
|
||||
<Grouped title="Change password" style={{ padding: '1em' }}>
|
||||
{currentUser.isFirstLogin && (
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { type MRT_ColumnDef } from 'material-react-table'
|
||||
import { useGetAllSpeciesQuery } from '../../redux/speciesReducer'
|
||||
import { Species } from '@/shared/types'
|
||||
import { TableView } from '../TableView/TableView'
|
||||
import { SynonymsModal } from './SynonymsModal'
|
||||
|
||||
export const SpeciesTable = ({ selectorFn }: { selectorFn?: (id: Species) => void }) => {
|
||||
const [selectedSpecies, setSelectedSpecies] = useState<string | undefined>()
|
||||
const [modalOpen, setModalOpen] = useState<boolean>(false)
|
||||
const { data: speciesQueryData, isFetching } = useGetAllSpeciesQuery()
|
||||
|
||||
const handleSpeciesRowActionClick = (row: Species) => {
|
||||
setSelectedSpecies(row.species_id.toString())
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
const columns = useMemo<MRT_ColumnDef<Species>[]>(
|
||||
() => [
|
||||
{
|
||||
@@ -367,16 +376,20 @@ export const SpeciesTable = ({ selectorFn }: { selectorFn?: (id: Species) => voi
|
||||
}
|
||||
|
||||
return (
|
||||
<TableView<Species>
|
||||
title="Species"
|
||||
selectorFn={selectorFn}
|
||||
idFieldName="species_id"
|
||||
columns={columns}
|
||||
isFetching={isFetching}
|
||||
visibleColumns={visibleColumns}
|
||||
data={speciesQueryData}
|
||||
url="species"
|
||||
enableColumnFilterModes={true}
|
||||
/>
|
||||
<>
|
||||
<TableView<Species>
|
||||
title="Species"
|
||||
selectorFn={selectorFn}
|
||||
idFieldName="species_id"
|
||||
columns={columns}
|
||||
isFetching={isFetching}
|
||||
visibleColumns={visibleColumns}
|
||||
data={speciesQueryData}
|
||||
url="species"
|
||||
enableColumnFilterModes={true}
|
||||
tableRowAction={handleSpeciesRowActionClick}
|
||||
/>
|
||||
<SynonymsModal open={modalOpen} onClose={() => setModalOpen(false)} selectedSpecies={selectedSpecies} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Box, Modal, Alert, Typography, IconButton, Table, TableBody, TableRow, TableCell } from '@mui/material'
|
||||
import { useGetSpeciesDetailsQuery } from '../../redux/speciesReducer'
|
||||
import { skipToken } from '@reduxjs/toolkit/query/react'
|
||||
import CloseIcon from '@mui/icons-material/Close'
|
||||
import '../../styles/species/SynonymsModal.css'
|
||||
|
||||
export const SynonymsModal = ({
|
||||
open,
|
||||
onClose,
|
||||
selectedSpecies,
|
||||
}: {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
selectedSpecies: string | undefined
|
||||
}) => {
|
||||
const { isError, isFetching, data } = useGetSpeciesDetailsQuery(selectedSpecies ? selectedSpecies : skipToken)
|
||||
|
||||
if (isFetching) {
|
||||
return (
|
||||
<Modal open={open} onClose={onClose}>
|
||||
<Box className="modal-content"></Box>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} className="synonyms-modal modal">
|
||||
<Box className="modal-content">
|
||||
<Box className="modal-header">
|
||||
<Typography variant="h6" component="h2">
|
||||
{`Synonyms ${isError ? '' : `- ${data?.genus_name} ${data?.species_name}`}`}
|
||||
</Typography>
|
||||
|
||||
<IconButton sx={{ color: 'white' }} onClick={onClose} aria-label="close" className="close-button">
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
{isError ? (
|
||||
<Alert severity="error" sx={{ m: 2 }}>
|
||||
Failed to load species details. Please try again later.
|
||||
</Alert>
|
||||
) : (
|
||||
<Table className="modal-table">
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<strong>Genus</strong>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<strong>Species</strong>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<strong>Comments</strong>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{data?.com_taxa_synonym?.map((synonym, index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell>{synonym.syn_genus_name}</TableCell>
|
||||
<TableCell>{synonym.syn_species_name}</TableCell>
|
||||
<TableCell>{synonym.syn_comment}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</Box>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Box, Button, Tooltip } from '@mui/material'
|
||||
import { Box, Button, Tooltip, Typography } from '@mui/material'
|
||||
import { MRT_RowData, MRT_Row } from 'material-react-table'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import ManageSearchIcon from '@mui/icons-material/ManageSearch'
|
||||
@@ -11,32 +11,58 @@ export const ActionComponent = <T extends MRT_RowData>({
|
||||
checkRowRestriction,
|
||||
url,
|
||||
selectorFn,
|
||||
tableRowAction,
|
||||
}: {
|
||||
row: MRT_Row<T>
|
||||
idFieldName: keyof T
|
||||
checkRowRestriction?: ((row: T) => boolean) | undefined
|
||||
url: string | undefined
|
||||
selectorFn?: (id: T) => void
|
||||
tableRowAction?: (row: T) => void
|
||||
}) => {
|
||||
const navigate = useNavigate()
|
||||
|
||||
const id = row.original[idFieldName]
|
||||
|
||||
const getIconToShow = () => {
|
||||
if (selectorFn) return <AddCircleOutlineIcon />
|
||||
if (selectorFn) {
|
||||
return <AddCircleOutlineIcon />
|
||||
} else if (tableRowAction) {
|
||||
return (
|
||||
<Typography
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
textAlign: 'center',
|
||||
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
}}
|
||||
variant="button"
|
||||
component="p"
|
||||
>
|
||||
S
|
||||
</Typography>
|
||||
)
|
||||
}
|
||||
return <ManageSearchIcon />
|
||||
}
|
||||
|
||||
const onClick = () => {
|
||||
const onClick = (event: React.MouseEvent) => {
|
||||
if (selectorFn) {
|
||||
event.stopPropagation()
|
||||
selectorFn(row.original)
|
||||
} else if (tableRowAction) {
|
||||
event.stopPropagation()
|
||||
tableRowAction(row.original)
|
||||
} else {
|
||||
navigate(`/${url}/${id}`)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Box display="flex" gap="0.2em" alignItems="center" width="3.6em">
|
||||
<Box display="flex" gap="0.2em" alignItems="center">
|
||||
<Button data-cy={`detailview-button-${id}`} variant="text" style={{ width: '2em' }} onClick={onClick}>
|
||||
{getIconToShow()}
|
||||
</Button>
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
MRT_VisibilityState,
|
||||
MRT_TableInstance,
|
||||
} from 'material-react-table'
|
||||
import { Box, CircularProgress, Paper } from '@mui/material'
|
||||
import { Box, CircularProgress, Paper, Tooltip } from '@mui/material'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { ActionComponent } from './ActionComponent'
|
||||
import { usePageContext } from '../Page'
|
||||
@@ -18,6 +18,8 @@ import { useUser } from '@/hooks/user'
|
||||
import { defaultPagination, defaultPaginationSmall } from '@/common'
|
||||
import '../../styles/TableView.css'
|
||||
import { TableToolBar } from './TableToolBar'
|
||||
import NotListedLocationIcon from '@mui/icons-material/NotListedLocation'
|
||||
import '../../styles/tableview/TableView.css'
|
||||
|
||||
type TableStateInUrl = 'sorting' | 'columnfilters' | 'pagination'
|
||||
|
||||
@@ -35,6 +37,7 @@ export const TableView = <T extends MRT_RowData>({
|
||||
idFieldName,
|
||||
checkRowRestriction,
|
||||
selectorFn,
|
||||
tableRowAction,
|
||||
url,
|
||||
title,
|
||||
combinedExport,
|
||||
@@ -46,14 +49,16 @@ export const TableView = <T extends MRT_RowData>({
|
||||
serverSidePagination,
|
||||
isFetching,
|
||||
}: {
|
||||
title?: string
|
||||
data: T[] | undefined
|
||||
columns: MRT_ColumnDef<T>[]
|
||||
visibleColumns: MRT_VisibilityState
|
||||
idFieldName: keyof T
|
||||
checkRowRestriction?: (row: T) => boolean
|
||||
selectorFn?: (id: T) => void
|
||||
// here
|
||||
tableRowAction?: (row: T) => void
|
||||
url?: string
|
||||
title?: string
|
||||
combinedExport?: (lids: number[]) => Promise<void>
|
||||
kmlExport?: (table: MRT_TableInstance<T>) => void
|
||||
svgExport?: (table: MRT_TableInstance<T>) => void
|
||||
@@ -115,6 +120,14 @@ export const TableView = <T extends MRT_RowData>({
|
||||
const table = useMaterialReactTable({
|
||||
columns: columns,
|
||||
data: data || [],
|
||||
muiTableBodyRowProps: ({ row }) => ({
|
||||
onClick: () => {
|
||||
navigate(`/${url}/${row.original[idFieldName]}`)
|
||||
},
|
||||
sx: {
|
||||
cursor: 'pointer',
|
||||
},
|
||||
}),
|
||||
state: {
|
||||
columnFilters,
|
||||
showColumnFilters: true,
|
||||
@@ -127,7 +140,27 @@ export const TableView = <T extends MRT_RowData>({
|
||||
columnVisibility: visibleColumns,
|
||||
},
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
renderRowActions: ({ row }) => <ActionComponent {...{ selectorFn, url, checkRowRestriction, row, idFieldName }} />,
|
||||
renderRowActions: ({ row }) => {
|
||||
return (
|
||||
<Box className="row-actions-column">
|
||||
<Box>
|
||||
<ActionComponent {...{ selectorFn, url, checkRowRestriction, row, idFieldName }} />
|
||||
</Box>
|
||||
<Box>
|
||||
{row.original.has_synonym && (
|
||||
<ActionComponent {...{ selectorFn, tableRowAction, url, checkRowRestriction, row, idFieldName }} />
|
||||
)}
|
||||
</Box>
|
||||
<Box display="flex" alignItems="center" px="1.35em">
|
||||
{row.original.has_no_locality && (
|
||||
<Tooltip title="This species is not currently in any locality" placement="right-start">
|
||||
<NotListedLocationIcon color="disabled" sx={{}} />
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
},
|
||||
displayColumnDefOptions: { 'mrt-row-actions': { size: 50, header: '' } },
|
||||
enableRowActions: true,
|
||||
enableMultiSort: !serverSidePagination,
|
||||
@@ -204,7 +237,7 @@ export const TableView = <T extends MRT_RowData>({
|
||||
<Paper elevation={5}>
|
||||
{user && (
|
||||
<div className="table-top-row">
|
||||
<Box className="title">{title ?? ''}</Box>
|
||||
{title && <Box className="title">{title}</Box>}
|
||||
<TableToolBar<T>
|
||||
table={table}
|
||||
combinedExport={combinedExport}
|
||||
@@ -216,7 +249,6 @@ export const TableView = <T extends MRT_RowData>({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<MaterialReactTable table={table} />
|
||||
</Paper>
|
||||
)
|
||||
|
||||
@@ -28,6 +28,7 @@ import { ProjectDetails } from './Project/ProjectDetails'
|
||||
import { RegionTable } from './Region/RegionTable'
|
||||
import { RegionDetails } from './Region/RegionDetails'
|
||||
import { UserState } from '@/redux/userReducer'
|
||||
import { FrontPage } from './FrontPage'
|
||||
|
||||
const noRights: EditRights = {}
|
||||
const fullRights: EditRights = { new: true, edit: true, delete: true }
|
||||
@@ -51,6 +52,8 @@ export const localityPage = (
|
||||
/>
|
||||
)
|
||||
|
||||
export const frontPage = <FrontPage />
|
||||
|
||||
export const crossSearchPage = (
|
||||
<Page
|
||||
tableView={<CrossSearchTable />}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
# Country border polygon and bounding box data update instructions
|
||||
|
||||
|
||||
## Create GeoJSON File in QGIS
|
||||
|
||||
Follow the steps below to create a GeoJSON file from a layer in QGIS.
|
||||
|
||||
### Prerequisites
|
||||
- QGIS installed on your computer (version 3.x or later recommended)
|
||||
- A vector layer loaded in QGIS (e.g., shapefile, CSV with geometry, or other spatial data)
|
||||
|
||||
### Instructions
|
||||
|
||||
1. **Open your project in QGIS.**
|
||||
|
||||
2. **Load the layer you want to export**, if it's not already visible:
|
||||
- Use `Layer` > `Add Layer` > `Add Vector Layer…` to load spatial data.
|
||||
|
||||
3. **Right-click on the layer** in the **Layers Panel** and select:
|
||||
- `Export` > `Save Features As…`
|
||||
|
||||
4. In the **Format** dropdown, choose:
|
||||
- `GeoJSON`
|
||||
|
||||
5. Set the **File name**:
|
||||
- Click the `…` button next to the file name field and choose a save location.
|
||||
- Make sure the file ends with `.geojson`
|
||||
|
||||
6. Choose the **CRS** (Coordinate Reference System):
|
||||
- Use `EPSG:4326 - WGS 84` for GeoJSON (standard for web use).
|
||||
|
||||
7. Choose just geometry to export, no other attributes/fields:
|
||||
- You can choose which features or fields to export, but choose just geometry.
|
||||
|
||||
8. Click **OK** to export.
|
||||
|
||||
9. Your GeoJSON file is now saved and ready to use.
|
||||
|
||||
### Notes
|
||||
- You can preview the GeoJSON file in QGIS or any code/text editor.
|
||||
- If you're using the file for web mapping (e.g., Leaflet, Mapbox), ensure the CRS is WGS 84 (`EPSG:4326`).
|
||||
|
||||
---
|
||||
|
||||
## Country data conversion script
|
||||
|
||||
In this directory, there is a script for converting a country administrative boundaries `.geojson` file into data useful for this application.
|
||||
When run, the script will output two files:
|
||||
|
||||
- `countryPolygons.ts`: An exported array of polygons representing country boundaries.
|
||||
Each polygon is an array, inside which each coordinate is an array of two numbers.
|
||||
The array is of type `number[][][]`.
|
||||
- `countryBoundingBoxes.ts`: An object of the following type:
|
||||
```ts
|
||||
{
|
||||
[key: string] : {
|
||||
top: number;
|
||||
left: number;
|
||||
bottom: number;
|
||||
right: number;
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
Where `key` is the country name and each member is an edge of the country's bounding box.
|
||||
`top` and `bottom` are longitudes while `left` and `right` are latitudes.
|
||||
|
||||
### Usage
|
||||
|
||||
The script can be run with `node` and accepts a single command line argument, the country border `.geojson` filepath.
|
||||
For example to convert the already included `.geojson` in this directory one would run:
|
||||
|
||||
```
|
||||
$ node countryExport.js world-administrative-boundaries.geojson
|
||||
```
|
||||
|
||||
Which will either quietly create the aforementioned files in this directory or crash with an error.
|
||||
|
||||
#### Country name mappings
|
||||
|
||||
Sometimes country names between the country name list in the validators folder and the U.N. data differ.
|
||||
For this purpose, at the top of the script there is a mapping object to convert the U.N. name to the name used in this application.
|
||||
Simply edit this object as needed.
|
||||
|
||||
```js
|
||||
const nameMappings = {
|
||||
// 'U.N. name': 'Our in-project name',
|
||||
'Antigua & Barbuda': 'Antigua and Barbuda',
|
||||
'U.K. of Great Britain and Northern Ireland': 'United Kingdom',
|
||||
'United States Virgin Islands': 'Virgin Islands',
|
||||
|
||||
// ...
|
||||
}
|
||||
```
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,117 @@
|
||||
import fs from 'fs'
|
||||
|
||||
// Edit these in cases where names in frontend/src/shared/validators/countryList.ts
|
||||
// differ from the country boundary data
|
||||
const nameMappings = {
|
||||
'Antigua & Barbuda': 'Antigua and Barbuda',
|
||||
'Bosnia & Herzegovina': 'Bosnia and Herzegovina',
|
||||
'Brunei Darussalam': 'Brunei',
|
||||
'Democratic Republic of the Congo': 'Congo, Democratic republic of the (prev. Zaire)',
|
||||
"Côte d'Ivoire": "Cote D'Ivoire",
|
||||
'Timor-Leste': 'East Timor',
|
||||
'French Southern and Antarctic Territories': 'French Southern and Antarctic Lands',
|
||||
Bahamas: 'Bahamas, The',
|
||||
Gambia: 'Gambia, The',
|
||||
'Iran (Islamic Republic of)': 'Iran',
|
||||
"Lao People's Democratic Republic": 'Laos',
|
||||
'Libyan Arab Jamahiriya': 'Libya',
|
||||
'The former Yugoslav Republic of Macedonia': 'North Macedonia',
|
||||
'Micronesia (Federated States of)': 'Micronesia, Federated States of',
|
||||
'Moldova, Republic of': 'Moldova',
|
||||
"Democratic People's Republic of Korea": 'North Korea',
|
||||
'Russian Federation': 'Russia',
|
||||
'South Georgia & the South Sandwich Islands': 'South Georgia and the South Sandwich Islands',
|
||||
'Republic of Korea': 'South Korea',
|
||||
'Syrian Arab Republic': 'Syria',
|
||||
'United Republic of Tanzania': 'Tanzania',
|
||||
'U.K. of Great Britain and Northern Ireland': 'United Kingdom',
|
||||
'United States Virgin Islands': 'Virgin Islands',
|
||||
}
|
||||
|
||||
function main() {
|
||||
if (process.argv.length != 3) {
|
||||
console.log('Usage: node countryDataExportTool.js [administrative boundaries .geojson file]')
|
||||
return
|
||||
}
|
||||
|
||||
const filepath = process.argv[2]
|
||||
const data = fs.readFileSync(filepath, 'utf8')
|
||||
let borders = JSON.parse(data)
|
||||
|
||||
const polygons = []
|
||||
const boundingBoxes = {}
|
||||
borders.features.forEach(feature => {
|
||||
const bounds = {
|
||||
top: null,
|
||||
left: null,
|
||||
bottom: null,
|
||||
right: null,
|
||||
}
|
||||
|
||||
const halvedBounds = {
|
||||
top: null,
|
||||
left: null,
|
||||
bottom: null,
|
||||
right: null,
|
||||
}
|
||||
|
||||
feature.geometry.coordinates.forEach(poly => {
|
||||
polygons.push(
|
||||
poly[0].map(coord => {
|
||||
const lat = coord[1]
|
||||
const long = coord[0]
|
||||
|
||||
if (!bounds.top) bounds.top = lat
|
||||
if (!bounds.bottom) bounds.bottom = lat
|
||||
if (!bounds.left) bounds.left = long
|
||||
if (!bounds.right) bounds.right = long
|
||||
bounds.top = Math.min(bounds.top, lat)
|
||||
bounds.bottom = Math.max(bounds.bottom, lat)
|
||||
bounds.left = Math.min(bounds.left, long)
|
||||
bounds.right = Math.max(bounds.right, long)
|
||||
|
||||
if (!halvedBounds.top) halvedBounds.top = lat
|
||||
if (!halvedBounds.bottom) halvedBounds.bottom = lat
|
||||
if (!halvedBounds.right) halvedBounds.right = -180
|
||||
if (!halvedBounds.left) halvedBounds.left = long
|
||||
halvedBounds.top = Math.min(halvedBounds.top, lat)
|
||||
halvedBounds.bottom = Math.max(halvedBounds.bottom, lat)
|
||||
|
||||
if (long < 0) halvedBounds.right = Math.max(halvedBounds.right, long)
|
||||
else halvedBounds.left = Math.min(halvedBounds.left, long)
|
||||
|
||||
return [lat, long]
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
let country = feature.properties.name
|
||||
if (country in nameMappings) country = nameMappings[country]
|
||||
|
||||
const boundsWidth = bounds.right - bounds.left
|
||||
const halvedBoundsWidth = halvedBounds.right + 180 + 180 - halvedBounds.left
|
||||
const box = boundsWidth < halvedBoundsWidth ? bounds : halvedBounds
|
||||
boundingBoxes[country] = box
|
||||
})
|
||||
|
||||
const polygonString = 'export const countryPolygons: number[][][] = ' + JSON.stringify(polygons) + ';'
|
||||
const boundsType = `export type CountryBoundingBox = {
|
||||
top: number;
|
||||
left: number;
|
||||
bottom: number;
|
||||
right: number;
|
||||
};
|
||||
|
||||
export type CountryBoundingBoxes =
|
||||
{
|
||||
[key: string] : CountryBoundingBox
|
||||
};
|
||||
|
||||
`
|
||||
const boundsString =
|
||||
boundsType + 'export const countryBoundingBoxes: CountryBoundingBoxes = ' + JSON.stringify(boundingBoxes) + ';'
|
||||
fs.writeFileSync('countryPolygons.ts', polygonString)
|
||||
fs.writeFileSync('countryBoundingBoxes.ts', boundsString)
|
||||
}
|
||||
|
||||
main()
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -15,6 +15,11 @@ const personsApi = api.injectEndpoints({
|
||||
}),
|
||||
providesTags: result => (result ? [{ type: 'person', id: result.initials }] : []),
|
||||
}),
|
||||
getPersonDetailsId: builder.mutation<PersonDetailsType, string>({
|
||||
query: id => ({
|
||||
url: `/person/${id}`,
|
||||
}),
|
||||
}),
|
||||
editPerson: builder.mutation<PersonDetailsType, EditDataType<PersonDetailsType>>({
|
||||
query: person => ({
|
||||
url: `/person`,
|
||||
@@ -26,4 +31,5 @@ const personsApi = api.injectEndpoints({
|
||||
}),
|
||||
})
|
||||
|
||||
export const { useGetAllPersonsQuery, useGetPersonDetailsQuery, useEditPersonMutation } = personsApi
|
||||
export const { useGetAllPersonsQuery, useGetPersonDetailsQuery, useEditPersonMutation, useGetPersonDetailsIdMutation } =
|
||||
personsApi
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { api } from './api'
|
||||
import { ActivityStatistic, Statistics } from '@/shared/types'
|
||||
|
||||
const statisticsApi = api.injectEndpoints({
|
||||
endpoints: builder => ({
|
||||
getStatistics: builder.query<Statistics, void>({
|
||||
query: () => ({
|
||||
url: `/statistics`,
|
||||
}),
|
||||
}),
|
||||
getLocalityStatistics: builder.query<ActivityStatistic[], void>({
|
||||
query: () => ({
|
||||
url: `/statistics/locality`,
|
||||
}),
|
||||
}),
|
||||
getSpeciesStatistics: builder.query<ActivityStatistic[], void>({
|
||||
query: () => ({
|
||||
url: `/statistics/species`,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
export const { useGetStatisticsQuery, useGetLocalityStatisticsQuery, useGetSpeciesStatisticsQuery } = statisticsApi
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
|
||||
import { api } from '@/redux/api'
|
||||
import { Role } from '@/shared/types'
|
||||
import { Role, UserDetailsType } from '@/shared/types'
|
||||
|
||||
export type LoginResponse = {
|
||||
token: string
|
||||
@@ -35,6 +35,13 @@ const userApi = api.injectEndpoints({
|
||||
method: 'PUT',
|
||||
}),
|
||||
}),
|
||||
createUser: builder.mutation<void, UserDetailsType>({
|
||||
query: (user: UserDetailsType) => ({
|
||||
url: '/user/new',
|
||||
body: user,
|
||||
method: 'POST',
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -77,4 +84,4 @@ const userSlice = createSlice({
|
||||
|
||||
export const { setUser, setToken, clearUser, removeFirstLogin } = userSlice.actions
|
||||
export const userReducer = userSlice.reducer
|
||||
export const { useTryLoginMutation, useChangePasswordMutation } = userApi
|
||||
export const { useTryLoginMutation, useChangePasswordMutation, useCreateUserMutation } = userApi
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 1.0 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
@@ -5,6 +5,15 @@ import * as LogPrisma from '../../../../backend/prisma/generated/now_log_test_cl
|
||||
import { Editable, FixBigInt, RowState } from './util'
|
||||
import { Decimal } from '../../../../backend/prisma/generated/now_test_client/runtime/library'
|
||||
|
||||
export type UserDetailsType = {
|
||||
password: string | null
|
||||
user_name: string | null
|
||||
now_user_group: string | null
|
||||
initials: string
|
||||
}
|
||||
|
||||
export type Statistics = { localityCount: number; speciesCount: number; localitySpeciesCount: number }
|
||||
export type ActivityStatistic = { year: number; month: number; surname: string }
|
||||
export type SpeciesType = FixBigInt<Prisma.com_species>
|
||||
export type SedimentaryStructureValues = Prisma.now_ss_values
|
||||
export type CollectingMethod = Prisma.now_coll_meth
|
||||
@@ -328,6 +337,15 @@ export type RegionDetails = Prisma.now_reg_coord & { now_reg_coord_people: Array
|
||||
now_reg_coord_country: Array<RegionCountry>
|
||||
}
|
||||
|
||||
export type PersonDetails = {
|
||||
initials: string | null
|
||||
first_name: string | null
|
||||
surname: string | null
|
||||
email: string | null
|
||||
organization: string | null
|
||||
country: string | null
|
||||
}
|
||||
|
||||
// used in Region's CoordinatorTab to allow adding com_people field to the editData manually
|
||||
export type RegionDetailsWithComPeople = Prisma.now_reg_coord & {
|
||||
now_reg_coord_people: Array<RegionCoordinator & { com_people?: Prisma.com_people }>
|
||||
@@ -474,6 +492,9 @@ export type GeonamesJSON = {
|
||||
|
||||
export type ParsedGeoname = Pick<Geoname, 'name' | 'countryName' | 'fclName' | 'adminName1' | 'lat' | 'lng'>
|
||||
|
||||
export type UserGroup = 'su (admin)' | 'eu (edit unrestricted)' | 'er (edit restricted)' | 'ro (read only)'
|
||||
export const userGroups = ['ro (read only)', 'er (edit restricted)', 'eu (edit unrestricted)', 'su (admin)']
|
||||
|
||||
export type SimplifiedLocality = {
|
||||
lid: number
|
||||
dec_lat: number
|
||||
|
||||
@@ -9,22 +9,37 @@ export const validatePerson = (
|
||||
initials: {
|
||||
name: 'initials',
|
||||
required: true,
|
||||
asString: true,
|
||||
minLength: 2,
|
||||
maxLength: 8,
|
||||
},
|
||||
first_name: {
|
||||
name: 'First Name',
|
||||
required: true,
|
||||
asString: true,
|
||||
minLength: 2,
|
||||
maxLength: 20,
|
||||
},
|
||||
surname: {
|
||||
name: 'Surname',
|
||||
required: true,
|
||||
asString: true,
|
||||
minLength: 2,
|
||||
maxLength: 20,
|
||||
},
|
||||
email: {
|
||||
name: 'Email',
|
||||
required: true,
|
||||
asString: true,
|
||||
minLength: 5,
|
||||
maxLength: 30,
|
||||
},
|
||||
organization: {
|
||||
name: 'Organization',
|
||||
required: true,
|
||||
asString: true,
|
||||
minLength: 2,
|
||||
maxLength: 30,
|
||||
},
|
||||
country: {
|
||||
name: 'Country',
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
#add-user-modal .content {
|
||||
width: 100%;
|
||||
max-width: 40em;
|
||||
}
|
||||
|
||||
#add-user-modal .inputs {
|
||||
display: grid;
|
||||
padding: 2em;
|
||||
|
||||
grid-template-columns: 1fr 2fr;
|
||||
align-items: center;
|
||||
row-gap: 0.8em;
|
||||
column-gap: 1em;
|
||||
}
|
||||
|
||||
#add-user-modal .inputs > label {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
#add-user-modal .save-btn-container {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-top: 1em;
|
||||
}
|
||||
|
||||
#add-user-modal .password-input-container {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
#add-user-modal .password-input-container .field {
|
||||
flex: 1;
|
||||
margin-right: 0.6em;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 600px) {
|
||||
#add-user-modal .inputs {
|
||||
grid-template-columns: 1fr;
|
||||
column-gap: 0;
|
||||
row-gap: 0.3em;
|
||||
}
|
||||
|
||||
#add-user-modal .inputs > label {
|
||||
margin-top: 0.6em;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
#wrapper {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
footer {
|
||||
background-color: #2e7fab;
|
||||
color: white;
|
||||
padding: 0.6em;
|
||||
padding-left: 1em;
|
||||
padding-top: 0.7em;
|
||||
font-weight: 500;
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
#front-page .top-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
#front-page a {
|
||||
color: #2e40aa;
|
||||
}
|
||||
|
||||
#front-page h3 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
#front-page .top-info .left {
|
||||
margin-left: 1em;
|
||||
}
|
||||
|
||||
#front-page .top-info .image-container {
|
||||
/* aspect-ratio: initial; */
|
||||
flex: 1;
|
||||
margin-left: 3em;
|
||||
display: flex;
|
||||
gap: 0.4em;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
#front-page .top-info .image-container img {
|
||||
width: 100%;
|
||||
max-width: 16em;
|
||||
min-width: 10em;
|
||||
}
|
||||
|
||||
#front-page p {
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
#front-page p,
|
||||
#front-page h1 {
|
||||
max-width: 40em;
|
||||
}
|
||||
|
||||
#front-page .map-container {
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
margin-top: 0em;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
#front-page .stats {
|
||||
display: flex;
|
||||
gap: 1.4em;
|
||||
font-size: 1.1rem;
|
||||
padding: 1em;
|
||||
padding-bottom: 0;
|
||||
top: -1px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
#front-page .stats span b {
|
||||
margin-right: 0.2em;
|
||||
}
|
||||
|
||||
#front-page .map-container .map {
|
||||
--margin-top: 18%;
|
||||
margin-top: calc(var(--margin-top) * -1);
|
||||
width: 100%;
|
||||
clip-path: polygon(0% 23%, 100% var(--margin-top), 97% 62%, 10% 70%);
|
||||
background-color: #2e7fab;
|
||||
margin-bottom: -23%;
|
||||
}
|
||||
|
||||
#front-page .map-container .map img {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#front-page .map-container:hover .map img {
|
||||
transform: translateX(calc(var(--x) * (-1 / 1.5))) translateY(calc(var(--y) * (-1 / 1.5))) scale(1.5);
|
||||
transform-origin: 0 0;
|
||||
}
|
||||
|
||||
#front-page .additional-info {
|
||||
display: flex;
|
||||
justify-content: stretch;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 3em;
|
||||
}
|
||||
|
||||
#front-page .additional-info p {
|
||||
white-space: break-word;
|
||||
word-wrap: break-word;
|
||||
text-wrap: wrap;
|
||||
overflow-x: hidden;
|
||||
max-width: 100vw;
|
||||
}
|
||||
|
||||
#front-page .additional-info > div {
|
||||
flex: 1;
|
||||
min-width: 24em;
|
||||
}
|
||||
|
||||
#front-page .additional-info .activity-stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
#front-page .additional-info .activity-stats > div {
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
#front-page .stat-table {
|
||||
padding-right: 2em;
|
||||
}
|
||||
|
||||
#front-page .stat-table table {
|
||||
width: fit-content;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
#front-page .stat-table table * {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
#front-page .stat-table table td,
|
||||
#front-page .stat-table table th {
|
||||
border: 1px solid rgb(177, 177, 177);
|
||||
border-width: 0 1px 1px 0;
|
||||
text-align: left;
|
||||
padding: 0.1em 0.4em;
|
||||
}
|
||||
|
||||
#front-page .stat-table table th {
|
||||
border-width: 0 1px 2px 0;
|
||||
}
|
||||
|
||||
#front-page .stat-table table th:last-child,
|
||||
#front-page .stat-table table td:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
#front-page .stat-table table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 900px) {
|
||||
#front-page .stats {
|
||||
padding-bottom: 1em;
|
||||
}
|
||||
|
||||
#front-page .top-info .image-container {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -1,67 +1,125 @@
|
||||
#map-container {
|
||||
#localities-map .map-container {
|
||||
position: relative;
|
||||
--map-height: 70vh;
|
||||
overflow: hidden;
|
||||
height: 200px;
|
||||
height: calc(var(--map-height) * 0.15);
|
||||
transition: height 0.2s ease-out;
|
||||
margin-bottom: 0.8em;
|
||||
border-radius: 0.25em;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
#blur-container {
|
||||
z-index: 9999;
|
||||
#localities-map .gradient {
|
||||
z-index: 400;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
height: 800px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
opacity: 1;
|
||||
transition: opacity 0.3s ease-in-out;
|
||||
background: linear-gradient(to bottom, rgba(255, 255, 255, 0) 85%, rgb(255, 255, 255) 100%);
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s ease-out;
|
||||
background: linear-gradient(to bottom, rgba(255, 255, 255, 0) 40%, rgb(255, 255, 255) 100%);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#blur-container.open {
|
||||
#localities-map.open .gradient {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#localities-map .map-btn-up {
|
||||
font-size: 50px;
|
||||
transform: rotateX(180deg);
|
||||
transition: transform 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
#localities-map .map-btn-down {
|
||||
font-size: 50px;
|
||||
transform: rotateX(0deg);
|
||||
transition: transform 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
#map-container.open {
|
||||
#localities-map.open .map-container {
|
||||
height: var(--map-height);
|
||||
display: flex;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#map-container > .map {
|
||||
#localities-map .map-container .map {
|
||||
height: var(--map-height);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#localities-map .cluster-btn {
|
||||
#localities-map:not(.open) .map-container .map {
|
||||
pointer-events: none !important;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
#localities-map:not(.open) .map-container .map * {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#localities-map:not(.open) button.cluster {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#localities-map button.cluster {
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
background-color: #fdfeff;
|
||||
height: 2.7em;
|
||||
width: 5.5em;
|
||||
position: absolute;
|
||||
padding: 0.3em 0.6em;
|
||||
z-index: 1000;
|
||||
margin-left: 4.2em;
|
||||
margin-top: 0.95em;
|
||||
left: 4.2em;
|
||||
top: 0.90em;
|
||||
border-radius: 0.25em;
|
||||
border: none;
|
||||
box-shadow: 0 0 0 0.15em rgba(0, 0, 0, 0.22);
|
||||
}
|
||||
|
||||
#localities-map .button-row {
|
||||
#localities-map button.cluster:hover {
|
||||
background-color: #f4f4f4;
|
||||
}
|
||||
#localities-map .open-button {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 1rem;
|
||||
height: 4em;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#localities-map .downarrow {
|
||||
--line-width: 4px;
|
||||
--radius: 3px;
|
||||
--angle: 130deg;
|
||||
width: 2em;
|
||||
height: 2em;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
transform: translateY(-0.4em) rotate(-45deg);
|
||||
transition: transform 0.3s ease-out;
|
||||
}
|
||||
|
||||
#localities-map.open .downarrow {
|
||||
transform: scaleY(-1) translateY(-1.8em) rotate(-45deg);
|
||||
}
|
||||
|
||||
#localities-map .downarrow::before,
|
||||
#localities-map .downarrow::after {
|
||||
content: "";
|
||||
display: block;
|
||||
background-color: #a5a5a5;
|
||||
transition: background-color 0.2s ease-out;
|
||||
}
|
||||
|
||||
#localities-map:not(.open):hover .open-button .downarrow::before,
|
||||
#localities-map:not(.open):hover .open-button .downarrow::after,
|
||||
#localities-map.open .open-button:hover .downarrow::before,
|
||||
#localities-map.open .open-button:hover .downarrow::after {
|
||||
background-color: #606060;
|
||||
}
|
||||
|
||||
#localities-map .downarrow::before {
|
||||
height: 100%;
|
||||
width: var(--line-width);
|
||||
border-radius: var(--radius);
|
||||
transform: rotate(calc(45deg - var(--angle) * 0.5));
|
||||
transform-origin: 50% 100%;
|
||||
}
|
||||
|
||||
#localities-map .downarrow::after {
|
||||
width: 100%;
|
||||
height: var(--line-width);
|
||||
border-radius: var(--radius);
|
||||
transform: rotate(calc(-1 * (45deg - var(--angle) * 0.5)));
|
||||
transform-origin: 0% 50%;
|
||||
margin-left: calc(-1 * var(--line-width));
|
||||
}
|
||||
|
||||
@@ -1,40 +1,44 @@
|
||||
.species-table {
|
||||
overflow-x: auto;
|
||||
padding-bottom: 1em;
|
||||
margin: 2em;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 1em;
|
||||
margin: 2em;
|
||||
}
|
||||
|
||||
.species-table table {
|
||||
width: fit-content ;
|
||||
border-collapse: collapse;
|
||||
width: fit-content;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.species-table table * {
|
||||
white-space: nowrap;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.species-table table td ,
|
||||
.species-table table th
|
||||
{
|
||||
border: 1px solid rgb(177, 177, 177);
|
||||
border-width: 0 1px 1px 0;
|
||||
text-align: left;
|
||||
padding: 0.1em 0.4em;
|
||||
.species-table table td,
|
||||
.species-table table th {
|
||||
border: 1px solid rgb(177, 177, 177);
|
||||
border-width: 0 1px 1px 0;
|
||||
text-align: left;
|
||||
padding: 0.1em 0.4em;
|
||||
}
|
||||
|
||||
.species-table table th {
|
||||
border-width: 0 1px 2px 0;
|
||||
border-width: 0 1px 2px 0;
|
||||
}
|
||||
|
||||
.species-table table th:last-child ,
|
||||
.species-table table th:last-child,
|
||||
.species-table table td:last-child {
|
||||
border-right: none;
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.species-table table tr:last-child td {
|
||||
border-bottom: none;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.locality-details-panel {
|
||||
padding: 1em 2em;
|
||||
padding: 1em 2em;
|
||||
}
|
||||
|
||||
.locality-details-panel a {
|
||||
text-decoration: none;
|
||||
color: black;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
#main-navbar {
|
||||
background-color: #2e7fab;
|
||||
--color-hl: #3896c9;
|
||||
padding-left: 0;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
#main-navbar > * {
|
||||
@@ -25,6 +27,7 @@
|
||||
|
||||
#main-navbar .username-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: 2px solid var(--color-hl);
|
||||
border-radius: 0.25rem;
|
||||
padding: 0.4em 1em 0.3em 0.5em;
|
||||
@@ -34,11 +37,17 @@
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
#main-navbar .container {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
#main-navbar .menu-list {
|
||||
margin-left: 1.4em;
|
||||
margin-left: 2em;
|
||||
padding: 0.6em 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
#main-navbar .dropdown-link {
|
||||
@@ -77,3 +86,13 @@
|
||||
#main-navbar .username-box:hover {
|
||||
background-color: var(--color-hl);
|
||||
}
|
||||
|
||||
#main-navbar .logo-link {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
#main-navbar .logo-link img {
|
||||
height: 4em;
|
||||
transform-origin: 0 80%;
|
||||
transform: scale(1.03);
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
/* width was 40em*/
|
||||
.sliding-modal-container .content {
|
||||
--max-width: calc(100vw - 6em);
|
||||
--max-width: calc(100vw - 16em);
|
||||
background-color: white;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
@@ -33,10 +33,11 @@
|
||||
|
||||
@media screen and (max-width: 600px) {
|
||||
.sliding-modal-container .content {
|
||||
--max-width: calc(100vw - 3em);
|
||||
}}
|
||||
--max-width: calc(100vw - 3em);
|
||||
}
|
||||
}
|
||||
|
||||
.sliding-modal-container.open .content {
|
||||
.sliding-modal-container.open .content {
|
||||
transform: translateX(0);
|
||||
pointer-events: initial;
|
||||
}
|
||||
@@ -44,4 +45,4 @@
|
||||
.sliding-modal-container.open .background {
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
pointer-events: initial;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,9 +12,9 @@
|
||||
background-color: white;
|
||||
padding: 2em 2em;
|
||||
width: calc(100% - 4em);
|
||||
height: calc(100% - 2em);
|
||||
max-height: calc(100% - 2em);
|
||||
height: fit-content;
|
||||
max-width: 50em;
|
||||
max-height: fit-content;
|
||||
border-radius: 0.25rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
.synonyms-modal .modal-content {
|
||||
padding: 0;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background-color: #3896c9;
|
||||
color: white;
|
||||
padding: 8px 16px;
|
||||
font-weight: 700;
|
||||
border-radius: 0.25rem 0.25rem 0 0;
|
||||
}
|
||||
|
||||
.modal-table {
|
||||
padding: 1em;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
text-align: center;
|
||||
gap: 16px;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.close-button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
|
||||
.close-button:focus {
|
||||
outline: none;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
.row-actions-column {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
gap: 1px;
|
||||
min-width: 13em;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { fail } from 'assert'
|
||||
import { countryBoundingBoxes } from '../country_data/countryBoundingBoxes'
|
||||
import { validCountries } from '../shared/validators/countryList'
|
||||
import { it, describe } from '@jest/globals'
|
||||
|
||||
describe('Country bounding box coordinate checking', () => {
|
||||
it('All countries are present', () => {
|
||||
// NOTE: Possible political connotations are purely coincidental.
|
||||
// The ignored countries and territories simply represent names not
|
||||
// present in the U.N. data used for country boundaries.
|
||||
const ignore = [
|
||||
'Aland Islands',
|
||||
'Antarctica',
|
||||
'Ascension Island',
|
||||
'Ashmore and Cartier Islands',
|
||||
'Baker Island',
|
||||
'Bassas Da India',
|
||||
'Bonaire',
|
||||
'Clipperton Island',
|
||||
'Coral Sea Islands',
|
||||
'Curacao',
|
||||
'Europa Island',
|
||||
'Falkland Islands (Islas Malvinas)',
|
||||
'Heard Island and Mcdonald Islands',
|
||||
'Howland Island',
|
||||
'Isle Of Man',
|
||||
'Jan Mayen',
|
||||
'Juan De Nova Island',
|
||||
'Kosovo',
|
||||
'Macau',
|
||||
'Midway Islands',
|
||||
"No Man's Land",
|
||||
'Pitcairn Islands',
|
||||
'Saba',
|
||||
'Saint Barthelemy',
|
||||
'Saint Eustatius',
|
||||
'Saint Helena',
|
||||
'Saint Martin',
|
||||
'Saint Pierre and Miquelon',
|
||||
'Svalbard',
|
||||
'Tromelin Island',
|
||||
'United States Minor Outlying Islands',
|
||||
'Vatican City',
|
||||
'Wake Island',
|
||||
'Wallis and Futuna',
|
||||
'Palestine',
|
||||
]
|
||||
|
||||
for (const country of validCountries) {
|
||||
if (ignore.includes(country)) continue
|
||||
if (!(country in countryBoundingBoxes)) {
|
||||
fail(`${country} not in bounding box data.`)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
import { CountryBoundingBox } from '@/country_data/countryBoundingBoxes'
|
||||
|
||||
// Checks whether or not given latitude longitude point is within a bounding box.
|
||||
export const isPointInBoxes = (dec_lat: number, dec_long: number, boxes: CountryBoundingBox[]): boolean => {
|
||||
for (const box of boxes) {
|
||||
if (dec_lat < box.top) continue
|
||||
if (dec_lat > box.bottom) continue
|
||||
if (dec_long < box.left) continue
|
||||
if (dec_long > box.right) continue
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// Splits bounding box that wraps around the longitude
|
||||
export const boundingBoxSplit = (box: CountryBoundingBox): CountryBoundingBox[] => {
|
||||
if (box.right >= box.left) return [box]
|
||||
return [
|
||||
{
|
||||
top: box.top,
|
||||
bottom: box.bottom,
|
||||
left: -180,
|
||||
right: box.right,
|
||||
},
|
||||
{
|
||||
top: box.top,
|
||||
bottom: box.bottom,
|
||||
left: box.left,
|
||||
right: 180,
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -31,7 +31,7 @@
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["../backend/**/*", "./src/components/Map/country_borders_WGS84.ts"],
|
||||
"exclude": ["../backend/**/*", "./src/country_data/**"],
|
||||
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user