Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Prototype for ROR using MUI/Synapse components #939

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { Meta, StoryObj } from '@storybook/react'
import RORInstitutionField from './RORInstitutionField'
import React, { useState } from 'react'
import { Box } from '@mui/material'

const meta = {
title: 'Components/RORInstitutionField',
component: RORInstitutionField,
render: function RenderFn(args) {
const [value, setValue] = useState<string>(args.value)

return (
<>
<RORInstitutionField {...args} value={value} onChange={setValue} />

<Box mt={2}>Actual value: {value}</Box>
</>
)
},
} satisfies Meta
export default meta
type Story = StoryObj<typeof meta>

export const Demo: Story = {
args: {
value: '',
},
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import React, { useMemo, useState } from 'react'
import { Autocomplete, Box, TextField, Typography } from '@mui/material'
import { useDebouncedEffect } from '@react-hookz/web'
import { useGetOrganization, useSearchRegistry } from '../../synapse-queries'
import { ROROrganization } from '../../ror-client/types/ROROrganization'

type RORLinkedInstitutionFieldProps = {
value: string
onChange: (value: string) => void
}

function getOrganizationDisplayName(organization: ROROrganization) {
return organization.names.filter(name =>
name.types.includes('ror_display'),
)[0]
}

export default function RORInstitutionField(
props: RORLinkedInstitutionFieldProps,
) {
const { value = '', onChange } = props
const [searchValue, setSearchValue] = useState('')

useDebouncedEffect(
() => {
setSearchValue(value)
},
[value],
500,
)

let rorId: string | null = null
if (value.startsWith('https://ror.org/')) {
rorId = value.split('https://ror.org/')[1]
}

const { data: organization, isLoading: isLoadingSelectedOrganization } =
useGetOrganization(rorId || '', {
enabled: !!rorId,
})

const { data: searchResults, isLoading: isLoadingSearch } = useSearchRegistry(
searchValue,
{
enabled: !rorId && searchValue.length > 0,
},
)

const inputValue = useMemo(() => {
if (rorId) {
if (isLoadingSelectedOrganization) {
return 'Loading...'
} else if (organization) {
return getOrganizationDisplayName(organization).value
}
}
return value
}, [isLoadingSelectedOrganization, organization, rorId, value])

return (
<Autocomplete
freeSolo
loading={isLoadingSearch}
onInputChange={(_event, newValue) => onChange(newValue)}
onChange={(_event, newValue) => () => {
if (typeof newValue === 'string' || newValue === null) {
onChange(newValue || '')
} else {
onChange(`https://ror.org/${newValue.id}`)
}
}}
renderInput={params => <TextField {...params} label="Organization" />}
inputValue={inputValue}
getOptionLabel={option =>
typeof option === 'string' ? option : option.id
}
renderOption={(props, option) => {
const displayName = getOrganizationDisplayName(option).value
const otherNames = option.names.filter(
name => !name.types.includes('ror_display'),
)
return (
<Box component="li" {...props}>
<div>
<Typography variant={'body1'}>{displayName}</Typography>
<Typography
variant={'smallText1'}
sx={{
color: 'grey.700',
fontStyle: 'italic',
fontSize: '11px',
}}
>
{otherNames.map(name => name.value).join(', ')}
</Typography>
</div>
</Box>
)
}}
filterOptions={x => x}
options={searchResults?.items || []}
/>
)
}
16 changes: 16 additions & 0 deletions packages/synapse-react-client/src/ror-client/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { ROROrganization, RORSearchResult } from './types/ROROrganization'
import { fetchWithExponentialTimeout } from '../synapse-client/HttpClient'

export async function searchRegistry(query: string): Promise<RORSearchResult> {
return fetchWithExponentialTimeout(
`https://api.ror.org/v2/organizations?query=${query}`,
{ headers: { ['Content-Type']: 'application/json' } },
)
}

export async function getOrganization(rorId: string): Promise<ROROrganization> {
return fetchWithExponentialTimeout(
`https://api.ror.org/v2/organizations/${rorId}`,
{ headers: { ['Content-Type']: 'application/json' } },
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
export type ROROrganizationType =
| 'education'
| 'funder'
| 'healthcare'
| 'company'
| 'archive'
| 'nonprofit'
| 'government'
| 'facility'
| 'other'

export type ROROrganizationName = {
value: string
types: Array<'acronym' | 'alias' | 'label' | 'ror_display'>
}

export type ROROrganizationRelationship = {
id: string
type: Array<'related' | 'parent' | 'child' | 'successor' | 'predecessor'>
label: string
}

export type RORLocation = {
geonames_id: number
geonames_details: {
name: string
lat?: number | null
lng?: number | null
country_code?: string | null
country_name?: string | null
}
}

export type RORLink = {
type: 'website' | 'wikipedia'
value: string
}

/**
* https://github.com/ror-community/ror-schema/blob/master/ror_schema_v2_0.json
*/
export type ROROrganization = {
/* Unique ROR ID for the organization */
id: string

/* The domains registered to a particular institution */
domains: Array<string> | null
/* Year the organization was established (CE) */
established: number | null
/* The organization's website and Wikipedia page */
links: Array<RORLink> | null
/* The location of the organization */
locations: Array<RORLocation>
/* Names the organization goes by. Allowed name types: acronym, alias, label, ror_display */
names: Array<ROROrganizationName>
/* Related organizations in ROR. Allowed relationship types: related, parent, child, predecessor, successor */
relationships: Array<ROROrganizationRelationship> | null
/* Whether the organization is active */
status: 'active' | 'inactive' | 'withdrawn'
/* Organization type */
types: Array<ROROrganizationType>

/* Other identifiers for the organization. Allowed ID types: fundref, grid, isni, wikidata */
// external_ids: Array | null - not required for our purposes
/* Container for administrative information about the record */
// admin: object - not required for our purposes
}

export type RORSearchResult = {
items: ROROrganization[]
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ const MAX_RETRY = 3
* handling errors returned by the Synapse backend.
* @throws SynapseClientError
*/
const fetchWithExponentialTimeout = async <TResponse>(
export const fetchWithExponentialTimeout = async <TResponse>(
requestInfo: RequestInfo,
options: RequestInit,
delayMs = 1000,
Expand Down
1 change: 1 addition & 0 deletions packages/synapse-react-client/src/synapse-queries/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export * from './entity'
export * from './search'
export * from './user'
export * from './oauth'
export * from './ror'
export * from './team'
export * from './KeyFactory'
export * from './QueryFilterUtils'
Expand Down
46 changes: 46 additions & 0 deletions packages/synapse-react-client/src/synapse-queries/ror/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import {
useQuery,
useQueryClient,
UseQueryOptions,
} from '@tanstack/react-query'
import { SynapseClientError } from '../../utils'
import {
ROROrganization,
RORSearchResult,
} from '../../ror-client/types/ROROrganization'
import { getOrganization, searchRegistry } from '../../ror-client'

export function useGetOrganization(
rorId: string,
options?: Partial<UseQueryOptions<ROROrganization, SynapseClientError>>,
) {
return useQuery({
staleTime: Infinity,
...options,
queryKey: ['ror', 'organization', rorId],
queryFn: () => getOrganization(rorId),
})
}

export function useSearchRegistry(
query: string,
options?: Partial<UseQueryOptions<RORSearchResult, SynapseClientError>>,
) {
const queryClient = useQueryClient()
return useQuery({
staleTime: Infinity,
...options,
queryKey: ['ror', 'search', query],
queryFn: async () => {
const results = await searchRegistry(query)
results.items.forEach(item => {
let id = item.id
if (id.startsWith('https://ror.org/')) {
id = id.split('https://ror.org/')[1]
}
queryClient.setQueryData(['ror', 'organization', id], item)
})
return results
},
})
}
Loading