diff --git a/src/app/app/virtual-lab/lab/[virtualLabId]/project/[projectId]/explore/interactive/add/experimental/morphology/page.tsx b/src/app/app/virtual-lab/lab/[virtualLabId]/project/[projectId]/explore/interactive/add/experimental/morphology/page.tsx new file mode 100755 index 000000000..199ef2bf3 --- /dev/null +++ b/src/app/app/virtual-lab/lab/[virtualLabId]/project/[projectId]/explore/interactive/add/experimental/morphology/page.tsx @@ -0,0 +1,20 @@ +'use client'; + +import { useParams } from 'next/navigation'; + +import ContributeConfig from '@/features/contribute'; +import { WorkspaceContext } from '@/types/common'; + +type Params = WorkspaceContext & { circuit_id: string }; + +export default function ContributeConfiguration() { + const { virtualLabId, projectId } = useParams(); + + return ( + + ); +} diff --git a/src/app/app/virtual-lab/lab/[virtualLabId]/project/[projectId]/explore/interactive/add/layout.tsx b/src/app/app/virtual-lab/lab/[virtualLabId]/project/[projectId]/explore/interactive/add/layout.tsx new file mode 100755 index 000000000..fdbc79a4d --- /dev/null +++ b/src/app/app/virtual-lab/lab/[virtualLabId]/project/[projectId]/explore/interactive/add/layout.tsx @@ -0,0 +1,6 @@ +import { ReactNode } from 'react'; +import ContributeLayout from '@/components/explore-section/ContributeLayout'; + +export default function ContributeDataLayout({ children }: { children: ReactNode }) { + return {children}; +} diff --git a/src/components/entities-type-stats/interactive-navigation-menu.tsx b/src/components/entities-type-stats/interactive-navigation-menu.tsx index 9346c16fc..bcbf5438a 100644 --- a/src/components/entities-type-stats/interactive-navigation-menu.tsx +++ b/src/components/entities-type-stats/interactive-navigation-menu.tsx @@ -60,8 +60,8 @@ function EntityTypeStats(props: StatsPanelProps) { return match(selectedTab) .with('experimental-data', () => ( <> - {Object.entries(ExperimentalEntitiesTileTypes).map(([key, value]) => { - const href = `${pathName}/${value?.explore.basePrefix}/${value.slug}`; + {Object.entries(ExperimentalEntitiesTileTypes).map(([key, value], index) => { + const baseHref = `${pathName}/${value?.explore.basePrefix}/${value.slug}`; let records = ''; let isError = false; @@ -74,16 +74,38 @@ function EntityTypeStats(props: StatsPanelProps) { isError = !!error || typeof tmpResult === 'string'; } + // Find the last occurrence of '/interactive/' and insert '/add/' after it + const lastInteractiveIndex = pathName.lastIndexOf('/interactive'); + let addHref = baseHref; + if (lastInteractiveIndex !== -1) { + const pathBeforeInteractive = pathName.substring( + 0, + lastInteractiveIndex + '/interactive'.length + ); + const pathAfterInteractive = baseHref.substring(baseHref.indexOf('/experimental')); // Assuming /experimental is always after /interactive + addHref = `${pathBeforeInteractive}/add${pathAfterInteractive}`; + } + return ( - +
+ + {index === 0 && ( + + add + + )} +
); })} diff --git a/src/components/explore-section/ContributeLayout/index.tsx b/src/components/explore-section/ContributeLayout/index.tsx new file mode 100755 index 000000000..55bceb0f1 --- /dev/null +++ b/src/components/explore-section/ContributeLayout/index.tsx @@ -0,0 +1,47 @@ +// File: app/virtual-lab/explore/interactive/add/layout.tsx (or wherever your 'add' route lives) + +'use client'; + +import { ReactNode } from 'react'; +import { usePathname } from 'next/navigation'; +import { ErrorBoundary } from 'react-error-boundary'; +import SimpleErrorComponent from '@/components/GenericErrorFallback'; +import BackToInteractiveExplorationBtn from '@/components/explore-section/BackToInteractiveExplorationBtn'; + +export default function ContributeLayout({ children }: { children: ReactNode }) { + const pathname = usePathname(); + + // Determine the href for the back button. + // Goal: If current is /virtual-lab/explore/interactive/add/experimental/morphology + // It should go back to /virtual-lab/explore/interactive + const splittedPathname = pathname.split('/'); + let interactivePageHref = '/'; // Default fallback + + const addIndex = splittedPathname.indexOf('add'); + + if (addIndex > -1) { + // If 'add' is found, take all segments up to (but not including) 'add' + interactivePageHref = splittedPathname.slice(0, addIndex).join('/'); + } else { + // Fallback: If 'add' is not in the path (unexpected for this layout), + // go back one level from the current path. + interactivePageHref = splittedPathname.slice(0, splittedPathname.length - 1).join('/'); + } + + // Ensure the path starts with a '/' if it's not empty (e.g., if it's just 'virtual-lab/explore/interactive') + if (interactivePageHref === '') { + interactivePageHref = '/'; + } else if (!interactivePageHref.startsWith('/')) { + interactivePageHref = '/' + interactivePageHref; + } + + return ( +
+ + + +
{children}
+
+
+ ); +} diff --git a/src/features/cell-composition/how-to.md b/src/features/cell-composition/how-to.md index bcbb3c3a0..0076f4d73 100644 --- a/src/features/cell-composition/how-to.md +++ b/src/features/cell-composition/how-to.md @@ -1,10 +1,15 @@ # HOW-TO: Construct Brain Cell Composition -This short document explains how cell composition has been constructed, which builds a hierarchical representation of brain cell types and their densities across brain regions. +This short document explains how cell composition has been constructed, +which builds a hierarchical representation of brain cell types and their +densities across brain regions. ## Overview -The cell composition feature processes hierarchical brain region data along with cell type information to generate a unified view of neuron and glial cell distributions. It handles relationships between `brain_region`, `mTypes`, `eTypes`. +The cell composition feature processes hierarchical brain region data +along with cell type information to generate a unified view of neuron +and glial cell distributions. It handles relationships between +`brain_region`, `mTypes`, `eTypes`. ## Data Flow @@ -22,69 +27,3 @@ flowchart TD I --> K[return needed data] J --> K ``` - -## How to process: - -1. **Initialization** - - - start with a brain region id (selected by the user) - - retrieve all leaf regions under that brain region (use `brainRegionHierarchyAtom`) - - create volume maps for each leaf region (using `brainRegionAtlasAtom`) - -2. **Node Construction** - - - for each leaf region: - - process mTypes and their child eTypes - - build tree nodes with cell counts and densities - - calculate composition data based on volumes - -3. **Node Aggregation** - - - merge nodes that represent the same mType/eType across different leaf regions - - sum cell counts across regions - - recalculate densities based on aggregated volumes - - create links between parent mTypes and child eTypes - -4. **Final Calculations** - - - calculate total neuron and glial cell counts - - determine total volume (not needed for now) - - compute the whole composition densities - -## Implementation Details - -### Composite IDs - -The system uses composite IDs (`mTypeId__eTypeId`) to ensure that when the same eType appears under different mTypes, they are treated as distinct nodes. This preserves the hierarchical relationship. - -```mermaid -graph TD - mType1 --> eType1_1[eType1 under mType1] - mType1 --> eType2_1[eType2 under mType1] - mType2 --> eType1_2[eType1 under mType2] - mType2 --> eType3[eType3] -``` - -### Volume and Density calculations: - -- **cell count** = density × volume -- **aggregated density** = totalCellCount / TotalVolume -- **neuron count scale** = 1e-9 - -### Node merging: - -When the same node (by composite ID) appears in multiple leaf regions: - -1. merge the lists of associated leaf regions -2. sum the cell counts from all regions -3. recalculate densities based on the total volume of all associated regions -4. combine related nodes (for mTypes pointing to eTypes) - -## Output: - -The final output contains: - -- **nodes**: Array of tree nodes representing mTypes and eTypes -- **links**: Connections between parent mTypes and child eTypes -- **totalVolume**: Combined volume of all leaf regions -- **totalComposition**: Overall neuron and glial cell densities and counts diff --git a/src/features/contribute/_components/atoms/index.ts b/src/features/contribute/_components/atoms/index.ts new file mode 100644 index 000000000..25ceed256 --- /dev/null +++ b/src/features/contribute/_components/atoms/index.ts @@ -0,0 +1,170 @@ +import { atom } from 'jotai'; +import { atomWithRefresh } from 'jotai/utils'; +import isEqual from 'lodash/isEqual'; + +import { downloadAsset } from '@/api/entitycore/queries/assets'; +import { getCircuit } from '@/api/entitycore/queries/model/circuit'; +import { getCircuitSimulations } from '@/api/entitycore/queries/simulation/circuit-simulation'; +import { getCircuitSimulationExecutions } from '@/api/entitycore/queries/simulation/circuit-simulation-execution'; +import { getCircuitSimulationResult } from '@/api/entitycore/queries/simulation/circuit-simulation-result'; +import { EntityTypeValue } from '@/api/entitycore/types'; +import { ICircuit } from '@/api/entitycore/types/entities/circuit'; +import { ICircuitSimulation } from '@/api/entitycore/types/entities/circuit-simulation'; +import { ICircuitSimulationExecution } from '@/api/entitycore/types/entities/circuit-simulation-execution'; +import { ICircuitSimulationResult } from '@/api/entitycore/types/entities/circuit-simulation-result'; +import { getLatestSimExecStatus } from '@/features/small-microcircuit/_components/utils'; +import { SimExecStatusMap } from '@/features/small-microcircuit/types'; +import { WorkspaceContext } from '@/types/common'; +import { atomFamilyWithExpiration, readAtomFamilyWithExpiration } from '@/util/atoms'; + +const simExecBySimIdAtomFamily = readAtomFamilyWithExpiration( + ({ simulationId, context }: { simulationId: string; context: WorkspaceContext }) => + atom>(async () => { + const simulationExecutionFilters = { used__id: simulationId }; + const res = await getCircuitSimulationExecutions({ + filters: simulationExecutionFilters, + context, + }); + + return res.data[0]; + }), + { + ttl: 120000, // 2 minutes + areEqual: isEqual, + } +); + +export const simExecRemoteStatusMapAtomFamily = atomFamilyWithExpiration( + ({ simulationIds, context }: { simulationIds: string[]; context: WorkspaceContext }) => + atomWithRefresh>(async () => { + const simulationExecutionFilters = { used__id__in: simulationIds.join(',') }; + const res = await getCircuitSimulationExecutions({ + filters: simulationExecutionFilters, + context, + }); + + return res.data.reduce( + (map, simExec) => map.set(simExec.used[0].id, simExec.status), + new Map() + ); + }), + { + ttl: 120000, // 2 minutes + areEqual: isEqual, + } +); + +type SimExecStatusMapAtomFamilyArg = { simulationIds: string[]; context: WorkspaceContext }; + +export const simExecStatusMapAtomFamily = atomFamilyWithExpiration( + ({ simulationIds, context }: SimExecStatusMapAtomFamilyArg) => { + const simExecRemoteStatusMapAtom = simExecRemoteStatusMapAtomFamily({ + simulationIds, + context, + }); + + const localStatusMapAtom = atom(new Map()); + + return atom, [SimExecStatusMap], void>( + async (get) => { + const remoteStatusMap = await get(simExecRemoteStatusMapAtom); + const localStatusMap = get(localStatusMapAtom); + + const simIds = Array.from(new Set([...remoteStatusMap.keys(), ...localStatusMap.keys()])); + const statusMap = simIds.reduce((map, simId) => { + const remoteStatus = remoteStatusMap.get(simId); + const localStatus = localStatusMap.get(simId); + // If both are set we take the latest possible one, + // because the status change in a particular sequence. + // See definition of getLatestSimExecStatus + const status = + remoteStatus && localStatus + ? getLatestSimExecStatus(remoteStatus, localStatus) + : (localStatus ?? remoteStatus); + return map.set(simId, status); + }, new Map()); + + return statusMap; + }, + (get, set, newStatusMap) => set(localStatusMapAtom, new Map(newStatusMap)) + ); + }, + { + ttl: 120000, // 2 minutes + areEqual: isEqual, + } +); + +export const simResultBySimIdAtomFamily = readAtomFamilyWithExpiration( + ({ simulationId, context }: { simulationId: string; context: WorkspaceContext }) => + atom>(async (get) => { + const execution = await get(simExecBySimIdAtomFamily({ simulationId, context })); + + if (!execution?.generated?.[0]) { + throw new Error('Simulation Result not found'); + } + + return getCircuitSimulationResult({ id: execution.generated[0].id, context }); + }), + { + ttl: 120000, // 2 minutes + areEqual: isEqual, + } +); + +export const simulationsByCampaignIdAtomFamily = readAtomFamilyWithExpiration( + ({ campaignId, context }: { campaignId: string; context: WorkspaceContext }) => + atom>(async () => { + const filters = { simulation_campaign_id: campaignId }; + const res = await getCircuitSimulations({ filters, context }); + + return res.data; + }), + { + ttl: 120000, // 2 minutes + areEqual: isEqual, + } +); + +export const circuitAtomFamily = readAtomFamilyWithExpiration( + ({ circuitId, context }: { circuitId: string; context: WorkspaceContext }) => + atom>(async () => { + return getCircuit({ id: circuitId, context }); + }), + { + ttl: 120000, // 2 minutes + areEqual: isEqual, + } +); + +export const fileAtomFamily = readAtomFamilyWithExpiration( + ({ + id, + entityId, + entityType, + assetPath, + context, + }: { + id: string; + entityId: string; + entityType: EntityTypeValue; + assetPath?: string; + context: WorkspaceContext; + }) => + atom>(async () => { + const res = await downloadAsset({ + ctx: context, + entityId, + id, + entityType, + assetPath, + asRawResponse: true, + }); + + return res.json(); + }), + { + ttl: 120000, // 2 minutes + areEqual: isEqual, + } +); diff --git a/src/features/contribute/_components/circuit-details/circuit-details.tsx b/src/features/contribute/_components/circuit-details/circuit-details.tsx new file mode 100644 index 000000000..f8f856fd3 --- /dev/null +++ b/src/features/contribute/_components/circuit-details/circuit-details.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import { Input } from 'antd'; + +import Tooltip from '../tooltip'; + +import { ICircuit } from '@/api/entitycore/types/entities/circuit'; + +interface CircuitDetailsProps { + className?: string; + circuit: ICircuit; +} + +export default function CircuitDetails({ className, circuit }: CircuitDetailsProps) { + return ( +
+ + + + +
+ ); +} diff --git a/src/features/contribute/_components/circuit-details/index.ts b/src/features/contribute/_components/circuit-details/index.ts new file mode 100644 index 000000000..42451142f --- /dev/null +++ b/src/features/contribute/_components/circuit-details/index.ts @@ -0,0 +1 @@ +export { default } from './circuit-details'; diff --git a/src/features/contribute/_components/circuit-name/circuit-name.module.css b/src/features/contribute/_components/circuit-name/circuit-name.module.css new file mode 100644 index 000000000..61b1f9711 --- /dev/null +++ b/src/features/contribute/_components/circuit-name/circuit-name.module.css @@ -0,0 +1,50 @@ +.circuitName { + position: relative; + text-align: end; +} + +.circuitName > .name svg { + margin-left: 1em; + color: #005; +} + +.name { + font-weight: bold; + font-variant: small-caps; +} + +.description { + margin-top: 16px; + white-space: pre-wrap; + position: absolute; + background: #0050b3ee; + color: #fff; + padding: 0.5em 1em; + border-radius: 0.25em; + position: absolute; + left: 0; + right: 0; + width: 100%; + pointer-events: none; + z-index: 9; + text-align: start; + transition: all 0.3s; + opacity: 0; +} + +.circuitName:hover .description { + opacity: 1; +} + +.description::after { + content: ''; + border: 16px solid transparent; + border-bottom: 16px solid #0050b3ee; + position: absolute; + top: -16px; + left: 50%; + width: 0; + height: 0; + margin: -16px; + transform: scale(0.5, 1); +} diff --git a/src/features/contribute/_components/circuit-preview/circuit-preview.module.css b/src/features/contribute/_components/circuit-preview/circuit-preview.module.css new file mode 100644 index 000000000..2e4f7d702 --- /dev/null +++ b/src/features/contribute/_components/circuit-preview/circuit-preview.module.css @@ -0,0 +1,14 @@ +div.circuitPreview { + width: 100%; + height: 100%; + min-height: 0; + overflow: visible; +} + +.circuitPreview > .image { + background: #fff; + border-radius: 1em; + box-shadow: 0 2px 8px #0002; + width: 100%; + height: 100%; +} diff --git a/src/features/contribute/_components/circuit-preview/circuit-preview.tsx b/src/features/contribute/_components/circuit-preview/circuit-preview.tsx new file mode 100644 index 000000000..8862833f2 --- /dev/null +++ b/src/features/contribute/_components/circuit-preview/circuit-preview.tsx @@ -0,0 +1,25 @@ +/* eslint-disable @next/next/no-img-element */ +import React from 'react'; + +import { useCircuitImageURL } from '../hooks/circuit'; +import { classNames } from '@/util/utils'; +import { ICircuit } from '@/api/entitycore/types/entities/circuit'; +import ZoomableImage from '@/components/zoomable-image'; + +import styles from './circuit-preview.module.css'; + +interface CircuitPreviewProps { + className?: string; + circuit: ICircuit | undefined | null; +} + +export default function CircuitPreview({ className, circuit }: CircuitPreviewProps) { + const url = useCircuitImageURL(circuit?.id); + + return ( +
+ {/* Circuit preview */} + +
+ ); +} diff --git a/src/features/contribute/_components/circuit-preview/index.ts b/src/features/contribute/_components/circuit-preview/index.ts new file mode 100644 index 000000000..677278f17 --- /dev/null +++ b/src/features/contribute/_components/circuit-preview/index.ts @@ -0,0 +1 @@ +export { default } from './circuit-preview'; diff --git a/src/features/contribute/_components/components.tsx b/src/features/contribute/_components/components.tsx new file mode 100644 index 000000000..cbf36e002 --- /dev/null +++ b/src/features/contribute/_components/components.tsx @@ -0,0 +1,949 @@ +import { useEffect, useState, useRef } from 'react'; +import { atom, useAtom } from 'jotai'; +import { InputNumber, Input, Select, Button } from 'antd'; +import { CheckCircleOutlined, CloseCircleOutlined, PlusCircleOutlined } from '@ant-design/icons'; +import isNil from 'lodash/isNil'; + +import { JSONSchema } from '../types'; +import { isPlainObject } from './utils'; +import CircuitDetails from './circuit-details'; +import Tooltip from './tooltip'; + +import { ICircuit } from '@/api/entitycore/types/entities/circuit'; +import { classNames } from '@/util/utils'; + +type Primitive = null | boolean | number | string; +interface Object { + [key: string]: Primitive | Primitive[] | Object; +} + +export type ConfigValue = Primitive | Primitive[] | Object; + +export type Config = Record; + +// Updated structure for MTYPE classification +const MTYPE_CLASSES = [ + { mtype_pref_label: 'L23_NBC', mtype_id: '6605787b-ba14-43fd-a954-de9cff4b15a0' }, + { mtype_pref_label: 'L23_NGC', mtype_id: 'dd16dca0-e567-416b-b8b7-f8fbcaa05af0' }, + { mtype_pref_label: 'L23_PC', mtype_id: '0791edc9-7ad4-4a94-a4a5-feab9b690d7e' }, + { mtype_pref_label: 'L23_PTPC', mtype_id: '52ea242f-6591-425a-8eae-962fa0b4dfe0' }, + { mtype_pref_label: 'L23_SBC', mtype_id: 'fbb8b577-92f4-4c93-b355-0982af5a3c7c' }, + { mtype_pref_label: 'L23_STPC', mtype_id: '93be8237-9861-4870-9977-ff1cf9e7462c' }, + { mtype_pref_label: 'L2_ChC', mtype_id: '91ba3deb-1139-4bc6-a12f-6a64a0ed0e92' }, + { mtype_pref_label: 'L2_IPC', mtype_id: 'e55f12e1-807c-42f6-ba98-91d6d30c57d7' }, + { mtype_pref_label: 'L2_MC', mtype_id: 'ea51f2c8-95fc-4940-a400-c37a3ff2d9eb' }, + { mtype_pref_label: 'L2_PC', mtype_id: 'dd73956b-423e-42c5-87d9-9e2cc84356b9' }, + { mtype_pref_label: 'L2_TPC', mtype_id: '7abf03d5-30b0-41ae-a02b-1f4e26c243a8' }, + { mtype_pref_label: 'L2_TPC:A', mtype_id: '9b04acb1-4737-4088-8d22-0658414bdda1' }, + { mtype_pref_label: 'L2_TPC:B', mtype_id: '4b6862b9-c438-4dfc-a2e6-1ad4d7a00eda' }, + { mtype_pref_label: 'L3_MC', mtype_id: '52578494-b41c-499b-9717-5d11f4b2f068' }, + { mtype_pref_label: 'L3_PC', mtype_id: '87fec7dd-7a2f-400a-aee0-94d1946cf1ab' }, + { mtype_pref_label: 'L3_TPC', mtype_id: '229c31f1-a6ec-4d8c-85d3-d8175ffde109' }, + { mtype_pref_label: 'L3_TPC:A', mtype_id: 'dd346e90-7bca-4976-bf9a-303b6a94b339' }, + { mtype_pref_label: 'L3_TPC:B', mtype_id: 'a71d226c-2c56-40ee-a4be-9726fc430932' }, + { mtype_pref_label: 'L3_TPC:C', mtype_id: 'd9b7bd4d-cec9-4fec-a448-79320de89f2a' }, + { mtype_pref_label: 'L4_BP', mtype_id: 'a55f6ce7-068a-4c5e-a883-de5f4304612e' }, + { mtype_pref_label: 'L4_BTC', mtype_id: '8315d249-6678-4d55-b581-3b6f9eb48e86' }, + { mtype_pref_label: 'L4_ChC', mtype_id: '0e4f3036-0d14-4fd9-b7a8-87f5e90a9fa6' }, + { mtype_pref_label: 'L4_DBC', mtype_id: '8de61c06-31e8-4483-bd98-608bc874b369' }, + { mtype_pref_label: 'L4_LBC', mtype_id: 'bb875a91-4ae5-4f6f-b050-5ad952e9cd6c' }, + { mtype_pref_label: 'L4_MC', mtype_id: '0bdf029e-a55a-444c-a7c9-9d0ff51239a5' }, + { mtype_pref_label: 'L4_NBC', mtype_id: '72673af9-2f2b-4a9a-95dc-552777ab63b9' }, + { mtype_pref_label: 'L4_NGC', mtype_id: '41f41550-5e0e-4de7-b52d-62110c338a27' }, + { mtype_pref_label: 'L4_PC', mtype_id: 'ad5769c6-7e86-4433-8f34-9efcb4f0d182' }, + { mtype_pref_label: 'L4_SBC', mtype_id: '43a7d86b-71c5-4a10-be62-ef6cf95ca694' }, + { mtype_pref_label: 'L4_SSC', mtype_id: '400a55f7-e162-4fd1-80a0-4f2facea7cec' }, + { mtype_pref_label: 'L4_TPC', mtype_id: '02e13718-5227-4c28-b838-04dd0c2c67f2' }, + { mtype_pref_label: 'L4_UPC', mtype_id: '2ef7e0b5-39e4-441b-a72a-c7186afa7f5c' }, + { mtype_pref_label: 'L56_PC', mtype_id: '629d6d6f-93f9-43d8-8a99-277740fd8f22' }, + { mtype_pref_label: 'L5_BP', mtype_id: '7b16c860-ae76-4ddf-b093-4e28620b3712' }, +]; + +// Helper function to check if a field value is considered "empty" or invalid +const isEmptyValue = (value: ConfigValue): boolean => { + if (isNil(value) || value === '') return true; + if (Array.isArray(value) && value.length === 0) return true; + if (typeof value === 'object' && value !== null && Object.keys(value).length === 0) return true; + return false; +}; + +// Helper function to validate required fields +const getRequiredFieldErrors = ( + state: Record, + schema: JSONSchema +): string[] => { + const errors: string[] = []; + const requiredFields = schema.required || []; + + requiredFields.forEach((fieldName) => { + const value = state[fieldName]; + if (isEmptyValue(value)) { + const fieldSchema = schema.properties?.[fieldName]; + const fieldTitle = fieldSchema?.title || fieldName; + errors.push(`${fieldTitle} is required`); + } + }); + + return errors; +}; + +export function JSONSchemaForm({ + disabled, + schema, + stateAtom, + config, + circuit, + onAddReferenceClick, + nodeId, + currentCategory, + onValidationChange, +}: { + disabled: boolean; + config: Config; + schema: JSONSchema; + circuit: ICircuit | undefined | null; + stateAtom: ReturnType>; + onAddReferenceClick: (reference: string) => void; + nodeId?: string; + currentCategory?: string; + onValidationChange?: (isValid: boolean, errors: string[]) => void; +}) { + const skip = ['type']; + + const [state, setState] = useAtom(stateAtom); + const [addingElement, setAddingElement] = useState<{ [key: string]: boolean }>({ + legacy_id: true, + }); + const [newElement, setNewElement] = useState<{ [key: string]: number | string | null }>({}); + const [validationErrors, setValidationErrors] = useState([]); + const [touchedFields, setTouchedFields] = useState>(new Set()); + + const referenceTypesToConfigKeys: Record = { + NeuronSetReference: 'neuron_sets', + TimestampsReference: 'timestamps', + }; + + const referenceTypesToTitles: Record = { + NeuronSetReference: 'Neuron Set', + TimestampsReference: 'Timestamps', + }; + + // Callback ref to avoid dependency issues + const onValidationChangeRef = useRef(onValidationChange); + useEffect(() => { + onValidationChangeRef.current = onValidationChange; + }); + + // Validate form whenever state changes + useEffect(() => { + const errors = getRequiredFieldErrors(state, schema); + + // Only update if errors actually changed + setValidationErrors((prevErrors) => { + const errorsChanged = + prevErrors.length !== errors.length || + prevErrors.some((error, index) => error !== errors[index]); + + if (errorsChanged && onValidationChangeRef.current) { + onValidationChangeRef.current(errors.length === 0, errors); + } + + return errorsChanged ? errors : prevErrors; + }); + }, [state, schema]); + + useEffect(() => { + if (!schema.properties) return; + + const initial: Record = {}; + + Object.entries(schema.properties).forEach(([key, value]) => { + if (key === 'type') initial[key] = value.const ?? null; + else if (key === 'legacy_id') initial[key] = []; + else if (key === 'strain_id') initial[key] = null; + else if (key === 'license_id') + initial[key] = 'c268a20e-b78a-4332-a5e1-38e26c4454b9'; // Default to undefined UUID + else initial[key] = value.default ?? null; + }); + + // Auto-populate brain region id if we're in morphology category + if (currentCategory === 'morphology' && nodeId) { + const brainRegionIdKey = Object.keys(schema.properties || {}).find((key) => { + const normalizedKey = key.toLowerCase().replace(/[\s_]/g, ''); + return ( + normalizedKey === 'brainregionid' || + normalizedKey === 'brain_region_id' || + normalizedKey === 'brainregion' + ); + }); + + if (brainRegionIdKey) { + initial[brainRegionIdKey] = nodeId; + } + } + + // Auto-populate species id (hardcoded mouse species ID) + const speciesIdKey = Object.keys(schema.properties || {}).find((key) => { + const normalizedKey = key.toLowerCase().replace(/[\s_]/g, ''); + return ( + normalizedKey === 'speciesid' || + normalizedKey === 'species_id' || + normalizedKey === 'species' + ); + }); + + if (speciesIdKey) { + initial[speciesIdKey] = 'b7ad4cca-4ac2-4095-9781-37fb68fe9ca1'; + } + + // Auto-populate atlas id + const atlasIdKey = Object.keys(schema.properties || {}).find((key) => { + const normalizedKey = key.toLowerCase().replace(/[\s_]/g, ''); + return ( + normalizedKey === 'atlasid' || normalizedKey === 'atlas_id' || normalizedKey === 'atlas' + ); + }); + + if (atlasIdKey) { + initial[atlasIdKey] = 'e3e70682-c209-4cac-a29f-6fbed82c07cd'; + } + + setState((prev) => ({ ...initial, ...prev })); + }, [stateAtom, setState, schema.properties, nodeId, currentCategory]); + + // Helper function to mark field as touched + const markFieldTouched = (fieldName: string) => { + setTouchedFields((prev) => new Set(prev).add(fieldName)); + }; + + // Helper function to check if a field has an error + const hasFieldError = (fieldName: string): boolean => { + const isRequired = schema.required?.includes(fieldName); + const isTouched = touchedFields.has(fieldName); + const isEmpty = isEmptyValue(state[fieldName]); + return isRequired && isTouched && isEmpty; + }; + + // Helper function to get field error message + const getFieldErrorMessage = (fieldName: string): string | null => { + if (!hasFieldError(fieldName)) return null; + const fieldSchema = schema.properties?.[fieldName]; + const fieldTitle = fieldSchema?.title || fieldName; + return `${fieldTitle} is required`; + }; + + function renderInput(k: string, v: JSONSchema) { + const obj = { + ...v, + ...v.anyOf?.find((subv) => subv.type !== 'array' && subv.type !== 'null'), + }; + const normalizedKey = k.toLowerCase().replace(/[\s_]/g, ''); + + const fieldError = getFieldErrorMessage(k); + const hasError = hasFieldError(k); + + const isBrainRegionIdField = + currentCategory === 'morphology' && + (normalizedKey === 'brainregionid' || + normalizedKey === 'brain_region_id' || + normalizedKey === 'brainregion'); + const isSpeciesIdField = + normalizedKey === 'speciesid' || + normalizedKey === 'species_id' || + normalizedKey === 'species'; + const isAtlasIdField = + normalizedKey === 'atlasid' || normalizedKey === 'atlas_id' || normalizedKey === 'atlas'; + const isExperimentDateField = + normalizedKey === 'experimentdate' || + normalizedKey === 'experiment_date' || + normalizedKey === 'date' || + v.title?.toLowerCase().includes('date'); + const isStrainIdField = + normalizedKey === 'strainid' || normalizedKey === 'strain_id' || normalizedKey === 'strain'; + const isAgePeriodField = normalizedKey === 'ageperiod' || normalizedKey === 'age_period'; + const isLegacyIdField = normalizedKey === 'legacyid' || normalizedKey === 'legacy_id'; + const isLicenseIdField = normalizedKey === 'licenseid' || normalizedKey === 'license_id'; + const isMtypeClassIdField = normalizedKey === 'mtypeclassid'; + + if (isBrainRegionIdField && nodeId) { + return ( +
+ markFieldTouched(k)} + value={nodeId} + readOnly + /> +
+ ); + } + + if (isSpeciesIdField) { + return ( +
+ markFieldTouched(k)} + value="b7ad4cca-4ac2-4095-9781-37fb68fe9ca1" + readOnly + /> +
+ ); + } + + if (isAtlasIdField) { + return ( +
+ markFieldTouched(k)} + value="e3e70682-c209-4cac-a29f-6fbed82c07cd" + readOnly + /> +
+ ); + } + + if (isExperimentDateField) { + const formatDate = (value: string) => { + const cleaned = value.replace(/[^\d\s-]/g, ''); + const parts = cleaned.split(/[\s-]+/).filter((part) => part.length > 0); + if (parts.length === 0) return ''; + if (parts.length === 1) return parts[0]; + if (parts.length === 2) return `${parts[0]} ${parts[1]}`; + return parts.slice(0, 3).join(' '); + }; + + const validateDateFormat = (value: string) => { + if (!value) return true; + const parts = value.split(' '); + if (parts.length !== 3) return false; + const [day, month, year] = parts; + const dayNum = parseInt(day, 10); + const monthNum = parseInt(month, 10); + const yearNum = parseInt(year, 10); + return ( + dayNum >= 1 && + dayNum <= 31 && + monthNum >= 1 && + monthNum <= 12 && + yearNum >= 1900 && + yearNum <= new Date().getFullYear() + ); + }; + + const currentValue = typeof state[k] === 'string' ? state[k] : ''; + const isValid = validateDateFormat(currentValue); + const showDateError = !isValid && currentValue; + + return ( +
+ markFieldTouched(k)} + value={currentValue} + className={`w-full ${hasError || showDateError ? 'border-red-500' : ''}`} + onChange={(e) => { + const formatted = formatDate(e.currentTarget.value); + setState({ ...state, [k]: formatted }); + }} + placeholder="DD MM YYYY (e.g., 15 03 2024)" + /> + {showDateError && ( +
+ Please use format: DD MM YYYY (day month year) +
+ )} + {fieldError &&
{fieldError}
} +
+ ); + } + + if (isStrainIdField) { + const allSpeciesStrains = [ + { + species_id: 'b7ad4cca-4ac2-4095-9781-37fb68fe9ca1', + species_name: 'Mouse', + strains: { + 'C57BL/6': '123e4567-e89b-12d3-a456-426614174000', + 'BALB/c': '456e7890-e29b-41d4-a716-446655440001', + }, + }, + { + species_id: '789e0123-e29b-41d4-a716-446655440002', + species_name: 'Rat', + strains: { + 'Sprague Dawley': '890e1234-e29b-41d4-a716-446655440003', + }, + }, + ]; + + const speciesId = 'b7ad4cca-4ac2-4095-9781-37fb68fe9ca1'; + const selectedSpecies = allSpeciesStrains.find((s) => s.species_id === speciesId); + + if (!selectedSpecies) { + return ( +
+ + {fieldError &&
{fieldError}
} +
+ ); + } + + const strainOptions = Object.entries(selectedSpecies.strains).map(([name, id]) => ({ + label: name, + value: id, + })); + + return ( +
+ markFieldTouched(k)} + onChange={(newV) => { + setState({ ...state, [k]: newV }); + markFieldTouched(k); + }} + value={state[k]} + options={obj.enum.map((subv: string) => ({ + label: subv, + value: subv, + }))} + placeholder="Select age period" + /> + {fieldError &&
{fieldError}
} +
+ ); + } + return ( +
+ markFieldTouched(k)} + value={typeof state[k] === 'string' ? state[k] : ''} + onChange={(e) => { + setState({ ...state, [k]: e.currentTarget.value }); + }} + placeholder="Enter age period" + /> + {fieldError &&
{fieldError}
} +
+ ); + } + + if (isLegacyIdField) { + return ( +
+
+
+ {Array.isArray(state[k]) && + state[k].map((e) => ( +
+ {e}{' '} + {!disabled && ( + { + const newElements = [...(Array.isArray(state[k]) ? state[k] : [])]; + newElements.splice(newElements.indexOf(e), 1); + setState({ ...state, [k]: newElements }); + markFieldTouched(k); + }} + /> + )} +
+ ))} +
+ {!addingElement[k] && !disabled && ( + setAddingElement({ ...addingElement, [k]: true })} + className="text-primary-8" + /> + )} + {addingElement[k] && !disabled && ( +
+ setNewElement({ ...newElement, [k]: e.currentTarget.value })} + placeholder="Enter legacy ID" + /> + {newElement[k] && ( + { + setState({ + ...state, + [k]: [...(Array.isArray(state[k]) ? state[k] : []), newElement[k]], + }); + setAddingElement({ ...addingElement, [k]: false }); + setNewElement({ ...newElement, [k]: null }); + markFieldTouched(k); + }} + /> + )} + { + setAddingElement({ ...addingElement, [k]: false }); + setNewElement({ ...newElement, [k]: null }); + }} + className="text-primary-8" + /> +
+ )} +
+ {fieldError &&
{fieldError}
} +
+ ); + } + + // New condition for 'license id' field + if (isLicenseIdField) { + const licenseOptions = { + undefined: 'c268a20e-b78a-4332-a5e1-38e26c4454b9', + 'CC BY-NC-SA 4.0 Deed': '9e766299-b873-4162-9207-30fcd583d933', + 'CC BY-NC 2.0 Deed': 'fafcd04d-a967-4eec-b2e1-3afb2d63a41a', + 'CC BY-NC 4.0 Deed': '1283454d-b5ad-488f-acb7-d00b9f02873d', + 'CC BY 2.0 Deed': '211ac318-504d-44b9-b250-3cae8980d2e9', + 'CC BY 4.0 Deed': 'ad8686db-3cdd-4e3f-bcbd-812380a9eba7', + 'CC0 1.0 Deed': '74b8c953-67f5-4e95-ac58-095274901328', + 'NGV_Data Licence_v1.0': '0c39107e-3b68-4f1f-904a-5c7f1b4f89c5', + }; + + const options = Object.entries(licenseOptions).map(([name, id]) => ({ + label: name, + value: id, + })); + + // Find the key corresponding to the current state value to set the label + const currentLicenseLabel = Object.keys(licenseOptions).find( + (key) => licenseOptions[key] === state[k] + ); + + return ( +
+ markFieldTouched(k)} + onChange={(newV) => { + setState({ ...state, [k]: newV }); + markFieldTouched(k); + }} + value={currentMtypeLabel || state[k]} + options={options} + placeholder="Select an MTYPE CLASS" + /> + {fieldError &&
{fieldError}
} +
+ ); + } + + if (k === 'circuit' && circuit) return ; + + if (v.is_block_reference && v.properties && typeof v.properties.type.const === 'string') { + const referenceKey = referenceTypesToConfigKeys[v.properties.type.const]; + const referenceTitle = referenceTypesToTitles[v.properties.type.const]; + if (!referenceKey) return null; + const referenceConfig = config[referenceKey]; + if (!isPlainObject(referenceConfig)) return null; + + const referees = Object.entries(referenceConfig).filter(([, val]) => { + return isPlainObject(val); + }); + + if (referees.length === 0) { + return ( +
+ + {fieldError &&
{fieldError}
} +
+ ); + } + + const defaultV = + isPlainObject(state[k]) && typeof state[k].block_name === 'string' + ? state[k].block_name + : null; + + return ( +
+ markFieldTouched(k)} + onChange={(newV) => { + setState({ ...state, [k]: newV }); + markFieldTouched(k); + }} + value={state[k]} + options={obj.enum.map((subv: string) => ({ + label: subv, + value: subv, + }))} + placeholder={`Select ${v.title || k}`} + /> + {fieldError &&
{fieldError}
} +
+ ); + } + + if (obj.type === 'number' || obj.type === 'integer') { + return ( +
+ { + setState({ ...state, [k]: value }); + }} + onBlur={() => markFieldTouched(k)} + className={`w-full ${hasError ? 'border-red-500' : ''}`} + /> + {fieldError &&
{fieldError}
} +
+ ); + } + + if (obj.type === 'string') { + return ( +
+ markFieldTouched(k)} + value={typeof state[k] === 'string' ? state[k] : ''} + onChange={(e) => { + setState({ ...state, [k]: e.currentTarget.value }); + }} + /> + {fieldError &&
{fieldError}
} +
+ ); + } + + return ( +
+ markFieldTouched(k)} + value={typeof state[k] === 'string' ? state[k] : ''} + onChange={(e) => { + setState({ ...state, [k]: e.currentTarget.value }); + }} + placeholder={`Enter value for ${v.title || k}`} + /> + {fieldError &&
{fieldError}
} +
+ ); + } + + function getFieldTitle(k: string, v: JSONSchema, normalizedKey: string): string { + if (normalizedKey === 'strainid' || normalizedKey === 'strain_id' || normalizedKey === 'strain') + return 'STRAIN'; + if (normalizedKey === 'ageperiod' || normalizedKey === 'age_period') return 'AGE PERIOD'; + if (normalizedKey === 'licenseid' || normalizedKey === 'license_id') return 'LICENSE'; + if (normalizedKey === 'mtypeclassid') return 'MTYPE CLASS'; + return v.title || k; + } + + return ( +
+
{schema.title}
+
{schema.description}
+ + {/* Show overall validation errors summary if there are any */} + {validationErrors.length > 0 && ( +
+
+ Please fix the following required fields: +
+
    + {validationErrors.map((error) => ( +
  • {error}
  • + ))} +
+
+ )} + +
+ {schema.properties && + Object.entries(schema.properties) + .filter(([k]) => { + return !skip.includes(k); + }) + .map(([k, v]) => { + const isRequired = schema.required?.includes(k); + const normalizedKey = k.toLowerCase().replace(/[\s_]/g, ''); + const fieldTitle = getFieldTitle(k, v, normalizedKey); + + return ( +
+
+
+ {fieldTitle} + {isRequired && *} +
+ {v.units &&
{v.units}
} +
+ {renderInput(k, v)} +
+ ); + })} +
+
+ ); +} + +export function Tab({ + tab, + selectedTab, + children, + onClick, + rounded = 'rounded-full', + extraClass, + disabled, +}: { + tab: string; + selectedTab: string; + onClick?: () => void; + rounded?: 'rounded-l-full' | 'rounded-r-full' | 'rounded-full'; + children?: React.ReactNode; + extraClass?: string; + disabled?: boolean; +}) { + return ( + + ); +} + +export function Chevron({ rotate }: { rotate?: number }) { + return ( + + + + ); +} diff --git a/src/features/contribute/_components/file-viewer/index.tsx b/src/features/contribute/_components/file-viewer/index.tsx new file mode 100644 index 000000000..e8d736a3a --- /dev/null +++ b/src/features/contribute/_components/file-viewer/index.tsx @@ -0,0 +1,81 @@ +import { Suspense } from 'react'; +import { match } from 'ts-pattern'; +import { useAtomValue } from 'jotai'; + +import { File } from '../simulation-files'; +import { fileAtomFamily } from '../atoms'; + +import EphysViewer from '@/features/ephys-viewer'; +import { classNames } from '@/util/utils'; +import { WorkspaceContext } from '@/types/common'; +import { ICircuitSimulationResult } from '@/api/entitycore/types/entities/circuit-simulation-result'; + +type FileViewerProps = { + file?: File; + context: WorkspaceContext; + className?: string; +}; + +export function FileViewer({ file, context, className = '' }: FileViewerProps) { + const fileName = file?.assetPath?.split('/').at(-1) ?? file?.asset.path.split('/').at(-1); + const fileExt = fileName?.split('.').at(-1)?.toLowerCase(); + + const viewerContent = match(fileExt) + .with(undefined, () =>

Select a file for preview

) + .with('json', () => ) + .with('nwb', () => ) + .otherwise(() => ); + + return ( +
+
+ Loading...
}>{viewerContent} +
+ + ); +} + +type JsonFileViewerProps = { + file: File; + context: WorkspaceContext; +}; + +function JsonFileViewer({ file, context }: JsonFileViewerProps) { + const parsedJson = useAtomValue( + fileAtomFamily({ + id: file.asset.id, + entityId: file.entity.id, + entityType: file.entity.type, + assetPath: file.assetPath, + context, + }) + ); + + return
{JSON.stringify(parsedJson, null, 2)}
; +} + +type NwbFileViewerProps = { + file: File; + context: WorkspaceContext; +}; + +function NwbFileViewer({ file, context }: NwbFileViewerProps) { + return ; +} + +type PlaceholderFileViewerProps = { + file: File; +}; + +function PlaceholderFileViewer({ file }: PlaceholderFileViewerProps) { + const fileName = file?.assetPath?.split('/').at(-1) ?? file?.asset.path.split('/').at(-1); + const fileExt = fileName?.split('.').at(-1)?.toLowerCase(); + + return ( +
+ + Preview for {fileExt} files is not supported yet + +
+ ); +} diff --git a/src/features/contribute/_components/hooks/circuit.ts b/src/features/contribute/_components/hooks/circuit.ts new file mode 100644 index 000000000..4d3ae3c9b --- /dev/null +++ b/src/features/contribute/_components/hooks/circuit.ts @@ -0,0 +1,88 @@ +import React from 'react'; + +import { getCircuit } from '@/api/entitycore/queries/model/circuit'; +import { ICircuit } from '@/api/entitycore/types/entities/circuit'; +import { useAppNotification } from '@/components/notification'; +import { AssetLabel } from '@/api/entitycore/types/shared/global'; +import { downloadAsset } from '@/api/entitycore/queries/assets'; +import { EntityTypeEnum } from '@/api/entitycore/types'; + +const pendingQueries = new Map>(); + +/** + * Retrieve a circuit from EntityCore. + * @param circuitId + * @returns `undefined` if the query is pending, `null` if an error occured and the circuit in case of success. + */ +export function useCircuit(circuitId: string | undefined): ICircuit | undefined | null { + const { error } = useAppNotification(); + const [circuit, setCircuit] = React.useState(undefined); + + React.useEffect(() => { + if (!circuitId) { + setCircuit(undefined); + return; + } + + getQuery(circuitId) + .then(setCircuit) + .catch((ex) => { + error({ + message: `Unable to retrieve circuit "${circuitId}"!\n${ex}`, + }); + setCircuit(null); + }); + }, [circuitId, error]); + + return circuit; +} + +export function useCircuitImageURL(circuitId: string | undefined) { + const { error } = useAppNotification(); + const [url, setUrl] = React.useState(undefined); + const circuit = useCircuit(circuitId); + + React.useEffect(() => { + const action = async () => { + if (!circuit) return; + + const asset = circuit.assets.find( + (item) => item.label === AssetLabel.simulation_designer_image + ); + if (!asset) { + error({ message: `No image found for circuit "${circuit.name}" (${circuitId})!` }); + return; + } + try { + const resp = await downloadAsset({ + entityType: EntityTypeEnum.Circuit, + entityId: circuit.id, + id: asset.id, + asRawResponse: false, + }); + if (!(resp instanceof ArrayBuffer)) { + throw new Error('Wrong image format: expected ArrayBuffer!'); + } + const blob = new Blob([resp], { type: asset.content_type }); + const newUrl = URL.createObjectURL(blob); + setUrl(newUrl); + } catch (ex) { + error({ + message: `Unable to download image for circuit "${circuit.name}"!\n${ex}`, + }); + } + }; + action(); + }, [circuit, circuitId, error]); + + return url; +} + +function getQuery(circuitId: string): Promise { + const query = pendingQueries.get(circuitId); + if (query) return query; + + const newQuery = getCircuit({ id: circuitId }); + pendingQueries.set(circuitId, newQuery); + return newQuery; +} diff --git a/src/features/contribute/_components/hooks/config-atom.ts b/src/features/contribute/_components/hooks/config-atom.ts new file mode 100644 index 000000000..9fbb48e61 --- /dev/null +++ b/src/features/contribute/_components/hooks/config-atom.ts @@ -0,0 +1,30 @@ +import React from 'react'; +import { atom, useAtom } from 'jotai'; + +import { JSONSchema, AtomsMap } from '../../types'; +import { Config } from '../components'; +import { isAtom } from '../utils'; + +export function useConfigAtom(schema: JSONSchema | null, atomsMap: AtomsMap) { + const configAtom = React.useMemo(() => { + return atom((get) => { + const result: Config = {}; + Object.keys(atomsMap).forEach((key) => { + if (isAtom(atomsMap[key])) result[key] = get(atomsMap[key]); + else { + result[key] = {}; + Object.entries(atomsMap[key]).forEach(([subkey, v]) => { + if (typeof result[key] === 'string') return; + result[key][subkey] = get(v); + }); + } + }); + + result.type = schema?.properties?.type.const ?? ''; + + return result; + }); + }, [atomsMap, schema]); + const [config] = useAtom(configAtom); + return config; +} diff --git a/src/features/contribute/_components/hooks/schema.ts b/src/features/contribute/_components/hooks/schema.ts new file mode 100644 index 000000000..0177adf09 --- /dev/null +++ b/src/features/contribute/_components/hooks/schema.ts @@ -0,0 +1,149 @@ +import React from 'react'; +import { atom } from 'jotai'; +import { NotificationInstance } from 'antd/es/notification/interface'; + +import $RefParser from '@apidevtools/json-schema-ref-parser'; + +import { AtomsMap, JSONSchema } from '../../types'; +import { ConfigValue, Config } from '../components'; +import { isPlainObject, isAtom } from '../utils'; +import { assertErrorMessage } from '@/util/utils'; + +export function useObioneJsonSchema( + notification: NotificationInstance, + setSchema: React.Dispatch>, + setAtomsMap: (atomsMap: AtomsMap) => void, + initialConfig?: Config +) { + React.useEffect(() => { + async function fetchSpec() { + try { + const res = await fetch(`${process.env.NEXT_PUBLIC_OBI_ONE_URL}/openapi.json`); + const json = await res.json(); + const dereferenced = await $RefParser.dereference(json); + // @ts-ignore + const theSchema = dereferenced.components.schemas.SimulationsForm as JSONSchema; + if (!theSchema.properties) return; + + setSchema(theSchema); + + const map: { + [key: string]: + | ReturnType>> + | Record>>>; + } = {}; + + if (initialConfig) { + Object.entries(initialConfig) + .filter(([k]) => { + return isRootCategory(theSchema, k); + }) + .forEach(([k, v]) => { + if (isPlainObject(v)) map[k] = atom>(v); + }); + + Object.entries(initialConfig) + .filter(([k]) => { + return !isRootCategory(theSchema, k); + }) + .forEach(([k, v]) => { + map[k] = {}; + + Object.entries(v).forEach(([subK, subV]) => { + if (!isPlainObject(subV) || isAtom(map[k])) return; + map[k][subK] = atom>(subV); + }); + }); + } else { + // Setting up initial values and constants. + Object.entries(theSchema.properties).forEach(([k, v]) => { + if (!v.additionalProperties) { + const initial: Record = {}; + + if (v.properties) + Object.entries(v.properties).forEach(([subkey, subValue]) => { + if (subkey === 'type') initial[subkey] = subValue.const ?? null; + else initial[subkey] = subValue.default ?? null; + }); + + map[k] = atom>(initial); + } else map[k] = {}; + }); + } + + setAtomsMap(map); + } catch (e) { + // eslint-disable-next-line no-console + console.error(assertErrorMessage(e)); + notification.error({ message: assertErrorMessage(e) }); + } + } + + fetchSpec(); + }, [notification, setAtomsMap, setSchema, initialConfig]); +} + +export function isRootCategory(schema: JSONSchema, key: string) { + return schema.properties?.[key] && !schema.properties[key].additionalProperties; +} + +export function resolveKey(schema: JSONSchema, tabKey: string, itemIdx: number | null) { + if (typeof itemIdx === null) throw new Error('Invalid itemIdx'); + if (!schema.properties?.[tabKey]?.singular_name) throw new Error(`Invalid schema for ${tabKey}`); + if (isRootCategory(schema, tabKey)) throw new Error("Shouldn't be a root category"); + + return `${schema.properties[tabKey].singular_name.replaceAll(' ', '')}_${itemIdx}`; +} + +export function useObioneJsonConfigurationSchema( + circuitId: string, + notification: NotificationInstance, + setSchema: React.Dispatch>, + setAtomsMap: (atomsMap: AtomsMap) => void +) { + React.useEffect(() => { + async function fetchSpec() { + try { + const res = await fetch(`http://0.0.0.0:8100/openapi.json`); + // const res = await fetch(`${process.env.NEXT_PUBLIC_OBI_ONE_URL}/openapi.json`); + + const json = await res.json(); + const dereferenced = await $RefParser.dereference(json); + // @ts-ignore + const theSchema = dereferenced.components.schemas.ContributeMorphologyForm as JSONSchema; + if (!theSchema.properties) return; + + setSchema(theSchema); + + const map: { + [key: string]: + | ReturnType>> + | Record>>>; + } = {}; + + // Setting up initial values and constants. + Object.entries(theSchema.properties).forEach(([k, v]) => { + if (!v.additionalProperties) { + const initial: Record = {}; + + if (v.properties) + Object.entries(v.properties).forEach(([subkey, subValue]) => { + if (subkey === 'type') initial[subkey] = subValue.const ?? null; + else initial[subkey] = subValue.default ?? null; + }); + + map[k] = atom>(initial); + } else map[k] = {}; + }); + + setAtomsMap(map); + } catch (e) { + // eslint-disable-next-line no-console + console.error(assertErrorMessage(e)); + notification.error({ message: assertErrorMessage(e) }); + } + } + + fetchSpec(); + }, [notification, setAtomsMap, setSchema]); +} diff --git a/src/features/contribute/_components/section.tsx b/src/features/contribute/_components/section.tsx new file mode 100644 index 000000000..b8dc739c7 --- /dev/null +++ b/src/features/contribute/_components/section.tsx @@ -0,0 +1,221 @@ +/* eslint-disable no-param-reassign */ +import React, { Fragment } from 'react'; +import { atom } from 'jotai'; +import { + CheckCircleFilled, + DeleteOutlined, + PlusCircleOutlined, + WarningFilled, +} from '@ant-design/icons'; +import { ErrorObject } from 'ajv'; + +import { AtomsMap, JSONSchema } from '../types'; +import { Chevron, Config, ConfigValue, Tab } from './components'; +import { isAtom, isPlainObject } from './utils'; +import { isRootCategory, resolveKey } from './hooks/schema'; + +import { classNames } from '@/util/utils'; + +export function Section({ + schema, + k, + sectionSchema, + atomsMap, + setAtomsMap, + configTab, + setConfigTab, + config, + campaignId, + loading, + errors, + selectedItemIdx, + setSelectedItemIdx, + setEditing, + setSelectedCategory, + readOnly, +}: { + schema: JSONSchema | null; // The global schema + k: string; // secition key + sectionSchema: JSONSchema; // The schema for this section + atomsMap: AtomsMap; + setAtomsMap: React.Dispatch>; + configTab: string; // Key for selected section + setConfigTab: (configTab: string) => void; + config: Config; + campaignId: string; + loading: boolean; + errors: ErrorObject, unknown>[] | null | undefined; + selectedItemIdx: number | null; + setSelectedItemIdx: (selectedItemIdx: number | null) => void; + setEditing: React.Dispatch>; + setSelectedCategory: React.Dispatch>; + readOnly?: boolean; +}) { + if (!schema || !schema?.properties) return; + + const handleHeaderClick = (subkey: string, subValue: unknown) => { + if (isPlainObject(subValue)) { + setSelectedCategory(typeof subValue.type === 'string' ? subValue.type : ''); + setSelectedItemIdx(parseInt(subkey.split('_')[1], 10)); + } + setEditing(true); + }; + + return ( + <> + { + if (configTab === k && !isRootCategory(schema, k)) { + setEditing(false); + setSelectedCategory(''); + setSelectedItemIdx(null); + setConfigTab(''); + return; + } + + setConfigTab(k); + setSelectedItemIdx(null); + if (!sectionSchema.additionalProperties) setEditing(true); + else setEditing(false); + }} + extraClass="w-full flex justify-between h-[50px] min-h-[50px] items-center drop-shadow" + > + {schema.properties?.[k]?.title} +
+ {errors?.find((error) => error.instancePath.startsWith('/' + k)) ? ( + + ) : ( + + )} + +
+
+ {sectionSchema.additionalProperties && configTab === k && config[k] && ( + <> + {Object.entries(config[k]).map(([subkey, subValue]) => { + const idx = parseInt(subkey.split('_')[1], 10); + + const isSelected = configTab === k && idx === selectedItemIdx; + + return ( + +
handleHeaderClick(subkey, subValue)} + onKeyDown={(evt) => { + if (evt.key === ' ' || evt.key === 'Enter') { + handleHeaderClick(subkey, subValue); + } + }} + > + {subkey} +
+ {errors?.find((error) => error.instancePath.startsWith(`/${k}/${subkey}`)) ? ( + + ) : ( + + )} + + {!campaignId && !loading && !readOnly && ( + { + e.stopPropagation(); + + setSelectedCategory(''); + setEditing(false); + + const selectedTabAtoms = atomsMap[configTab]; + if (!isAtom(selectedTabAtoms)) { + const refereeKey = resolveKey(schema, configTab, idx); + + delete selectedTabAtoms[refereeKey]; + + // Initialize case + const configInitialize = config.initialize; + if ( + isPlainObject(configInitialize) && + isPlainObject(configInitialize.node_set) && + typeof configInitialize.node_set.block_name === 'string' && + configInitialize.node_set.block_name === refereeKey + ) { + atomsMap.initialize = atom>({ + ...configInitialize, + node_set: null, + }); + } + + // Check all keys in the config + Object.entries(config) + .filter(([configK]) => configK !== 'initialize') + .forEach(([configK, configV]) => { + if (typeof configV !== 'object') return; + + // Check all keys in a section (e.g stimuli, recordings) + Object.entries(configV).forEach(([entryKey, entryV]) => { + if (!isPlainObject(entryV)) return; + + // Check all values in a particular object (a single stimuli, a single timestamp, etc) + Object.entries(entryV).forEach(([fieldK, field]) => { + if ( + !isPlainObject(entryV) || + !isPlainObject(field) || + typeof field.block_name !== 'string' || + isAtom(atomsMap[configK]) || // skip top level atoms (e.g initialize) + field.block_name !== refereeKey + ) + return; + + // Deleting the reference to current object + + delete entryV[fieldK]; //eslint-disable-line + + // The atom that has a reference to current object + atomsMap[configK][entryKey] = + atom>(entryV); + }); + }); + }); + + setAtomsMap({ + ...atomsMap, + [configTab]: { + ...selectedTabAtoms, + }, + }); + } + + setSelectedItemIdx(null); + }} + /> + )} +
+
+
+ ); + })} + {!campaignId && !loading && !readOnly && ( + + )} + + )} + + ); +} diff --git a/src/features/contribute/_components/simulation-files/index.tsx b/src/features/contribute/_components/simulation-files/index.tsx new file mode 100644 index 000000000..0d2a8ca2c --- /dev/null +++ b/src/features/contribute/_components/simulation-files/index.tsx @@ -0,0 +1,198 @@ +import { useAtomValue } from 'jotai'; +import { Suspense, useMemo } from 'react'; +import { ErrorBoundary } from 'react-error-boundary'; + +import { ICircuitSimulation } from '@/api/entitycore/types/entities/circuit-simulation'; +import { CircuitSimulationExecutionStatus } from '@/api/entitycore/types/entities/circuit-simulation-execution'; +import { IEntity } from '@/api/entitycore/types/entities/entity'; +import { IAsset } from '@/api/entitycore/types/shared/global'; +import { + circuitAtomFamily, + simResultBySimIdAtomFamily, +} from '@/features/small-microcircuit/_components/atoms'; +import { WorkspaceContext } from '@/types/common'; +import { classNames } from '@/util/utils'; + +export type File = { + asset: IAsset; + entity: IEntity; + assetPath?: string; +}; + +type SimulationFilesProps = { + simulation: ICircuitSimulation; + execStatus?: CircuitSimulationExecutionStatus | null; + selectedFile?: File; + onSelect: (file: File) => void; + context: WorkspaceContext; +}; + +export function SimulationFiles({ + simulation, + execStatus, + selectedFile, + onSelect, + context, +}: SimulationFilesProps) { + const outputAvailable = + execStatus && + [CircuitSimulationExecutionStatus.ERROR, CircuitSimulationExecutionStatus.DONE].includes( + execStatus + ); + + return ( + <> +

Input files

+ +

Output files

+ Loading...}> + {outputAvailable && ( + There was an issue loading output files + } + resetKeys={[simulation]} + > + + + )} + + + ); +} + +type SimulationInputFilesProps = { + simulation: ICircuitSimulation; + context: WorkspaceContext; + selectedFile?: File; + onSelect: (file: File) => void; + className?: string; +}; + +function SimulationInputFiles({ + simulation, + context, + selectedFile, + onSelect, + className = '', +}: SimulationInputFilesProps) { + const circuit = useAtomValue(circuitAtomFamily({ circuitId: simulation.entity_id, context })); + // TODO: fetch circuitConfig + const sonataCircuitAsset = circuit.assets.find((asset) => asset.label === 'sonata_circuit'); + const circuitConfigFile: File = useMemo( + () => ({ + entity: circuit, + asset: sonataCircuitAsset!, + assetPath: 'circuit_config.json', + }), + [circuit, sonataCircuitAsset] + ); + + const files: File[] = useMemo( + () => [circuitConfigFile, ...simulation.assets.map((asset) => ({ asset, entity: simulation }))], + [simulation, circuitConfigFile] + ); + + return ( +
+ {files.map((file) => ( + + ))} +
+ ); +} + +type SimulationOutputFilesProps = { + simulation: ICircuitSimulation; + context: WorkspaceContext; + selectedFile?: File; + onSelect: (file: File) => void; + className?: string; +}; + +function SimulationOutputFiles({ + simulation, + onSelect, + selectedFile, + context, + className = '', +}: SimulationOutputFilesProps) { + const simResult = useAtomValue( + simResultBySimIdAtomFamily({ simulationId: simulation.id, context }) + ); + + const files: File[] = useMemo( + () => simResult.assets.map((asset) => ({ asset, entity: simResult })), + [simResult] + ); + + return ( +
+ {files.map((file) => ( + + ))} +
+ ); +} + +type SimulationFileProps = { + file: File; + selected?: boolean; + onSelect: (file: File) => void; +}; + +function SimulationFile({ file, selected, onSelect }: SimulationFileProps) { + const fileName = file.assetPath?.split('/').at(-1) ?? file.asset.path.split('/').at(-1); + const fileExt = fileName?.split('.').at(-1); + + return ( + + ); +} diff --git a/src/features/contribute/_components/simulation-status/index.tsx b/src/features/contribute/_components/simulation-status/index.tsx new file mode 100644 index 000000000..499d77215 --- /dev/null +++ b/src/features/contribute/_components/simulation-status/index.tsx @@ -0,0 +1,50 @@ +import { CircuitSimulationExecutionStatus } from '@/api/entitycore/types/entities/circuit-simulation-execution'; + +export function SimulationStatusBadge({ status }: { status?: CircuitSimulationExecutionStatus }) { + const color = status ? statusColorMap[status] : '#fafafa'; + const showSpinner = status && ['pending', 'running'].includes(status); + + // TODO: move spinner outside of the module. + + return ( +
+ {showSpinner && ( + + + + + )} + + + {status ?? ''} + +
+ ); +} +const statusColorMap: Record = { + created: '#434343', + pending: '#fa8c16', + running: '#1890ff', + done: '#389e0d', + error: '#f5222d', +}; diff --git a/src/features/contribute/_components/tabs-selector/index.ts b/src/features/contribute/_components/tabs-selector/index.ts new file mode 100644 index 000000000..e516b026d --- /dev/null +++ b/src/features/contribute/_components/tabs-selector/index.ts @@ -0,0 +1 @@ +export { default } from './tabs-selector'; diff --git a/src/features/contribute/_components/tabs-selector/tabs-selector.tsx b/src/features/contribute/_components/tabs-selector/tabs-selector.tsx new file mode 100644 index 000000000..3b2b02a17 --- /dev/null +++ b/src/features/contribute/_components/tabs-selector/tabs-selector.tsx @@ -0,0 +1,43 @@ +import React from 'react'; + +import { Tab } from '../components'; +import { TabType } from '../../types'; +import { classNames } from '@/util/utils'; + +interface TabsSelectorProps { + className?: string; + tab: TabType; + setTab(tab: TabType): void; + disableSimulationTab: boolean; +} + +export default function TabsSelector({ + className, + tab, + setTab, + disableSimulationTab, +}: TabsSelectorProps) { + return ( +
+
+ setTab('configuration')} + > + Configuration + + setTab('simulations')} + disabled={disableSimulationTab} + > + Simulations + +
+
+ ); +} diff --git a/src/features/contribute/_components/tooltip/index.ts b/src/features/contribute/_components/tooltip/index.ts new file mode 100644 index 000000000..bb106e207 --- /dev/null +++ b/src/features/contribute/_components/tooltip/index.ts @@ -0,0 +1 @@ +export { default } from './tooltip'; diff --git a/src/features/contribute/_components/tooltip/tooltip.module.css b/src/features/contribute/_components/tooltip/tooltip.module.css new file mode 100644 index 000000000..a4e563aa8 --- /dev/null +++ b/src/features/contribute/_components/tooltip/tooltip.module.css @@ -0,0 +1,39 @@ +.tooltipContainer { + position: relative; + overflow: visible; +} + +.tooltip { + position: relative; + background: #0050b3ee; + color: #fff; + padding: 0.5em 1em; + border-radius: 0.25em; + position: absolute; + left: 0; + right: 0; + bottom: 0; + width: 100%; + pointer-events: none; + z-index: 9; + transform: translateY(calc(100% + 16px)); + transition: all 0.2s; + opacity: 0; +} + +.tooltipContainer:hover .tooltip { + opacity: 1; +} + +.tooltip::after { + content: ''; + border: 16px solid transparent; + border-bottom: 16px solid #0050b3ee; + position: absolute; + top: -16px; + left: 50%; + width: 0; + height: 0; + margin: -16px; + transform: scale(0.5, 1); +} diff --git a/src/features/contribute/_components/tooltip/tooltip.tsx b/src/features/contribute/_components/tooltip/tooltip.tsx new file mode 100644 index 000000000..45a1501f4 --- /dev/null +++ b/src/features/contribute/_components/tooltip/tooltip.tsx @@ -0,0 +1,20 @@ +import React from 'react'; + +import { classNames } from '@/util/utils'; + +import styles from './tooltip.module.css'; + +interface TooltipProps { + className?: string; + value?: React.ReactNode; + children: React.ReactNode; +} + +export default function Tooltip({ className, value, children }: TooltipProps) { + return ( +
+ {children} + {value &&
{value}
} +
+ ); +} diff --git a/src/features/contribute/_components/utils.ts b/src/features/contribute/_components/utils.ts new file mode 100644 index 000000000..f4f3c34a2 --- /dev/null +++ b/src/features/contribute/_components/utils.ts @@ -0,0 +1,73 @@ +import { Atom } from 'jotai'; +import uniq from 'lodash/uniq'; +import { CircuitSimulationExecutionStatus } from '@/api/entitycore/types/entities/circuit-simulation-execution'; + +export type Primitive = null | boolean | number | string; +export interface ConfigObject { + [key: string]: Primitive | Primitive[] | ConfigObject; +} + +export function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && !Array.isArray(value) && value !== null; +} + +export function isAtom(val: unknown): val is Atom { + return typeof val === 'object' && val !== null && 'read' in val; +} + +export const ORDERING: Record = { + assets: { + order: 0, + category: 'Assets', + }, + morphology: { + order: 1, + category: 'Setup', + }, + contribution: { + order: 2, + category: 'Contribution', + }, + subject: { + order: 4, + category: 'Subject', + }, + license: { + order: 5, + category: 'License', + }, + mtype: { + order: 6, + category: 'Mtype', + }, + // publication: { + // order: 7, + // category: 'Publication', + // }, + // scientificartifact: { + // order: 8, + // category: 'Scientific Artifact', + // }, +}; + +export const CATEGORIES: string[] = uniq(Object.values(ORDERING).map((o) => o.category)); + +const simExecStatusListordered = [ + CircuitSimulationExecutionStatus.CREATED, + CircuitSimulationExecutionStatus.PENDING, + CircuitSimulationExecutionStatus.RUNNING, + CircuitSimulationExecutionStatus.DONE, + CircuitSimulationExecutionStatus.ERROR, +]; + +export function getLatestSimExecStatus( + remoteStatus: CircuitSimulationExecutionStatus, + localStatus: CircuitSimulationExecutionStatus +) { + const remoteStatusIdx = simExecStatusListordered.indexOf(remoteStatus); + const localStatusIdx = simExecStatusListordered.indexOf(localStatus); + + const latestStatus = Math.max(remoteStatusIdx, localStatusIdx); + + return simExecStatusListordered[latestStatus]; +} diff --git a/src/features/contribute/error-registry.ts b/src/features/contribute/error-registry.ts new file mode 100755 index 000000000..afc71adf6 --- /dev/null +++ b/src/features/contribute/error-registry.ts @@ -0,0 +1,7 @@ +import { messages } from '@/i18n/en/simulation'; + +const errorCodeToTranslationKey: Record = { + ACCOUNTING_INSUFFICIENT_FUNDS_ERROR: messages.LowFundsError, +}; + +export default errorCodeToTranslationKey; diff --git a/src/features/contribute/index.tsx b/src/features/contribute/index.tsx new file mode 100755 index 000000000..78aa0b8a4 --- /dev/null +++ b/src/features/contribute/index.tsx @@ -0,0 +1,656 @@ +'use client'; + +import { + LoadingOutlined, + UploadOutlined, + CheckCircleFilled, + WarningFilled, + RightOutlined, +} from '@ant-design/icons'; +import Ajv, { AnySchema } from 'ajv'; +import { atom } from 'jotai'; +import { Fragment, useMemo, useRef, useState, KeyboardEvent, useEffect } from 'react'; +import { Config, ConfigValue, JSONSchemaForm } from './_components/components'; +import { useConfigAtom } from './_components/hooks/config-atom'; +import { + isRootCategory, + resolveKey, + useObioneJsonConfigurationSchema, +} from './_components/hooks/schema'; +import { Section } from './_components/section'; +import { CATEGORIES, isAtom, ORDERING } from './_components/utils'; +import { AtomsMap, JSONSchema } from './types'; +import { resolveDataKey } from '@/utils/key-builder'; +import { useBrainRegionHierarchy } from '@/features/brain-region-hierarchy/context'; +import ApiError from '@/api/error'; +import authFetch from '@/authFetch'; +import { useAppNotification } from '@/components/notification'; +import { classNames } from '@/util/utils'; +import styles from './small-microcircuit.module.css'; + +export default function ContributeMorphologyConfiguration({ + circuitId, + virtualLabId, + projectId, + initialCampaignId, + initialConfig, +}: { + circuitId: string; + virtualLabId: string; + projectId: string; + initialCampaignId?: string; + initialConfig?: Config; +}) { + if (!!initialCampaignId !== !!initialConfig) { + throw new Error('Both or none of initialCampaignId, initialConfigId should be passed'); + } + + const { node } = useBrainRegionHierarchy({ + dataKey: resolveDataKey({ section: 'explore', projectId }), + }); + + const [configTab, setConfigTab] = useState('info'); + const [editing, setEditing] = useState(true); + const [schema, setSchema] = useState(null); + const [selectedCategory, setSelectedCategory] = useState(''); + const [selectedItemIdx, setSelectedItemIdx] = useState(null); + const [loading, setLoading] = useState(false); + const [showConfig, setShowConfig] = useState(false); + const [selectedFile, setSelectedFile] = useState(null); + const [fileStatus, setFileStatus] = useState<{ + message?: string; + }>({}); + const [newJsonPayload, setNewJsonPayload] = useState(null); + const notification = useAppNotification(); + const [campaignId, setCampaignId] = useState(initialCampaignId ?? ''); + const initialConfigValidated = useRef(false); + + // Add success state + const [isSuccess, setIsSuccess] = useState(false); + + // Add validation state + const [formValidation, setFormValidation] = useState<{ + isValid: boolean; + errors: string[]; + }>({ + isValid: true, + errors: [], + }); + + // Debug log to verify selectedFile state + useEffect(() => { + console.log('selectedFile state:', selectedFile); + }, [selectedFile]); + + const selectedCatSchema = schema?.properties?.[configTab]?.additionalProperties?.anyOf?.find( + (s) => s.properties?.type.const === selectedCategory + ); + + const handleAddReferenceClick = (referenceTab: string) => { + setConfigTab(referenceTab); + setEditing(true); + setSelectedCategory(''); + }; + + // Handle validation changes from the form + const handleValidationChange = (isValid: boolean, errors: string[]) => { + setFormValidation({ isValid, errors }); + }; + + const readOnly = initialConfig !== undefined; + + const validate = useMemo(() => { + const ajv = new Ajv({ strictSchema: false, allErrors: true }); + if (!schema) { + return; + } + return ajv.compile(schema as AnySchema); + }, [schema]); + + const [atomsMap, setAtomsMap] = useState({}); + + const config = useConfigAtom(schema, atomsMap, node.id, configTab); + + if (validate && initialConfig && !initialConfigValidated.current) { + initialConfigValidated.current = true; + validate(initialConfig); + if (validate.errors) { + throw new Error('Invalid Simulation Campaign Configuration'); + } + } + + const errors = useMemo(() => { + if (validate) { + validate(config); + } + return validate?.errors; + }, [validate, config]); + + useObioneJsonConfigurationSchema(circuitId, notification, setSchema, setAtomsMap); + + // Update the submit button condition + const canSubmit = + !errors?.length && !loading && !readOnly && selectedFile && formValidation.isValid; + + // Show success page if upload was successful + if (isSuccess) { + return ( +
+
+
+ ✓ Morphology created successfully +
+ +
+
+ ); + } + + if (!schema) { + return ( +
+ +
+ ); + } + + return ( +
+
+
+
+
+ {/* ... form validation */} +
Assets
+
{ + setConfigTab('assets'); + setEditing(true); + setSelectedCategory(''); + setSelectedItemIdx(null); + }} + onKeyDown={(e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + setConfigTab('assets'); + setEditing(true); + setSelectedCategory(''); + setSelectedItemIdx(null); + } + }} + > + Assets +
+ {selectedFile ? ( + <> + + + + ) : ( + <> + + + + )} +
+
+ {CATEGORIES.filter((c) => c !== 'Assets').map((c) => ( + +
{c}
+ {schema.properties && + Object.entries(schema.properties) + .filter(([k]) => k !== 'type' && ORDERING[k]?.category === c) + .sort((a, b) => { + const order = (k: string) => ORDERING[k]?.order ?? 999; + return order(a[0]) - order(b[0]); + }) + .map(([k, v]) => ( +
+ ))} + + ))} +
+ {!readOnly && ( + + )} +
+
+ {configTab === 'assets' && editing && ( +
+
+ UPLOAD MORPHOLOGY FILE +
+
+ One reference file should be loaded. +
+
+
+ + {fileStatus?.message && ( + {fileStatus.message} + )} +
+
+
+ )} + {schema.properties && + schema.properties?.[configTab]?.additionalProperties?.anyOf && + !selectedCategory && + editing && + configTab !== 'assets' && ( +
+ {schema.properties[configTab].additionalProperties.anyOf.map((o) => ( +
{ + if (isRootCategory(schema, configTab)) return; + setSelectedCategory(o.properties?.type.const ?? ''); + const initial: Record = {}; + if (o.properties) { + Object.entries(o.properties).forEach(([subkey, subValue]) => { + initial[subkey] = + subkey === 'type' + ? (subValue.const ?? null) + : (subValue.default ?? null); + }); + } + const itemIndexes = Object.keys(atomsMap[configTab]).map((subkey) => + parseInt(subkey.split('_')[1], 10) + ); + itemIndexes.sort((a, b) => a - b); + const itemIdx = (itemIndexes.at(-1) ?? -1) + 1; + setSelectedItemIdx(itemIdx); + setAtomsMap({ + ...atomsMap, + [configTab]: { + ...atomsMap[configTab], + [resolveKey(schema, configTab, itemIdx)]: + atom>(initial), + }, + }); + }} + onKeyDown={(e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + if (isRootCategory(schema, configTab)) return; + setSelectedCategory(o.properties?.type.const ?? ''); + const initial: Record = {}; + if (o.properties) { + Object.entries(o.properties).forEach(([subkey, subValue]) => { + initial[subkey] = + subkey === 'type' + ? (subValue.const ?? null) + : (subValue.default ?? null); + }); + } + const itemIndexes = Object.keys(atomsMap[configTab]).map((subkey) => + parseInt(subkey.split('_')[1], 10) + ); + itemIndexes.sort((a, b) => a - b); + const itemIdx = (itemIndexes.at(-1) ?? -1) + 1; + setSelectedItemIdx(itemIdx); + setAtomsMap({ + ...atomsMap, + [configTab]: { + ...atomsMap[configTab], + [resolveKey(schema, configTab, itemIdx)]: + atom>(initial), + }, + }); + } + }} + > +
{o.title}
+
{o.description}
+
+ ))} +
+ )} + {schema.properties && + schema.properties?.[configTab] && + editing && + (isRootCategory(schema, configTab) || selectedCatSchema) && + configTab !== 'assets' && ( + + )} +
+
+ {showConfig && ( +
+
+
+

New Record JSON

+ +
+ +
+              {JSON.stringify(newJsonPayload, null, 2)}
+            
+ +
+ +
+
+
+ )} +
+ ); +} diff --git a/src/features/contribute/small-microcircuit.module.css b/src/features/contribute/small-microcircuit.module.css new file mode 100755 index 000000000..0a4777633 --- /dev/null +++ b/src/features/contribute/small-microcircuit.module.css @@ -0,0 +1,32 @@ +div.threeColumns { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 2fr); + gap: 5px; + height: calc(100% - 100px); +} + +div.threeColumns > div { + min-height: 0; + height: 100%; +} + +div.threeColumns > div.scrollable { + overflow-y: auto; + overflow-x: visible; +} + +div.threeColumns > div:first-child { + display: grid; + grid-template-rows: 1fr auto; + gap: 1em; + overflow: unset; +} + +header.header { + display: flex; + flex-wrap: nowrap; + flex-direction: row; + justify-content: space-between; + align-items: center; + gap: 1em; +} diff --git a/src/features/contribute/types.ts b/src/features/contribute/types.ts new file mode 100755 index 000000000..2217e8e2b --- /dev/null +++ b/src/features/contribute/types.ts @@ -0,0 +1,35 @@ +import { atom } from 'jotai'; + +import { ConfigValue } from './_components/components'; +import { CircuitSimulationExecutionStatus } from '@/api/entitycore/types/entities/circuit-simulation-execution'; + +export type JSONSchema = { + type?: 'string' | 'number' | 'integer' | 'object' | 'array' | 'boolean' | 'null'; + properties?: { [key: string]: JSONSchema }; + items?: JSONSchema | JSONSchema[]; + required?: string[]; + enum?: any[]; + const?: string; + additionalProperties?: JSONSchema; + oneOf?: JSONSchema[]; + anyOf?: JSONSchema[]; + allOf?: JSONSchema[]; + not?: JSONSchema; + format?: string; + title?: string; + description?: string; + default?: any; + examples?: any[]; + [key: string]: any; + singular_name?: string; +}; + +export interface AtomsMap { + [key: string]: + | ReturnType>> + | Record>>>; +} + +export type TabType = 'configuration' | 'simulations'; + +export type SimExecStatusMap = Map; diff --git a/src/page-wrappers/build/me-model/configure.tsx b/src/page-wrappers/build/me-model/configure.tsx index 6a196af8a..0b7408feb 100644 --- a/src/page-wrappers/build/me-model/configure.tsx +++ b/src/page-wrappers/build/me-model/configure.tsx @@ -49,8 +49,7 @@ function Header({ stateId, virtualLabId, projectId }: WorkspaceContext & { state const contributors = useAtomValue(virtualLabProjectUsersAtomFamily({ projectId, virtualLabId })) ?.data?.users; - const { mmodel } = sessionValue; - const { emodel } = sessionValue; + const { mmodel, emodel } = sessionValue; const fields = [ { className: 'col-span-6', @@ -125,8 +124,7 @@ function CustomButton({ className, 'bg-primary-9 h-14 rounded-none border border-white px-14 text-white', 'hover:border-primary-8! hover:bg-primary-8! hover:border! hover:font-bold hover:text-white! hover:shadow-xs', - 'disabled:border-gray-400 disabled:bg-white! disabled:text-gray-700! disabled:hover:text-gray-700!', - 'disabled:hover:border-gray-400! disabled:hover:bg-white! disabled:hover:text-gray-700!' + 'disabled:border-gray-400 disabled:bg-white! disabled:text-gray-700! disabled:hover:border-gray-400! disabled:hover:bg-white! disabled:hover:text-gray-700!' )} type="default" size="large" @@ -178,7 +176,7 @@ export default function Configure({ ctx, searchParams }: Props) { return; } - const showErrorNotification = (error: any, type: 'validation' | 'http') => { + const showErrorNotification = (error: unknown, type: 'validation' | 'http') => { let message = messages.DefaultErrorMsg; if (type === 'http') message = @@ -253,6 +251,7 @@ export default function Configure({ ctx, searchParams }: Props) { } catch (runAnalysisError) { const message = messages.RunAnalysisError; notification.error({ message, duration: 20 }); + console.error(runAnalysisError); } refreshDataAtom();