From 656e966edf9face5139da0557642f6e65d82d345 Mon Sep 17 00:00:00 2001 From: bilalesi Date: Wed, 27 Aug 2025 18:33:24 +0200 Subject: [PATCH] add memodel and synaptome build --- package.json | 5 +- pnpm-lock.yaml | 23 +- src/api/entitycore/types/entities/me-model.ts | 2 +- .../types/entities/single-neuron-synaptome.ts | 60 +- .../[projectId]/workflows/_template.tsx | 6 - .../workflows/build/browse/[type]/page.tsx | 3 +- .../build/configure/memodel/page.tsx | 35 + .../single-neuron-synaptome/page.tsx | 35 + .../workflows/build/new/[type]/layout.tsx | 46 ++ .../workflows/build/new/[type]/page.tsx | 39 + .../[projectId]/workflows/build/page.tsx | 3 - .../[projectId]/workflows/page.tsx | 9 +- .../workflows/simulate/browse/[type]/page.tsx | 3 +- .../workflows/simulate/new/[type]/layout.tsx | 4 +- src/app/layout.tsx | 1 + .../FilterControls/FilterControls.tsx | 2 +- .../FilterControls.tsx | 2 +- src/components/icons/Settings.tsx | 4 +- src/components/icons/buttons.tsx | 11 + src/components/icons/index.ts | 2 +- src/components/neuron-viewer/index.tsx | 6 +- ...ons.tsx => neuron-viewer-with-actions.tsx} | 4 +- src/constants.tsx | 4 +- .../definitions/renderer.tsx | 2 +- .../simulation/paired-neurons-simulation.ts | 8 +- .../small-microcircuit-simulation.ts | 8 +- src/features/brain-region-dropdown/index.tsx | 204 +++++ .../brain-region-hierarchy/context.tsx | 2 +- .../me-model/build/create.state-session.tsx | 2 +- .../setup/advanced-simulation-config.tsx | 2 +- .../build/create.state-session.tsx | 2 +- .../build/phases/placement-config.tsx | 2 +- src/features/views/listing/browse-entity.tsx | 19 +- src/features/views/listing/browse-library.tsx | 1 - ...ionStorage.tsx => use-session-storage.tsx} | 0 src/styles/globals.css | 2 +- .../hooks/use-query-extended-entity-type.tsx | 29 +- src/ui/molecules/popover/index.tsx | 43 + .../data-table/elements/filter-controls.tsx | 37 +- .../data-table/elements/pagination.tsx | 2 +- src/ui/segments/data-table/index.tsx | 41 +- src/ui/segments/data-table/table.tsx | 29 +- src/ui/segments/explore/browse-link.tsx | 4 +- src/ui/segments/explore/entity-left-menu.tsx | 55 +- src/ui/segments/explore/entity-link-count.tsx | 91 ++- src/ui/segments/explore/helpers.ts | 64 +- src/ui/segments/mini-detail-view/index.tsx | 76 +- .../previews/me-model-preview.tsx | 24 +- src/ui/segments/project/activities/index.tsx | 103 +-- .../segments/project/bottom-nav-shortcuts.tsx | 2 +- .../workflows/build/memodel/e-model.tsx | 201 +++++ .../workflows/build/memodel/header.tsx | 19 + .../workflows/build/memodel/helpers.tsx | 57 ++ .../workflows/build/memodel/index.tsx | 53 ++ .../workflows/build/memodel/m-model.tsx | 144 ++++ .../segments/workflows/build/memodel/menu.tsx | 237 ++++++ .../workflows/build/memodel/overview.tsx | 83 ++ .../build/single-neuron-synaptome/header.tsx | 19 + .../build/single-neuron-synaptome/helpers.tsx | 72 ++ .../build/single-neuron-synaptome/index.tsx | 37 + .../single-neuron-synaptome/me-model.tsx | 76 ++ .../build/single-neuron-synaptome/menu.tsx | 398 ++++++++++ .../single-neuron-synaptome/overview.tsx | 81 ++ .../synapse-configuration.tsx | 35 + .../synapse-set-item.tsx | 750 ++++++++++++++++++ .../synapse-set-menu-item.tsx | 265 +++++++ .../elements/browse-build-action.tsx | 44 + ...-action.tsx => browse-simulate-action.tsx} | 0 .../workflows/elements/build-breadcrumb.tsx | 65 ++ .../segments/workflows/elements/helpers.tsx | 3 + ...breadcrumb.tsx => simulate-breadcrumb.tsx} | 3 +- src/ui/segments/workspaces/top-menu-nav.tsx | 24 +- src/ui/use-query-keys/data.tsx | 9 +- src/ui/use-query-keys/workspace.tsx | 4 +- src/util/date.tsx | 12 + src/utils/format.ts | 9 + 76 files changed, 3499 insertions(+), 364 deletions(-) delete mode 100644 src/app/app/v2/[virtualLabId]/[projectId]/workflows/_template.tsx create mode 100644 src/app/app/v2/[virtualLabId]/[projectId]/workflows/build/configure/memodel/page.tsx create mode 100644 src/app/app/v2/[virtualLabId]/[projectId]/workflows/build/configure/single-neuron-synaptome/page.tsx create mode 100644 src/app/app/v2/[virtualLabId]/[projectId]/workflows/build/new/[type]/layout.tsx create mode 100644 src/app/app/v2/[virtualLabId]/[projectId]/workflows/build/new/[type]/page.tsx delete mode 100644 src/app/app/v2/[virtualLabId]/[projectId]/workflows/build/page.tsx rename src/components/neuron-viewer/{NeuronViewerWithActions.tsx => neuron-viewer-with-actions.tsx} (97%) create mode 100644 src/features/brain-region-dropdown/index.tsx rename src/hooks/{useSessionStorage.tsx => use-session-storage.tsx} (100%) create mode 100644 src/ui/molecules/popover/index.tsx create mode 100644 src/ui/segments/workflows/build/memodel/e-model.tsx create mode 100644 src/ui/segments/workflows/build/memodel/header.tsx create mode 100644 src/ui/segments/workflows/build/memodel/helpers.tsx create mode 100644 src/ui/segments/workflows/build/memodel/index.tsx create mode 100644 src/ui/segments/workflows/build/memodel/m-model.tsx create mode 100644 src/ui/segments/workflows/build/memodel/menu.tsx create mode 100644 src/ui/segments/workflows/build/memodel/overview.tsx create mode 100644 src/ui/segments/workflows/build/single-neuron-synaptome/header.tsx create mode 100644 src/ui/segments/workflows/build/single-neuron-synaptome/helpers.tsx create mode 100644 src/ui/segments/workflows/build/single-neuron-synaptome/index.tsx create mode 100644 src/ui/segments/workflows/build/single-neuron-synaptome/me-model.tsx create mode 100644 src/ui/segments/workflows/build/single-neuron-synaptome/menu.tsx create mode 100644 src/ui/segments/workflows/build/single-neuron-synaptome/overview.tsx create mode 100644 src/ui/segments/workflows/build/single-neuron-synaptome/synapse-configuration.tsx create mode 100644 src/ui/segments/workflows/build/single-neuron-synaptome/synapse-set-item.tsx create mode 100644 src/ui/segments/workflows/build/single-neuron-synaptome/synapse-set-menu-item.tsx create mode 100644 src/ui/segments/workflows/elements/browse-build-action.tsx rename src/ui/segments/workflows/elements/{browse-action.tsx => browse-simulate-action.tsx} (100%) create mode 100644 src/ui/segments/workflows/elements/build-breadcrumb.tsx rename src/ui/segments/workflows/elements/{simulation-breadcrumb.tsx => simulate-breadcrumb.tsx} (98%) diff --git a/package.json b/package.json index 2ad006b79..221f35632 100644 --- a/package.json +++ b/package.json @@ -44,10 +44,10 @@ "@portabletext/react": "^3.2.0", "@radix-ui/react-accordion": "^1.1.1", "@radix-ui/react-checkbox": "^1.0.3", - "@radix-ui/react-dialog": "^1.0.4", + "@radix-ui/react-dialog": "^1.1.7", "@radix-ui/react-dropdown-menu": "^2.0.5", "@radix-ui/react-menubar": "^1.1.6", - "@radix-ui/react-popover": "^1.0.5", + "@radix-ui/react-popover": "^1.1.7", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-slider": "^1.1.2", "@radix-ui/react-slot": "^1.2.3", @@ -67,6 +67,7 @@ "@t3-oss/env-nextjs": "^0.13.0", "@tanstack/react-query": "^5.84.1", "@tanstack/react-query-devtools": "^5.84.1", + "@tanstack/react-virtual": "^3.13.12", "@testing-library/user-event": "^14.5.1", "@tolokoban/tgd": "^2.0.33", "@types/d3": "^7.4.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e77716126..709c406ab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -63,7 +63,7 @@ importers: specifier: ^1.0.3 version: 1.1.5(@types/react-dom@19.1.1(@types/react@19.1.0))(@types/react@19.1.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-dialog': - specifier: ^1.0.4 + specifier: ^1.1.7 version: 1.1.7(@types/react-dom@19.1.1(@types/react@19.1.0))(@types/react@19.1.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-dropdown-menu': specifier: ^2.0.5 @@ -72,7 +72,7 @@ importers: specifier: ^1.1.6 version: 1.1.7(@types/react-dom@19.1.1(@types/react@19.1.0))(@types/react@19.1.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-popover': - specifier: ^1.0.5 + specifier: ^1.1.7 version: 1.1.7(@types/react-dom@19.1.1(@types/react@19.1.0))(@types/react@19.1.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-select': specifier: ^2.2.6 @@ -131,6 +131,9 @@ importers: '@tanstack/react-query-devtools': specifier: ^5.84.1 version: 5.84.2(@tanstack/react-query@5.84.2(react@19.1.0))(react@19.1.0) + '@tanstack/react-virtual': + specifier: ^3.13.12 + version: 3.13.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@testing-library/user-event': specifier: ^14.5.1 version: 14.6.1(@testing-library/dom@10.4.0) @@ -4537,8 +4540,8 @@ packages: react: '>=16.8' react-dom: '>=16.8' - '@tanstack/react-virtual@3.13.11': - resolution: {integrity: sha512-u5EaOSJOq08T9NXFuDopMdxZBNDFuEMohIFFU45fBYDXXh9SjYdbpNq1OLFSOpQnDRPjqgmY96ipZTkzom9t9Q==} + '@tanstack/react-virtual@3.13.12': + resolution: {integrity: sha512-Gd13QdxPSukP8ZrkbgS2RwoZseTTbQPLnQEn7HY/rqtM+8Zt95f7xKC7N0EsKs7aoz0WzZ+fditZux+F8EzYxA==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -4547,8 +4550,8 @@ packages: resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==} engines: {node: '>=12'} - '@tanstack/virtual-core@3.13.11': - resolution: {integrity: sha512-ORL6UyuZJ0D9X33LDR4TcgcM+K2YiS2j4xbvH1vnhhObwR1Z4dKwPTL/c0kj2Yeb4Yp2lBv1wpyVaqlohk8zpg==} + '@tanstack/virtual-core@3.13.12': + resolution: {integrity: sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA==} '@testing-library/dom@10.4.0': resolution: {integrity: sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==} @@ -16939,15 +16942,15 @@ snapshots: react: 19.1.0 react-dom: 19.1.0(react@19.1.0) - '@tanstack/react-virtual@3.13.11(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@tanstack/react-virtual@3.13.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: - '@tanstack/virtual-core': 3.13.11 + '@tanstack/virtual-core': 3.13.12 react: 19.1.0 react-dom: 19.1.0(react@19.1.0) '@tanstack/table-core@8.21.3': {} - '@tanstack/virtual-core@3.13.11': {} + '@tanstack/virtual-core@3.13.12': {} '@testing-library/dom@10.4.0': dependencies: @@ -24532,7 +24535,7 @@ snapshots: '@sanity/uuid': 3.0.2 '@sentry/react': 8.55.0(react@19.1.0) '@tanstack/react-table': 8.21.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@tanstack/react-virtual': 3.13.11(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@tanstack/react-virtual': 3.13.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@types/react-is': 19.0.0 '@types/shallow-equals': 1.0.3 '@types/speakingurl': 13.0.6 diff --git a/src/api/entitycore/types/entities/me-model.ts b/src/api/entitycore/types/entities/me-model.ts index 76f95973b..17195d063 100644 --- a/src/api/entitycore/types/entities/me-model.ts +++ b/src/api/entitycore/types/entities/me-model.ts @@ -84,7 +84,7 @@ export interface IMEModelFilter } export const CreateMEModelSchema = z.object({ - name: z.string(), + name: z.string().nonempty(), description: z.string(), validation_status: z.nativeEnum(ValidationStatus), brain_region_id: z.string().uuid(), diff --git a/src/api/entitycore/types/entities/single-neuron-synaptome.ts b/src/api/entitycore/types/entities/single-neuron-synaptome.ts index 317fe9137..1d4df30b6 100644 --- a/src/api/entitycore/types/entities/single-neuron-synaptome.ts +++ b/src/api/entitycore/types/entities/single-neuron-synaptome.ts @@ -71,8 +71,8 @@ const SingleNeuronSynaptomeExclusionRuleSchema = z }) .refine( (data) => { - if (!isNil(data.distance_soma_gte) || !isNil(data.distance_soma_lte)) return true; - return false; + if (isNil(data.distance_soma_gte) && isNil(data.distance_soma_lte)) return false; + return true; }, { message: 'At least one of distance_soma_gte or distance_soma_lte must be provided', @@ -80,48 +80,48 @@ const SingleNeuronSynaptomeExclusionRuleSchema = z } ); -export const SingleNeuronSynaptomeConfigurationSchema = z - .object({ - id: z.string().uuid(), - name: z.string(), - target: z.string().optional(), - seed: z.number(), - color: z.string(), - formula: z.string().optional(), - soma_synapse_count: z.number().optional(), - type: z.union([z.literal(110), z.literal(10)]), - exclusion_rules: z.array(SingleNeuronSynaptomeExclusionRuleSchema).nullable(), - }) - .superRefine((synapse, ctx) => { +export const SingleNeuronSynaptomeBaseSchema = z.object({ + id: z.string().uuid(), + name: z.string().nonempty(), + target: z.string().optional(), + seed: z.number(), + color: z.string(), + formula: z.string().optional(), + soma_synapse_count: z.number().optional(), + type: z.union([z.literal(110), z.literal(10)]), + exclusion_rules: z.array(SingleNeuronSynaptomeExclusionRuleSchema).nullable(), +}); + +export const SingleNeuronSynaptomeConfigurationSchema = SingleNeuronSynaptomeBaseSchema.superRefine( + (synapse, ctx) => { if (synapse.target !== 'soma' && isNil(synapse.formula)) { - return ctx.addIssue({ + ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'formula should be provided when target is different then "soma"', path: ['formula'], }); } + if (synapse.target === 'soma' && isNil(synapse.soma_synapse_count)) { - return ctx.addIssue({ + ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'soma_synapse_count must be a valid number when target is "soma"', path: ['soma_synapse_count'], }); } - }) - .superRefine(async (synapse, ctx) => { - if (synapse.target !== 'soma') { - return validateSingleNeuronSynapseGenerationFormula(synapse.formula!).then((v) => { - if (!v) { - return ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'formula is not valid', - path: ['formula'], - }); - } + } +).superRefine(async (synapse, ctx) => { + if (synapse.target !== 'soma') { + const v = await validateSingleNeuronSynapseGenerationFormula(synapse.formula!); + if (!v) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'formula is not valid', + path: ['formula'], }); } - return true; - }); + } +}); export type TSingleNeuronSynaptomeConfiguration = z.infer< typeof SingleNeuronSynaptomeConfigurationSchema diff --git a/src/app/app/v2/[virtualLabId]/[projectId]/workflows/_template.tsx b/src/app/app/v2/[virtualLabId]/[projectId]/workflows/_template.tsx deleted file mode 100644 index 262b996e9..000000000 --- a/src/app/app/v2/[virtualLabId]/[projectId]/workflows/_template.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import { ReactNode } from 'react'; -import { PageTransition } from '@/ui/segments/workflows/page-transition'; - -export default function Template({ children }: { children: ReactNode }) { - return {children}; -} diff --git a/src/app/app/v2/[virtualLabId]/[projectId]/workflows/build/browse/[type]/page.tsx b/src/app/app/v2/[virtualLabId]/[projectId]/workflows/build/browse/[type]/page.tsx index 874837611..32daefe2f 100644 --- a/src/app/app/v2/[virtualLabId]/[projectId]/workflows/build/browse/[type]/page.tsx +++ b/src/app/app/v2/[virtualLabId]/[projectId]/workflows/build/browse/[type]/page.tsx @@ -1,6 +1,6 @@ import snakeCase from 'lodash/snakeCase'; -import { BrowseAction } from '@/ui/segments/workflows/elements/browse-action'; +import { BrowseAction } from '@/ui/segments/workflows/elements/browse-build-action'; import { BrowseEntityScope } from '@/features/views/listing/browse-entity'; import { WorkspaceScope, WorkspaceSection } from '@/constants'; @@ -31,6 +31,7 @@ export default async function Page({ classNames={{ container: 'max-h-full' }} dataType={dataType} scope={scope ?? WorkspaceScope.Public} + miniViewProps={{ section: WorkspaceSection.BuildWorkflow }} />
diff --git a/src/app/app/v2/[virtualLabId]/[projectId]/workflows/build/configure/memodel/page.tsx b/src/app/app/v2/[virtualLabId]/[projectId]/workflows/build/configure/memodel/page.tsx new file mode 100644 index 000000000..559f4dec4 --- /dev/null +++ b/src/app/app/v2/[virtualLabId]/[projectId]/workflows/build/configure/memodel/page.tsx @@ -0,0 +1,35 @@ +import { Header } from '@/ui/segments/workflows/build/memodel/header'; +import { Menu } from '@/ui/segments/workflows/build/memodel/menu'; +import { Content } from '@/ui/segments/workflows/build/memodel'; + +import type { BuildStepKeys } from '@/ui/segments/workflows/build/memodel/helpers'; +import type { ServerSideComponentProp, WorkspaceContext } from '@/types/common'; + +export default async function Page({ + searchParams, +}: ServerSideComponentProp< + WorkspaceContext & { id: string }, + { step: BuildStepKeys; sessionId: string } +>) { + let { sessionId } = await searchParams; + if (!sessionId) sessionId = crypto.randomUUID(); + + return ( +
+
+
+
+
+ +
+ +
+
+
+ ); +} diff --git a/src/app/app/v2/[virtualLabId]/[projectId]/workflows/build/configure/single-neuron-synaptome/page.tsx b/src/app/app/v2/[virtualLabId]/[projectId]/workflows/build/configure/single-neuron-synaptome/page.tsx new file mode 100644 index 000000000..7c6bf988f --- /dev/null +++ b/src/app/app/v2/[virtualLabId]/[projectId]/workflows/build/configure/single-neuron-synaptome/page.tsx @@ -0,0 +1,35 @@ +import { Header } from '@/ui/segments/workflows/build/single-neuron-synaptome/header'; +import { Menu } from '@/ui/segments/workflows/build/single-neuron-synaptome/menu'; +import { Content } from '@/ui/segments/workflows/build/single-neuron-synaptome'; + +import type { BuildStepKeys } from '@/ui/segments/workflows/build/single-neuron-synaptome/helpers'; +import type { ServerSideComponentProp, WorkspaceContext } from '@/types/common'; + +export default async function Page({ + searchParams, +}: ServerSideComponentProp< + WorkspaceContext & { id: string }, + { step: BuildStepKeys; sessionId: string } +>) { + let { sessionId } = await searchParams; + if (!sessionId) sessionId = crypto.randomUUID(); + + return ( +
+
+
+
+
+ +
+ +
+
+
+ ); +} diff --git a/src/app/app/v2/[virtualLabId]/[projectId]/workflows/build/new/[type]/layout.tsx b/src/app/app/v2/[virtualLabId]/[projectId]/workflows/build/new/[type]/layout.tsx new file mode 100644 index 000000000..4af3bfb8d --- /dev/null +++ b/src/app/app/v2/[virtualLabId]/[projectId]/workflows/build/new/[type]/layout.tsx @@ -0,0 +1,46 @@ +'use client'; + +import { type ReactNode, useState } from 'react'; +import { motion } from 'motion/react'; + +import { BuildWorkflowsBreadcrumb } from '@/ui/segments/workflows/elements/build-breadcrumb'; +import { useDisableElementOverflow } from '@/ui/hooks/use-disable-element-overflow'; +import { useSelectEntityClickEvent } from '@/ui/segments/mini-detail-view/event'; +import { cn } from '@/utils/css-class'; + +export default function Layout({ children }: { children: ReactNode }) { + const [miniViewPresent, setMiniViewPresent] = useState(false); + useDisableElementOverflow({ id: 'workspace-body' }); + useSelectEntityClickEvent((ev) => { + setMiniViewPresent(ev.detail.display); + }); + + return ( +
+ + + {children} + +
+ ); +} diff --git a/src/app/app/v2/[virtualLabId]/[projectId]/workflows/build/new/[type]/page.tsx b/src/app/app/v2/[virtualLabId]/[projectId]/workflows/build/new/[type]/page.tsx new file mode 100644 index 000000000..00293ca8d --- /dev/null +++ b/src/app/app/v2/[virtualLabId]/[projectId]/workflows/build/new/[type]/page.tsx @@ -0,0 +1,39 @@ +import snakeCase from 'lodash/snakeCase'; + +import { BrowseEntityScope } from '@/features/views/listing/browse-entity'; +import { WorkspaceScope, WorkspaceSection } from '@/constants'; + +import type { TExtendedEntitiesTypeDict } from '@/api/entitycore/types/extended-entity-type'; +import type { ServerSideComponentProp, WorkspaceContext } from '@/types/common'; +import type { TWorkspaceScope } from '@/constants'; +import type { KebabCase } from '@/utils/type'; + +export default async function Page({ + params, + searchParams, +}: ServerSideComponentProp< + WorkspaceContext & { type: KebabCase }, + { scope: TWorkspaceScope | null } +>) { + const { scope } = await searchParams; + const { type } = await params; + + const dataType = snakeCase(type) as TExtendedEntitiesTypeDict; + + return ( + + ); +} diff --git a/src/app/app/v2/[virtualLabId]/[projectId]/workflows/build/page.tsx b/src/app/app/v2/[virtualLabId]/[projectId]/workflows/build/page.tsx deleted file mode 100644 index db6413644..000000000 --- a/src/app/app/v2/[virtualLabId]/[projectId]/workflows/build/page.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function Page() { - return
Build main page
; -} diff --git a/src/app/app/v2/[virtualLabId]/[projectId]/workflows/page.tsx b/src/app/app/v2/[virtualLabId]/[projectId]/workflows/page.tsx index 997fb60cb..82c315aee 100644 --- a/src/app/app/v2/[virtualLabId]/[projectId]/workflows/page.tsx +++ b/src/app/app/v2/[virtualLabId]/[projectId]/workflows/page.tsx @@ -12,7 +12,7 @@ import { V2_MIGRATION_TEMPORARY_BASE_PATH } from '@/config'; import type { TExtendedEntitiesTypeDict } from '@/api/entitycore/types/extended-entity-type'; import type { ServerSideComponentProp, WorkspaceContext } from '@/types/common'; -import type { TCategoryValue } from '@/ui/segments/workflows/elements/helpers'; +import { CategoryValues, type TCategoryValue } from '@/ui/segments/workflows/elements/helpers'; export default function Page({ params }: ServerSideComponentProp) { useDisableElementOverflow({ id: 'workspace-body' }); @@ -32,6 +32,13 @@ export default function Page({ params }: ServerSideComponentProp { updateWorkflowState((prev) => ({ ...prev, entityType: value })); + if (category === CategoryValues.Build) { + const sessionId = crypto.randomUUID(); + push( + `${V2_MIGRATION_TEMPORARY_BASE_PATH}/${virtualLabId}/${projectId}/workflows/${category}/configure/${kebabCase(value)}?sessionId=${sessionId}` + ); + return; + } push( `${V2_MIGRATION_TEMPORARY_BASE_PATH}/${virtualLabId}/${projectId}/workflows/${category}/browse/${kebabCase(value)}` ); diff --git a/src/app/app/v2/[virtualLabId]/[projectId]/workflows/simulate/browse/[type]/page.tsx b/src/app/app/v2/[virtualLabId]/[projectId]/workflows/simulate/browse/[type]/page.tsx index 99083c9e2..907efda95 100644 --- a/src/app/app/v2/[virtualLabId]/[projectId]/workflows/simulate/browse/[type]/page.tsx +++ b/src/app/app/v2/[virtualLabId]/[projectId]/workflows/simulate/browse/[type]/page.tsx @@ -1,6 +1,6 @@ import snakeCase from 'lodash/snakeCase'; -import { BrowseAction } from '@/ui/segments/workflows/elements/browse-action'; +import { BrowseAction } from '@/ui/segments/workflows/elements/browse-simulate-action'; import { BrowseEntityScope } from '@/features/views/listing/browse-entity'; import { WorkspaceScope, WorkspaceSection } from '@/constants'; @@ -31,6 +31,7 @@ export default async function Page({ classNames={{ container: 'max-h-full' }} dataType={dataType} scope={scope ?? WorkspaceScope.Public} + miniViewProps={{ section: WorkspaceSection.SimulateWorkflow }} />
diff --git a/src/app/app/v2/[virtualLabId]/[projectId]/workflows/simulate/new/[type]/layout.tsx b/src/app/app/v2/[virtualLabId]/[projectId]/workflows/simulate/new/[type]/layout.tsx index 09cc79708..430979979 100644 --- a/src/app/app/v2/[virtualLabId]/[projectId]/workflows/simulate/new/[type]/layout.tsx +++ b/src/app/app/v2/[virtualLabId]/[projectId]/workflows/simulate/new/[type]/layout.tsx @@ -3,7 +3,7 @@ import { type ReactNode, useState } from 'react'; import { motion } from 'motion/react'; -import { SimulationBreadcrumb } from '@/ui/segments/workflows/elements/simulation-breadcrumb'; +import { SimulateWorkflowsBreadcrumb } from '@/ui/segments/workflows/elements/simulate-breadcrumb'; import { useDisableElementOverflow } from '@/ui/hooks/use-disable-element-overflow'; import { useSelectEntityClickEvent } from '@/ui/segments/mini-detail-view/event'; import { cn } from '@/utils/css-class'; @@ -17,7 +17,7 @@ export default function Layout({ children }: { children: ReactNode }) { return (
- + {children} diff --git a/src/components/FilterControls/FilterControls.tsx b/src/components/FilterControls/FilterControls.tsx index 4a2e3a6c8..dc1e04f21 100644 --- a/src/components/FilterControls/FilterControls.tsx +++ b/src/components/FilterControls/FilterControls.tsx @@ -1,6 +1,6 @@ import { HTMLProps, PropsWithChildren, useState } from 'react'; import ControlPanel from './ControlPanel'; -import SettingsIcon from '@/components/icons/Settings'; +import { SettingsIcon } from '@/components/icons/Settings'; import { classNames } from '@/util/utils'; export default function FilterControls({ diff --git a/src/components/explore-section/ExploreSectionListingView/FilterControls.tsx b/src/components/explore-section/ExploreSectionListingView/FilterControls.tsx index 602b2568b..8330aab59 100644 --- a/src/components/explore-section/ExploreSectionListingView/FilterControls.tsx +++ b/src/components/explore-section/ExploreSectionListingView/FilterControls.tsx @@ -14,11 +14,11 @@ import { unwrap } from 'jotai/utils'; import { Spin } from 'antd'; import ExploreSectionNameSearch from '@/components/explore-section/ExploreSectionListingView/ExploreSectionNameSearch'; -import SettingsIcon from '@/components/icons/Settings'; import { activeColumnsAtom } from '@/state/explore-section/list-view-atoms'; import { ExploreDataScope } from '@/types/explore-section/application'; import { filterHasValue } from '@/features/listing-filter-panel/util'; +import { SettingsIcon } from '@/components/icons/Settings'; import { classNames } from '@/util/utils'; import type { TExtendedEntitiesTypeDict } from '@/api/entitycore/types/extended-entity-type'; diff --git a/src/components/icons/Settings.tsx b/src/components/icons/Settings.tsx index d4efa7220..9ca252af3 100644 --- a/src/components/icons/Settings.tsx +++ b/src/components/icons/Settings.tsx @@ -6,7 +6,7 @@ type SettingsIconProps = { fill?: string; }; -export default function SettingsIcon({ className, style, fill }: SettingsIconProps) { +export function SettingsIcon({ className, style, fill }: SettingsIconProps) { return ( ); } + +export default SettingsIcon; diff --git a/src/components/icons/buttons.tsx b/src/components/icons/buttons.tsx index 8d8d7d4d9..27d0c3e28 100644 --- a/src/components/icons/buttons.tsx +++ b/src/components/icons/buttons.tsx @@ -194,3 +194,14 @@ export function ArrowOpenRight(props: React.SVGProps) { ); } + +export function ArrowSyncFilled(props: React.SVGProps) { + return ( + + + + ); +} diff --git a/src/components/icons/index.ts b/src/components/icons/index.ts index dc86ee744..bc114a57d 100644 --- a/src/components/icons/index.ts +++ b/src/components/icons/index.ts @@ -24,10 +24,10 @@ import IconPlus from './Plus'; import RangeIcon from './Range'; import ReadMeIcon from './ReadMeIcon'; import ResetIcon from './ResetIcon'; -import SettingsIcon from './Settings'; import UndoIcon from './UndoIcon'; import UserIcon from './UserIcon'; import VirtualLabIcon from './VirtualLab'; +import { SettingsIcon } from './Settings'; import { ZoomInIcon, ZoomOutIcon } from './Zoom'; export { diff --git a/src/components/neuron-viewer/index.tsx b/src/components/neuron-viewer/index.tsx index 229ffcc24..6cab1b36c 100644 --- a/src/components/neuron-viewer/index.tsx +++ b/src/components/neuron-viewer/index.tsx @@ -135,14 +135,14 @@ export default function NeuronViewer({ } return ( -
+
{loading && (
)}
{children?.({ useZoomer, diff --git a/src/components/neuron-viewer/NeuronViewerWithActions.tsx b/src/components/neuron-viewer/neuron-viewer-with-actions.tsx similarity index 97% rename from src/components/neuron-viewer/NeuronViewerWithActions.tsx rename to src/components/neuron-viewer/neuron-viewer-with-actions.tsx index 7f068aeb3..1fc2f855c 100644 --- a/src/components/neuron-viewer/NeuronViewerWithActions.tsx +++ b/src/components/neuron-viewer/neuron-viewer-with-actions.tsx @@ -24,7 +24,7 @@ type Props = { virtualLabId: string; projectId: string; }; -export default function NeuronViewerContainer({ +export function NeuronViewerContainer({ meModelId, zoomPlacement = 'right', useZoomer = false, @@ -108,3 +108,5 @@ export default function NeuronViewerContainer({ ); } + +export default NeuronViewerContainer; diff --git a/src/constants.tsx b/src/constants.tsx index 76a9040e7..4a06055b5 100644 --- a/src/constants.tsx +++ b/src/constants.tsx @@ -13,7 +13,9 @@ export const WorkspaceScope = { Project: 'project', Bookmarks: 'bookmarks', Custom: 'custom', - BuildMeModel: 'build-me-model', + BuildMeModelM: 'build-me-model/m-model', + BuildMeModelE: 'build-me-model/e-model', + BuildSynaptomeModel: 'build-single-neuron-synaptome-model/memodel', } as const; export type TWorkspaceScope = (typeof WorkspaceScope)[keyof typeof WorkspaceScope]; diff --git a/src/entity-configuration/definitions/renderer.tsx b/src/entity-configuration/definitions/renderer.tsx index eb47c9856..44e708e7e 100644 --- a/src/entity-configuration/definitions/renderer.tsx +++ b/src/entity-configuration/definitions/renderer.tsx @@ -77,7 +77,7 @@ export const renderDictionaryKeys = ( ); }; -export const renderDate = (isoDateString: string) => { +export const renderDate = (isoDateString?: string | null) => { if (!isoDateString) return EmptyValue; return format(parseISO(isoDateString), 'dd.MM.yyyy'); }; diff --git a/src/entity-configuration/domain/simulation/paired-neurons-simulation.ts b/src/entity-configuration/domain/simulation/paired-neurons-simulation.ts index 8c35d1d44..2770e41be 100644 --- a/src/entity-configuration/domain/simulation/paired-neurons-simulation.ts +++ b/src/entity-configuration/domain/simulation/paired-neurons-simulation.ts @@ -32,12 +32,12 @@ async function resolveSimulationCampaigns({ withFacets, context, filters, - circuitFilter, + circuitScaleFilter, }: { withFacets?: boolean; context: WorkspaceContext | undefined; filters?: Partial; - circuitFilter?: Partial; + circuitScaleFilter?: Partial; }) { // eslint-disable-next-line no-param-reassign filters = discardBrainRegionQueryParams(filters); @@ -80,7 +80,7 @@ async function resolveSimulationCampaigns({ const circuits = await getCircuits({ context, - filters: { id__in: source.data.map((l) => l.entity_id), ...circuitFilter }, + filters: { id__in: source.data.map((l) => l.entity_id), ...circuitScaleFilter }, }); const circuitMap = keyBy(circuits.data, 'id'); const result = enrichedData.map((entity) => ({ @@ -143,7 +143,7 @@ export const PairedNeuronCircuitSimulation: EntityCoreTypeConfig[0]) => resolveSimulationCampaigns({ ...params, - circuitFilter: { + circuitScaleFilter: { scale: 'pair', }, }), diff --git a/src/entity-configuration/domain/simulation/small-microcircuit-simulation.ts b/src/entity-configuration/domain/simulation/small-microcircuit-simulation.ts index 20b8b4320..2e31db369 100644 --- a/src/entity-configuration/domain/simulation/small-microcircuit-simulation.ts +++ b/src/entity-configuration/domain/simulation/small-microcircuit-simulation.ts @@ -33,12 +33,12 @@ async function resolveSimulationCampaigns({ withFacets, context, filters, - circuitFilter, + circuitScaleFilter, }: { withFacets?: boolean; context: WorkspaceContext | undefined; filters?: Partial; - circuitFilter?: Partial; + circuitScaleFilter?: Partial; }) { // eslint-disable-next-line no-param-reassign filters = discardBrainRegionQueryParams(filters); @@ -80,7 +80,7 @@ async function resolveSimulationCampaigns({ const circuits = await getCircuits({ context, - filters: { id__in: source.data.map((l) => l.entity_id), ...circuitFilter }, + filters: { id__in: source.data.map((l) => l.entity_id), ...circuitScaleFilter }, }); const circuitMap = keyBy(circuits.data, 'id'); const result = enrichedData.map((entity) => ({ @@ -143,7 +143,7 @@ export const SmallMicrocircuitSimulation: EntityCoreTypeConfig[0]) => resolveSimulationCampaigns({ ...params, - circuitFilter: { + circuitScaleFilter: { scale: 'small', }, }), diff --git a/src/features/brain-region-dropdown/index.tsx b/src/features/brain-region-dropdown/index.tsx new file mode 100644 index 000000000..854776a09 --- /dev/null +++ b/src/features/brain-region-dropdown/index.tsx @@ -0,0 +1,204 @@ +import { CheckOutlined, DownOutlined, LoadingOutlined, SearchOutlined } from '@ant-design/icons'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import { loadable, unwrap } from 'jotai/utils'; +import { useAtomValue } from 'jotai'; +import omit from 'lodash/omit'; + +import { Popover, PopoverContent, PopoverTrigger } from '@/ui/molecules/popover'; +import { useDefaultBreakpoint } from '@/ui/hooks/create-break-point'; +import { + brainRegionBasicCellGroupsRegionsHierarchyAtom, + useBrainRegionHierarchy, + useSetSelectedBrainRegion, + type BrainRegionHierarchyOption, +} from '@/features/brain-region-hierarchy/context'; +import { Button } from '@/ui/molecules/button'; +import { BrainIcon } from '@/components/icons'; +import { cn } from '@/utils/css-class'; + +export function BrainRegionDropdown({ dataKey }: { dataKey: string }) { + const breakpoint = useDefaultBreakpoint(); + const [open, setOpen] = useState(false); + const [searchTerm, setSearchTerm] = useState(''); + const [parent, setParent] = useState(null); + + const brainRegionHierarchy = useAtomValue( + useMemo(() => unwrap(brainRegionBasicCellGroupsRegionsHierarchyAtom), []) + ); + const isLoading = + useAtomValue(loadable(brainRegionBasicCellGroupsRegionsHierarchyAtom)).state === 'loading'; + + const { updateSelectedBrainRegion } = useSetSelectedBrainRegion(); + + const { node, updateHierarchyConfig } = useBrainRegionHierarchy({ + dataKey, + }); + + const parentSetter = useCallback( + (el: HTMLDivElement) => { + setParent(el); + }, + [setParent] + ); + + const filteredOptions = useMemo>(() => { + const options = (brainRegionHierarchy?.options ?? []) as Array; + if (!searchTerm.trim()) return options; + + return options.filter(({ label }) => label.toLowerCase().includes(searchTerm.toLowerCase())); + }, [brainRegionHierarchy, searchTerm]); + + const rowVirtualizer = useVirtualizer({ + count: filteredOptions.length, + enabled: open && filteredOptions.length > 0 && !!parent, + useAnimationFrameWithResizeObserver: true, + getScrollElement: () => parent, + estimateSize: (index) => { + const option = filteredOptions[index]; + if (!option) return 40; + const charsPerLine = 25; + const lineHeight = 24; + const lines = Math.ceil(option.label.length / charsPerLine); + return lines * lineHeight + 16; + }, + overscan: 5, + paddingEnd: 10, + }); + + useEffect(() => { + let id: number; + if (open && parent) { + id = requestAnimationFrame(() => { + rowVirtualizer.measure(); + }); + } + return () => { + if (id) { + cancelAnimationFrame(id); + } + }; + }, [open, rowVirtualizer, parent]); + + const onSelect = (option: BrainRegionHierarchyOption) => { + updateHierarchyConfig(option.data); + updateSelectedBrainRegion(omit(option.data, 'children')); + setOpen(false); + }; + + const currentValue = (v: string) => + brainRegionHierarchy?.options.find(({ value: _value }) => v === _value); + + const handleOpenChange = (newOpen: boolean) => { + setOpen(newOpen); + if (!newOpen) { + setSearchTerm(''); + } + }; + + return ( + + + + + + +
+
+ + setSearchTerm(e.target.value)} + className={cn( + 'border-none', + 'placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50', + { 'h-9 text-base': breakpoint === 'l' }, + { 'h-10 text-lg': breakpoint === 'xl' } + )} + /> +
+
+ +
+ {filteredOptions.length === 0 ? ( +
+ No brain regions found +
+ ) : ( +
+ {rowVirtualizer.getVirtualItems().map((virtualItem) => { + const option = filteredOptions[virtualItem.index]; + if (!option) return null; + + const { value: v, label, data } = option; + + return ( +
+ +
+ ); + })} +
+ )} +
+
+
+ ); +} diff --git a/src/features/brain-region-hierarchy/context.tsx b/src/features/brain-region-hierarchy/context.tsx index 18013ace7..f9a33b19d 100644 --- a/src/features/brain-region-hierarchy/context.tsx +++ b/src/features/brain-region-hierarchy/context.tsx @@ -50,7 +50,7 @@ export const DEFAULT_BRAIN_REGION_ANNOTATION_FIELD = 'annotation_value'; export const DEFAULT_BRAIN_REGION_QUERY_ID = 'br_id'; export const DEFAULT_BRAIN_REGION_QUERY_ANNOTATION_VALUE = 'br_av'; -type BrainRegionHierarchyOption = { +export type BrainRegionHierarchyOption = { value: string; label: string; data: IBrainRegionHierarchy; diff --git a/src/features/entities/me-model/build/create.state-session.tsx b/src/features/entities/me-model/build/create.state-session.tsx index faa5bf1cb..6c66d5711 100644 --- a/src/features/entities/me-model/build/create.state-session.tsx +++ b/src/features/entities/me-model/build/create.state-session.tsx @@ -1,4 +1,4 @@ -import { useSessionStorage } from '@/hooks/useSessionStorage'; +import { useSessionStorage } from '@/hooks/use-session-storage'; import type { IReconstructionMorphology } from '@/api/entitycore/types/entities/reconstruction-morphology'; import type { BrainRegionHierarchyBase } from '@/api/entitycore/types/entities/brain-region'; diff --git a/src/features/entities/neuron-simulation/experiment/setup/advanced-simulation-config.tsx b/src/features/entities/neuron-simulation/experiment/setup/advanced-simulation-config.tsx index 4c5cc9fa5..44cb27d08 100644 --- a/src/features/entities/neuron-simulation/experiment/setup/advanced-simulation-config.tsx +++ b/src/features/entities/neuron-simulation/experiment/setup/advanced-simulation-config.tsx @@ -2,9 +2,9 @@ import { useParams } from 'next/navigation'; -import NeuronViewerContainer from '@/components/neuron-viewer/NeuronViewerWithActions'; import Wrapper from '@/features/entities/neuron-simulation/experiment/elements/wrapper'; import ParameterView from '@/features/entities/neuron-simulation/experiment/steps-wizard'; +import { NeuronViewerContainer } from '@/components/neuron-viewer/neuron-viewer-with-actions'; import type { SingleNeuronSynaptomePayload } from '@/features/entities/neuron-simulation/experiment/containers/synaptome'; import type { WorkspaceContext } from '@/types/common'; diff --git a/src/features/entities/single-neuron-synaptome/build/create.state-session.tsx b/src/features/entities/single-neuron-synaptome/build/create.state-session.tsx index a68d32c57..a08fc38c6 100644 --- a/src/features/entities/single-neuron-synaptome/build/create.state-session.tsx +++ b/src/features/entities/single-neuron-synaptome/build/create.state-session.tsx @@ -1,7 +1,7 @@ import { parseAsString, useQueryStates } from 'nuqs'; import type { Parser } from 'nuqs'; -import { useSessionStorage } from '@/hooks/useSessionStorage'; +import { useSessionStorage } from '@/hooks/use-session-storage'; import type { WorkspaceContext } from '@/types/common'; import type { IMEModel } from '@/api/entitycore/types'; diff --git a/src/features/entities/single-neuron-synaptome/build/phases/placement-config.tsx b/src/features/entities/single-neuron-synaptome/build/phases/placement-config.tsx index c7c81c651..5861ae837 100644 --- a/src/features/entities/single-neuron-synaptome/build/phases/placement-config.tsx +++ b/src/features/entities/single-neuron-synaptome/build/phases/placement-config.tsx @@ -8,7 +8,7 @@ import DefaultLoadingSuspense from '@/components/DefaultLoadingSuspense'; import type { WorkspaceContext } from '@/types/common'; const NeuronViewerContainer = dynamic( - () => import('@/components/neuron-viewer/NeuronViewerWithActions'), + () => import('@/components/neuron-viewer/neuron-viewer-with-actions'), { ssr: false, } diff --git a/src/features/views/listing/browse-entity.tsx b/src/features/views/listing/browse-entity.tsx index d658f9558..1ae968da0 100644 --- a/src/features/views/listing/browse-entity.tsx +++ b/src/features/views/listing/browse-entity.tsx @@ -3,7 +3,7 @@ 'use client'; import { useAtom, useAtomValue, useSetAtom } from 'jotai'; -import { useState, type ComponentProps } from 'react'; +import { ReactElement, useState, type ComponentProps } from 'react'; import { WarningOutlined } from '@ant-design/icons'; import compact from 'lodash/compact'; import dynamic from 'next/dynamic'; @@ -32,10 +32,14 @@ import type { TExtendedEntitiesTypeDict } from '@/api/entitycore/types/extended- import type { EntityCoreIdentifiableNamed } from '@/api/entitycore/types/shared/global'; import type { EntityCoreResponse } from '@/api/entitycore/types/shared/response'; import type { TWorkspaceScope, TWorkspaceSection } from '@/constants'; +import type { Props as MainTableProps } from '@/ui/segments/data-table'; -const MainTable = dynamic(() => import('@/ui/segments/data-table'), { ssr: false }); +const MainTable = dynamic(() => import('@/ui/segments/data-table'), { ssr: false }) as ( + props: MainTableProps +) => ReactElement | null; type Props = { + id?: string; section?: TWorkspaceSection; requireBrainRegion?: boolean; requireMiniDetailView?: boolean; @@ -44,16 +48,19 @@ type Props = { miniView?: ComponentProps<'div'>['className']; }; scope: TWorkspaceScope; + defaultBrainRegion?: string; dataType: TExtendedEntitiesTypeDict; mainTableProps?: Partial>; miniViewProps?: Partial>; }; export function BrowseEntityScope({ + id, classNames, section = WorkspaceSection.Explore, requireBrainRegion = true, requireMiniDetailView = true, + defaultBrainRegion, dataType, scope, mainTableProps, @@ -61,7 +68,7 @@ export function BrowseEntityScope({ }: Props) { const { virtualLabId, projectId } = useWorkspace(); - const dataKey = compact([virtualLabId, projectId, section, dataType, scope]).join('/'); + const dataKey = compact([virtualLabId, projectId, section, dataType, scope, id]).join('/'); const entity = getEntityByExtendedType({ type: dataType }); const setPageNumber = useSetAtom(corePageNumberAtom(dataKey)); @@ -98,6 +105,7 @@ export function BrowseEntityScope({ }); }, requireBrainRegion, + defaultBrainRegion, enabled: ({ queryKey }) => { const [{ queryParameters }] = queryKey; if (requireBrainRegion && !get(queryParameters, 'within_brain_region_brain_region_id', null)) @@ -138,8 +146,8 @@ export function BrowseEntityScope({ return ( <>
({ workspace, queryFn, requireBrainRegion, + defaultBrainRegion, ...rest }: { context: QueryContext; @@ -109,6 +124,7 @@ export function useQueryExtendedEntityType({ | undefined; useKeepPreviousData?: boolean; requireBrainRegion?: boolean; + defaultBrainRegion?: string; } & Omit< UseQueryOptions< TData, @@ -127,7 +143,10 @@ export function useQueryExtendedEntityType({ >, 'queryKey' | 'queryFn' | 'placeholderData' >) { - const queryParameters = useQueryParameters({ context }, requireBrainRegion); + const queryParameters = useQueryParameters( + { context, workspace }, + { requireBrainRegion, defaultBrainRegion } + ); return useQuery({ queryKey: buildQueryKey({ workspace, context, queryParameters, requireBrainRegion }), queryFn, diff --git a/src/ui/molecules/popover/index.tsx b/src/ui/molecules/popover/index.tsx new file mode 100644 index 000000000..6f8dbd114 --- /dev/null +++ b/src/ui/molecules/popover/index.tsx @@ -0,0 +1,43 @@ +/* eslint-disable react/jsx-props-no-spreading */ + +'use client'; + +import * as React from 'react'; +import * as PopoverPrimitive from '@radix-ui/react-popover'; +import { cn } from '@/utils/css-class'; + +function Popover({ ...props }: React.ComponentProps) { + return ; +} + +function PopoverTrigger({ ...props }: React.ComponentProps) { + return ; +} + +function PopoverContent({ + className, + align = 'center', + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +function PopoverAnchor({ ...props }: React.ComponentProps) { + return ; +} + +export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }; diff --git a/src/ui/segments/data-table/elements/filter-controls.tsx b/src/ui/segments/data-table/elements/filter-controls.tsx index b6e8d4f50..88eb6f010 100644 --- a/src/ui/segments/data-table/elements/filter-controls.tsx +++ b/src/ui/segments/data-table/elements/filter-controls.tsx @@ -1,18 +1,12 @@ 'use client'; -import { HTMLProps, ReactNode, useEffect, useMemo, useState } from 'react'; -import { useAtomValue } from 'jotai'; -import { unwrap } from 'jotai/utils'; -import { Spin } from 'antd'; - -import SettingsIcon from '@/components/icons/Settings'; +import { HTMLProps, ReactNode } from 'react'; import { filterHasValue } from '@/ui/segments/data-table/elements/listing-filter-panel/util'; -import { coreActiveColumnsAtom } from '@/ui/segments/data-table/elements/context'; +import { SettingsIcon } from '@/components/icons/Settings'; import { classNames } from '@/util/utils'; import { cn } from '@/utils/css-class'; -import type { TExtendedEntitiesTypeDict } from '@/api/entitycore/types/extended-entity-type'; import type { CoreFilter } from '@/entity-configuration/definitions/types'; function FilterBtn({ disabled, className, children, onClick }: HTMLProps) { @@ -39,8 +33,6 @@ export function FilterControls({ children, displayControlPanel, setDisplayControlPanel, - dataType, - dataKey, filters, disabled, className, @@ -48,40 +40,27 @@ export function FilterControls({ children?: ReactNode; displayControlPanel: boolean; setDisplayControlPanel: (v: boolean) => void; - dataType: TExtendedEntitiesTypeDict; - dataKey: string; filters?: CoreFilter[]; disabled?: boolean; className?: HTMLProps['className']; }) { - const [activeColumnsLength, setActiveColumnsLength] = useState(undefined); - - const activeColumns = useAtomValue( - useMemo(() => unwrap(coreActiveColumnsAtom({ dataType, key: dataKey })), [dataType, dataKey]) - ); - const selectedFiltersCount = filters ? filters.filter((filter) => filterHasValue(filter)).length : 0; - useEffect(() => { - if (activeColumns && activeColumns.length) { - setActiveColumnsLength(activeColumns.length - 1); - } - }, [activeColumns]); - const onFilterClick = () => setDisplayControlPanel(!displayControlPanel); + return (
{children} -
+
{selectedFiltersCount} -
+
Filters - + {/* {activeColumnsLength ? ( <> {activeColumnsLength} active{' '} @@ -109,7 +88,7 @@ export function FilterControls({ ) : ( )} - + */}
diff --git a/src/ui/segments/data-table/elements/pagination.tsx b/src/ui/segments/data-table/elements/pagination.tsx index 83ff36020..b297b9df2 100644 --- a/src/ui/segments/data-table/elements/pagination.tsx +++ b/src/ui/segments/data-table/elements/pagination.tsx @@ -24,8 +24,8 @@ export function Pagination({ dataKey, size, resultPagination, className }: Props return ( = { }; dataScope?: TWorkspaceScope; columns: ColumnProps[]; - controlsVisible: boolean; dataType: TExtendedEntitiesTypeDict; workspace?: WorkspaceContext; cls?: { @@ -51,7 +52,7 @@ export type Props = { showLoadingState?: boolean; isLoading?: boolean; dataSource: Array; - rowClassName?: ComponentProps<'td'>['className']; + rowClassName?: string | TableProps['rowClassName']; tableStyle?: CSSProperties | undefined; }; @@ -62,7 +63,6 @@ export function MainTable({ workspace, cls, facets, - controlsVisible, renderButton, showLoadingState, isLoading, @@ -106,25 +106,26 @@ export function MainTable({ >
-
- -
- +
+ {(dataScope === WorkspaceScope.BuildMeModelM || + dataScope === WorkspaceScope.BuildSynaptomeModel) && ( + + )} + +
@@ -135,7 +136,6 @@ export function MainTable({ onCellClick={onCellClick} renderButton={renderButton} selectionType={selectionType} - controlsVisible={controlsVisible} onRowsSelected={onRowsSelected} dataKey={dataKey} rowClassName={rowClassName} @@ -143,6 +143,11 @@ export function MainTable({ onRow={onRow} sticky={sticky} className={cls?.table} + controls={ +
+ +
+ } /> {displayControlPanel && filters && ( diff --git a/src/ui/segments/data-table/table.tsx b/src/ui/segments/data-table/table.tsx index dbcd97fb9..d27f4490d 100644 --- a/src/ui/segments/data-table/table.tsx +++ b/src/ui/segments/data-table/table.tsx @@ -1,11 +1,12 @@ 'use client'; -import { CSSProperties, ReactNode, useCallback, useRef, useState } from 'react'; import { VerticalAlignMiddleOutlined } from '@ant-design/icons'; import { ConfigProvider, Table, TableProps } from 'antd'; +import { useCallback, useRef, useState } from 'react'; import isString from 'lodash/isString'; import type { ExpandableConfig, RowSelectionType } from 'antd/es/table/interface'; +import type { CSSProperties, ReactNode } from 'react'; import type { TableRef } from 'antd/es/table'; import { @@ -219,8 +220,6 @@ export function WrapperTable({ selectionType, onRowsSelected, scrollable = true, - controlsVisible = true, - autohideControls = false, dataKey, expandableConfig, rowClassName, @@ -228,18 +227,18 @@ export function WrapperTable({ onRow, className, dataType, + controls, }: TableProps & AdditionalTableProps & { renderButton?: (props: RenderButtonProps) => ReactNode; selectionType?: RowSelectionType; scrollable?: boolean; - controlsVisible?: boolean; onRowsSelected?: (rows: Array) => void; - autohideControls?: boolean; dataKey: string; expandableConfig?: ExpandableConfig; tableStyle?: CSSProperties | undefined; dataType: TExtendedEntitiesTypeDict; + controls?: ReactNode; }) { const { rowSelection, selectedRows, clearSelectedRows } = useRowSelection({ dataKey, @@ -265,15 +264,17 @@ export function WrapperTable({ className={className} onRow={onRow} /> - {(!autohideControls || (autohideControls && selectedRows.length > 0)) && ( - - )} + + {controls} + + {/* {(!autohideControls || (autohideControls && selectedRows.length > 0)) && ( + )} */} ); } diff --git a/src/ui/segments/explore/browse-link.tsx b/src/ui/segments/explore/browse-link.tsx index 0748baab1..319c4f06b 100644 --- a/src/ui/segments/explore/browse-link.tsx +++ b/src/ui/segments/explore/browse-link.tsx @@ -3,6 +3,8 @@ import { usePathname, useSearchParams } from 'next/navigation'; import snakeCase from 'lodash/snakeCase'; import Link from 'next/link'; +import type { ReactNode } from 'react'; + import { getEntityTypeFromUrlOnEntityScope } from '@/ui/segments/explore/helpers'; import { Button } from '@/ui/molecules/button'; import { cn } from '@/utils/css-class'; @@ -17,7 +19,7 @@ export function BrowseLink({ isLoading: boolean; type: string; title: string; - count: number | null; + count: ReactNode; href: string; }) { const searchParams = useSearchParams(); diff --git a/src/ui/segments/explore/entity-left-menu.tsx b/src/ui/segments/explore/entity-left-menu.tsx index c42ea825a..fd3bfaca4 100644 --- a/src/ui/segments/explore/entity-left-menu.tsx +++ b/src/ui/segments/explore/entity-left-menu.tsx @@ -1,78 +1,29 @@ 'use client'; import { AnimatePresence, motion } from 'motion/react'; -import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { useSession } from 'next-auth/react'; import { Suspense, useState } from 'react'; +import delay from 'lodash/delay'; import { TreeSkeleton } from '@/features/brain-region-hierarchy/brain-region-skeleton'; -import { getPersons } from '@/api/entitycore/queries/general/person-agent'; import { EntityLinkCount } from '@/ui/segments/explore/entity-link-count'; import { BrainRegionHierarchy } from '@/features/brain-region-hierarchy'; -import { keyBuilder as userKeyBuilder } from '@/ui/use-query-keys/user'; import { ExploreLeftMenuContext, RegionBanner, } from '@/features/brain-region-hierarchy/region-banner'; -import { - getAllEntitiesCount, - getElectricalCellRecordingsCount, - getSimulationsCount, -} from '@/ui/segments/explore/helpers'; -import { useWorkspace } from '@/ui/hooks/use-workspace'; -import { keyBuilder } from '@/ui/use-query-keys/data'; import type { TExploreLeftMenuContext } from '@/features/brain-region-hierarchy/region-banner'; -import type { TTreeNode } from '@/components/tree/types'; type Props = { dataKey: string }; export function EntityLeftMenu({ dataKey }: Props) { - const queryClient = useQueryClient(); - const { virtualLabId, projectId } = useWorkspace(); - const [view, updateView] = useState( ExploreLeftMenuContext.BrainRegionHierarchy ); - const session = useSession(); - const { data: person } = useQuery({ - queryKey: userKeyBuilder.person({ userId: session.data?.user.id }), - queryFn: () => getPersons({ filters: { sub_id: session.data?.user.id } }), - enabled: Boolean(session.data?.user.id), - }); - - const personId = person?.data.at(0)?.id; const onSwitchView = (_view: TExploreLeftMenuContext) => updateView(_view); - - const onClickBrainRegion = async (node: TTreeNode) => { - const params = { - virtualLabId, - projectId, - brainRegionId: node.id, - }; - - await queryClient.prefetchQuery({ - queryKey: keyBuilder.dataCount({ ...params }), - queryFn: () => getAllEntitiesCount({ ...params }), - }); - - await queryClient.prefetchQuery({ - queryKey: keyBuilder.electricalCellRecordingsCount({ ...params }), - queryFn: () => - getElectricalCellRecordingsCount({ - ...params, - }), - }); - - await queryClient.prefetchQuery({ - queryKey: keyBuilder.userSimulationsCount({ ...params, personId }), - queryFn: () => - getSimulationsCount({ - ...params, - personId, - }), - }); + const onClickBrainRegion = async () => { + delay(() => onSwitchView(ExploreLeftMenuContext.DataGroup), 100); }; return ( diff --git a/src/ui/segments/explore/entity-link-count.tsx b/src/ui/segments/explore/entity-link-count.tsx index e845df7b8..e55b3d0cc 100644 --- a/src/ui/segments/explore/entity-link-count.tsx +++ b/src/ui/segments/explore/entity-link-count.tsx @@ -1,5 +1,8 @@ import { useQueries, useQuery } from '@tanstack/react-query'; +import { useSearchParams } from 'next/navigation'; import { useSession } from 'next-auth/react'; +import { useAtomValue } from 'jotai'; +import { unwrap } from 'jotai/utils'; import { match } from 'ts-pattern'; import { useMemo } from 'react'; import kebabCase from 'lodash/kebabCase'; @@ -7,27 +10,30 @@ import get from 'lodash/get'; import { useFilteredCircuits } from '@/components/explore-section/Circuit/ListView/ExploreCircuitTable'; import { ExtendedEntitiesTypeDict } from '@/api/entitycore/types/extended-entity-type'; -import { useGetSelectedBrainRegion } from '@/features/brain-region-hierarchy/context'; +import { + brainRegionBasicCellGroupsRegionsHierarchyAtom, + useGetSelectedBrainRegion, +} from '@/features/brain-region-hierarchy/context'; import { PillTabs, PillTabsList, PillTabsTrigger } from '@/ui/molecules/tabs'; import { getPersons } from '@/api/entitycore/queries/general/person-agent'; import { keyBuilder as userKeyBuilder } from '@/ui/use-query-keys/user'; import { useDefaultBreakpoint } from '@/ui/hooks/create-break-point'; -import { EntityTypeDict } from '@/api/entitycore/types/entity-type'; import { BrowseLink } from '@/ui/segments/explore/browse-link'; import { V2_MIGRATION_TEMPORARY_BASE_PATH } from '@/config'; import { useTabs } from '@/components/detail-view-tabs'; import { useWorkspace } from '@/ui/hooks/use-workspace'; import { keyBuilder } from '@/ui/use-query-keys/data'; import { - getElectricalCellRecordingsCount, ExperimentalEntitiesTileTypes, ModelEntitiesTileTypes, SimulationEntitiesTileTypes, getSimulationsCount, - getAllEntitiesCount, + getAllEntitiesCountScoped, } from '@/ui/segments/explore/helpers'; import { cn } from '@/utils/css-class'; +import type { TWorkspaceScope } from '@/constants'; + export const ExploreDataTypeTabs = { Experimental: 'experimental', Models: 'models', @@ -64,9 +70,14 @@ type Props = { export function EntityLinkCount({ dataKey }: Props) { const breakpoint = useDefaultBreakpoint(); const session = useSession(); + const scope = useSearchParams().get('scope') as TWorkspaceScope; const { virtualLabId, projectId } = useWorkspace(); const { selectedBrainRegion } = useGetSelectedBrainRegion(); + const brainRegionHierarchy = useAtomValue( + useMemo(() => unwrap(brainRegionBasicCellGroupsRegionsHierarchyAtom), []) + ); + const { activeTab, onChangeTab } = useTabs({ tabsConfig: tabsConfigItems, tabKey: 'group', @@ -75,46 +86,45 @@ export function EntityLinkCount({ dataKey }: Props) { const { filteredCircuits } = useFilteredCircuits({ dataKey }); + const { data: personId } = useQuery({ + queryKey: userKeyBuilder.person({ userId: session.data?.user.id }), + queryFn: () => getPersons({ filters: { sub_id: session.data?.user.id } }), + enabled: Boolean(session.data?.user.id), + select: (data) => data?.data.at(0)?.id, + }); + const params = { virtualLabId, projectId, brainRegionId: selectedBrainRegion?.id!, + scope, + personId, }; - const { data: person } = useQuery({ - queryKey: userKeyBuilder.person({ userId: session.data?.user.id }), - queryFn: () => getPersons({ filters: { sub_id: session.data?.user.id } }), - enabled: Boolean(session.data?.user.id), - }); - const personId = person?.data.at(0)?.id; - const [ { isLoading: allLoading, data: allData }, - { isLoading: ephysLoading, data: ephysData }, { isLoading: simsLoading, data: simsData }, + { isLoading: rootLoading, data: rootData }, ] = useQueries({ queries: [ { queryKey: keyBuilder.dataCount({ ...params }), - queryFn: () => getAllEntitiesCount({ ...params }), + queryFn: () => getAllEntitiesCountScoped({ ...params }), enabled: Boolean(selectedBrainRegion?.id), }, { - queryKey: keyBuilder.electricalCellRecordingsCount({ ...params }), + queryKey: keyBuilder.userSimulationsCount({ ...params }), queryFn: () => - getElectricalCellRecordingsCount({ + getSimulationsCount({ ...params, }), - enabled: Boolean(selectedBrainRegion?.id), + enabled: Boolean(selectedBrainRegion?.id) && Boolean(personId), }, { - queryKey: keyBuilder.userSimulationsCount({ ...params, personId }), + queryKey: keyBuilder.dataCount({ ...params, brainRegionId: brainRegionHierarchy?.root.id }), queryFn: () => - getSimulationsCount({ - ...params, - personId, - }), - enabled: Boolean(selectedBrainRegion?.id) && Boolean(personId), + getAllEntitiesCountScoped({ ...params, brainRegionId: brainRegionHierarchy?.root.id! }), + enabled: Boolean(brainRegionHierarchy?.root.id), }, ], }); @@ -122,23 +132,20 @@ export function EntityLinkCount({ dataKey }: Props) { const experimentalState = useMemo( () => [ ...Object.entries(ExperimentalEntitiesTileTypes).map(([, value]) => { - if (value.type === EntityTypeDict.ElectricalCellRecording) { - return { ...value, isLoading: ephysLoading }; - } - return { ...value, isLoading: allLoading }; + return { ...value, isLoading: allLoading || rootLoading }; }), ], - [allLoading, ephysLoading] + [allLoading, rootLoading] ); const modelState = useMemo( () => [ ...Object.entries(ModelEntitiesTileTypes).map(([, value]) => ({ ...value, - isLoading: allLoading, + isLoading: allLoading || rootLoading, })), ], - [allLoading] + [allLoading, rootLoading] ); const simulationState = useMemo( @@ -155,18 +162,22 @@ export function EntityLinkCount({ dataKey }: Props) { .with(ExploreDataTypeTabs.Experimental, () => ( <> {experimentalState.map((value) => { - let count: number | null = get(allData, value.extendedType, null); - if (value.type === EntityTypeDict.ElectricalCellRecording) { - count = ephysData?.pagination.total_items ?? null; - } + const count: number | null = get(allData, value.extendedType, null); + const rootCount: number | null = get(rootData, value.extendedType, null); const link = `${V2_MIGRATION_TEMPORARY_BASE_PATH}/${virtualLabId}/${projectId}/explore/browse/entity/${kebabCase(value.extendedType)}`; + return ( + {count} of + {rootCount} + + } isLoading={value.isLoading} /> ); @@ -177,6 +188,7 @@ export function EntityLinkCount({ dataKey }: Props) { <> {modelState.map((value) => { const count = get(allData, value.extendedType, null); + const rootCount: number | null = get(rootData, value.extendedType, null); const link = `${V2_MIGRATION_TEMPORARY_BASE_PATH}/${virtualLabId}/${projectId}/explore/browse/entity/${kebabCase(value.extendedType)}`; return ( + {count} of + {rootCount} + + } isLoading={value.isLoading} /> ); @@ -194,7 +211,7 @@ export function EntityLinkCount({ dataKey }: Props) { href={`${V2_MIGRATION_TEMPORARY_BASE_PATH}/${virtualLabId}/${projectId}/explore/browse/entity/${kebabCase('circuit')}`} type={ExtendedEntitiesTypeDict.Circuit} title="Circuit" - count={filteredCircuits.count} + count={`${filteredCircuits.count}`} isLoading={false} /> @@ -211,7 +228,7 @@ export function EntityLinkCount({ dataKey }: Props) { href={link} type={value.extendedType} title={value.title} - count={count} + count={`${count}`} isLoading={value.isLoading} /> ); diff --git a/src/ui/segments/explore/helpers.ts b/src/ui/segments/explore/helpers.ts index ba75cfb18..e325f48e0 100644 --- a/src/ui/segments/explore/helpers.ts +++ b/src/ui/segments/explore/helpers.ts @@ -15,9 +15,11 @@ import { BoutonDensity } from '@/entity-configuration/domain/experimental/bouton import { getEntitiesCount } from '@/api/entitycore/queries/general/entity'; import { MEmodel } from '@/entity-configuration/domain/model/me-model'; import { Emodel } from '@/entity-configuration/domain/model/e-model'; +import { WorkspaceScope } from '@/constants'; import { env } from '@/env'; import type { WorkspaceContext } from '@/types/common'; +import type { TWorkspaceScope } from '@/constants'; // import { Circuit } from '@/entity-configuration/domain/model/circuit'; @@ -30,9 +32,9 @@ export const ExperimentalEntitiesTileTypes = { } as const; export const ModelEntitiesTileTypes = { + SingleNeuronSynaptome, Emodel, MEmodel, - SingleNeuronSynaptome, } as const; export const SimulationEntitiesTileTypes = { @@ -71,6 +73,47 @@ export function getAllEntitiesCount({ }); } +export async function getAllEntitiesCountScoped({ + virtualLabId, + projectId, + brainRegionId, + scope, + personId, +}: WorkspaceContext & { + brainRegionId: string; + personId: string | undefined; + scope: TWorkspaceScope; +}) { + const items = { ...ExperimentalEntitiesTileTypes, ...ModelEntitiesTileTypes }; + const promises = Object.fromEntries( + Object.entries(items).map(([, value]) => { + return [ + value.extendedType, + value.api.query?.list?.({ + withFacets: false, + context: { + virtualLabId, + projectId, + }, + filters: { + page: 1, + page_size: 1, + within_brain_region_hierarchy_id: env.NEXT_PUBLIC_DEFAULT_BRAIN_REGION_HIERARCHY_ID, + within_brain_region_brain_region_id: brainRegionId ?? null, + within_brain_region_ascendants: false, + ...(scope === WorkspaceScope.Project ? { created_by__id: personId } : {}), + }, + }), + ]; + }) + ); + const result = await pProps(promises); + + return Object.fromEntries( + Object.entries(result).map(([key, value]) => [key, value?.pagination.total_items ?? 0]) + ); +} + export async function getSimulationsCount({ virtualLabId, projectId, @@ -90,19 +133,16 @@ export async function getSimulationsCount({ filters: { page: 1, page_size: 1, - ...([ - SimulationEntitiesTileTypes.SingleNeuronSimulation.extendedType, - SimulationEntitiesTileTypes.SingleNeuronSynaptomeSimulation.extendedType, - ].includes(value.extendedType) - ? { - within_brain_region_hierarchy_id: - env.NEXT_PUBLIC_DEFAULT_BRAIN_REGION_HIERARCHY_ID, - within_brain_region_brain_region_id: brainRegionId ?? null, - within_brain_region_ascendants: false, - } - : {}), + within_brain_region_hierarchy_id: env.NEXT_PUBLIC_DEFAULT_BRAIN_REGION_HIERARCHY_ID, + within_brain_region_brain_region_id: brainRegionId ?? null, + within_brain_region_ascendants: false, created_by__id: personId, }, + circuitFilter: { + within_brain_region_hierarchy_id: env.NEXT_PUBLIC_DEFAULT_BRAIN_REGION_HIERARCHY_ID, + within_brain_region_brain_region_id: brainRegionId ?? null, + within_brain_region_ascendants: false, + }, }), ]; }) diff --git a/src/ui/segments/mini-detail-view/index.tsx b/src/ui/segments/mini-detail-view/index.tsx index b6c224aad..489c1be3a 100644 --- a/src/ui/segments/mini-detail-view/index.tsx +++ b/src/ui/segments/mini-detail-view/index.tsx @@ -11,10 +11,7 @@ import Link from 'next/link'; import { SingleNeuronSynaptomePreview } from '@/ui/segments/mini-detail-view/previews/single-neuron-synaptome-preview'; import { getViewDefinitionByExtendedType } from '@/entity-configuration/definitions/view-defs'; import { MEModelPreview } from '@/ui/segments/mini-detail-view/previews/me-model-preview'; -import { - ExtendedEntitiesTypeDict, - TExtendedEntitiesTypeDict, -} from '@/api/entitycore/types/extended-entity-type'; +import { ExtendedEntitiesTypeDict } from '@/api/entitycore/types/extended-entity-type'; import { bookmarkToProjectLibrary } from '@/api/virtual-lab-svc/queries/bookmark'; import { renderPreview } from '@/entity-configuration/definitions/renderer'; import { getFieldDefinition } from '@/entity-configuration/definitions'; @@ -23,10 +20,9 @@ import { ExpandableText } from '@/ui/molecules/more-less-text'; import { useCopyToClipboard } from '@/hooks/useCopyClipboard'; import { downloadArchive } from '@/services/entity-download'; import { V2_MIGRATION_TEMPORARY_BASE_PATH } from '@/config'; -import { useLocalStorage } from '@/hooks/use-local-storage'; import { useWorkspace } from '@/ui/hooks/use-workspace'; import { Card, CardTitle } from '@/ui/molecules/card'; -import { LAST_REGISTERED_WORKFLOW, WorkspaceSection } from '@/constants'; +import { WorkspaceSection } from '@/constants'; import { Button } from '@/ui/molecules/button'; import { makeSelectEntityClickEvent, @@ -137,6 +133,9 @@ export function MiniDetailView({ .with({ section: WorkspaceSection.SimulateWorkflow }, () => ( )) + .with({ section: WorkspaceSection.BuildWorkflow }, () => ( + + )) .otherwise(() => null); return ( @@ -255,7 +254,7 @@ function ExploreActions({ record }: { record: T
); } + +function WorkflowBuildActions({ record }: { record: T }) { + const { virtualLabId, projectId } = useWorkspace(); + const onWorkflowClick = () => {}; + + return ( +
+ + +
+ ); +} diff --git a/src/ui/segments/mini-detail-view/previews/me-model-preview.tsx b/src/ui/segments/mini-detail-view/previews/me-model-preview.tsx index 4121de33a..58b889690 100644 --- a/src/ui/segments/mini-detail-view/previews/me-model-preview.tsx +++ b/src/ui/segments/mini-detail-view/previews/me-model-preview.tsx @@ -1,13 +1,23 @@ -import { JSX } from 'react'; +import { ComponentProps, JSX } from 'react'; import { Image } from 'antd'; import { renderPreview } from '@/entity-configuration/definitions/renderer'; import { hasAssets } from '@/api/entitycore/guards'; +import { cn } from '@/utils/css-class'; import type { EntityCoreResource } from '@/api/entitycore/types/shared/global'; import type { IMEModel } from '@/api/entitycore/types'; -export function MEModelPreview({ record }: { record: IMEModel }) { +type Props = { + record: IMEModel; + cls?: { + container?: ComponentProps<'div'>['className']; + morphology?: ComponentProps<'div'>['className']; + emodel?: ComponentProps<'div'>['className']; + }; +}; + +export function MEModelPreview({ record, cls }: Props) { const morphology = (record as IMEModel)?.morphology; let morphologyPreview = null; if (hasAssets(morphology)) @@ -15,7 +25,7 @@ export function MEModelPreview({ record }: { record: IMEModel }) { morphology, undefined, undefined, - 'rounded-md h-auto relative w-full! bg-white', + cn('rounded-md h-auto relative w-full! bg-white ', cls?.morphology), 'w-full! h-[200px]! flex!', true, (src) => ( @@ -27,11 +37,12 @@ export function MEModelPreview({ record }: { record: IMEModel }) { /> ) ); + const tracePreview: JSX.Element | null = renderPreview( record as unknown as EntityCoreResource, undefined, undefined, - 'rounded-md h-auto relative w-full! bg-white ', + cn('rounded-md h-auto relative w-full! bg-white ', cls?.emodel), 'w-full! h-[200px]! flex!', true, (src) => ( @@ -45,7 +56,10 @@ export function MEModelPreview({ record }: { record: IMEModel }) { ); return (
{morphologyPreview} diff --git a/src/ui/segments/project/activities/index.tsx b/src/ui/segments/project/activities/index.tsx index c0923eb52..02ff099c7 100644 --- a/src/ui/segments/project/activities/index.tsx +++ b/src/ui/segments/project/activities/index.tsx @@ -1,11 +1,9 @@ 'use client'; -import { Empty, Table, ConfigProvider } from 'antd'; import { RightSquareOutlined } from '@ant-design/icons'; - +import { Empty, Table, ConfigProvider } from 'antd'; import { ColumnsType } from 'antd/es/table'; import { useState } from 'react'; - import Link from 'next/link'; import get from 'lodash/get'; @@ -113,55 +111,58 @@ export function ProjectActivities() { - {!data && !isLoading && ( -

You don’t have any activities yet

+ {!data?.pagination.total_items && !isLoading ? ( + + You don’t have any activities yet + + ) : ( +
+ + { + setPage(_page); + }, + className: cn( + '[&_.ant-pagination-item-active]:bg-primary-9 [&_.ant-pagination-item-active_a]:text-white!', + '[&_.ant-pagination-disabled_button]:text-neutral-2 [&_button.ant-pagination-item-link]:text-primary-9' + ), + }} + locale={{ + emptyText: ( + + No activities found for{' '} + {getEntityByExtendedType({ type: scale })?.title}. + + } + /> + ), + }} + /> + + )} -
- -
{ - setPage(_page); - }, - className: cn( - '[&_.ant-pagination-item-active]:bg-primary-9 [&_.ant-pagination-item-active_a]:text-white!', - '[&_.ant-pagination-disabled_button]:text-neutral-2 [&_button.ant-pagination-item-link]:text-primary-9' - ), - }} - locale={{ - emptyText: ( - - No activities found for{' '} - {getEntityByExtendedType({ type: scale })?.title}. - - } - /> - ), - }} - /> - - diff --git a/src/ui/segments/project/bottom-nav-shortcuts.tsx b/src/ui/segments/project/bottom-nav-shortcuts.tsx index 4e6577d98..0628192bf 100644 --- a/src/ui/segments/project/bottom-nav-shortcuts.tsx +++ b/src/ui/segments/project/bottom-nav-shortcuts.tsx @@ -28,7 +28,7 @@ export function Shortcuts() { const breakpoint = useDefaultBreakpoint(); return ( -
+
Would you like to:
{links.map(({ key, title, url }) => ( diff --git a/src/ui/segments/workflows/build/memodel/e-model.tsx b/src/ui/segments/workflows/build/memodel/e-model.tsx new file mode 100644 index 000000000..d184b486c --- /dev/null +++ b/src/ui/segments/workflows/build/memodel/e-model.tsx @@ -0,0 +1,201 @@ +'use client'; + +import { ReloadOutlined } from '@ant-design/icons'; +import { useAtomValue } from 'jotai'; +import { unwrap } from 'jotai/utils'; +import { useMemo } from 'react'; +import { Image } from 'antd'; + +import type { HTMLAttributes, TdHTMLAttributes } from 'react'; + +import { brainRegionBasicCellGroupsRegionsHierarchyAtom } from '@/features/brain-region-hierarchy/context'; +import { useBuildMeModelSessionState, label } from '@/ui/segments/workflows/build/memodel/helpers'; +import { ExtendedEntitiesTypeDict } from '@/api/entitycore/types/extended-entity-type'; +import { BrowseEntityScope } from '@/features/views/listing/browse-entity'; +import { EntityCoreResource } from '@/api/entitycore/types/shared/global'; +import { WorkspaceScope, WorkspaceSection } from '@/constants'; +import { useWorkspace } from '@/ui/hooks/use-workspace'; +import { + renderArray, + renderDate, + renderEmptyOrValue, + renderPreview, +} from '@/entity-configuration/definitions/renderer'; +import { Button } from '@/ui/molecules/button'; +import { cn } from '@/utils/css-class'; + +import type { IEModel } from '@/api/entitycore/types'; + +type Props = { + sessionId: string; +}; + +export const EmodelBlackList = [ + 'EM__CBXgr_GrC_cNAC', + 'EM__CBXmo_StC_cNAC', + 'EM__CBXpu_PuC_cNAC', + 'EM__CBXgr_GoC_cAC', + 'EM__MOBmi_MC_cNAC', + 'EM__MOBgr_sGC_dNAC', +]; + +function checkSelectedEmodelBlackList(e: IEModel) { + return EmodelBlackList.includes(e.name); +} + +export function EModel({ sessionId }: Props) { + const { virtualLabId, projectId } = useWorkspace(); + const { setSessionValue, sessionValue } = useBuildMeModelSessionState({ + sessionId, + virtualLabId, + projectId, + }); + + const brainRegionHierarchy = useAtomValue( + useMemo(() => unwrap(brainRegionBasicCellGroupsRegionsHierarchyAtom), []) + ); + + return ( + { + const record = rows.at(0); + setSessionValue({ + ...sessionValue, + emodel: record as unknown as IEModel, + }); + }, + onRow: (row) => { + if (checkSelectedEmodelBlackList(row as IEModel)) + // this new line in the attribute is required to be displayed in two lines + return { + 'black-listed': `This e-model cannot be combined + with any morphology for now. + `, + } as HTMLAttributes & TdHTMLAttributes; + return {}; + }, + // eslint-disable-next-line + rowClassName: (row) => { + return checkSelectedEmodelBlackList(row as IEModel) + ? cn( + 'bg-gray-200 [&_td]:bg-gray-200! hover:[bg-gray-200] [&:hover_td]:bg-gray-200', + '[&_.ant-radio-input]:pointer-events-none [&_.ant-radio-wrapper]:pointer-events-none [&_.ant-radio]:pointer-events-none [&_.ant-radio-input]:pointer-events-none', + '[&_.ant-radio-input]:cursor-not-allowed [&_.ant-radio-wrapper]:cursor-not-allowed', + '[&_.ant-table-cell]:cursor-not-allowed', + ` + [tr:has(.ant-radio-wrapper)]:relative! + [tr:has(.ant-radio-wrapper)]:hover:after:content-[attr(black-listed)]! + [tr:has(.ant-radio-wrapper)]:hover:after:absolute + [tr:has(.ant-radio-wrapper)]:hover:after:bg-yellow-100 + [tr:has(.ant-radio-wrapper)]:hover:after:backdrop-blur-xl + [tr:has(.ant-radio-wrapper)]:hover:after:border + [tr:has(.ant-radio-wrapper)]:hover:after:border-yellow-100/20 + [tr:has(.ant-radio-wrapper)]:hover:after:shadow-lg + [tr:has(.ant-radio-wrapper)]:hover:after:text-primary-8 + [tr:has(.ant-radio-wrapper)]:hover:after:text-sm + [tr:has(.ant-radio-wrapper)]:hover:after:px-2 + [tr:has(.ant-radio-wrapper)]:hover:after:py-1 + [tr:has(.ant-radio-wrapper)]:hover:after:rounded + [tr:has(.ant-radio-wrapper)]:hover:after:whitespace-pre-line + [tr:has(.ant-radio-wrapper)]:hover:after:z-50 + [tr:has(.ant-radio-wrapper)]:hover:after:top-[10px] + [tr:has(.ant-radio-wrapper)]:hover:after:left-[10px] + ` + ) + : ''; + }, + }} + /> + ); +} + +export function EModelMiniDetail({ sessionId }: Props) { + const { virtualLabId, projectId } = useWorkspace(); + + const { setSessionValue, sessionValue } = useBuildMeModelSessionState({ + sessionId, + virtualLabId, + projectId, + }); + const data = sessionValue.emodel; + const details = [ + { label: 'Name', value: renderEmptyOrValue(data?.name), className: 'font-bold' }, + { label: 'Exemplar morphology', value: renderEmptyOrValue(data?.exemplar_morphology.name) }, + { label: 'Brain Region', value: renderEmptyOrValue(data?.brain_region.name) }, + { label: 'Species', value: renderEmptyOrValue(data?.species.name) }, + { + label: 'E-Type', + value: renderEmptyOrValue(renderArray(data?.etypes?.map((m) => m.pref_label) || [])), + }, + { + label: 'Created By', + value: renderEmptyOrValue(data?.created_by?.pref_label), + }, + { + label: 'Created At', + value: renderDate(data?.creation_date), + }, + ]; + + const content = details.map(({ value, label: text, className }) => { + return ( +
+ {label(text!, 'secondary')} +
{value}
+
+ ); + }); + + const onReset = () => setSessionValue({ ...sessionValue, emodel: undefined }); + + return ( +
+
+
+

E-Model

+
+ Select another model + +
+
+
{content}
+
+
+ {renderPreview( + data as unknown as EntityCoreResource, + undefined, + undefined, + 'rounded-2xl h-full relative w-full!', + 'w-full! h-full! flex!', + true, + (src) => ( + + ) + )} +
+
+ ); +} diff --git a/src/ui/segments/workflows/build/memodel/header.tsx b/src/ui/segments/workflows/build/memodel/header.tsx new file mode 100644 index 000000000..c27ada590 --- /dev/null +++ b/src/ui/segments/workflows/build/memodel/header.tsx @@ -0,0 +1,19 @@ +'use client'; + +import { useDefaultBreakpoint } from '@/ui/hooks/create-break-point'; +import { Button } from '@/ui/molecules/button'; +import { cn } from '@/utils/css-class'; + +export function Header() { + const breakpoint = useDefaultBreakpoint(); + return ( + + ); +} diff --git a/src/ui/segments/workflows/build/memodel/helpers.tsx b/src/ui/segments/workflows/build/memodel/helpers.tsx new file mode 100644 index 000000000..2faa01009 --- /dev/null +++ b/src/ui/segments/workflows/build/memodel/helpers.tsx @@ -0,0 +1,57 @@ +'use client'; + +import type { ReactNode } from 'react'; + +import { useSessionStorage } from '@/hooks/use-session-storage'; +import { cn } from '@/utils/css-class'; + +import type { BrainRegionHierarchyBase } from '@/api/entitycore/types/entities/brain-region'; +import type { IEModel, IReconstructionMorphology } from '@/api/entitycore/types'; + +export const BuildStep = { + Info: 'info', + MModel: 'm-model', + EModel: 'e-model', +} as const; +export type BuildStepKeys = (typeof BuildStep)[keyof typeof BuildStep]; + +export const useBuildMeModelSessionState = ({ + sessionId, + virtualLabId, + projectId, +}: { + sessionId: string; + virtualLabId: string; + projectId: string; +}) => { + const { setSessionValue, removeSessionValue, sessionValue } = useSessionStorage<{ + virtualLabId: string; + projectId: string; + name?: string; + description?: string; + brainRegion?: BrainRegionHierarchyBase; + mmodel?: IReconstructionMorphology; + emodel?: IEModel; + }>(sessionId, { + virtualLabId, + projectId, + }); + + return { + setSessionValue, + removeSessionValue, + sessionValue, + }; +}; + +export const label = (text: string, type: 'main' | 'secondary' = 'main', extra?: ReactNode) => ( + + {text} {extra} + +); diff --git a/src/ui/segments/workflows/build/memodel/index.tsx b/src/ui/segments/workflows/build/memodel/index.tsx new file mode 100644 index 000000000..dd4a0f22a --- /dev/null +++ b/src/ui/segments/workflows/build/memodel/index.tsx @@ -0,0 +1,53 @@ +'use client'; + +import { useSearchParams } from 'next/navigation'; +import { match, P } from 'ts-pattern'; +import isNil from 'lodash/isNil'; + +import { + BuildStep, + useBuildMeModelSessionState, +} from '@/ui/segments/workflows/build/memodel/helpers'; +import { EModel, EModelMiniDetail } from '@/ui/segments/workflows/build/memodel/e-model'; +import { MModel, MModelMiniDetail } from '@/ui/segments/workflows/build/memodel/m-model'; +import { Info } from '@/ui/segments/workflows/build/memodel/overview'; +import { useWorkspace } from '@/ui/hooks/use-workspace'; + +type Props = { + sessionId: string; +}; + +export function Content({ sessionId }: Props) { + const searchParams = useSearchParams(); + const { virtualLabId, projectId } = useWorkspace(); + const step = searchParams.get('step') ?? BuildStep.Info; + const { sessionValue } = useBuildMeModelSessionState({ + sessionId, + virtualLabId, + projectId, + }); + + const content = match({ step, sessionValue }) + .with({ step: BuildStep.Info }, () => ) + .with( + { step: BuildStep.MModel }, + () => isNil(sessionValue) || isNil(sessionValue.mmodel), + () => + ) + .with( + { step: BuildStep.MModel, sessionValue: { mmodel: P.nonNullable.select('mmodel') } }, + () => + ) + .with( + { step: BuildStep.EModel }, + () => isNil(sessionValue) || isNil(sessionValue.emodel), + () => + ) + .with( + { step: BuildStep.EModel, sessionValue: { emodel: P.nonNullable.select('emodel') } }, + () => + ) + .otherwise(() => null); + + return content; +} diff --git a/src/ui/segments/workflows/build/memodel/m-model.tsx b/src/ui/segments/workflows/build/memodel/m-model.tsx new file mode 100644 index 000000000..a87b1ad8b --- /dev/null +++ b/src/ui/segments/workflows/build/memodel/m-model.tsx @@ -0,0 +1,144 @@ +'use client'; + +import { ReloadOutlined } from '@ant-design/icons'; +import { Image } from 'antd'; + +import { label, useBuildMeModelSessionState } from '@/ui/segments/workflows/build/memodel/helpers'; +import { ExtendedEntitiesTypeDict } from '@/api/entitycore/types/extended-entity-type'; +import { BrowseEntityScope } from '@/features/views/listing/browse-entity'; +import { WorkspaceScope, WorkspaceSection } from '@/constants'; +import { useWorkspace } from '@/ui/hooks/use-workspace'; +import { cn } from '@/utils/css-class'; + +import type { IReconstructionMorphology } from '@/api/entitycore/types'; + +import { + renderArray, + renderDate, + renderEmptyOrValue, + renderLicense, + renderPreview, +} from '@/entity-configuration/definitions/renderer'; +import { EntityCoreResource } from '@/api/entitycore/types/shared/global'; +import { Button } from '@/ui/molecules/button'; + +type Props = { + sessionId: string; +}; + +export function MModel({ sessionId }: Props) { + const { virtualLabId, projectId } = useWorkspace(); + const { setSessionValue, sessionValue } = useBuildMeModelSessionState({ + sessionId, + virtualLabId, + projectId, + }); + + return ( + { + const record = rows.at(0); + setSessionValue({ + ...sessionValue, + mmodel: record as unknown as IReconstructionMorphology, + }); + }, + }} + /> + ); +} + +export function MModelMiniDetail({ sessionId }: { sessionId: string }) { + const { virtualLabId, projectId } = useWorkspace(); + + const { setSessionValue, sessionValue } = useBuildMeModelSessionState({ + sessionId, + virtualLabId, + projectId, + }); + const data = sessionValue.mmodel; + + const details = [ + { label: 'Name', value: renderEmptyOrValue(data?.name), className: 'font-bold' }, + { label: 'Description', value: renderEmptyOrValue(data?.description) }, + { label: 'Brain Region', value: renderEmptyOrValue(data?.brain_region.name) }, + { label: 'Species', value: renderEmptyOrValue(data?.species.name) }, + { + label: 'M-Type', + value: renderEmptyOrValue(renderArray(data?.mtypes?.map((m) => m.pref_label) || [])), + }, + { + label: 'License', + value: renderEmptyOrValue(renderLicense({ license: data?.license })), + }, + { + label: 'Created By', + value: renderEmptyOrValue(data?.created_by?.pref_label), + }, + { + label: 'Created At', + value: renderDate(data?.creation_date), + }, + ]; + + const content = details.map(({ value, label: text, className }) => { + return ( +
+ {label(text!, 'secondary')} +
{value}
+
+ ); + }); + + const onReset = () => setSessionValue({ ...sessionValue, mmodel: undefined }); + + return ( +
+
+
+

M-Model

+
+ Select another model + +
+
+
{content}
+
+
+ {renderPreview( + data as unknown as EntityCoreResource, + undefined, + undefined, + 'rounded-2xl h-full relative w-full!', + 'w-full! h-full! flex!', + true, + (src) => ( + + ) + )} +
+
+ ); +} diff --git a/src/ui/segments/workflows/build/memodel/menu.tsx b/src/ui/segments/workflows/build/memodel/menu.tsx new file mode 100644 index 000000000..ea24523d4 --- /dev/null +++ b/src/ui/segments/workflows/build/memodel/menu.tsx @@ -0,0 +1,237 @@ +'use client'; + +import { useRouter, useSearchParams } from 'next/navigation'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { + CheckCircleFilled, + LoadingOutlined, + RightOutlined, + SettingFilled, +} from '@ant-design/icons'; +import omit from 'lodash/omit'; +import { z } from 'zod'; + +import { Tooltip, TooltipContent, TooltipTrigger } from '@/ui/molecules/tooltip'; +import { createMEModel } from '@/api/entitycore/queries/model/me-model'; +import { useDefaultBreakpoint } from '@/ui/hooks/create-break-point'; +import { V2_MIGRATION_TEMPORARY_BASE_PATH } from '@/config'; +import { WorkspaceContextSchema } from '@/types/common'; +import { OneshotSession } from '@/services/accounting'; +import { useWorkspace } from '@/ui/hooks/use-workspace'; +import { ServiceSubtype } from '@/types/accounting'; +import { + useBuildMeModelSessionState, + BuildStepKeys, + BuildStep, +} from '@/ui/segments/workflows/build/memodel/helpers'; +import { + CreateMEModelSchema, + ValidationStatus, + type IMEModel, +} from '@/api/entitycore/types/entities/me-model'; +import { Button } from '@/ui/molecules/button'; +import { cn } from '@/utils/css-class'; + +const CreateMeModelContextSchema = CreateMEModelSchema.merge(WorkspaceContextSchema); +type TCreateMeModelContext = z.infer; + +export function Menu({ sessionId }: { sessionId: string }) { + const breakpoint = useDefaultBreakpoint(); + const queryClient = useQueryClient(); + const searchParams = useSearchParams(); + const { replace, push: navigate } = useRouter(); + const { virtualLabId, projectId } = useWorkspace(); + const step = searchParams.get('step'); + + const { sessionValue } = useBuildMeModelSessionState({ + sessionId, + virtualLabId, + projectId, + }); + + const onStepChange = (s: BuildStepKeys) => { + const query = new URLSearchParams(searchParams); + query.set('step', s); + + replace( + `${V2_MIGRATION_TEMPORARY_BASE_PATH}/${virtualLabId}/${projectId}/workflows/build/configure/memodel?${query.toString()}` + ); + }; + + const payload: Partial = { + virtualLabId, + projectId, + name: sessionValue.name, + description: sessionValue.description ?? '', + emodel_id: sessionValue.emodel?.id, + morphology_id: sessionValue.mmodel?.id, + species_id: sessionValue.mmodel?.species.id, + brain_region_id: sessionValue.mmodel?.brain_region.id ?? sessionValue.brainRegion?.id, + strain_id: sessionValue.mmodel?.strain?.id ?? null, + validation_status: ValidationStatus.Initialized, + }; + + const buildMeModel = async () => { + let validatedPayload: TCreateMeModelContext | null = null; + + validatedPayload = await CreateMeModelContextSchema.parseAsync(payload); + const accountingSession = new OneshotSession({ + subtype: ServiceSubtype.SingleCellBuild, + virtualLabId, + projectId, + count: 1, + }); + + const data = await accountingSession.useWith(() => + createMEModel({ + body: omit(validatedPayload, ['virtualLabId', 'projectId']), + context: { virtualLabId, projectId }, + }) + ); + + return data; + }; + + const mutate = useMutation({ + mutationFn: buildMeModel, + onSuccess: (data) => { + navigate( + `${V2_MIGRATION_TEMPORARY_BASE_PATH}/${virtualLabId}/${projectId}/explore/view/memodel/${data.id}` + ); + }, + async onSettled() { + await queryClient.invalidateQueries({ + queryKey: [{ context: { key: `${virtualLabId}/${projectId}/explore/memodel/project` } }], + }); + await queryClient.invalidateQueries({ + queryKey: [ + 'workspace/activities', + { virtualLabId, projectId, scale: 'memodel', type: 'build', entity: 'memodel' }, + ], + }); + }, + }); + + const result = CreateMeModelContextSchema.safeParse(payload); + const disabled = mutate.isPending || !!result.error; + + return ( +
+
Setup
+ +
Modeling
+ + + + +
+ +
+
+ {disabled && ( + +

+ Please fill all the required information
along with selecting m-model and + e-model +

+
+ )} +
+
+ ); +} diff --git a/src/ui/segments/workflows/build/memodel/overview.tsx b/src/ui/segments/workflows/build/memodel/overview.tsx new file mode 100644 index 000000000..4728b9635 --- /dev/null +++ b/src/ui/segments/workflows/build/memodel/overview.tsx @@ -0,0 +1,83 @@ +'use client'; + +import { useSession } from 'next-auth/react'; +import { Form, Input } from 'antd'; + +import { useBuildMeModelSessionState, label } from '@/ui/segments/workflows/build/memodel/helpers'; +import { useWorkspace } from '@/ui/hooks/use-workspace'; +import { makeDateToAppFormat } from '@/util/date'; + +type Props = { + sessionId: string; +}; + +export function Info({ sessionId }: Props) { + const { data } = useSession(); + const { virtualLabId, projectId } = useWorkspace(); + const [form] = Form.useForm(); + + const { setSessionValue, sessionValue } = useBuildMeModelSessionState({ + sessionId, + virtualLabId, + projectId, + }); + + const onValuesChange = (changedValues: { name: string; description?: string }) => { + setSessionValue({ ...sessionValue, ...changedValues }); + }; + + return ( +
+
+ *)} + name="name" + validateTrigger="onBlur" + rules={[{ required: true, message: 'Please provide a name!' }]} + > + + + + + +
+
+
{label('Created by', 'secondary')}
+
{data?.user.name ?? data?.user.username}
+
+
+
{label('created at', 'secondary')}
+
+ {makeDateToAppFormat(new Date().toISOString())} +
+
+
+ +
+
+ ); +} diff --git a/src/ui/segments/workflows/build/single-neuron-synaptome/header.tsx b/src/ui/segments/workflows/build/single-neuron-synaptome/header.tsx new file mode 100644 index 000000000..c27ada590 --- /dev/null +++ b/src/ui/segments/workflows/build/single-neuron-synaptome/header.tsx @@ -0,0 +1,19 @@ +'use client'; + +import { useDefaultBreakpoint } from '@/ui/hooks/create-break-point'; +import { Button } from '@/ui/molecules/button'; +import { cn } from '@/utils/css-class'; + +export function Header() { + const breakpoint = useDefaultBreakpoint(); + return ( + + ); +} diff --git a/src/ui/segments/workflows/build/single-neuron-synaptome/helpers.tsx b/src/ui/segments/workflows/build/single-neuron-synaptome/helpers.tsx new file mode 100644 index 000000000..b5544365a --- /dev/null +++ b/src/ui/segments/workflows/build/single-neuron-synaptome/helpers.tsx @@ -0,0 +1,72 @@ +'use client'; + +import type { ReactNode } from 'react'; +import superjson from 'superjson'; + +import { useSessionStorage } from '@/hooks/use-session-storage'; +import { useWorkspace } from '@/ui/hooks/use-workspace'; +import { cn } from '@/utils/css-class'; + +import type { TSingleNeuronSynaptomeConfiguration } from '@/api/entitycore/types/entities/single-neuron-synaptome'; +import type { IMEModel } from '@/api/entitycore/types'; +import type { WorkspaceContext } from '@/types/common'; + +type Props = { + sessionId: string; +}; + +export const BuildStep = { + Info: 'info', + MEModel: 'me-model', + SynapseSet: 'synapse-set', +} as const; +export type BuildStepKeys = (typeof BuildStep)[keyof typeof BuildStep]; + +export function useBuildSingleNeuronSynaptomeSessionState(props: Props) { + const { virtualLabId, projectId } = useWorkspace(); + const { sessionValue, removeSessionValue, setSessionValue } = useSessionStorage< + | (Partial & { + seed: number; + name?: string | undefined; + description?: string | undefined; + memodel?: IMEModel | undefined; + synapseSets?: Map; + synapseCount?: Map; + }) + | null + >( + props.sessionId, + { + seed: 100, + name: undefined, + description: undefined, + memodel: undefined, + virtualLabId, + projectId, + synapseSets: undefined, + }, + { + initializeWithValue: true, + serializer: (value) => superjson.stringify(value), + deserializer: (value) => superjson.parse(value), + } + ); + + return { + sessionValue, + setSessionValue, + removeSessionValue, + }; +} + +export const label = (text: string, type: 'main' | 'secondary' = 'main', extra?: ReactNode) => ( + + {text} {extra} + +); diff --git a/src/ui/segments/workflows/build/single-neuron-synaptome/index.tsx b/src/ui/segments/workflows/build/single-neuron-synaptome/index.tsx new file mode 100644 index 000000000..b586a35bc --- /dev/null +++ b/src/ui/segments/workflows/build/single-neuron-synaptome/index.tsx @@ -0,0 +1,37 @@ +'use client'; + +import { useSearchParams } from 'next/navigation'; +import { match, P } from 'ts-pattern'; + +import { SynapseSetConfiguration } from '@/ui/segments/workflows/build/single-neuron-synaptome/synapse-configuration'; +import { MEModel } from '@/ui/segments/workflows/build/single-neuron-synaptome/me-model'; +import { Info } from '@/ui/segments/workflows/build/single-neuron-synaptome/overview'; +import { useDisableElementOverflow } from '@/ui/hooks/use-disable-element-overflow'; +import { + BuildStep, + useBuildSingleNeuronSynaptomeSessionState, +} from '@/ui/segments/workflows/build/single-neuron-synaptome/helpers'; + +type Props = { + sessionId: string; +}; + +export function Content({ sessionId }: Props) { + useDisableElementOverflow({ id: 'workspace-body' }); + const searchParams = useSearchParams(); + const step = searchParams.get('step') ?? BuildStep.Info; + const { sessionValue } = useBuildSingleNeuronSynaptomeSessionState({ + sessionId, + }); + + const content = match({ step, sessionValue }) + .with({ step: BuildStep.Info }, () => ) + .with({ step: BuildStep.MEModel }, () => ) + .with( + { step: BuildStep.SynapseSet, sessionValue: { memodel: P.nonNullable.select('memodel') } }, + () => + ) + .otherwise(() => null); + + return content; +} diff --git a/src/ui/segments/workflows/build/single-neuron-synaptome/me-model.tsx b/src/ui/segments/workflows/build/single-neuron-synaptome/me-model.tsx new file mode 100644 index 000000000..2a8e6bc7c --- /dev/null +++ b/src/ui/segments/workflows/build/single-neuron-synaptome/me-model.tsx @@ -0,0 +1,76 @@ +'use client'; + +import { motion } from 'motion/react'; +import { useState } from 'react'; + +import { useBuildSingleNeuronSynaptomeSessionState } from '@/ui/segments/workflows/build/single-neuron-synaptome/helpers'; +import { ExtendedEntitiesTypeDict } from '@/api/entitycore/types/extended-entity-type'; +import { useDisableElementOverflow } from '@/ui/hooks/use-disable-element-overflow'; +import { useSelectEntityClickEvent } from '@/ui/segments/mini-detail-view/event'; +import { BrowseEntityScope } from '@/features/views/listing/browse-entity'; +import { WorkspaceScope, WorkspaceSection } from '@/constants'; +import { cn } from '@/utils/css-class'; + +import type { IMEModel } from '@/api/entitycore/types'; + +type Props = { + sessionId: string; +}; + +export function MEModel({ sessionId }: Props) { + const [miniViewPresent, setMiniViewPresent] = useState(false); + useDisableElementOverflow({ id: 'workspace-body' }); + useSelectEntityClickEvent((ev) => { + setMiniViewPresent(ev.detail.display); + }); + + const { setSessionValue, sessionValue } = useBuildSingleNeuronSynaptomeSessionState({ + sessionId, + }); + + return ( + + { + const record = rows.at(0); + setSessionValue({ + ...sessionValue, + seed: sessionValue?.seed ?? 100, + memodel: record as unknown as IMEModel, + }); + }, + }} + /> + + ); +} diff --git a/src/ui/segments/workflows/build/single-neuron-synaptome/menu.tsx b/src/ui/segments/workflows/build/single-neuron-synaptome/menu.tsx new file mode 100644 index 000000000..0c2ac6a66 --- /dev/null +++ b/src/ui/segments/workflows/build/single-neuron-synaptome/menu.tsx @@ -0,0 +1,398 @@ +'use client'; + +import { usePathname, useRouter, useSearchParams } from 'next/navigation'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { + CheckCircleFilled, + LoadingOutlined, + RightOutlined, + SettingFilled, +} from '@ant-design/icons'; +import isNil from 'lodash/isNil'; +import { z } from 'zod'; + +import { DEFAULT_SYNAPSE_VALUE } from '@/features/entities/single-neuron-synaptome/build/elements/synapse-config-form'; +import { SynapseSetMenuItems } from '@/ui/segments/workflows/build/single-neuron-synaptome/synapse-set-menu-item'; +import { + SingleNeuronSynaptomeBaseSchema, + SingleNeuronSynaptomeConfigurationSchema, +} from '@/api/entitycore/types/entities/single-neuron-synaptome'; +import { SingleNeuronSynaptome } from '@/entity-configuration/domain/model/single-neuron-synaptome'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/ui/molecules/tooltip'; +import { useDefaultBreakpoint } from '@/ui/hooks/create-break-point'; +import { + useBuildSingleNeuronSynaptomeSessionState, + BuildStepKeys, + BuildStep, +} from '@/ui/segments/workflows/build/single-neuron-synaptome/helpers'; +import { createJsonAsset } from '@/api/entitycore/queries/assets'; +import { useAppNotification } from '@/components/notification'; +import { V2_MIGRATION_TEMPORARY_BASE_PATH } from '@/config'; +import { keyBuilder } from '@/ui/use-query-keys/workspace'; +import { useWorkspace } from '@/ui/hooks/use-workspace'; +import { OneshotSession } from '@/services/accounting'; +import { ServiceSubtype } from '@/types/accounting'; +import { messages } from '@/i18n/en/synaptome'; +import { Button } from '@/ui/molecules/button'; +import { tryCatch } from '@/api/utils'; +import { cn } from '@/utils/css-class'; + +import type { IAsset } from '@/api/entitycore/types/shared/global'; +import type { + ISingleNeuronSynaptome, + TSingleNeuronSynaptomeConfiguration, +} from '@/api/entitycore/types/entities/single-neuron-synaptome'; + +type Props = { sessionId: string }; + +const mainFormSchema = z.object({ + name: z.string().nonempty().min(1), + description: z.string().optional(), + me_model_id: z.string().uuid(), + seed: z.number().nonnegative(), +}); + +export function Menu({ sessionId }: Props) { + const notification = useAppNotification(); + const breakpoint = useDefaultBreakpoint(); + const searchParams = useSearchParams(); + const queryClient = useQueryClient(); + const pathname = usePathname(); + const { replace, push: navigate } = useRouter(); + const { virtualLabId, projectId } = useWorkspace(); + const step = searchParams.get('step'); + + const { sessionValue, setSessionValue } = useBuildSingleNeuronSynaptomeSessionState({ + sessionId, + }); + + const onStepChange = (s: BuildStepKeys) => { + const query = new URLSearchParams(searchParams); + query.set('step', s); + + replace( + `${V2_MIGRATION_TEMPORARY_BASE_PATH}/${virtualLabId}/${projectId}/workflows/build/configure/single-neuron-synaptome?${query.toString()}` + ); + }; + + // virtualLabId, + // projectId, + // name: sessionValue.name, + // description: sessionValue.description ?? '', + // emodel_id: sessionValue.emodel?.id, + // morphology_id: sessionValue.mmodel?.id, + // species_id: sessionValue.mmodel?.species.id, + // brain_region_id: sessionValue.mmodel?.brain_region.id ?? sessionValue.brainRegion?.id, + // strain_id: sessionValue.mmodel?.strain?.id ?? null, + // validation_status: ValidationStatus.Initialized, + // }; + + // const buildMeModel = async () => { + // let validatedPayload: TCreateMeModelContext | null = null; + + // try { + // validatedPayload = await CreateMeModelContextSchema.parseAsync(payload); + // } catch (err) { + // throw err; + // } + + // const accountingSession = new OneshotSession({ + // subtype: ServiceSubtype.SingleCellBuild, + // virtualLabId, + // projectId, + // count: 1, + // }); + + // const data = await accountingSession.useWith(() => + // createMEModel({ + // body: omit(validatedPayload, ['virtualLabId', 'projectId']), + // context: { virtualLabId, projectId }, + // }) + // ); + + // return data; + // }; + + // const mutate = useMutation({ + // mutationFn: buildMeModel, + // onSuccess: (data) => { + // navigate( + // `${V2_MIGRATION_TEMPORARY_BASE_PATH}/${virtualLabId}/${projectId}/explore/view/memodel/${data.id}` + // ); + // }, + // async onSettled() { + // await queryClient.invalidateQueries({ + // queryKey: [{ context: { key: `${virtualLabId}/${projectId}/explore/memodel/project` } }], + // }); + // await queryClient.invalidateQueries({ + // queryKey: [ + // 'workspace/activities', + // { virtualLabId, projectId, scale: 'memodel', type: 'build', entity: 'memodel' }, + // ], + // }); + // }, + // }); + + // const result = CreateMeModelContextSchema.safeParse(payload); + // const paths = flatten(flatten(result.error?.issues).map((o) => o.path)); + + // const isInfo = intersection(paths, ['name', 'description']).length > 0; + // const isEmodel = intersection(paths, ['emodel_id']).length > 0; + // const isMmodel = + // intersection(paths, ['brain_region_id', 'morphology_id', 'species_id']).length > 0; + + // const disabled = mutate.isPending || !!result.error; + + const onAdd = () => { + const id = crypto.randomUUID(); + const queryParams = new URLSearchParams(searchParams); + queryParams.set('set', id); + queryParams.set('step', BuildStep.SynapseSet); + const synapseSetsMap = new Map([]); + synapseSetsMap.set(id, { + ...DEFAULT_SYNAPSE_VALUE, + id, + seed: 100, + }); + + setSessionValue({ + ...sessionValue, + seed: sessionValue?.seed ?? 100, + synapseSets: synapseSetsMap, + }); + + replace(`${pathname}?${queryParams.toString()}`); + }; + + const validSetsCount = Array.from(sessionValue?.synapseSets?.values() ?? [])?.filter( + (o) => SingleNeuronSynaptomeBaseSchema.safeParse(o).success + ).length; + + const validateMainForm = mainFormSchema.safeParse({ + name: sessionValue?.name, + description: sessionValue?.description, + me_model_id: sessionValue?.memodel?.id, + seed: sessionValue?.seed, + }).success; + + const buildSynaptome = async () => { + const validationPromises = Array.from(sessionValue?.synapseSets?.entries() ?? []).map( + ([, value]) => SingleNeuronSynaptomeConfigurationSchema.safeParseAsync(value) + ); + const sets = (await Promise.all(validationPromises)).filter((o) => o.success); + + const build = async () => { + const { data, error } = await tryCatch( + SingleNeuronSynaptome.api.query.create!({ + context: { virtualLabId, projectId }, + body: { + brain_region_id: sessionValue?.memodel?.brain_region.id, + name: sessionValue?.name, + description: sessionValue?.description || '', + seed: sessionValue?.seed, + me_model_id: sessionValue?.memodel?.id, + }, + }) + ); + if (error) throw new Error(messages.CreateSynaptomeEntityFailed); + + const { data: assetData, error: err } = await tryCatch( + createJsonAsset({ + ctx: { virtualLabId, projectId }, + entityId: data?.id, + entityType: SingleNeuronSynaptome.type, + path: `${SingleNeuronSynaptome.asset.configfile}_${data?.id}`, + label: SingleNeuronSynaptome.asset.configfile, + payload: { synapses: sets.map((o) => o.data) }, + }) + ); + + if (err) throw new Error(messages.CreateConfigurationAssetFailed); + return { + entity: data, + asset: assetData, + }; + }; + + const accountingSession = new OneshotSession({ + virtualLabId, + projectId, + subtype: ServiceSubtype.SynaptomeBuild, + count: 1, + }); + const result = await accountingSession.useWith<{ + entity: ISingleNeuronSynaptome; + asset: IAsset; + } | null>(build); + + return result; + }; + + const mutate = useMutation({ + mutationFn: buildSynaptome, + onSuccess: (data) => { + navigate( + `${V2_MIGRATION_TEMPORARY_BASE_PATH}/${virtualLabId}/${projectId}/explore/view/single-neuron-synaptome/${data?.entity.id}` + ); + }, + onError: (error) => { + const errorMessage = + (error as any)?.cause?.error_code === 'INSUFFICIENT_FUNDS' + ? messages.LowFundsError + : messages.CreationModelFailed; + notification.error({ + message: errorMessage, + duration: 7, + placement: 'topRight', + key: 'synaptome-config', + }); + }, + async onSettled() { + await queryClient.invalidateQueries({ + queryKey: [ + { + context: { + key: `${virtualLabId}/${projectId}/explore/single-neuron-synaptome/project`, + }, + }, + ], + }); + await queryClient.invalidateQueries({ + queryKey: [ + keyBuilder.activities({ + virtualLabId, + projectId, + scale: 'single_neuron_synaptome', + type: 'build', + entity: 'single_neuron_synaptome', + }), + ], + }); + }, + }); + + const disabled = !validSetsCount || !validateMainForm || mutate.isPending; + return ( +
+
Setup
+ +
Modeling
+ + + +
+ +
+
+ +

Please select me model first

+
+
+ {!isNil(sessionValue?.memodel) && ( +
+ +
+ )} + + +
+ +
+
+ {disabled && ( + +

+ Please fill all the required information
along with selecting me-model and + configuring synapses +

+
+ )} +
+
+ ); +} diff --git a/src/ui/segments/workflows/build/single-neuron-synaptome/overview.tsx b/src/ui/segments/workflows/build/single-neuron-synaptome/overview.tsx new file mode 100644 index 000000000..5eb8e545f --- /dev/null +++ b/src/ui/segments/workflows/build/single-neuron-synaptome/overview.tsx @@ -0,0 +1,81 @@ +'use client'; + +import { useSession } from 'next-auth/react'; +import { Form, Input } from 'antd'; + +import { + useBuildSingleNeuronSynaptomeSessionState, + label, +} from '@/ui/segments/workflows/build/single-neuron-synaptome/helpers'; +import { makeDateToAppFormat } from '@/util/date'; + +type Props = { + sessionId: string; +}; + +export function Info({ sessionId }: Props) { + const { data } = useSession(); + const [form] = Form.useForm(); + + const { setSessionValue, sessionValue } = useBuildSingleNeuronSynaptomeSessionState({ + sessionId, + }); + + const onValuesChange = (changedValues: { name: string; description?: string }) => { + setSessionValue({ ...sessionValue, seed: sessionValue?.seed ?? 100, ...changedValues }); + }; + + return ( +
+
+ *)} + name="name" + validateTrigger="onBlur" + rules={[{ required: true, message: 'Please provide a name!' }]} + > + + + + + +
+
+
{label('Created by', 'secondary')}
+
{data?.user.name ?? data?.user.username}
+
+
+
{label('created at', 'secondary')}
+
+ {makeDateToAppFormat(new Date().toISOString())} +
+
+
+ +
+
+ ); +} diff --git a/src/ui/segments/workflows/build/single-neuron-synaptome/synapse-configuration.tsx b/src/ui/segments/workflows/build/single-neuron-synaptome/synapse-configuration.tsx new file mode 100644 index 000000000..e59e414ad --- /dev/null +++ b/src/ui/segments/workflows/build/single-neuron-synaptome/synapse-configuration.tsx @@ -0,0 +1,35 @@ +import { SynapseSet } from '@/ui/segments/workflows/build/single-neuron-synaptome/synapse-set-item'; +import { useBuildSingleNeuronSynaptomeSessionState } from '@/ui/segments/workflows/build/single-neuron-synaptome/helpers'; +import { NeuronViewerContainer } from '@/components/neuron-viewer/neuron-viewer-with-actions'; +import { useWorkspace } from '@/ui/hooks/use-workspace'; + +type Props = { + sessionId: string; +}; + +export function SynapseSetConfiguration({ sessionId }: Props) { + const { virtualLabId, projectId } = useWorkspace(); + const { sessionValue } = useBuildSingleNeuronSynaptomeSessionState({ + sessionId, + }); + + return ( +
+ +
+ {sessionValue?.memodel?.id && ( + + )} +
+
+ ); +} diff --git a/src/ui/segments/workflows/build/single-neuron-synaptome/synapse-set-item.tsx b/src/ui/segments/workflows/build/single-neuron-synaptome/synapse-set-item.tsx new file mode 100644 index 000000000..a3ab790d1 --- /dev/null +++ b/src/ui/segments/workflows/build/single-neuron-synaptome/synapse-set-item.tsx @@ -0,0 +1,750 @@ +'use client'; + +import { useMemo, useReducer, useRef, useState, useEffect } from 'react'; +import { Form, Input, Select, InputNumber } from 'antd'; +import { useSearchParams } from 'next/navigation'; +import { useAtom, useAtomValue } from 'jotai'; +import { Color } from 'three'; +import { + CloseOutlined, + DeleteOutlined, + InfoCircleFilled, + LoadingOutlined, + PlusCircleOutlined, +} from '@ant-design/icons'; + +import findIndex from 'lodash/findIndex'; +import isEqual from 'lodash/isEqual'; +import groupBy from 'lodash/groupBy'; +import map from 'lodash/map'; + +import { useBuildSingleNeuronSynaptomeSessionState } from '@/ui/segments/workflows/build/single-neuron-synaptome/helpers'; +import { SECTION_TARGET_MAPPING } from '@/features/entities/single-neuron-synaptome/build/elements/constants'; +import { createBubblesInstanced } from '@/services/bluenaas-single-cell/renderer-utils'; +import { synapsesPlacementAtom } from '@/state/synaptome'; +import { + validateSingleNeuronSynapseGenerationFormula, + getSingleNeuronSynaptomePlacement, +} from '@/api/small-scale-simulator'; +import { SettingAdjustment } from '@/components/icons/SettingAdjustment'; +import { useAppNotification } from '@/components/notification'; +import { ArrowSyncFilled } from '@/components/icons/buttons'; +import { secNamesAtom } from '@/state/simulate/single-neuron'; +import { Button } from '@/ui/molecules/button'; +import { messages } from '@/i18n/en/synaptome'; +import { + sendDisplaySynapses3DEvent, + sendRemoveSynapses3DEvent, +} from '@/components/neuron-viewer/hooks/events'; +import { classNames, getRandomIntInclusive } from '@/util/utils'; +import { useWorkspace } from '@/ui/hooks/use-workspace'; +import { tryCatch } from '@/api/utils'; +import { cn } from '@/utils/css-class'; + +import { + SingleNeuronSynaptomeConfigurationSchema, + type TSingleNeuronSynaptomeConfiguration, +} from '@/api/entitycore/types/entities/single-neuron-synaptome'; + +type Props = { + sessionId: string; +}; + +const label = (text: string, required: boolean = false, cls?: string) => ( + + {text} {required && *} + +); + +function updateSeeds( + synaptomeMap: Map, + getNewSeed: (oldSeed: number, key: string) => number +): Map { + return new Map( + map(Array.from(synaptomeMap.entries()), ([key, config]) => [ + key, + { + ...config, + seed: getNewSeed(config.seed, key), + }, + ]) + ); +} + +export function SynapseSet({ sessionId }: Props) { + const params = useSearchParams(); + const notification = useAppNotification(); + const { virtualLabId, projectId } = useWorkspace(); + const [form] = Form.useForm(); + const secNames = useAtomValue(secNamesAtom); + const [isFormValid, setIsFormValid] = useState(false); + const [visualizeLoading, setLoadingVisualize] = useState(false); + const [synapsesPlacement, setSynapsesPlacementAtom] = useAtom(synapsesPlacementAtom); + const [displayExclusionRules, toggleDisplayExclusionRules] = useReducer((val) => !val, false); + const [displayFormulaHelp, toggleFormulaHelp] = useReducer((val) => !val, false); + const { sessionValue, setSessionValue } = useBuildSingleNeuronSynaptomeSessionState({ + sessionId, + }); + + const setId = params.get('set'); + const modelId = sessionValue?.memodel?.id; + const synapses = sessionValue?.synapseSets; + const config = setId ? synapses?.get(setId) : undefined; + + const configRef = useRef(config); + const previousSetIdRef = useRef(null); + + const groupedSections = Object.keys( + groupBy(secNames, (str) => { + const bracketIndex = findIndex(str, (char) => char === '['); + return bracketIndex !== -1 ? str.slice(0, bracketIndex) : str; + }) + ); + + const hasApic = groupedSections.includes('apic'); + + const targetOptions = groupedSections.map((value) => ({ + value, + label: + value === 'dend' && !hasApic + ? 'Dendrites' + : SECTION_TARGET_MAPPING[value as keyof typeof SECTION_TARGET_MAPPING], + })); + + const formValues = Form.useWatch([], form); + + const isAlreadyVisualized = useMemo(() => { + if (!config) return false; + return !!Object.values(synapsesPlacement ?? []).find( + (c) => + c?.synapsePlacementConfigId === config.id && + c.meshId && + isEqual(config, { ...config, ...form.getFieldsValue() }) + ); + }, [config, synapsesPlacement, form]); + + useEffect(() => { + SingleNeuronSynaptomeConfigurationSchema.parseAsync(formValues) + .then(() => { + setIsFormValid(true); + }) + .catch(() => { + setIsFormValid(false); + }); + }, [formValues]); + + useEffect(() => { + if (previousSetIdRef.current !== setId) { + previousSetIdRef.current = setId; + + if (config) { + form.resetFields(); + form.setFieldsValue(config); + configRef.current = config; + } else { + form.resetFields(); + configRef.current = undefined; + } + } + }, [setId, config, form]); + + const onHideSynapse = () => { + if (config?.id) { + const currentSynapsesPlacementConfig = synapsesPlacement?.[config.id]; + if (currentSynapsesPlacementConfig && currentSynapsesPlacementConfig.meshId) { + sendRemoveSynapses3DEvent(config?.id, currentSynapsesPlacementConfig.meshId); + setSynapsesPlacementAtom({ + ...synapsesPlacement, + [config.id]: { + ...currentSynapsesPlacementConfig, + count: undefined, + meshId: undefined, + }, + }); + } + } + }; + + const addNewExclusionRule = () => { + const id = crypto.randomUUID(); + const currentRules = form.getFieldValue(['exclusion_rules']) || []; + const newRuleIndex = currentRules.length; + + form.setFieldValue( + ['exclusion_rules'], + [ + ...currentRules, + { + id, + distance_soma_gte: null, + distance_soma_lte: null, + }, + ] + ); + + setTimeout(() => { + form + .validateFields([ + ['exclusion_rules', newRuleIndex, 'distance_soma_gte'], + ['exclusion_rules', newRuleIndex, 'distance_soma_lte'], + ]) + .catch(() => { + // validation errors are expected for empty fields, this is intentional + }); + }, 0); + }; + + const onTargetChange = (newTarget?: keyof typeof SECTION_TARGET_MAPPING) => { + if (config) { + const tempSessionValue = sessionValue; + if (newTarget === 'soma') { + config.target = newTarget; + config.formula = undefined; + config.soma_synapse_count = 50; + tempSessionValue?.synapseSets?.set(config.id, config); + + setSessionValue({ + ...tempSessionValue, + seed: tempSessionValue?.seed ?? 100, + synapseSets: tempSessionValue?.synapseSets, + }); + } + if (config?.target === 'soma' && newTarget !== 'soma') { + config.soma_synapse_count = undefined; + config.target = newTarget; + tempSessionValue?.synapseSets?.set(config.id, config); + setSessionValue({ + ...tempSessionValue, + seed: tempSessionValue?.seed ?? 100, + synapseSets: tempSessionValue?.synapseSets, + }); + } + } + }; + + const showExclusionRules = () => { + if (config && !config.exclusion_rules?.length && !displayExclusionRules) { + addNewExclusionRule(); + } + toggleDisplayExclusionRules(); + }; + + const exclusionRuleNotFilled = + config?.exclusion_rules?.some((p) => !p.distance_soma_gte && !p.distance_soma_lte) && + !displayExclusionRules; + + const onVisualizationError = async (response?: Error) => { + const index = + findIndex( + Array.from(sessionValue?.synapseSets?.entries() ?? []), + ([key]) => key === config?.id + ) + 1; + if (!response) { + notification.error({ + message: messages.GenerationSynapsesFailed.replace('$$', index.toString()), + placement: 'topRight', + }); + return; + } + + try { + notification.error({ + message: 'Failed to generate synapses, The error occurred in the server', + placement: 'topRight', + }); + } catch { + notification.error({ + message: messages.GenerationSynapsesFailed.replace('$$', index.toString()), + placement: 'topRight', + }); + } + }; + + const onApplyChanges = async (values: TSingleNeuronSynaptomeConfiguration) => { + if (isAlreadyVisualized) return; + + if (config) { + setLoadingVisualize(true); + onHideSynapse(); + const seed = sessionValue?.seed!; + try { + const configSet = { + color: config.color, + id: config.id, + seed: config.seed, + name: values.name, + formula: values.formula, + target: values.target, + type: values.type, + exclusion_rules: values.exclusion_rules ?? null, + soma_synapse_count: values.soma_synapse_count, + }; + const { data, error } = await tryCatch( + getSingleNeuronSynaptomePlacement({ + modelId: modelId!, + ctx: { virtualLabId, projectId }, + payload: { + seed, + config: configSet, + }, + }) + ); + + if (error) return onVisualizationError(error); + + const synapsePositions = data.synapses + .flat() + .flatMap((p) => p.synapses) + .map((o) => o.coordinates); + + const mesh = createBubblesInstanced(synapsePositions, new Color(config.color)); + sendDisplaySynapses3DEvent(config.id, mesh); + + const newSynapseSet = new Map(sessionValue?.synapseSets); + const newSynapseCount = new Map(sessionValue?.synapseCount); + newSynapseSet.set(config.id, configSet); + newSynapseCount.set(config.id, synapsePositions.length); + + setSessionValue({ + ...sessionValue, + seed: sessionValue?.seed ?? 100, + synapseSets: newSynapseSet, + synapseCount: newSynapseCount, + }); + + setSynapsesPlacementAtom({ + ...synapsesPlacement, + [config.id]: { + sectionSynapses: data.synapses, + count: synapsePositions.length, + meshId: mesh.uuid, + synapsePlacementConfigId: config.id, + }, + }); + + configRef.current = configSet; + } catch (error) { + return onVisualizationError(); + } finally { + setLoadingVisualize(false); + } + } + }; + + const onChangeSeed = (value: number | null) => { + setSessionValue({ + ...sessionValue, + seed: value ?? 100, + synapseSets: updateSeeds( + sessionValue?.synapseSets ?? new Map(), + () => Number(value) + getRandomIntInclusive(0, Number(value)) + ), + }); + Array.from(sessionValue?.synapseSets?.entries() ?? []).forEach(([, v]) => { + const mesh = synapsesPlacement?.[v.id]?.meshId; + if (mesh) { + sendRemoveSynapses3DEvent(v.id, mesh); + } + }); + }; + + return ( +
+
+
+ {label('seed', true)} + +
+
+
+
+ + + + + + +
+ + + +
+ +
+ {config?.target === 'soma' ? ( +
+
+
+ {label('Synapse Count', true)} +
+ + + +
+
+ ) : ( +
+
+
+ {label('Synapse distribution formula', true, 'normal-case')} + {displayFormulaHelp ? ( + + ) : ( + + )} +
+

+ Supports advanced math functions (e.g., sin(x), log(x), ...).
+ + https://docs.sympy.org/latest/index.html + +

+
+ + x: distance from soma (µm)} + rules={[ + { + required: true, + message: 'Please provide a valid distribution formula!', + async validator(_, value) { + if (config?.target === 'soma') { + return Promise.resolve(); + } + if (value) { + const result = await validateSingleNeuronSynapseGenerationFormula(value); + if (!result) return Promise.reject(); + return Promise.resolve(); + } + if (!value) return Promise.reject(); + }, + }, + ]} + validateTrigger="onBlur" + className="[&_.ant-form-item-required]:w-full" + > + + Synapses/µm + + } + /> + +
+ )} +
+ +
+ +
+ + {(fields, { remove: removeRule }) => ( +
+ {fields.map((f, indx) => { + return ( +
+
+
+ rule {indx + 1} +
+ +
+
+ Exclude synapses that are: +

where the distance from soma is:

+
+
+
+
+ greater or equal to +
+ { + const allValues = form.getFieldsValue(); + const currentRule = allValues.exclusion_rules?.[f.name]; + const gteValue = value; + const lteValue = currentRule?.distance_soma_lte; + + if ( + !gteValue && + gteValue !== 0 && + !lteValue && + lteValue !== 0 + ) { + return Promise.reject( + new Error('At least one distance value must be provided') + ); + } + + return Promise.resolve(); + }, + }, + ]} + > + { + // Trigger validation on the other field when this changes + form.validateFields([ + ['exclusion_rules', f.name, 'distance_soma_lte'], + ]); + }} + /> + +
+
+
+ less or equal to +
+ { + const allValues = form.getFieldsValue(); + const currentRule = allValues.exclusion_rules?.[f.name]; + const lteValue = value; + const gteValue = currentRule?.distance_soma_gte; + + // At least one field must be provided + if ( + !gteValue && + gteValue !== 0 && + !lteValue && + lteValue !== 0 + ) { + return Promise.reject( + new Error('At least one distance value must be provided') + ); + } + + return Promise.resolve(); + }, + }, + ]} + > + { + // Trigger validation on the other field when this changes + form.validateFields([ + ['exclusion_rules', f.name, 'distance_soma_gte'], + ]); + }} + /> + +
+
+
+ ); + })} +
+ )} +
+ +
+
+ +
+ +
+
+ +
+ ); +} diff --git a/src/ui/segments/workflows/build/single-neuron-synaptome/synapse-set-menu-item.tsx b/src/ui/segments/workflows/build/single-neuron-synaptome/synapse-set-menu-item.tsx new file mode 100644 index 000000000..9a818e65e --- /dev/null +++ b/src/ui/segments/workflows/build/single-neuron-synaptome/synapse-set-menu-item.tsx @@ -0,0 +1,265 @@ +'use client'; + +import { DeleteOutlined, EyeInvisibleOutlined, EyeOutlined, PlusOutlined } from '@ant-design/icons'; +import { usePathname, useRouter, useSearchParams } from 'next/navigation'; +import { useAtom } from 'jotai'; +import { Color } from 'three'; +import sample from 'lodash/sample'; + +import { useBuildSingleNeuronSynaptomeSessionState } from '@/ui/segments/workflows/build/single-neuron-synaptome/helpers'; +import { DEFAULT_SYNAPSE_VALUE } from '@/features/entities/single-neuron-synaptome/build/elements/synapse-config-form'; +import { SingleNeuronSynaptomeBaseSchema } from '@/api/entitycore/types/entities/single-neuron-synaptome'; +import { createBubblesInstanced } from '@/services/bluenaas-single-cell/renderer-utils'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/ui/molecules/tooltip'; +import { SIMULATION_COLORS } from '@/constants/simulate/single-neuron'; +import { useDefaultBreakpoint } from '@/ui/hooks/create-break-point'; +import { synapsesPlacementAtom } from '@/state/synaptome'; +import { formatCompactNumber } from '@/utils/format'; +import { getRandomIntInclusive } from '@/util/utils'; +import { Button } from '@/ui/molecules/button'; +import { + sendRemoveSynapses3DEvent, + sendDisplaySynapses3DEvent, +} from '@/components/neuron-viewer/hooks/events'; +import { cn } from '@/utils/css-class'; + +type Props = { sessionId: string }; + +export function SynapseSetMenuItems({ sessionId }: Props) { + const params = useSearchParams(); + const pathname = usePathname(); + const breakpoint = useDefaultBreakpoint(); + const { replace } = useRouter(); + const [synapsesPlacement, setSynapsesPlacementAtom] = useAtom(synapsesPlacementAtom); + const { sessionValue, setSessionValue } = useBuildSingleNeuronSynaptomeSessionState({ + sessionId, + }); + + const currentSet = params.get('set'); + + const onAdd = () => { + const id = crypto.randomUUID(); + const queryParams = new URLSearchParams(params); + queryParams.set('set', id); + const cloneMap = new Map(sessionValue?.synapseSets); + + cloneMap?.set(id, { + ...DEFAULT_SYNAPSE_VALUE, + id, + seed: (sessionValue?.seed ?? 0) + getRandomIntInclusive(0, sessionValue?.seed ?? 0), + color: sample(SIMULATION_COLORS) ?? SIMULATION_COLORS[cloneMap.size], + }); + + setSessionValue({ + ...sessionValue, + seed: sessionValue?.seed ?? 100, + synapseSets: cloneMap, + }); + + replace(`${pathname}?${queryParams.toString()}`); + }; + + const onSelectSet = (id: string) => { + const queryParams = new URLSearchParams(params); + queryParams.set('set', id); + replace(`${pathname}?${queryParams.toString()}`); + }; + + const onDeleteSet = (id: string) => { + const cloneMap = new Map(sessionValue?.synapseSets); + cloneMap.delete(id); + setSessionValue({ + ...sessionValue, + seed: sessionValue?.seed ?? 100, + synapseSets: cloneMap, + }); + const currentSynapsesPlacementConfig = synapsesPlacement?.[id]; + if (currentSynapsesPlacementConfig?.meshId) { + sendRemoveSynapses3DEvent(id, currentSynapsesPlacementConfig.meshId); + setSynapsesPlacementAtom({ + ...synapsesPlacement, + [id]: { + ...currentSynapsesPlacementConfig, + meshId: undefined, + }, + }); + } + if (currentSet === id) { + const queryParams = new URLSearchParams(params); + queryParams.delete('set'); + replace(`${pathname}?${queryParams.toString()}`); + } + }; + + const onToggleVisibility = (id: string) => { + const currentSynapsesPlacementConfig = synapsesPlacement?.[id]; + const synapseSet = sessionValue?.synapseSets?.get(id); + + if (currentSynapsesPlacementConfig?.meshId) { + sendRemoveSynapses3DEvent(id, currentSynapsesPlacementConfig.meshId); + setSynapsesPlacementAtom({ + ...synapsesPlacement, + [id]: { + ...currentSynapsesPlacementConfig, + meshId: undefined, + }, + }); + } else if (currentSynapsesPlacementConfig?.sectionSynapses && synapseSet) { + const synapsePositions = currentSynapsesPlacementConfig.sectionSynapses + .flat() + .flatMap((p) => p.synapses) + .map((o) => o.coordinates); + + const mesh = createBubblesInstanced(synapsePositions, new Color(synapseSet.color)); + sendDisplaySynapses3DEvent(id, mesh); + + setSynapsesPlacementAtom({ + ...synapsesPlacement, + [id]: { + ...currentSynapsesPlacementConfig, + meshId: mesh.uuid, + }, + }); + } + }; + + return ( +
+
+ {Array.from(sessionValue?.synapseSets?.values() ?? []) + ?.filter((o) => SingleNeuronSynaptomeBaseSchema.safeParse(o).success) + .map((o) => { + const isVisible = !!synapsesPlacement?.[o.id]?.meshId; + const canShow = !!synapsesPlacement?.[o.id]?.sectionSynapses; + const count = sessionValue?.synapseCount?.get(o.id); + return ( +
+ + +
+ + +
+ +
+
+ {!isVisible && !canShow && ( + +

Please apply changes again

+
+ )} +
+ + +
+
+ ); + })} +
+ + +
+ ); +} diff --git a/src/ui/segments/workflows/elements/browse-build-action.tsx b/src/ui/segments/workflows/elements/browse-build-action.tsx new file mode 100644 index 000000000..598a08c50 --- /dev/null +++ b/src/ui/segments/workflows/elements/browse-build-action.tsx @@ -0,0 +1,44 @@ +'use client'; + +import { useParams, usePathname } from 'next/navigation'; +import { PlusOutlined } from '@ant-design/icons'; +import lowerCase from 'lodash/lowerCase'; +import snakeCase from 'lodash/snakeCase'; +import Link from 'next/link'; + +import { getEntityByExtendedType } from '@/entity-configuration/domain/helpers'; +import { getWorkflowSegment } from '@/ui/segments/workflows/elements/helpers'; +import { useDefaultBreakpoint } from '@/ui/hooks/create-break-point'; +import { V2_MIGRATION_TEMPORARY_BASE_PATH } from '@/config'; +import { useWorkspace } from '@/ui/hooks/use-workspace'; +import { Button } from '@/ui/molecules/button'; + +import type { TExtendedEntitiesTypeDict } from '@/api/entitycore/types/extended-entity-type'; +import type { KebabCase } from '@/utils/type'; + +export function BrowseAction() { + const pathname = usePathname(); + const breakpoint = useDefaultBreakpoint(); + const segment = getWorkflowSegment(pathname); + const { type } = useParams<{ type: KebabCase }>(); + const { virtualLabId, projectId } = useWorkspace(); + + const link = `${V2_MIGRATION_TEMPORARY_BASE_PATH}/${virtualLabId}/${projectId}/workflows/${segment}/configure/${type}`; + const entity = getEntityByExtendedType({ type: snakeCase(type) as TExtendedEntitiesTypeDict }); + const title = `New ${lowerCase(entity?.title)}`; + + return ( + + ); +} diff --git a/src/ui/segments/workflows/elements/browse-action.tsx b/src/ui/segments/workflows/elements/browse-simulate-action.tsx similarity index 100% rename from src/ui/segments/workflows/elements/browse-action.tsx rename to src/ui/segments/workflows/elements/browse-simulate-action.tsx diff --git a/src/ui/segments/workflows/elements/build-breadcrumb.tsx b/src/ui/segments/workflows/elements/build-breadcrumb.tsx new file mode 100644 index 000000000..ee9fc0185 --- /dev/null +++ b/src/ui/segments/workflows/elements/build-breadcrumb.tsx @@ -0,0 +1,65 @@ +'use client'; + +import { useParams, usePathname } from 'next/navigation'; +import { RightOutlined } from '@ant-design/icons'; +import snakeCase from 'lodash/snakeCase'; +import Link from 'next/link'; + +import { getEntityByExtendedType } from '@/entity-configuration/domain/helpers'; +import { V2_MIGRATION_TEMPORARY_BASE_PATH } from '@/config'; +import { useWorkspace } from '@/ui/hooks/use-workspace'; +import { + getEntityTypeWorkflowConfigurationItem, + getCategoryDictItem, + getWorkflowSegment, +} from '@/ui/segments/workflows/elements/helpers'; +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbSeparator, +} from '@/ui/molecules/breadcrumb/index'; + +import type { TExtendedEntitiesTypeDict } from '@/api/entitycore/types/extended-entity-type'; +import type { KebabCase } from '@/utils/type'; + +export function BuildWorkflowsBreadcrumb() { + const pathname = usePathname(); + const segment = getWorkflowSegment(pathname); + + const { type } = useParams<{ type: KebabCase }>(); + const { virtualLabId, projectId } = useWorkspace(); + + const dataType = snakeCase(type) as TExtendedEntitiesTypeDict; + const category = getCategoryDictItem(segment)?.name; + + const selectTitle = getEntityByExtendedType({ type: dataType })?.title; + const buildTitle = getEntityTypeWorkflowConfigurationItem(dataType)?.label; + const homeLink = `${V2_MIGRATION_TEMPORARY_BASE_PATH}/${virtualLabId}/${projectId}/workflows/${segment}/browse/${type}`; + + return ( +
+ + + + + + {buildTitle} {category} + + + + + + + + Select {selectTitle} + + + +
+ ); +} diff --git a/src/ui/segments/workflows/elements/helpers.tsx b/src/ui/segments/workflows/elements/helpers.tsx index 585477ba2..cc3f77e28 100644 --- a/src/ui/segments/workflows/elements/helpers.tsx +++ b/src/ui/segments/workflows/elements/helpers.tsx @@ -27,6 +27,9 @@ export const CategoryDict = [ ] as const; export type TCategoryValue = (typeof CategoryDict)[number]['value']; +export const CategoryValues = Object.fromEntries(CategoryDict.map((c) => [c.label, c.value])) as { + [K in (typeof CategoryDict)[number] as K['label']]: K['value']; +}; type EntityTypeProperties = { disabled: boolean; diff --git a/src/ui/segments/workflows/elements/simulation-breadcrumb.tsx b/src/ui/segments/workflows/elements/simulate-breadcrumb.tsx similarity index 98% rename from src/ui/segments/workflows/elements/simulation-breadcrumb.tsx rename to src/ui/segments/workflows/elements/simulate-breadcrumb.tsx index 67433ac01..c120eb156 100644 --- a/src/ui/segments/workflows/elements/simulation-breadcrumb.tsx +++ b/src/ui/segments/workflows/elements/simulate-breadcrumb.tsx @@ -25,7 +25,7 @@ import { import type { TExtendedEntitiesTypeDict } from '@/api/entitycore/types/extended-entity-type'; import type { KebabCase } from '@/utils/type'; -export function SimulationBreadcrumb() { +export function SimulateWorkflowsBreadcrumb() { const pathname = usePathname(); const segment = getWorkflowSegment(pathname); @@ -37,7 +37,6 @@ export function SimulationBreadcrumb() { const buildType = getBuildTypeFromSimulateType(dataType); const selectTitle = getEntityByExtendedType({ type: buildType })?.title; const buildTitle = getEntityTypeWorkflowConfigurationItem(buildType)?.label; - const homeLink = `${V2_MIGRATION_TEMPORARY_BASE_PATH}/${virtualLabId}/${projectId}/workflows/${segment}/browse/${type}`; return ( diff --git a/src/ui/segments/workspaces/top-menu-nav.tsx b/src/ui/segments/workspaces/top-menu-nav.tsx index b6593f569..5e8c95dde 100644 --- a/src/ui/segments/workspaces/top-menu-nav.tsx +++ b/src/ui/segments/workspaces/top-menu-nav.tsx @@ -1,4 +1,4 @@ -import { PlusOutlined, MenuOutlined } from '@ant-design/icons'; +import { MenuOutlined } from '@ant-design/icons'; import { usePathname } from 'next/navigation'; import Link from 'next/link'; @@ -145,7 +145,7 @@ export function TopMenuNavigation() { {link.title} - @@ -156,7 +156,7 @@ export function TopMenuNavigation() { New {link.title.slice(0, -1)} - + */}
); @@ -182,19 +182,7 @@ export function TopMenuNavigation() { } return hashedLinks.map( - ({ - id, - key, - title, - url, - baseUrl, - icon, - allowText, - className: clx, - isActive, - hasAction, - action, - }) => ( + ({ id, key, title, url, baseUrl, icon, allowText, className: clx, isActive, hasAction }) => (
- {hasAction && action && ( + {/* {hasAction && action && (
- )} + )} */}
) diff --git a/src/ui/use-query-keys/data.tsx b/src/ui/use-query-keys/data.tsx index 2642a9f30..b13c5d0c6 100644 --- a/src/ui/use-query-keys/data.tsx +++ b/src/ui/use-query-keys/data.tsx @@ -1,4 +1,5 @@ -import { WorkspaceContext } from '@/types/common'; +import type { WorkspaceContext } from '@/types/common'; +import type { TWorkspaceScope } from '@/constants'; const prefix = 'explore-data'; @@ -7,9 +8,11 @@ export const keyBuilder = { virtualLabId, projectId, brainRegionId, - }: WorkspaceContext & { brainRegionId?: string }) => [ + personId, + scope, + }: WorkspaceContext & { brainRegionId?: string; personId?: string; scope: TWorkspaceScope }) => [ `${prefix}-count`, - { virtualLabId, projectId, brainRegionId: brainRegionId ?? '' }, + { virtualLabId, projectId, brainRegionId: brainRegionId ?? '', personId, scope }, ], userSimulationsCount: ({ virtualLabId, diff --git a/src/ui/use-query-keys/workspace.tsx b/src/ui/use-query-keys/workspace.tsx index 342cb8e6a..76619bee2 100644 --- a/src/ui/use-query-keys/workspace.tsx +++ b/src/ui/use-query-keys/workspace.tsx @@ -61,8 +61,8 @@ export const keyBuilder = { page, pageSize, }: WorkspaceContext & { - page: number; - pageSize: number; + page?: number; + pageSize?: number; scale: TExtendedEntitiesTypeDict; entity?: TExtendedEntitiesTypeDict; type: 'build' | 'simulate'; diff --git a/src/util/date.tsx b/src/util/date.tsx index ad0c535ae..b8f17c411 100644 --- a/src/util/date.tsx +++ b/src/util/date.tsx @@ -54,3 +54,15 @@ export function renderDateAndHour(date: string) {
); } + +export function makeDateToAppFormat(input: DateISOString) { + const date = validDate(input); + const formatter = new Intl.DateTimeFormat('en-GB', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + }); + + const formatted = formatter.format(date).replace(/\//g, '.'); + return formatted; +} diff --git a/src/utils/format.ts b/src/utils/format.ts index 8a960aec0..c77163a8b 100644 --- a/src/utils/format.ts +++ b/src/utils/format.ts @@ -41,3 +41,12 @@ export function formatBytes(bytes: number, decimals = 2): string { return parseFloat((bytes / k ** i).toFixed(dm)) + ' ' + sizes[i]; } + +export function formatCompactNumber(value: number, locale: string = 'en-US'): string { + const formatter = new Intl.NumberFormat(locale, { + notation: 'compact', + compactDisplay: 'short', + maximumFractionDigits: 2, + }); + return formatter.format(value); +}