diff --git a/packages/libs/eda/src/lib/core/components/VariableLink.tsx b/packages/libs/eda/src/lib/core/components/VariableLink.tsx index 02fd7d9c29..f1625ce260 100644 --- a/packages/libs/eda/src/lib/core/components/VariableLink.tsx +++ b/packages/libs/eda/src/lib/core/components/VariableLink.tsx @@ -70,12 +70,12 @@ export const VariableLink = forwardRef( tabIndex={0} style={finalStyle} onKeyDown={(event) => { - event.preventDefault(); if (disabled) { return; } if (event.key === 'Enter' || event.key === ' ') { linkConfig.onClick(value); + event.preventDefault(); } }} onClick={(event) => { diff --git a/packages/libs/eda/src/lib/notebook/EdaNotebook.scss b/packages/libs/eda/src/lib/notebook/EdaNotebook.scss new file mode 100644 index 0000000000..0f0a50c61b --- /dev/null +++ b/packages/libs/eda/src/lib/notebook/EdaNotebook.scss @@ -0,0 +1,35 @@ +.EdaNotebook { + .Heading { + display: flex; + gap: 2em; + align-items: baseline; + } + + .Paper { + max-width: 1250px; + padding: 1em; + margin: 1em auto; + background-color: #f3f3f3; + box-shadow: 0 0 2px #b5b5b5; + + > * + * { + margin-block-start: 1rem; + } + h2, + h3 { + padding: 0; + } + h3 { + font-size: 1em; + font-weight: 400; + line-height: 1.5; + } + } + + .Title { + fieldset { + padding: 0; + margin: 0; + } + } +} diff --git a/packages/libs/eda/src/lib/notebook/EdaNotebookAnalysis.tsx b/packages/libs/eda/src/lib/notebook/EdaNotebookAnalysis.tsx index 42dd05d224..327a3a7a20 100644 --- a/packages/libs/eda/src/lib/notebook/EdaNotebookAnalysis.tsx +++ b/packages/libs/eda/src/lib/notebook/EdaNotebookAnalysis.tsx @@ -1,16 +1,31 @@ -import React, { useState } from 'react'; -import { - Filter, - useAnalysis, - useStudyEntities, - useStudyMetadata, - useStudyRecord, -} from '../core'; +// Notes +// ===== +// +// - For now, we will only support "fixed" notebooks. If we want to allow "custom" notebooks, +// we have to make some decisions. +// - Do we want a top-down data flow? E.g., subsetting is global for an analysis. +// - Do we want to separate compute config from visualization? If so, how do we +// support that in the UI? +// - Do we want text-based cells? +// - Do we want download cells? It could have a preview. +// + +import React, { useCallback, useMemo } from 'react'; +import { useAnalysis, useStudyRecord } from '../core'; import { safeHtml } from '@veupathdb/wdk-client/lib/Utils/ComponentUtils'; import { SaveableTextEditor } from '@veupathdb/wdk-client/lib/Components'; -import Subsetting from '../workspace/Subsetting'; -import { useEntityCounts } from '../core/hooks/entityCounts'; -import FilterChipList from '../core/components/FilterChipList'; +import { ExpandablePanel } from '@veupathdb/coreui'; +import { NotebookCell as NotebookCellType } from './Types'; +import { NotebookCell } from './NotebookCell'; + +import './EdaNotebook.scss'; + +interface NotebookSettings { + /** Ordered array of notebook cells */ + cells: NotebookCellType[]; +} + +const NOTEBOOK_UI_SETTINGS_KEY = '@@NOTEBOOK@@'; interface Props { analysisId: string; @@ -18,69 +33,69 @@ interface Props { export function EdaNotebookAnalysis(props: Props) { const { analysisId } = props; + const studyRecord = useStudyRecord(); const analysisState = useAnalysis( analysisId === 'new' ? undefined : analysisId ); - const studyRecord = useStudyRecord(); - const studyMetadata = useStudyMetadata(); - const entities = useStudyEntities(); - const totalCountsResult = useEntityCounts(); - const filteredCountsResult = useEntityCounts( - analysisState.analysis?.descriptor.subset.descriptor + const { analysis } = analysisState; + const notebookSettings = useMemo((): NotebookSettings => { + const storedSettings = + analysis?.descriptor.subset.uiSettings[NOTEBOOK_UI_SETTINGS_KEY]; + if (storedSettings == null) + return { + cells: [ + { + type: 'subset', + title: 'Subset data', + }, + ], + }; + return storedSettings as any as NotebookSettings; + }, [analysis]); + const updateCell = useCallback( + (cell: Partial>, cellIndex: number) => { + const oldCell = notebookSettings.cells[cellIndex]; + const newCell = { ...oldCell, ...cell }; + const nextCells = notebookSettings.cells.concat(); + nextCells[cellIndex] = newCell; + const nextSettings = { + ...notebookSettings, + cells: nextCells, + }; + analysisState.setVariableUISettings({ + [NOTEBOOK_UI_SETTINGS_KEY]: nextSettings, + }); + }, + [analysisState, notebookSettings] ); - const [entityId, setEntityId] = useState(); - const [variableId, setVariableId] = useState(); return ( -
-

EDA Notebook

- {safeHtml(studyRecord.displayName, null, 'h2')} -

- -

-
- - Subset    - - analysisState.setFilters((filters) => - filters.filter( - (f) => - f.entityId !== filter.entityId || - f.variableId !== filter.variableId - ) - ) - } - variableLinkConfig={{ - type: 'button', - onClick: (value) => { - setEntityId(value?.entityId); - setVariableId(value?.variableId); - }, - }} - /> - - { - setEntityId(value?.entityId); - setVariableId(value?.variableId); - }, - }} - /> -
+
+
+

EDA Notebook

+
+
+
+

+ +

+

Study: {safeHtml(studyRecord.displayName)}

+
+ {notebookSettings.cells.map((cell, index) => ( + +
+ updateCell(update, index)} + /> +
+
+ ))} +
); } diff --git a/packages/libs/eda/src/lib/notebook/NotebookCell.tsx b/packages/libs/eda/src/lib/notebook/NotebookCell.tsx new file mode 100644 index 0000000000..dbf36328e3 --- /dev/null +++ b/packages/libs/eda/src/lib/notebook/NotebookCell.tsx @@ -0,0 +1,28 @@ +import { AnalysisState } from '../core'; +import { NotebookCell as NotebookCellType } from './Types'; +import { SubsettingNotebookCell } from './SubsettingNotebookCell'; + +interface Props { + analysisState: AnalysisState; + cell: NotebookCellType; + updateCell: (cell: Partial>) => void; +} + +/** + * Top-level component that delegates to imeplementations of NotebookCell variants. + */ +export function NotebookCell(props: Props) { + const { cell, analysisState, updateCell } = props; + switch (cell.type) { + case 'subset': + return ( + + ); + default: + return null; + } +} diff --git a/packages/libs/eda/src/lib/notebook/SubsettingNotebookCell.tsx b/packages/libs/eda/src/lib/notebook/SubsettingNotebookCell.tsx new file mode 100644 index 0000000000..ef817993a1 --- /dev/null +++ b/packages/libs/eda/src/lib/notebook/SubsettingNotebookCell.tsx @@ -0,0 +1,58 @@ +import { useMemo } from 'react'; +import { useEntityCounts } from '../core/hooks/entityCounts'; +import { useStudyEntities } from '../core/hooks/workspace'; +import { NotebookCellComponentProps } from './Types'; +import { VariableLinkConfig } from '../core/components/VariableLink'; +import FilterChipList from '../core/components/FilterChipList'; +import Subsetting from '../workspace/Subsetting'; + +export function SubsettingNotebookCell( + props: NotebookCellComponentProps<'subset'> +) { + const { analysisState, cell, updateCell } = props; + const { selectedVariable } = cell; + const entities = useStudyEntities(); + const totalCountsResult = useEntityCounts(); + const filteredCountsResult = useEntityCounts( + analysisState.analysis?.descriptor.subset.descriptor + ); + const variableLinkConfig = useMemo( + (): VariableLinkConfig => ({ + type: 'button', + onClick: (selectedVariable) => { + updateCell({ selectedVariable }); + }, + }), + [updateCell] + ); + return ( +
+
+ { + analysisState.setFilters((filters) => + filters.filter( + (f) => + f.entityId !== filter.entityId || + f.variableId !== filter.variableId + ) + ); + }} + variableLinkConfig={variableLinkConfig} + /> +
+ +
+ ); +} diff --git a/packages/libs/eda/src/lib/notebook/Types.ts b/packages/libs/eda/src/lib/notebook/Types.ts new file mode 100644 index 0000000000..9575556c55 --- /dev/null +++ b/packages/libs/eda/src/lib/notebook/Types.ts @@ -0,0 +1,44 @@ +import { AnalysisState } from '../core/hooks/analysis'; +import { VariableDescriptor } from '../core/types/variable'; + +export interface NotebookCellBase { + type: T; + title: string; +} + +export interface SubsettingNotebookCell extends NotebookCellBase<'subset'> { + selectedVariable?: Partial; +} + +export interface ComputeNotebookCell extends NotebookCellBase<'compute'> { + computeId: string; +} + +export interface VisualizationNotebookCell + extends NotebookCellBase<'visualization'> { + visualizationId: string; +} + +export interface TextNotebookCell extends NotebookCellBase<'text'> { + text: string; +} + +export type NotebookCell = + | SubsettingNotebookCell + | ComputeNotebookCell + | VisualizationNotebookCell + | TextNotebookCell; + +type FindByType = Union extends { type: Type } ? Union : never; + +export type NotebookCellOfType = FindByType< + NotebookCell, + T +>; + +export interface NotebookCellComponentProps { + analysisState: AnalysisState; + cell: NotebookCellOfType; + // Allow partial updates, but don't allow `type` to be changed. + updateCell: (cell: Omit>, 'type'>) => void; +}