From 0b6a59768565d1ba165f8edc6f0abdcf04cb839f Mon Sep 17 00:00:00 2001 From: hamed musallam Date: Fri, 29 May 2026 13:00:30 +0200 Subject: [PATCH 1/3] feat: improve external API settings --- .../tabs/external_api_tab.tsx | 41 +++++++++++++++---- .../validation/external_apis_validation.ts | 6 ++- 2 files changed, 38 insertions(+), 9 deletions(-) diff --git a/src/component/modal/setting/tanstack_general_settings/tabs/external_api_tab.tsx b/src/component/modal/setting/tanstack_general_settings/tabs/external_api_tab.tsx index d4dc1cd5c..a759d850f 100644 --- a/src/component/modal/setting/tanstack_general_settings/tabs/external_api_tab.tsx +++ b/src/component/modal/setting/tanstack_general_settings/tabs/external_api_tab.tsx @@ -17,7 +17,14 @@ import { defaultGeneralSettingsFormValues } from '../validation.js'; type API = z.input; function emptyApi(): API { - return { key: 'CT', serverLink: '', APIKey: '', uuid: crypto.randomUUID() }; + return { + uuid: crypto.randomUUID(), + key: 'ct', + name: '', + description: '', + apiUrl: '', + apiKey: '', + }; } const itemsAPI = EXTERNAL_API_KEYS.slice(); @@ -34,13 +41,16 @@ export const ExternalApiTab = withForm({ return [ helper.accessor('key', { header: 'Service', + meta: { + tdStyle: { width: '180px' }, + }, cell: ({ row: { index } }) => ( {(field) => ( field.handleChange(key)} intent={!field.state.meta.isValid ? 'danger' : 'none'} @@ -50,18 +60,35 @@ export const ExternalApiTab = withForm({ ), }), - helper.accessor('serverLink', { - header: 'Server link', + helper.accessor('name', { + header: 'Name', cell: ({ row: { index } }) => ( - + {(field) => } ), }), - helper.accessor('APIKey', { + helper.accessor('description', { + header: 'Description', + cell: ({ row: { index } }) => ( + + {(field) => } + + ), + }), + helper.accessor('apiUrl', { + header: 'API link', + cell: ({ row: { index } }) => ( + + {(field) => } + + ), + }), + + helper.accessor('apiKey', { header: 'API key', cell: ({ row: { index } }) => ( - + {(field) => } ), diff --git a/src/component/modal/setting/tanstack_general_settings/validation/external_apis_validation.ts b/src/component/modal/setting/tanstack_general_settings/validation/external_apis_validation.ts index 5b57df735..40a201d5a 100644 --- a/src/component/modal/setting/tanstack_general_settings/validation/external_apis_validation.ts +++ b/src/component/modal/setting/tanstack_general_settings/validation/external_apis_validation.ts @@ -5,8 +5,10 @@ import { withUUID } from './utils.ts'; const externalAPIValidation = z.object({ key: z.enum(EXTERNAL_API_KEYS.map(({ key }) => key)), - serverLink: z.url(), - APIKey: z.string(), + name: z.string().nonempty(), + description: z.string(), + apiUrl: z.url().nonempty(), + apiKey: z.string(), }); export const externalAPIWithUUIDValidation = withUUID(externalAPIValidation); export const externalAPIsValidation = z.array(externalAPIWithUUIDValidation); From 69d9d3198b281de8da43165e91145cc9327fb372 Mon Sep 17 00:00:00 2001 From: hamed musallam Date: Mon, 1 Jun 2026 13:50:19 +0200 Subject: [PATCH 2/3] feat: send spectra to Mixonat service --- .../SpectraPanel/SpectraPanelHeader.tsx | 180 ++++++++++++++++++ 1 file changed, 180 insertions(+) diff --git a/src/component/panels/SpectraPanel/SpectraPanelHeader.tsx b/src/component/panels/SpectraPanel/SpectraPanelHeader.tsx index e956c29a2..f341155e7 100644 --- a/src/component/panels/SpectraPanel/SpectraPanelHeader.tsx +++ b/src/component/panels/SpectraPanel/SpectraPanelHeader.tsx @@ -1,20 +1,28 @@ +import type { MaybeElement } from '@blueprintjs/core'; +import type { IconName } from '@blueprintjs/icons'; import type { ActiveSpectrum, + ExternalAPIKeyType, Spectrum2D, Spectrum, } from '@zakodium/nmrium-core'; import { SvgNmrResetScale, SvgNmrSameTop } from 'cheminfo-font'; import { memo, useCallback } from 'react'; +import { AiOutlineApi } from 'react-icons/ai'; import { FaCreativeCommonsSamplingPlus } from 'react-icons/fa'; import { IoColorPaletteOutline } from 'react-icons/io5'; import { MdFormatColorFill, MdOutlineFormatColorText } from 'react-icons/md'; +import { isSpectrum1D } from '../../../data/data1d/Spectrum1D/isSpectrum1D.ts'; import { useChartData } from '../../context/ChartContext.js'; import { useDispatch } from '../../context/DispatchContext.js'; import { usePreferences } from '../../context/PreferencesContext.js'; import { useToaster } from '../../context/ToasterContext.js'; import { useAlert } from '../../elements/Alert.js'; +import type { ToolbarPopoverMenuItem } from '../../elements/ToolbarPopoverItem.tsx'; +import { ToolbarPopoverItem } from '../../elements/ToolbarPopoverItem.tsx'; import { useActiveSpectra } from '../../hooks/useActiveSpectra.js'; +import { useSelectedSpectra } from '../../hooks/useSelectedSpectra.ts'; import useSpectrum from '../../hooks/useSpectrum.js'; import { useToggleSpectraVisibility } from '../../hooks/useToggleSpectraVisibility.js'; import type { DisplayerMode } from '../../reducer/Reducer.js'; @@ -24,6 +32,84 @@ import type { ToolbarItemProps } from '../header/DefaultPanelHeader.js'; import DefaultPanelHeader from '../header/DefaultPanelHeader.js'; import { SpectraAutomaticPickingButton } from '../header/SpectraAutomaticPickingButton.js'; +interface ItemContext { + spectra: Spectrum[] | null; + activeTab: string; +} + +interface ExternalApiItem { + icon: IconName | MaybeElement; + include?: (item: ItemContext) => boolean; + disable?: (item: ItemContext) => boolean; + disableMessage?: string; +} + +const EXTERNAL_API_DEFINITIONS: Record = { + ct: { + icon: , + disable: ({ spectra, activeTab }) => { + return activeTab === '1H' || spectra?.length === 0; + }, + disableMessage: + 'CT can only be used when there is at least one proton (1H) spectrum selected', + }, + + mixonat: { + icon: , + disable: ({ spectra, activeTab }) => { + return activeTab === '13C' || spectra?.length === 0; + }, + disableMessage: + 'MixOnat can only be used when there is at least one carbon (13C) spectrum selected', + }, +}; + +interface ExternalMenuOptions { + id: ExternalAPIKeyType; + apiUrl: string; + apiKey?: string; +} + +function useExternalApiMenuItems(): Array< + ToolbarPopoverMenuItem +> { + const spectra = useSelectedSpectra(); + const { + view: { + spectra: { activeTab }, + }, + } = useChartData(); + const { + current: { externalAPIs }, + } = usePreferences(); + + return externalAPIs + .filter(({ key }) => { + const option = EXTERNAL_API_DEFINITIONS?.[key]; + return !option?.include || option.include({ spectra, activeTab }); + }) + .map((options) => { + const { key, name, description, apiUrl, apkKey } = options; + const { icon, disable, disableMessage } = + EXTERNAL_API_DEFINITIONS?.[key] || {}; + const isDisabled = disable ? disable({ spectra, activeTab }) : false; + return { + icon, + text: name, + data: { id: key, apiUrl, apkKey }, + tooltip: { + title: name, + description: isDisabled ? disableMessage : description, + }, + style: { + cursor: isDisabled ? 'not-allowed' : undefined, + opacity: isDisabled ? 0.5 : 1, + }, + ...(isDisabled && { onClick: (e) => e.stopPropagation() }), // Disable onClick if the item is disabled + tooltipProps: { intent: isDisabled ? 'danger' : undefined }, + }; + }); +} function getMissingProjection(spectraData: any, activeTab: any) { let nucleus = activeTab.split(','); nucleus = nucleus[0] === nucleus[1] ? [nucleus[0]] : nucleus; @@ -36,6 +122,22 @@ function getMissingProjection(spectraData: any, activeTab: any) { } return missingNucleus; } + +function buildMixonatData(spectra: Spectrum[]) { + const result = []; + + for (const spectrum of spectra) { + if (!isSpectrum1D(spectrum)) continue; + + result.push({ + info: spectrum.info, + peaks: spectrum.peaks.values.map(({ x, y }) => ({ x, y })), + }); + } + + return { spectra: result }; +} + interface SpectraPanelHeaderProps { onSettingClick: () => void; } @@ -138,6 +240,74 @@ function SpectraPanelHeaderInner({ : {}, ); + const externalApiMenuItems = useExternalApiMenuItems(); + const spectra = useSelectedSpectra(); + + function handleSendToMixonat(options: ExternalMenuOptions) { + const { apiUrl: url } = options; + + if (!spectra) return; + + if (!url) { + toaster.show({ + message: 'MixOnat URL is not configured', + intent: 'danger', + }); + return; + } + + setTimeout(async () => { + const hideLoading = await toaster.showAsyncLoading({ + message: 'Sending spectra to Mixonat...', + }); + + try { + const body = buildMixonatData(spectra); + + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + throw new Error( + `Failed to submit the selected spectra to MixOnat (HTTP ${response.status}).`, + ); + } + + const result = await response.json(); + // eslint-disable-next-line no-console + console.log('MixOnat response:', result); + toaster.show({ + message: 'Spectra successfully sent to MixOnat', + intent: 'success', + }); + } catch (error) { + toaster.show({ + message: `MixOnat error: ${error instanceof Error ? error.message : 'Unknown error'}`, + intent: 'danger', + }); + } finally { + hideLoading(); + } + }, 0); + } + + function handleServiceClick(selected?: ExternalMenuOptions) { + if (!selected) return; + + const { id } = selected; + + if (id === 'ct') { + return; + } + + if (id === 'mixonat') { + return handleSendToMixonat(selected); + } + } + let leftButtons: ToolbarItemProps[] = getToggleVisibilityButtons(!hasActiveSpectra); @@ -184,6 +354,16 @@ function SpectraPanelHeaderInner({ active: spectraLabel.visible, onClick: toggleSpectraLabelHandler, }, + { + component: ( + + ), + }, ]); return ( From a5e11c290e354fa915df45b69b1becaf52817b0e Mon Sep 17 00:00:00 2001 From: hamed musallam Date: Tue, 2 Jun 2026 19:18:07 +0200 Subject: [PATCH 3/3] refactor: external service menu --- src/component/elements/ToolbarPopoverItem.tsx | 27 ++++++++++++------- .../SpectraPanel/SpectraPanelHeader.tsx | 11 +++----- 2 files changed, 21 insertions(+), 17 deletions(-) diff --git a/src/component/elements/ToolbarPopoverItem.tsx b/src/component/elements/ToolbarPopoverItem.tsx index 779dbcece..b83493301 100644 --- a/src/component/elements/ToolbarPopoverItem.tsx +++ b/src/component/elements/ToolbarPopoverItem.tsx @@ -36,7 +36,7 @@ export function ToolbarPopoverItem( ) { const { options, - onClick, + onClick: onClickHandler, icon, tooltip, tooltipProps, @@ -73,7 +73,6 @@ export function ToolbarPopoverItem( return ( ( placement: 'right', ...tooltipProps, })} - renderTarget={({ isOpen, ...props }) => ( - onClick?.(data)} - {...otherOptions} - {...props} - /> + renderTarget={({ isOpen, ...targetProps }) => ( + + !disabled && onClickHandler?.(data)} + /> + )} /> ); diff --git a/src/component/panels/SpectraPanel/SpectraPanelHeader.tsx b/src/component/panels/SpectraPanel/SpectraPanelHeader.tsx index f341155e7..e8c0cd3d7 100644 --- a/src/component/panels/SpectraPanel/SpectraPanelHeader.tsx +++ b/src/component/panels/SpectraPanel/SpectraPanelHeader.tsx @@ -48,7 +48,7 @@ const EXTERNAL_API_DEFINITIONS: Record = { ct: { icon: , disable: ({ spectra, activeTab }) => { - return activeTab === '1H' || spectra?.length === 0; + return activeTab !== '1H' || !spectra || spectra.length === 0; }, disableMessage: 'CT can only be used when there is at least one proton (1H) spectrum selected', @@ -57,7 +57,7 @@ const EXTERNAL_API_DEFINITIONS: Record = { mixonat: { icon: , disable: ({ spectra, activeTab }) => { - return activeTab === '13C' || spectra?.length === 0; + return activeTab !== '13C' || !spectra || spectra.length === 0; }, disableMessage: 'MixOnat can only be used when there is at least one carbon (13C) spectrum selected', @@ -93,6 +93,7 @@ function useExternalApiMenuItems(): Array< const { icon, disable, disableMessage } = EXTERNAL_API_DEFINITIONS?.[key] || {}; const isDisabled = disable ? disable({ spectra, activeTab }) : false; + return { icon, text: name, @@ -101,11 +102,7 @@ function useExternalApiMenuItems(): Array< title: name, description: isDisabled ? disableMessage : description, }, - style: { - cursor: isDisabled ? 'not-allowed' : undefined, - opacity: isDisabled ? 0.5 : 1, - }, - ...(isDisabled && { onClick: (e) => e.stopPropagation() }), // Disable onClick if the item is disabled + disabled: isDisabled, tooltipProps: { intent: isDisabled ? 'danger' : undefined }, }; });