diff --git a/packages/libs/coreui/src/components/theming/UIThemeProvider.tsx b/packages/libs/coreui/src/components/theming/UIThemeProvider.tsx index e6481d115a..db0c10088b 100644 --- a/packages/libs/coreui/src/components/theming/UIThemeProvider.tsx +++ b/packages/libs/coreui/src/components/theming/UIThemeProvider.tsx @@ -3,6 +3,7 @@ import { css, Global, ThemeProvider } from '@emotion/react'; import { useCoreUIFonts } from '../../hooks'; import { UITheme } from './types'; +import colors from '../../definitions/colors'; export type UIThemeProviderProps = { theme: UITheme; @@ -14,13 +15,32 @@ export default function UIThemeProvider({ children, }: UIThemeProviderProps) { useCoreUIFonts(); + // In addition to making the theme available via React Context, + // we will also expose the theme as custom CSS properties. return ( + typeof byHueOrValue === 'string' + ? [`--coreui-${colorName}: ${byHueOrValue};`] + : Object.entries(byHueOrValue).map( + ([hueName, colorValue]) => + `--coreui-${colorName}-${hueName}: ${colorValue};` + ) + )} + + --coreui-color-primary: ${theme.palette.primary.hue[ + theme.palette.primary.level + ]}; + --coreui-color-secondary: ${theme.palette.secondary.hue[ + theme.palette.secondary.level + ]}; + } + *:focus-visible { - outline: 2px solid - ${theme.palette.primary.hue[theme.palette.primary.level]}; + outline: 2px solid var(--coreui-color-primary); } `} /> diff --git a/packages/libs/eda/src/index.tsx b/packages/libs/eda/src/index.tsx index de7c8ec358..6432296ca0 100644 --- a/packages/libs/eda/src/index.tsx +++ b/packages/libs/eda/src/index.tsx @@ -60,6 +60,7 @@ import './index.css'; // snackbar import makeSnackbarProvider from '@veupathdb/coreui/lib/components/notifications/SnackbarProvider'; +import NotebookRoute from './lib/notebook/NotebookRoute'; // Set singleAppMode to the name of one app, if the eda should use one instance of one app only. // Otherwise, let singleAppMode remain undefined or set it to '' to allow multiple app instances. @@ -169,9 +170,20 @@ initialize({ All studies +

Notebook Links

+ ), }, + { + path: '/notebook', + exact: false, + component: () => , + }, { path: '/eda', exact: false, diff --git a/packages/libs/eda/src/lib/core/components/FilterChipList.tsx b/packages/libs/eda/src/lib/core/components/FilterChipList.tsx index 7d3bbef119..326b6151b6 100644 --- a/packages/libs/eda/src/lib/core/components/FilterChipList.tsx +++ b/packages/libs/eda/src/lib/core/components/FilterChipList.tsx @@ -10,7 +10,7 @@ import { colors, Warning } from '@veupathdb/coreui'; // Material UI CSS declarations const useStyles = makeStyles((theme) => ({ chips: { - display: 'flex', + display: 'inline-flex', flexWrap: 'wrap', '& > *:not(:last-of-type)': { // Spacing between chips 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.css b/packages/libs/eda/src/lib/notebook/EdaNotebook.css new file mode 100644 index 0000000000..be2665e7d6 --- /dev/null +++ b/packages/libs/eda/src/lib/notebook/EdaNotebook.css @@ -0,0 +1,116 @@ +.EdaNotebook { + .Heading { + display: flex; + gap: 2em; + align-items: baseline; + } + + .Paper { + /* A4 dimensions */ + --paper-width: 2480px; + --paper-height: 3508px; + --paper-scale: 0.5; + + width: calc(var(--paper-width) * var(--paper-scale)); + /* height: calc(var(--paper-height) * var(--paper-scale)); */ + + padding: 2em; + margin: 1em auto; + + /* background-color: #f3f3f3; */ + box-shadow: 0 0 2px #b5b5b5; + + > * + * { + margin-block-start: 1rem; + } + + .Heading { + display: flex; + flex-direction: row; + justify-content: space-between; + gap: 0.5em; + + h1 { + padding: 0; + font-size: 1.75em; + } + + h2 { + font-size: 1em; + font-weight: bold; + padding: 0.25em 0.5em; + color: var(--coreui-color-primary, black); + border: 2px solid; + border-radius: 0.25em; + background-color: color-mix( + in srgb, + var(--coreui-color-primary) 5%, + transparent + ); + } + } + + > details { + border: 1px solid; + border-color: color-mix( + in srgb, + var(--coreui-color-primary) 30%, + transparent + ); + border-top-left-radius: 0.5em; + border-top-right-radius: 0.5em; + border-bottom-left-radius: 0.5em; + border-bottom-right-radius: 0.5em; + + > summary { + padding: 0.75em; + cursor: pointer; + font-size: 1.2em; + font-weight: 500; + background-color: color-mix( + in srgb, + var(--coreui-color-primary) 10%, + transparent + ); + + &:hover { + background-color: color-mix( + in srgb, + var(--coreui-color-primary) 20%, + transparent + ); + } + + &:active { + background-color: color-mix( + in srgb, + var(--coreui-color-primary) 15%, + transparent + ); + } + + transition: background-color 100ms ease-in; + } + + &[open] > summary { + border-bottom: 1px solid; + border-color: color-mix( + in srgb, + var(--coreui-color-primary) 30%, + transparent + ); + } + + > div { + padding: 1em; + } + } + } + + .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 new file mode 100644 index 0000000000..6cb8f53d72 --- /dev/null +++ b/packages/libs/eda/src/lib/notebook/EdaNotebookAnalysis.tsx @@ -0,0 +1,97 @@ +// 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 { ExpandablePanel } from '@veupathdb/coreui'; +import { NotebookCell as NotebookCellType } from './Types'; +import { NotebookCell } from './NotebookCell'; + +import './EdaNotebook.css'; + +interface NotebookSettings { + /** Ordered array of notebook cells */ + cells: NotebookCellType[]; +} + +const NOTEBOOK_UI_SETTINGS_KEY = '@@NOTEBOOK@@'; + +interface Props { + analysisId: string; +} + +export function EdaNotebookAnalysis(props: Props) { + const { analysisId } = props; + const studyRecord = useStudyRecord(); + const analysisState = useAnalysis( + analysisId === 'new' ? undefined : analysisId + ); + 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] + ); + return ( +
+
+
+

+ +

+

{safeHtml(studyRecord.displayName)}

+
+ {notebookSettings.cells.map((cell, index) => ( +
+ {cell.title} + updateCell(update, index)} + /> +
+ ))} +
+
+ ); +} diff --git a/packages/libs/eda/src/lib/notebook/EdaNotebookLandingPage.tsx b/packages/libs/eda/src/lib/notebook/EdaNotebookLandingPage.tsx new file mode 100644 index 0000000000..bd1b86c167 --- /dev/null +++ b/packages/libs/eda/src/lib/notebook/EdaNotebookLandingPage.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { useWdkStudyRecords } from '../core/hooks/study'; +import { useConfiguredSubsettingClient } from '../core/hooks/client'; +import { Link, useRouteMatch } from 'react-router-dom'; +import { safeHtml } from '@veupathdb/wdk-client/lib/Utils/ComponentUtils'; + +interface Props { + edaServiceUrl: string; +} + +export function EdaNotebookLandingPage(props: Props) { + const subsettingClient = useConfiguredSubsettingClient(props.edaServiceUrl); + const datasets = useWdkStudyRecords(subsettingClient); + const { url } = useRouteMatch(); + return ( +
+

EDA Notebooks

+
+

Start a new notebook

+
    + {datasets?.map((dataset) => ( +
  • + {safeHtml( + dataset.displayName, + { to: `${url}/${dataset.attributes.dataset_id as string}/new` }, + Link + )} +
  • + ))} +
+
+
+
MY NOTEBOOKS
+
SHARED NOTEBOOKS
+
+ ); +} 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/NotebookRoute.tsx b/packages/libs/eda/src/lib/notebook/NotebookRoute.tsx new file mode 100644 index 0000000000..cec69d1b8f --- /dev/null +++ b/packages/libs/eda/src/lib/notebook/NotebookRoute.tsx @@ -0,0 +1,64 @@ +import React, { ComponentType } from 'react'; +import { Route, Switch, useRouteMatch } from 'react-router-dom'; +import { EdaNotebookLandingPage } from './EdaNotebookLandingPage'; +import { EdaNotebookAnalysis } from './EdaNotebookAnalysis'; +import { + EDAWorkspaceContainer, + useConfiguredAnalysisClient, + useConfiguredComputeClient, + useConfiguredDataClient, + useConfiguredDownloadClient, + useConfiguredSubsettingClient, +} from '../core'; +import { DocumentationContainer } from '../core/components/docs/DocumentationContainer'; +import { QueryClientProvider } from '@tanstack/react-query'; +import { queryClient } from '../core/api/queryClient'; + +interface Props { + edaServiceUrl: string; + datasetId?: string; + analysisId?: string; +} + +export default function NotebookRoute(props: Props) { + const { edaServiceUrl } = props; + const match = useRouteMatch(); + const analysisClient = useConfiguredAnalysisClient(edaServiceUrl); + const subsettingClient = useConfiguredSubsettingClient(edaServiceUrl); + const downloadClient = useConfiguredDownloadClient(edaServiceUrl); + const dataClient = useConfiguredDataClient(edaServiceUrl); + const computeClient = useConfiguredComputeClient(edaServiceUrl); + + return ( + + + + ( + + )} + /> + ( + + + + )} + /> + + + + ); +} 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; +}