From a6ce5c2e878789a766494d4da37c5f58a6213345 Mon Sep 17 00:00:00 2001 From: Nick Grosenbacher Date: Tue, 14 May 2024 12:39:01 -0400 Subject: [PATCH] Prototype for ROR using MUI/Synapse components --- .../RORInstitutionField.stories.tsx | 28 +++++ .../RORInstitutionField.tsx | 104 ++++++++++++++++++ .../src/ror-client/index.ts | 16 +++ .../src/ror-client/types/ROROrganization.ts | 71 ++++++++++++ .../src/synapse-client/HttpClient.ts | 2 +- .../src/synapse-queries/index.ts | 1 + .../src/synapse-queries/ror/index.ts | 46 ++++++++ 7 files changed, 267 insertions(+), 1 deletion(-) create mode 100644 packages/synapse-react-client/src/components/RORInstitutionField/RORInstitutionField.stories.tsx create mode 100644 packages/synapse-react-client/src/components/RORInstitutionField/RORInstitutionField.tsx create mode 100644 packages/synapse-react-client/src/ror-client/index.ts create mode 100644 packages/synapse-react-client/src/ror-client/types/ROROrganization.ts create mode 100644 packages/synapse-react-client/src/synapse-queries/ror/index.ts diff --git a/packages/synapse-react-client/src/components/RORInstitutionField/RORInstitutionField.stories.tsx b/packages/synapse-react-client/src/components/RORInstitutionField/RORInstitutionField.stories.tsx new file mode 100644 index 0000000000..e6eb307dee --- /dev/null +++ b/packages/synapse-react-client/src/components/RORInstitutionField/RORInstitutionField.stories.tsx @@ -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(args.value) + + return ( + <> + + + Actual value: {value} + + ) + }, +} satisfies Meta +export default meta +type Story = StoryObj + +export const Demo: Story = { + args: { + value: '', + }, +} diff --git a/packages/synapse-react-client/src/components/RORInstitutionField/RORInstitutionField.tsx b/packages/synapse-react-client/src/components/RORInstitutionField/RORInstitutionField.tsx new file mode 100644 index 0000000000..cf7416ffd8 --- /dev/null +++ b/packages/synapse-react-client/src/components/RORInstitutionField/RORInstitutionField.tsx @@ -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 ( + onChange(newValue)} + onChange={(_event, newValue) => () => { + if (typeof newValue === 'string' || newValue === null) { + onChange(newValue || '') + } else { + onChange(`https://ror.org/${newValue.id}`) + } + }} + renderInput={params => } + 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 ( + +
+ {displayName} + + {otherNames.map(name => name.value).join(', ')} + +
+
+ ) + }} + filterOptions={x => x} + options={searchResults?.items || []} + /> + ) +} diff --git a/packages/synapse-react-client/src/ror-client/index.ts b/packages/synapse-react-client/src/ror-client/index.ts new file mode 100644 index 0000000000..bf9e992f67 --- /dev/null +++ b/packages/synapse-react-client/src/ror-client/index.ts @@ -0,0 +1,16 @@ +import { ROROrganization, RORSearchResult } from './types/ROROrganization' +import { fetchWithExponentialTimeout } from '../synapse-client/HttpClient' + +export async function searchRegistry(query: string): Promise { + return fetchWithExponentialTimeout( + `https://api.ror.org/v2/organizations?query=${query}`, + { headers: { ['Content-Type']: 'application/json' } }, + ) +} + +export async function getOrganization(rorId: string): Promise { + return fetchWithExponentialTimeout( + `https://api.ror.org/v2/organizations/${rorId}`, + { headers: { ['Content-Type']: 'application/json' } }, + ) +} diff --git a/packages/synapse-react-client/src/ror-client/types/ROROrganization.ts b/packages/synapse-react-client/src/ror-client/types/ROROrganization.ts new file mode 100644 index 0000000000..c98c613781 --- /dev/null +++ b/packages/synapse-react-client/src/ror-client/types/ROROrganization.ts @@ -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 | null + /* Year the organization was established (CE) */ + established: number | null + /* The organization's website and Wikipedia page */ + links: Array | null + /* The location of the organization */ + locations: Array + /* Names the organization goes by. Allowed name types: acronym, alias, label, ror_display */ + names: Array + /* Related organizations in ROR. Allowed relationship types: related, parent, child, predecessor, successor */ + relationships: Array | null + /* Whether the organization is active */ + status: 'active' | 'inactive' | 'withdrawn' + /* Organization type */ + types: Array + + /* 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[] +} diff --git a/packages/synapse-react-client/src/synapse-client/HttpClient.ts b/packages/synapse-react-client/src/synapse-client/HttpClient.ts index 5b07bb0822..ca27a3b5c3 100644 --- a/packages/synapse-react-client/src/synapse-client/HttpClient.ts +++ b/packages/synapse-react-client/src/synapse-client/HttpClient.ts @@ -31,7 +31,7 @@ const MAX_RETRY = 3 * handling errors returned by the Synapse backend. * @throws SynapseClientError */ -const fetchWithExponentialTimeout = async ( +export const fetchWithExponentialTimeout = async ( requestInfo: RequestInfo, options: RequestInit, delayMs = 1000, diff --git a/packages/synapse-react-client/src/synapse-queries/index.ts b/packages/synapse-react-client/src/synapse-queries/index.ts index 477471abd8..66760f9c3e 100644 --- a/packages/synapse-react-client/src/synapse-queries/index.ts +++ b/packages/synapse-react-client/src/synapse-queries/index.ts @@ -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' diff --git a/packages/synapse-react-client/src/synapse-queries/ror/index.ts b/packages/synapse-react-client/src/synapse-queries/ror/index.ts new file mode 100644 index 0000000000..1176f9b438 --- /dev/null +++ b/packages/synapse-react-client/src/synapse-queries/ror/index.ts @@ -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>, +) { + return useQuery({ + staleTime: Infinity, + ...options, + queryKey: ['ror', 'organization', rorId], + queryFn: () => getOrganization(rorId), + }) +} + +export function useSearchRegistry( + query: string, + options?: Partial>, +) { + 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 + }, + }) +}