diff --git a/src/api/entitycore/types/extended-entity-type.ts b/src/api/entitycore/types/extended-entity-type.ts index 0419f1e17..376961b4c 100644 --- a/src/api/entitycore/types/extended-entity-type.ts +++ b/src/api/entitycore/types/extended-entity-type.ts @@ -16,7 +16,3 @@ export const ExtendedEntitiesTypeDict = { export type TExtendedEntitiesTypeDict = (typeof ExtendedEntitiesTypeDict)[keyof typeof ExtendedEntitiesTypeDict]; - -export const DEFAULT_CHECKLIST_RENDER_LENGTH = 8; -export const PAGE_SIZE = 30; -export const PAGE_NUMBER = 1; diff --git a/src/api/entitycore/types/shared/response.ts b/src/api/entitycore/types/shared/response.ts index d32ede0dd..8aee70aed 100644 --- a/src/api/entitycore/types/shared/response.ts +++ b/src/api/entitycore/types/shared/response.ts @@ -1,4 +1,4 @@ -interface Pagination { +export interface Pagination { page: number; page_size: number; total_items: number; diff --git a/src/app/app/log-in/page.tsx b/src/app/app/log-in/page.tsx index ce1fd20c7..3c26053f2 100644 --- a/src/app/app/log-in/page.tsx +++ b/src/app/app/log-in/page.tsx @@ -3,13 +3,9 @@ import { useSearchParams } from 'next/navigation'; import { signIn } from 'next-auth/react'; -import { - basePath, - isServer, - LATEST_VISITED_PROJECT_KEY, - V2_MIGRATION_TEMPORARY_BASE_PATH, -} from '@/config'; +import { basePath, isServer, V2_MIGRATION_TEMPORARY_BASE_PATH } from '@/config'; import { useLocalStorage } from '@/hooks/use-local-storage'; +import { LATEST_VISITED_PROJECT_KEY } from '@/constants'; export default function Page() { const searchParams = useSearchParams(); diff --git a/src/app/app/v2/[virtualLabId]/[projectId]/explore/(browse)/browse/[type]/page.tsx b/src/app/app/v2/[virtualLabId]/[projectId]/explore/(browse)/browse/[type]/page.tsx new file mode 100644 index 000000000..c204e9d4e --- /dev/null +++ b/src/app/app/v2/[virtualLabId]/[projectId]/explore/(browse)/browse/[type]/page.tsx @@ -0,0 +1,40 @@ +import { notFound } from 'next/navigation'; +import { match, P } from 'ts-pattern'; +import snakeCase from 'lodash/snakeCase'; + +import { getEntityByExtendedType } from '@/entity-configuration/domain/helpers'; +import { BrowseLibraryScope } from '@/features/views/listing/browse-library'; +import { BrowseStandardScope } from '@/features/views/listing/browse-scope'; +import { WorkspaceScope } from '@/constants'; +import { KebabCase } from '@/utils/type'; + +import type { TExtendedEntitiesTypeDict } from '@/api/entitycore/types/extended-entity-type'; +import type { ServerSideComponentProp, WorkspaceContext } from '@/types/common'; +import type { TWorkspaceScope } from '@/constants'; + +export default async function Page({ + params, + searchParams, +}: ServerSideComponentProp< + WorkspaceContext & { type: KebabCase }, + { scope: TWorkspaceScope | null } +>) { + const { type } = await params; + const { scope } = await searchParams; + + const entity = getEntityByExtendedType({ type: snakeCase(type) as TExtendedEntitiesTypeDict }); + + const content = match({ scope, entity }) + .with({ entity: P.nullish }, () => notFound()) + .with( + { + scope: P.union(P.nullish, WorkspaceScope.Public, WorkspaceScope.Project), + entity: P.not(P.nullish), + }, + () => + ) + .with({ scope: WorkspaceScope.Bookmarks }, () => ) + .otherwise(() => notFound()); + + return content; +} diff --git a/src/app/app/v2/[virtualLabId]/[projectId]/explore/(browse)/layout.tsx b/src/app/app/v2/[virtualLabId]/[projectId]/explore/(browse)/layout.tsx new file mode 100644 index 000000000..aa2dd547d --- /dev/null +++ b/src/app/app/v2/[virtualLabId]/[projectId]/explore/(browse)/layout.tsx @@ -0,0 +1,27 @@ +import type { ReactNode } from 'react'; + +import { DefaultContent as ExploreDefaultContent } from '@/ui/segments/explore/default-content'; +import { ExploreInnerLayout } from '@/ui/layouts/explore-inner-layout'; +import { ExploreHeader } from '@/ui/segments/explore/header'; +import { ExploreLayout } from '@/ui/layouts/explore-layout'; +import { resolveDataKey } from '@/utils/key-builder'; + +import type { ServerSideComponentProp, WorkspaceContext } from '@/types/common'; + +export default async function Page({ + children, + params, +}: ServerSideComponentProp & { children: ReactNode }) { + const { projectId } = await params; + + const dataKey = resolveDataKey({ projectId, section: 'explore' }); + + return ( + + + + {children} + + + ); +} diff --git a/src/app/app/v2/[virtualLabId]/[projectId]/explore/(browse)/page.tsx b/src/app/app/v2/[virtualLabId]/[projectId]/explore/(browse)/page.tsx new file mode 100644 index 000000000..4ee4cbf2d --- /dev/null +++ b/src/app/app/v2/[virtualLabId]/[projectId]/explore/(browse)/page.tsx @@ -0,0 +1,13 @@ +import { resolveDataKey } from '@/utils/key-builder'; +import { Atlas } from '@/ui/segments/explore/atlas'; + +import type { ServerSideComponentProp, WorkspaceContext } from '@/types/common'; + +export default async function Page({ + params: promisedParams, +}: ServerSideComponentProp) { + const { projectId } = await promisedParams; + const dataKey = resolveDataKey({ projectId, section: 'explore' }); + + return ; +} diff --git a/src/app/app/v2/[virtualLabId]/[projectId]/explore/(view)/view/[type]/[id]/page.tsx b/src/app/app/v2/[virtualLabId]/[projectId]/explore/(view)/view/[type]/[id]/page.tsx new file mode 100644 index 000000000..16dfcda2a --- /dev/null +++ b/src/app/app/v2/[virtualLabId]/[projectId]/explore/(view)/view/[type]/[id]/page.tsx @@ -0,0 +1,18 @@ +import type { TExtendedEntitiesTypeDict } from '@/api/entitycore/types/extended-entity-type'; +import type { ServerSideComponentProp, WorkspaceContext } from '@/types/common'; +import type { KebabCase } from '@/utils/type'; + +export default async function Page({ + params, +}: ServerSideComponentProp< + WorkspaceContext & { type: KebabCase; id: string }, + null +>) { + const { virtualLabId, projectId, type, id } = await params; + + return ( +
+
{JSON.stringify({ virtualLabId, projectId, type, id }, null, 2)}
+
+ ); +} diff --git a/src/app/app/v2/[virtualLabId]/[projectId]/explore/(view)/view/layout.tsx b/src/app/app/v2/[virtualLabId]/[projectId]/explore/(view)/view/layout.tsx new file mode 100644 index 000000000..1b07ece54 --- /dev/null +++ b/src/app/app/v2/[virtualLabId]/[projectId]/explore/(view)/view/layout.tsx @@ -0,0 +1,5 @@ +import type { ReactNode } from 'react'; + +export default function Layout({ children }: { children: ReactNode }) { + return
{children}
; +} diff --git a/src/app/app/v2/[virtualLabId]/[projectId]/explore/[type]/[id]/page.tsx b/src/app/app/v2/[virtualLabId]/[projectId]/explore/[type]/[id]/page.tsx deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/app/app/v2/[virtualLabId]/[projectId]/explore/page.tsx b/src/app/app/v2/[virtualLabId]/[projectId]/explore/page.tsx deleted file mode 100644 index 0400c3d7b..000000000 --- a/src/app/app/v2/[virtualLabId]/[projectId]/explore/page.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { ExploreLayout, ExploreInnerLayout } from '@/ui/layouts/explore-layout'; - -import { ExploreMenu } from '@/ui/segments/explore/left-menu'; -import { ExploreHeader } from '@/ui/segments/explore/header'; - -import { resolveDataKey } from '@/utils/key-builder'; -import { Atlas } from '@/ui/segments/explore/atlas'; -import { Card } from '@/ui/molecules/card'; - -import type { ServerSideComponentProp, WorkspaceContext } from '@/types/common'; - -export default async function Page({ - params: promisedParams, -}: ServerSideComponentProp) { - const { projectId } = await promisedParams; - const dataKey = resolveDataKey({ projectId, section: 'explore' }); - - return ( - - - -
- - - -
-
- -
-
-
- ); -} diff --git a/src/app/app/v2/[virtualLabId]/[projectId]/layout.tsx b/src/app/app/v2/[virtualLabId]/[projectId]/layout.tsx index 90342fa4a..562e19252 100644 --- a/src/app/app/v2/[virtualLabId]/[projectId]/layout.tsx +++ b/src/app/app/v2/[virtualLabId]/[projectId]/layout.tsx @@ -15,12 +15,12 @@ export default function Layout({ children }: Props) {
-
+
{children}
diff --git a/src/components/CenteredMessage/index.tsx b/src/components/CenteredMessage/index.tsx index bd98dd2c5..7e29ade4c 100644 --- a/src/components/CenteredMessage/index.tsx +++ b/src/components/CenteredMessage/index.tsx @@ -5,7 +5,7 @@ type CenteredMessageProps = { icon?: ReactElement; }; -export default function CenteredMessage({ message, icon }: CenteredMessageProps) { +export function CenteredMessage({ message, icon }: CenteredMessageProps) { return (
@@ -15,3 +15,5 @@ export default function CenteredMessage({ message, icon }: CenteredMessageProps)
); } + +export default CenteredMessage; diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 7df64dca6..91d987df4 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -60,7 +60,7 @@ export default function Search({ colorBgContainer, colorBgElevated: '#003A8C', // Drop down list bg colorBorder: colorBgContainer, // Makes it "transparent" - colorPrimary: 'white', // Seleced dropdown items color + colorPrimary: 'white', // Selected dropdown items color colorText: 'white', // Input value text colorTextSecondary: 'white', // Control item check mark colorTextTertiary: 'white', // Clear icon hover diff --git a/src/components/detail-view-tabs/index.tsx b/src/components/detail-view-tabs/index.tsx index 78c914d8c..ba7fc995e 100644 --- a/src/components/detail-view-tabs/index.tsx +++ b/src/components/detail-view-tabs/index.tsx @@ -84,7 +84,7 @@ export function useTabs({ defaultKey = undefined, }: Omit, 'cls'>) { const [activeTab, setActiveTab] = useQueryState( - `${tabKey}`, + tabKey, parseAsString .withOptions({ shallow, clearOnDefault }) .withDefault(defaultKey ?? tabsConfig?.at(0)!.key!) as Parser diff --git a/src/components/explore-section/ExploreSectionListingView/ExploreSectionNameSearch.tsx b/src/components/explore-section/ExploreSectionListingView/ExploreSectionNameSearch.tsx index d8ae03b69..858291a29 100644 --- a/src/components/explore-section/ExploreSectionListingView/ExploreSectionNameSearch.tsx +++ b/src/components/explore-section/ExploreSectionListingView/ExploreSectionNameSearch.tsx @@ -1,17 +1,16 @@ import { ChangeEvent, RefObject, useEffect, useRef, useState } from 'react'; -import { useAtom, useSetAtom } from 'jotai'; import { SearchOutlined } from '@ant-design/icons'; +import { useAtom, useSetAtom } from 'jotai'; import { pageNumberAtom, previousDataAtom, searchStringAtom, } from '@/state/explore-section/list-view-atoms'; -import { - PAGE_NUMBER, - TExtendedEntitiesTypeDict, -} from '@/api/entitycore/types/extended-entity-type'; import { useDebouncedCallback } from '@/hooks/hooks'; +import { DEFAULT_PAGE_NUMBER } from '@/constants'; + +import type { TExtendedEntitiesTypeDict } from '@/api/entitycore/types/extended-entity-type'; type SearchProps = { dataKey: string; @@ -35,7 +34,7 @@ export default function ExploreSectionNameSearch({ dataType, dataKey }: SearchPr const debouncedUpdateAtom = useDebouncedCallback( (searchStr: string) => { setPrevData([]); - setPageNumber(PAGE_NUMBER); + setPageNumber(DEFAULT_PAGE_NUMBER); setSearchString(searchStr); }, [setPageNumber, setPrevData, setSearchString], diff --git a/src/components/explore-section/ExploreSectionListingView/LoadMoreButton.tsx b/src/components/explore-section/ExploreSectionListingView/LoadMoreButton.tsx index 05ba63fb5..7d985cad4 100644 --- a/src/components/explore-section/ExploreSectionListingView/LoadMoreButton.tsx +++ b/src/components/explore-section/ExploreSectionListingView/LoadMoreButton.tsx @@ -8,9 +8,9 @@ import { } from '@/state/explore-section/list-view-atoms'; import { useBrainRegionHierarchy } from '@/features/brain-region-hierarchy/context'; import { ExploreDataScope } from '@/types/explore-section/application'; -import { PAGE_SIZE } from '@/api/entitycore/types/extended-entity-type'; import { VirtualLabInfo } from '@/types/virtual-lab/common'; import { useLoadableValue } from '@/hooks/hooks'; +import { DEFAULT_PAGE_SIZE } from '@/constants'; import { classNames } from '@/util/utils'; import type { TExtendedEntitiesTypeDict } from '@/api/entitycore/types/extended-entity-type'; @@ -58,13 +58,13 @@ export function useLoadMore( const loading = res.state === 'loading'; const showLoadMore = res.state === 'hasData' && - res.data.data.length + (res.data.pagination.page - 1) * PAGE_SIZE < + res.data.data.length + (res.data.pagination.page - 1) * DEFAULT_PAGE_SIZE < res.data.pagination.total_items; const loadMore = useCallback( (load: boolean = true) => { if (res.state === 'loading' || res.state === 'hasError' || !load) return; - if (res.data && res.data.data.length < PAGE_SIZE) return; + if (res.data && res.data.data.length < DEFAULT_PAGE_SIZE) return; // Store previous hits before fetching next page setPrevData(data); @@ -109,7 +109,7 @@ export default function LoadMoreButton({ hide(); }} > - Load {PAGE_SIZE} more results... + Load {DEFAULT_PAGE_SIZE} more results... ); } diff --git a/src/components/explore-section/ExploreSectionListingView/index.tsx b/src/components/explore-section/ExploreSectionListingView/index.tsx index ca01d1874..fde2e96b5 100644 --- a/src/components/explore-section/ExploreSectionListingView/index.tsx +++ b/src/components/explore-section/ExploreSectionListingView/index.tsx @@ -22,10 +22,8 @@ import { import { useBrainRegionHierarchy } from '@/features/brain-region-hierarchy/context'; import { EntityCoreIdentifiable } from '@/api/entitycore/types/shared/global'; import { ExploreDataScope } from '@/types/explore-section/application'; -import { - TExtendedEntitiesTypeDict, - PAGE_NUMBER, -} from '@/api/entitycore/types/extended-entity-type'; +import { TExtendedEntitiesTypeDict } from '@/api/entitycore/types/extended-entity-type'; +import { DEFAULT_PAGE_NUMBER } from '@/constants'; import { classNames } from '@/util/utils'; import type { RenderButtonProps } from '@/components/explore-section/ExploreSectionListingView/useRowSelection'; @@ -90,7 +88,7 @@ export default function ExploreSectionListingView { - setPageNumber(PAGE_NUMBER); + setPageNumber(DEFAULT_PAGE_NUMBER); setPrevData([]); setSortState(newSortState); }; diff --git a/src/components/listing-table/controls.tsx b/src/components/listing-table/controls.tsx index c27ba9585..f7b3a474d 100644 --- a/src/components/listing-table/controls.tsx +++ b/src/components/listing-table/controls.tsx @@ -57,7 +57,7 @@ export default function TableControls({ if (!visible) return null; return ( -
+
{left}
{children}
diff --git a/src/components/tree/elements/search.tsx b/src/components/tree/elements/search.tsx index 7ae4c6a43..0eef361e0 100644 --- a/src/components/tree/elements/search.tsx +++ b/src/components/tree/elements/search.tsx @@ -77,7 +77,8 @@ export default function TreeSearch({ options, onSelect }: Props) { '[&_.ant-select-selector]:rounded-full!', '[&_.ant-select-selector]:border-neutral-1!', '[&_.ant-select-selector]:shadow-lg!', - '[&_.ant-select-selection-search-input]:text-sm!' + '[&_.ant-select-selection-search-input]:text-sm!', + '[&_.ant-select-selection-placeholder]:text-neutral-3' )} /> diff --git a/src/config.ts b/src/config.ts index bb7832bb7..d9b17ee82 100644 --- a/src/config.ts +++ b/src/config.ts @@ -11,9 +11,6 @@ export const thumbnailGenerationBaseUrl = env.NEXT_PUBLIC_THUMBNAIL_GENERATION_B export const entityCoreUrl = env.NEXT_PUBLIC_ENTITY_CORE_URL; export const entityCorePublicVirtualLabId = env.NEXT_PUBLIC_ENTITY_CORE_PUBLIC_VIRTUAL_LAB_ID; export const entityCorePublicProjectId = env.NEXT_PUBLIC_ENTITY_CORE_PUBLIC_PROJECT_ID; -export const LATEST_VISITED_PROJECT_KEY = 'latest-visited-project'; -export const AUTO_INIT_WORKSPACE = 'automatic-init-workspace'; -export const AUTO_ONBOARDING_DONE = 'automatic-app-onboarding'; // TODO: remove this when we move off from ui-v2 folder export const V2_MIGRATION_TEMPORARY_BASE_PATH = '/app/v2'; diff --git a/src/constants.tsx b/src/constants.tsx new file mode 100644 index 000000000..eb37134a9 --- /dev/null +++ b/src/constants.tsx @@ -0,0 +1,17 @@ +export const LATEST_VISITED_PROJECT_KEY = 'latest-visited-project'; +export const AUTO_INIT_WORKSPACE = 'automatic-init-workspace'; +export const AUTO_ONBOARDING_DONE = 'automatic-app-onboarding'; + +export const DEFAULT_CHECKLIST_RENDER_LENGTH = 8; +export const DEFAULT_PAGE_SIZE = 30; +export const DEFAULT_PAGE_NUMBER = 1; + +export const WorkspaceScope = { + Public: 'public', + Project: 'project', + Bookmarks: 'bookmarks', + Custom: 'custom', + BuildMeModel: 'build-me-model', +} as const; + +export type TWorkspaceScope = (typeof WorkspaceScope)[keyof typeof WorkspaceScope]; diff --git a/src/entity-configuration/definitions/index.ts b/src/entity-configuration/definitions/index.ts index 4485776cf..2812186d1 100644 --- a/src/entity-configuration/definitions/index.ts +++ b/src/entity-configuration/definitions/index.ts @@ -14,7 +14,7 @@ import type { import type { EntityCoreIdentifiable } from '@/api/entitycore/types/shared/global'; import type { EntityCoreObjectTypes } from '@/api/entitycore/types'; -const fieldsDefinitionRegistry: Partial> = { +export const fieldsDefinitionRegistry: Partial> = { ...CommonFieldsDefinition, ...ExperimentalFieldsDefinition, ...ExperimentFieldsDefinition, diff --git a/src/features/bookmark/listing-table.tsx b/src/features/bookmark/listing-table.tsx index 9b076ebc7..95e98650b 100644 --- a/src/features/bookmark/listing-table.tsx +++ b/src/features/bookmark/listing-table.tsx @@ -15,7 +15,7 @@ import { } from '@/state/explore-section/list-view-atoms'; import { ExploreDataScope } from '@/types/explore-section/application'; import { resolveExploreDetailsPageUrl } from '@/utils/url-builder'; -import { PAGE_NUMBER } from '@/api/entitycore/types/extended-entity-type'; +import { DEFAULT_PAGE_NUMBER } from '@/constants'; import type { EntityCoreIdentifiable } from '@/api/entitycore/types/shared/global'; import type { TExtendedEntitiesTypeDict } from '@/api/entitycore/types/extended-entity-type'; @@ -46,7 +46,7 @@ export default function ListingTable({ const [sortState, setSortState] = useAtom(sortStateAtom({ key: dataKey })); const onSortChange = (newSortState: any) => { - setPageNumber(PAGE_NUMBER); + setPageNumber(DEFAULT_PAGE_NUMBER); setPrevData([]); setSortState(newSortState); }; diff --git a/src/features/brain-atlas-viewer/full-screen.tsx b/src/features/brain-atlas-viewer/full-screen.tsx index 2e0640951..9ce25880e 100644 --- a/src/features/brain-atlas-viewer/full-screen.tsx +++ b/src/features/brain-atlas-viewer/full-screen.tsx @@ -7,11 +7,11 @@ interface FullScreenProps { export default function FullScreen({ isFullScreen, onToggle }: FullScreenProps) { return ( -
+
{isFullScreen ? ( - + ) : ( - + )}
); diff --git a/src/features/brain-region-hierarchy/index.tsx b/src/features/brain-region-hierarchy/index.tsx index 392fdf1bf..46c765cbe 100644 --- a/src/features/brain-region-hierarchy/index.tsx +++ b/src/features/brain-region-hierarchy/index.tsx @@ -7,8 +7,7 @@ import Tree from '@/components/tree'; import { userJourneyTracker } from '@/components/explore-section/Literature/user-journey'; import { makeBrainRegionClickEvent } from '@/features/brain-region-hierarchy/event'; -import { pageNumberAtom } from '@/state/explore-section/list-view-atoms'; -import { PAGE_NUMBER } from '@/api/entitycore/types/extended-entity-type'; +import { corePageNumberAtom } from '@/ui/segments/data-table/elements/context'; import { scrollToNode } from '@/components/tree/elements/helpers'; import { HydrateWrapper } from '@/wrappers/hydrate-wrapper'; import { @@ -18,19 +17,26 @@ import { useBrainRegionHierarchy, useGetSelectedBrainRegion, } from '@/features/brain-region-hierarchy/context'; +import { DEFAULT_PAGE_NUMBER } from '@/constants'; import { classNames } from '@/util/utils'; import type { IBrainRegionHierarchy } from '@/api/entitycore/types/entities/brain-region'; import type { TTreeNode } from '@/components/tree/types'; -export function BrainRegionHierarchy({ dataKey }: { dataKey: string }) { +export function BrainRegionHierarchy({ + dataKey, + onClickCallback, +}: { + dataKey: string; + onClickCallback?: (node: TTreeNode) => void; +}) { const isCollapsed = useAtomValue(brainRegionSidebarAtom); const brainRegionHierarchyResult = useAtomValue(brainRegionBasicCellGroupsRegionsHierarchyAtom); const { updateHierarchyConfig } = useBrainRegionHierarchy({ dataKey, }); const { selectedBrainRegion } = useGetSelectedBrainRegion(); - const setPageNumber = useSetAtom(pageNumberAtom(dataKey)); + const setPageNumber = useSetAtom(corePageNumberAtom(dataKey)); if (!brainRegionHierarchyResult) { return ( @@ -49,8 +55,9 @@ export function BrainRegionHierarchy({ dataKey }: { dataKey: string }) { const onClick = (clickedNode: TTreeNode) => { updateHierarchyConfig(clickedNode as IBrainRegionHierarchy); scrollToNode(clickedNode as IBrainRegionHierarchy, 'center'); - setPageNumber(PAGE_NUMBER); + setPageNumber(DEFAULT_PAGE_NUMBER); makeBrainRegionClickEvent({ dataKey, node: clickedNode as IBrainRegionHierarchy }); + onClickCallback?.(clickedNode); userJourneyTracker.registerBrainRegionClick(clickedNode.name); }; diff --git a/src/features/brain-region-hierarchy/region-banner.tsx b/src/features/brain-region-hierarchy/region-banner.tsx index a75662d6b..577c49e20 100644 --- a/src/features/brain-region-hierarchy/region-banner.tsx +++ b/src/features/brain-region-hierarchy/region-banner.tsx @@ -31,7 +31,7 @@ export function RegionBanner({ view, onSwitchView }: Props) {
@@ -62,9 +62,9 @@ export function RegionBanner({ view, onSwitchView }: Props) { } > {view === ExploreLeftMenuContext.BrainRegionHierarchy ? ( - - ) : ( + ) : ( + )}
diff --git a/src/features/listing-filter-panel/checklist/index.tsx b/src/features/listing-filter-panel/checklist/index.tsx index 38120f7ee..9818acdc9 100644 --- a/src/features/listing-filter-panel/checklist/index.tsx +++ b/src/features/listing-filter-panel/checklist/index.tsx @@ -4,11 +4,11 @@ import { ReactNode, useState } from 'react'; import { InfoCircleFilled } from '@ant-design/icons'; import { useOptions } from '@/features/listing-filter-panel/checklist/use-options'; -import { DEFAULT_CHECKLIST_RENDER_LENGTH } from '@/api/entitycore/types/extended-entity-type'; import { getFieldDefinition } from '@/entity-configuration/definitions'; +import { CenteredMessage } from '@/components/CenteredMessage'; +import { DEFAULT_CHECKLIST_RENDER_LENGTH } from '@/constants'; import SearchFilter from '@/features/listing-filter-panel/search-filter'; -import CenteredMessage from '@/components/CenteredMessage'; import type { FacetLabelValuePair } from '@/features/listing-filter-panel/checklist/use-options'; import type { CheckListProps } from '@/features/listing-filter-panel/checklist/default-checklist'; diff --git a/src/features/listing-filter-panel/index.tsx b/src/features/listing-filter-panel/index.tsx index 6b15e3ac4..d10b464e6 100644 --- a/src/features/listing-filter-panel/index.tsx +++ b/src/features/listing-filter-panel/index.tsx @@ -100,7 +100,6 @@ export default function WithListingFilterPanel({ setFilters={setFilters} toggleDisplay={() => setDisplayControlPanel(false)} dataType={dataType} - dataScope={dataScope} dataKey={dataKey} facets={facets} virtualLabInfo={virtualLabInfo} diff --git a/src/features/listing-filter-panel/listing-filter-panel.tsx b/src/features/listing-filter-panel/listing-filter-panel.tsx index 05c78e686..a76637d5e 100644 --- a/src/features/listing-filter-panel/listing-filter-panel.tsx +++ b/src/features/listing-filter-panel/listing-filter-panel.tsx @@ -32,36 +32,31 @@ import { import { CoreFieldFilterTypeEnum } from '@/entity-configuration/definitions/fields-defs/enums'; import { getViewDefinitionByExtendedType } from '@/entity-configuration/definitions/view-defs'; import { defaultList } from '@/features/listing-filter-panel/checklist/default-checklist'; -import { ExploreDataScope } from '@/types/explore-section/application'; import { useBrainRegionHierarchy } from '@/features/brain-region-hierarchy/context'; -import { - TExtendedEntitiesTypeDict, - PAGE_NUMBER, -} from '@/api/entitycore/types/extended-entity-type'; import { FilterGroup } from '@/features/listing-filter-panel/filter-group'; import { getFieldDefinition } from '@/entity-configuration/definitions'; import { Facets } from '@/api/entitycore/types/shared/response'; import { fieldTitleSentenceCase } from '@/util/utils'; +import { DEFAULT_PAGE_NUMBER } from '@/constants'; +import type { TExtendedEntitiesTypeDict } from '@/api/entitycore/types/extended-entity-type'; +import type { WorkspaceContext } from '@/types/common'; import type { CoreFilter, CoreFilterValues, GteLteValue, ValueOrRangeFilter, } from '@/entity-configuration/definitions/types'; -import type { WorkspaceContext } from '@/types/common'; type Props = { children?: ReactNode; toggleDisplay: () => void; dataType: TExtendedEntitiesTypeDict; - dataScope?: ExploreDataScope; dataKey: string; filters: CoreFilter[]; facets: Facets | undefined; setFilters: any; showDisplayTrigger?: boolean; - resourceId?: string; virtualLabInfo?: WorkspaceContext; useBrainRegion?: boolean; }; @@ -176,13 +171,11 @@ export default function ListingFilterPanel({ children, toggleDisplay, dataType, - dataScope, dataKey, filters, setFilters, facets, showDisplayTrigger = true, - resourceId, virtualLabInfo, useBrainRegion, }: Props) { @@ -193,8 +186,6 @@ export default function ListingFilterPanel({ const resetFilters = useResetAtom( filtersAtom({ dataType, - dataScope, - resourceId, brainRegionId, key: dataKey, }) @@ -204,7 +195,6 @@ export default function ListingFilterPanel({ previousDataAtom({ workspace: virtualLabInfo, dataType, - dataScope, brainRegionId, key: dataKey, }) @@ -217,12 +207,11 @@ export default function ListingFilterPanel({ unwrap( activeColumnsAtom({ dataType, - dataScope, brainRegionId, key: dataKey, }) ), - [dataType, dataScope, brainRegionId, dataKey] + [dataType, brainRegionId, dataKey] ) ); @@ -258,7 +247,7 @@ export default function ListingFilterPanel({ }, [filters]); const submitValues = () => { - setPageNumber(PAGE_NUMBER); + setPageNumber(DEFAULT_PAGE_NUMBER); setPrevData([]); setFilters(filters?.map((fil: CoreFilter) => ({ ...fil, value: filterValues[fil.field] }))); diff --git a/src/features/views/listing/browse-library.tsx b/src/features/views/listing/browse-library.tsx new file mode 100644 index 000000000..20baff6d3 --- /dev/null +++ b/src/features/views/listing/browse-library.tsx @@ -0,0 +1,158 @@ +'use client'; + +import { useParams, useSearchParams } from 'next/navigation'; +import { useAtom, useAtomValue, useSetAtom } from 'jotai'; +import { useState } from 'react'; + +import snakeCase from 'lodash/snakeCase'; +import compact from 'lodash/compact'; +import get from 'lodash/get'; + +import { useDataTableColumns } from '@/ui/segments/data-table/elements/use-data-table-columns'; +import { useQueryExtendedEntityType } from '@/ui/hooks/use-query-extended-entity-type'; +import { + coreActiveColumnsAtom, + corePageNumberAtom, + coreSortStateAtom, +} from '@/ui/segments/data-table/elements/context'; +import { getEntityByExtendedType } from '@/entity-configuration/domain/helpers'; +import { MiniDetailView } from '@/ui/segments/mini-detail-view'; +import { useWorkspace } from '@/ui/hooks/use-workspace'; +import { MainTable } from '@/ui/segments/data-table'; +import { DEFAULT_PAGE_NUMBER } from '@/constants'; +import { cn } from '@/utils/css-class'; + +import type { TExtendedEntitiesTypeDict } from '@/api/entitycore/types/extended-entity-type'; +import type { EntityCoreIdentifiableNamed } from '@/api/entitycore/types/shared/global'; +import type { EntityCoreResponse } from '@/api/entitycore/types/shared/response'; +import type { WorkspaceContext } from '@/types/common'; +import type { TWorkspaceScope } from '@/constants'; +import type { KebabCase } from '@/utils/type'; +import { + makeSelectEntityClickEvent, + useSelectEntityClickEvent, +} from '@/ui/segments/mini-detail-view/event'; + +export function BrowseLibraryScope() { + const searchParams = useSearchParams(); + const scope = searchParams.get('scope') as TWorkspaceScope; + const { type } = useParams }>(); + const { virtualLabId, projectId } = useWorkspace(); + const dataKey = compact([virtualLabId, projectId, type, scope]).join('/'); + const dataType = snakeCase(type) as TExtendedEntitiesTypeDict; + + const entity = getEntityByExtendedType({ type: dataType }); + const setPageNumber = useSetAtom(corePageNumberAtom(dataKey)); + const [sortState, setSortState] = useAtom(coreSortStateAtom({ key: dataKey })); + const [miniViewPresent, updateDisplayMiniView] = useState(false); + + const onSortChange = (newSortState: any) => { + setPageNumber(DEFAULT_PAGE_NUMBER); + setSortState(newSortState); + }; + + const allColumns = useDataTableColumns({ + dataType, + sortState, + setSortState: onSortChange, + }); + + const activeColumns = useAtomValue(coreActiveColumnsAtom({ dataType, key: dataKey })); + const columns = allColumns.filter(({ key }) => (activeColumns || []).includes(key as string)); + + const { data, error, isPlaceholderData, isFetching, isLoading } = useQueryExtendedEntityType({ + context: { + key: dataKey, + workspaceScope: scope, + extendedEntityType: snakeCase(type) as TExtendedEntitiesTypeDict, + }, + workspace: { virtualLabId, projectId }, + queryFn: async ({ queryKey }) => { + const [{ workspace, queryParameters }] = queryKey; + return entity?.api?.query.list?.({ + withFacets: true, + filters: { ...queryParameters }, + context: workspace, + }); + }, + enabled: ({ queryKey }) => { + const [{ queryParameters }] = queryKey; + if (!get(queryParameters, 'within_brain_region_brain_region_id', null)) return false; + return true; + }, + }); + + const dataSource = (data as EntityCoreResponse)?.data; + const facets = (data as EntityCoreResponse)?.facets; + const pagination = (data as EntityCoreResponse)?.pagination; + + const onCellClick = (_: string, record: EntityCoreIdentifiableNamed) => { + // navigate( + // `${V2_MIGRATION_TEMPORARY_BASE_PATH}/${virtualLabId}/${projectId}/explore/view/${kebabCase(record.type)}/${record.id}` + // ); + makeSelectEntityClickEvent({ + display: true, + data: record, + }); + }; + + useSelectEntityClickEvent((event) => { + updateDisplayMiniView(event.detail.display); + }); + + if (error) + return ( +
+
{JSON.stringify(error, null, 2)}
+
+ ); + + return ( + <> +
+
+ + controlsVisible + showLoadingState + sticky={{ offsetHeader: 75.5 }} + isLoading={(isPlaceholderData && isFetching) || isLoading} + dataScope={scope} + dataSource={dataSource ?? []} + dataType={dataType} + workspace={{ virtualLabId, projectId }} + dataKey={dataKey} + columns={columns} + facets={facets} + onCellClick={onCellClick} + resultPagination={{ + pagination, + totalData: dataSource?.length, + }} + cls={{ + table: cn( + '[&_.ant-table]:bg-neutral-1! [&_.ant-table-header_th]:bg-neutral-1!', + '[&_.ant-table-placeholder]:bg-neutral-1! [&_.ant-table-tbody_tr.ant-table-placeholder]:bg-neutral-1!' + ), + }} + /> +
+
+
+ +
+ + ); +} diff --git a/src/features/views/listing/browse-scope.tsx b/src/features/views/listing/browse-scope.tsx new file mode 100644 index 000000000..45bc60491 --- /dev/null +++ b/src/features/views/listing/browse-scope.tsx @@ -0,0 +1,158 @@ +'use client'; + +import { useParams, useSearchParams } from 'next/navigation'; +import { useAtom, useAtomValue, useSetAtom } from 'jotai'; +import { useState } from 'react'; + +import snakeCase from 'lodash/snakeCase'; +import compact from 'lodash/compact'; +import get from 'lodash/get'; + +import { useDataTableColumns } from '@/ui/segments/data-table/elements/use-data-table-columns'; +import { useQueryExtendedEntityType } from '@/ui/hooks/use-query-extended-entity-type'; +import { + coreActiveColumnsAtom, + corePageNumberAtom, + coreSortStateAtom, +} from '@/ui/segments/data-table/elements/context'; +import { getEntityByExtendedType } from '@/entity-configuration/domain/helpers'; +import { MiniDetailView } from '@/ui/segments/mini-detail-view'; +import { useWorkspace } from '@/ui/hooks/use-workspace'; +import { MainTable } from '@/ui/segments/data-table'; +import { DEFAULT_PAGE_NUMBER } from '@/constants'; +import { cn } from '@/utils/css-class'; + +import type { TExtendedEntitiesTypeDict } from '@/api/entitycore/types/extended-entity-type'; +import type { EntityCoreIdentifiableNamed } from '@/api/entitycore/types/shared/global'; +import type { EntityCoreResponse } from '@/api/entitycore/types/shared/response'; +import type { WorkspaceContext } from '@/types/common'; +import type { TWorkspaceScope } from '@/constants'; +import type { KebabCase } from '@/utils/type'; +import { + makeSelectEntityClickEvent, + useSelectEntityClickEvent, +} from '@/ui/segments/mini-detail-view/event'; + +export function BrowseStandardScope() { + const searchParams = useSearchParams(); + const scope = searchParams.get('scope') as TWorkspaceScope; + const { type } = useParams }>(); + const { virtualLabId, projectId } = useWorkspace(); + const dataKey = compact([virtualLabId, projectId, type, scope]).join('/'); + const dataType = snakeCase(type) as TExtendedEntitiesTypeDict; + + const entity = getEntityByExtendedType({ type: dataType }); + const setPageNumber = useSetAtom(corePageNumberAtom(dataKey)); + const [sortState, setSortState] = useAtom(coreSortStateAtom({ key: dataKey })); + const [miniViewPresent, updateDisplayMiniView] = useState(false); + + const onSortChange = (newSortState: any) => { + setPageNumber(DEFAULT_PAGE_NUMBER); + setSortState(newSortState); + }; + + const allColumns = useDataTableColumns({ + dataType, + sortState, + setSortState: onSortChange, + }); + + const activeColumns = useAtomValue(coreActiveColumnsAtom({ dataType, key: dataKey })); + const columns = allColumns.filter(({ key }) => (activeColumns || []).includes(key as string)); + + const { data, error, isPlaceholderData, isFetching, isLoading } = useQueryExtendedEntityType({ + context: { + key: dataKey, + workspaceScope: scope, + extendedEntityType: snakeCase(type) as TExtendedEntitiesTypeDict, + }, + workspace: { virtualLabId, projectId }, + queryFn: async ({ queryKey }) => { + const [{ workspace, queryParameters }] = queryKey; + return entity?.api?.query.list?.({ + withFacets: true, + filters: { ...queryParameters }, + context: workspace, + }); + }, + enabled: ({ queryKey }) => { + const [{ queryParameters }] = queryKey; + if (!get(queryParameters, 'within_brain_region_brain_region_id', null)) return false; + return true; + }, + }); + + const dataSource = (data as EntityCoreResponse)?.data; + const facets = (data as EntityCoreResponse)?.facets; + const pagination = (data as EntityCoreResponse)?.pagination; + + const onCellClick = (_: string, record: EntityCoreIdentifiableNamed) => { + // navigate( + // `${V2_MIGRATION_TEMPORARY_BASE_PATH}/${virtualLabId}/${projectId}/explore/view/${kebabCase(record.type)}/${record.id}` + // ); + makeSelectEntityClickEvent({ + display: true, + data: record, + }); + }; + + useSelectEntityClickEvent((event) => { + updateDisplayMiniView(event.detail.display); + }); + + if (error) + return ( +
+
{JSON.stringify(error, null, 2)}
+
+ ); + + return ( + <> +
+
+ + controlsVisible + showLoadingState + sticky={{ offsetHeader: 75.5 }} + isLoading={(isPlaceholderData && isFetching) || isLoading} + dataScope={scope} + dataSource={dataSource ?? []} + dataType={dataType} + workspace={{ virtualLabId, projectId }} + dataKey={dataKey} + columns={columns} + facets={facets} + onCellClick={onCellClick} + resultPagination={{ + pagination, + totalData: dataSource?.length, + }} + cls={{ + table: cn( + '[&_.ant-table]:bg-neutral-1! [&_.ant-table-header_th]:bg-neutral-1!', + '[&_.ant-table-placeholder]:bg-neutral-1! [&_.ant-table-tbody_tr.ant-table-placeholder]:bg-neutral-1!' + ), + }} + /> +
+
+
+ +
+ + ); +} diff --git a/src/hooks/useExploreColumns.tsx b/src/hooks/useExploreColumns.tsx index c6a7ebfb3..80e6076ac 100644 --- a/src/hooks/useExploreColumns.tsx +++ b/src/hooks/useExploreColumns.tsx @@ -4,8 +4,8 @@ import { ReactNode, useCallback, useEffect, useMemo, useState } from 'react'; import { ColumnProps } from 'antd/lib/table'; import throttle from 'lodash/throttle'; +import { fieldsDefinitionRegistry, getFieldDefinition } from 'src/entity-configuration/definitions'; import { SortState } from '@/types/explore-section/application'; -import fieldsDefinitionRegistry, { getFieldDefinition } from 'src/entity-configuration/definitions'; import { EntityCoreFields } from '@/entity-configuration/definitions/fields-defs/enums'; import { ViewsDefinitionRegistry } from '@/entity-configuration/definitions/view-defs'; diff --git a/src/hooks/useMorphoMetrics.tsx b/src/hooks/useMorphoMetrics.tsx index 78e1d34dd..293b3f9de 100644 --- a/src/hooks/useMorphoMetrics.tsx +++ b/src/hooks/useMorphoMetrics.tsx @@ -1,7 +1,7 @@ import groupBy from 'lodash/groupBy'; import omit from 'lodash/omit'; -import fieldsDefinitionRegistry, { getFieldDefinition } from '@/entity-configuration/definitions'; +import { fieldsDefinitionRegistry, getFieldDefinition } from '@/entity-configuration/definitions'; import { getViewDefinitionByExtendedType } from '@/entity-configuration/definitions/view-defs'; import { EmptyValue } from '@/entity-configuration/definitions/renderer'; import { ExtendedEntitiesTypeDict } from '@/api/entitycore/types/extended-entity-type'; diff --git a/src/state/explore-section/list-view-atoms.ts b/src/state/explore-section/list-view-atoms.ts index 7c1f3bfa2..24b7dd96d 100644 --- a/src/state/explore-section/list-view-atoms.ts +++ b/src/state/explore-section/list-view-atoms.ts @@ -10,7 +10,6 @@ import { } from '@/entity-configuration/definitions/fields-defs/enums'; import { ExploreDataScope, SortState } from '@/types/explore-section/application'; -import { PAGE_NUMBER, PAGE_SIZE } from '@/api/entitycore/types/extended-entity-type'; import { transformFiltersToQuery } from '@/api/entitycore/transformers'; import { getEntityByExtendedType } from '@/entity-configuration/domain/helpers'; import { EntityCoreResponse } from '@/api/entitycore/types/shared/response'; @@ -22,6 +21,7 @@ import { import { DEFAULT_BRAIN_REGION_HIERARCHY_ID } from '@/features/brain-region-hierarchy/context'; import { getFieldsDefinition } from '@/entity-configuration/definitions'; import { CoreFilter } from '@/entity-configuration/definitions/types'; +import { DEFAULT_PAGE_NUMBER, DEFAULT_PAGE_SIZE } from '@/constants'; import { compactRecord } from '@/utils/dictionary'; import type { EntityCoreExtendedType } from '@/entity-configuration/domain/helpers'; @@ -43,7 +43,7 @@ const isListAtomEqual = (a: DataAtomBinding, b: DataAtomBinding): boolean => { }; export const pageNumberAtom = atomFamily((_key: string) => { - const childAtom = atom(PAGE_NUMBER); + const childAtom = atom(DEFAULT_PAGE_NUMBER); childAtom.debugLabel = `page-number/${_key}`; return childAtom; }); @@ -158,13 +158,13 @@ export const dataAtom = atomFamily((ctx: DataAt pagination: { total_items: 0, page: 1, - page_size: PAGE_SIZE, + page_size: DEFAULT_PAGE_SIZE, }, } as EntityCoreResponse; } } const queryParameters = compactRecord({ - page_size: PAGE_SIZE, + page_size: DEFAULT_PAGE_SIZE, page: pageNumber, search: isEmpty(searchString) ? null : searchString, order_by: `${sortState.order === 'asc' ? '+' : '-'}${sortState.backendField}`, @@ -190,7 +190,7 @@ export const dataAtom = atomFamily((ctx: DataAt pagination: { total_items: 0, page: 1, - page_size: PAGE_SIZE, + page_size: DEFAULT_PAGE_SIZE, }, } as EntityCoreResponse; } diff --git a/src/ui/hooks/use-query-extended-entity-type.tsx b/src/ui/hooks/use-query-extended-entity-type.tsx new file mode 100644 index 000000000..c8e1bd050 --- /dev/null +++ b/src/ui/hooks/use-query-extended-entity-type.tsx @@ -0,0 +1,126 @@ +import { + useQuery, + keepPreviousData, + UseQueryOptions, + type QueryFunction, +} from '@tanstack/react-query'; +import { useAtomValue } from 'jotai'; +import isEmpty from 'lodash/isEmpty'; + +import { + DEFAULT_BRAIN_REGION_HIERARCHY_ID, + selectedBrainRegionAtom, +} from '@/features/brain-region-hierarchy/context'; +import { transformFiltersToQuery } from '@/api/entitycore/transformers'; +import { compactRecord } from '@/utils/dictionary'; +import { + coreFiltersAtom, + corePageNumberAtom, + coreSearchStringAtom, + coreSortStateAtom, +} from '@/ui/segments/data-table/elements/context'; +import { DEFAULT_PAGE_SIZE, type TWorkspaceScope } from '@/constants'; + +import type { TExtendedEntitiesTypeDict } from '@/api/entitycore/types/extended-entity-type'; +import type { WorkspaceContext } from '@/types/common'; + +export type QueryContext = { + key: string; + extendedEntityType: TExtendedEntitiesTypeDict; + workspaceScope: TWorkspaceScope; +}; + +export function buildQueryKey({ + context, + workspace, + queryParameters, +}: { + context: QueryContext; + workspace: WorkspaceContext; + queryParameters: Record; +}): [ + { + workspace: WorkspaceContext; + context: QueryContext; + queryParameters: {} | Record; + }, +] { + return [{ workspace, context, queryParameters }]; +} + +function useQueryParameters({ context }: { context: QueryContext }) { + const selectedBrainRegin = useAtomValue(selectedBrainRegionAtom); + const sortState = useAtomValue(coreSortStateAtom({ key: context.key })); + const searchString = useAtomValue(coreSearchStringAtom(context.key)); + const pageNumber = useAtomValue(corePageNumberAtom(context.key)); + const filters = useAtomValue( + coreFiltersAtom({ dataType: context.extendedEntityType, key: context.key }) + ); + + const queryParameters = compactRecord({ + page_size: DEFAULT_PAGE_SIZE, + page: pageNumber, + search: isEmpty(searchString) ? null : searchString, + order_by: `${sortState.order === 'asc' ? '+' : '-'}${sortState.backendField}`, + within_brain_region_hierarchy_id: DEFAULT_BRAIN_REGION_HIERARCHY_ID, + within_brain_region_brain_region_id: selectedBrainRegin?.id, + within_brain_region_ascendants: false, + ...transformFiltersToQuery(filters as any), + }); + + return queryParameters; +} + +export function useQueryExtendedEntityType({ + context, + workspace, + queryFn, + useKeepPreviousData = true, + ...rest +}: { + context: QueryContext; + workspace: WorkspaceContext; + queryFn: + | QueryFunction< + TData, + [ + { + workspace: { + virtualLabId: string; + projectId: string; + }; + context: QueryContext; + queryParameters: {} | Record; + }, + ], + never + > + | undefined; + useKeepPreviousData?: boolean; +} & Omit< + UseQueryOptions< + TData, + TError, + TData, + [ + { + workspace: { + virtualLabId: string; + projectId: string; + }; + context: QueryContext; + queryParameters: {} | Record; + }, + ] + >, + 'queryKey' | 'queryFn' | 'placeholderData' +>) { + const queryParameters = useQueryParameters({ context }); + return useQuery({ + queryKey: buildQueryKey({ workspace, context, queryParameters }), + queryFn, + // NOTE: if we don't use this option, the isLoading should be used in the component + placeholderData: useKeepPreviousData ? keepPreviousData : undefined, + ...rest, + }); +} diff --git a/src/ui/hooks/useQueryScience.tsx b/src/ui/hooks/useQueryScience.tsx index 8b718ded6..96630dae6 100644 --- a/src/ui/hooks/useQueryScience.tsx +++ b/src/ui/hooks/useQueryScience.tsx @@ -10,8 +10,8 @@ import isEmpty from 'lodash/isEmpty'; import { DEFAULT_BRAIN_REGION_HIERARCHY_ID } from '@/features/brain-region-hierarchy/context'; import { transformFiltersToQuery } from '@/api/entitycore/transformers'; -import { PAGE_SIZE } from '@/api/entitycore/types/extended-entity-type'; import { compactRecord } from '@/utils/dictionary'; +import { DEFAULT_PAGE_SIZE } from '@/constants'; import { sortStateAtom, searchStringAtom, @@ -38,7 +38,7 @@ function useQueryParameters({ const filters = useAtomValue(filtersAtom(ctx)); const queryParameters = compactRecord({ - page_size: PAGE_SIZE, + page_size: DEFAULT_PAGE_SIZE, page: pageNumber, search: isEmpty(searchString) ? null : searchString, order_by: `${sortState.order === 'asc' ? '+' : '-'}${sortState.backendField}`, diff --git a/src/ui/layouts/explore-inner-layout.tsx b/src/ui/layouts/explore-inner-layout.tsx new file mode 100644 index 000000000..cd6cc1a67 --- /dev/null +++ b/src/ui/layouts/explore-inner-layout.tsx @@ -0,0 +1,30 @@ +'use client'; + +import { type ReactNode, useState } from 'react'; + +import { useSelectEntityClickEvent } from '@/ui/segments/mini-detail-view/event'; +import { cn } from '@/utils/css-class'; + +type Props = { + children: ReactNode; +}; + +export function ExploreInnerLayout({ children }: Props) { + const [miniViewPresent, setMiniViewPresent] = useState(false); + useSelectEntityClickEvent((ev) => { + setMiniViewPresent(ev.detail.display); + }); + + return ( +
+ {children} +
+ ); +} diff --git a/src/ui/layouts/explore-layout.tsx b/src/ui/layouts/explore-layout.tsx index 3d071d5e7..f722d842a 100644 --- a/src/ui/layouts/explore-layout.tsx +++ b/src/ui/layouts/explore-layout.tsx @@ -14,14 +14,3 @@ export function ExploreLayout({ children }: Props) {
); } - -export function ExploreInnerLayout({ children }: Props) { - return ( -
- {children} -
- ); -} diff --git a/src/ui/layouts/project-root-layout.tsx b/src/ui/layouts/project-root-layout.tsx index 7d998a0a5..f10473211 100644 --- a/src/ui/layouts/project-root-layout.tsx +++ b/src/ui/layouts/project-root-layout.tsx @@ -5,7 +5,7 @@ import { useNextStep } from 'nextstepjs'; import { defaultWorkspaceTour } from '@/ui/segments/app-setup/discover-app'; import { useLocalStorage } from '@/hooks/use-local-storage'; -import { AUTO_ONBOARDING_DONE } from '@/config'; +import { AUTO_ONBOARDING_DONE } from '@/constants'; type Props = { children: ReactNode; @@ -30,7 +30,7 @@ export function ProjectRootLayout({ children }: Props) { return (
{children}
diff --git a/src/ui/segments/ai/container.tsx b/src/ui/segments/ai/container.tsx index a3a7e8a16..9caa65486 100644 --- a/src/ui/segments/ai/container.tsx +++ b/src/ui/segments/ai/container.tsx @@ -43,10 +43,12 @@ export function Container(): JSX.Element { return '24rem'; }, [isCollapsed, isFullscreen]); - const targetHeight = useMemo( - () => (isFullscreen ? 'calc(100vh - 1rem)' : 'calc(100vh - 5.7rem)'), - [isFullscreen] - ); + const targetHeight = useMemo(() => { + if (isFullscreen) return 'calc(100vh - 1rem)'; + if (isExpanded) return 'calc(100vh - 6rem)'; + if (isCollapsed) return 'calc(100vh - 6.5rem)'; + return 'calc(100vh - 5.2rem)'; + }, [isFullscreen, isExpanded, isCollapsed]); // avoid noticeable morphing of border radius by locking it during animation const getRadius = (s: TPanelState): number => (s === PanelState.Collapsed ? 9999 : 16); @@ -68,9 +70,9 @@ export function Container(): JSX.Element { id="workspace-ai" className={cn( 'text-white [grid-area:ai]', - { 'text-primary-9 mx-3 bg-white shadow-lg': isExpanded }, + { 'text-primary-9 mr-3 bg-white shadow-lg': isExpanded }, { 'text-primary-9 my-2 bg-white px-4 shadow-lg': isFullscreen }, - { 'bg-primary-9 border-primary-9 m-2 text-white shadow-md': isCollapsed } + { 'bg-primary-9 border-primary-9 my-2 mr-3 text-white shadow-md': isCollapsed } )} animate={{ width: targetWidth, diff --git a/src/ui/segments/app-setup/discover-app.tsx b/src/ui/segments/app-setup/discover-app.tsx index 5117e4f55..0f73e9ca1 100644 --- a/src/ui/segments/app-setup/discover-app.tsx +++ b/src/ui/segments/app-setup/discover-app.tsx @@ -6,10 +6,10 @@ import type { CardComponentProps, Tour } from 'nextstepjs'; import type { ReactNode } from 'react'; import { useLocalStorage } from '@/hooks/use-local-storage'; -import { AUTO_ONBOARDING_DONE } from '@/config'; import { Button } from '@/ui/molecules/button'; import { Card } from '@/ui/molecules/card'; import { cn } from '@/utils/css-class'; +import { AUTO_ONBOARDING_DONE } from '@/constants'; export function AppOnboardingProvider({ children }: { children: ReactNode }) { return ( diff --git a/src/ui/segments/data-table/elements/column-key-to-filter.ts b/src/ui/segments/data-table/elements/column-key-to-filter.ts new file mode 100644 index 000000000..1c5a01905 --- /dev/null +++ b/src/ui/segments/data-table/elements/column-key-to-filter.ts @@ -0,0 +1,68 @@ +import get from 'lodash/get'; +import { + CoreFieldFilterTypeEnum, + EntityCoreFields, +} from '@/entity-configuration/definitions/fields-defs/enums'; +import { getFieldDefinition } from '@/entity-configuration/definitions'; + +import type { TExtendedEntitiesTypeDict } from '@/api/entitycore/types/extended-entity-type'; +import type { CoreFilter } from '@/entity-configuration/definitions/types'; + +export function columnKeyToFilter( + key: EntityCoreFields, + dataType: TExtendedEntitiesTypeDict +): CoreFilter { + const fieldConfig = getFieldDefinition(key); + if (!fieldConfig) { + return { + field: key, + type: CoreFieldFilterTypeEnum.Text, + value: '', + }; + } + const constraint = get(fieldConfig.perTypeConstraint, dataType); + switch (fieldConfig.filter) { + case CoreFieldFilterTypeEnum.CheckList: + return { + field: key, + type: CoreFieldFilterTypeEnum.CheckList, + value: [], + constraint: constraint ?? fieldConfig.defaultConstraint, + }; + case CoreFieldFilterTypeEnum.DateRange: + return { + field: key, + type: CoreFieldFilterTypeEnum.DateRange, + value: { gte: null, lte: null }, + constraint: constraint ?? fieldConfig.defaultConstraint, + }; + case CoreFieldFilterTypeEnum.ValueRange: + return { + field: key, + type: CoreFieldFilterTypeEnum.ValueRange, + value: { gte: null, lte: null }, + constraint: constraint ?? fieldConfig.defaultConstraint, + }; + case CoreFieldFilterTypeEnum.ValueOrRange: + return { + field: key, + type: CoreFieldFilterTypeEnum.ValueOrRange, + value: null, + constraint: constraint ?? fieldConfig.defaultConstraint, + }; + case CoreFieldFilterTypeEnum.Text: + return { + field: key, + type: CoreFieldFilterTypeEnum.Text, + value: '', + constraint: constraint ?? fieldConfig.defaultConstraint, + }; + default: + return { + field: key, + type: null, + value: null, + constraint: constraint ?? fieldConfig.defaultConstraint, + }; + } +} diff --git a/src/ui/segments/data-table/elements/context.ts b/src/ui/segments/data-table/elements/context.ts new file mode 100644 index 000000000..ad41869da --- /dev/null +++ b/src/ui/segments/data-table/elements/context.ts @@ -0,0 +1,80 @@ +import { atomFamily, atomWithDefault } from 'jotai/utils'; +import { atom } from 'jotai'; +import _get from 'lodash/get'; + +import { columnKeyToFilter } from '@/ui/segments/data-table/elements/column-key-to-filter'; +import { EntityCoreFields } from '@/entity-configuration/definitions/fields-defs/enums'; +import { getFieldsDefinition } from '@/entity-configuration/definitions'; +import { + getViewDefinitionByExtendedType, + ViewsDefinitionRegistry, +} from '@/entity-configuration/definitions/view-defs'; +import { DEFAULT_PAGE_NUMBER } from '@/constants'; + +import type { EntityCoreExtendedType } from '@/entity-configuration/domain/helpers'; +import type { CoreFilter } from '@/entity-configuration/definitions/types'; +import type { SortState } from '@/types/explore-section/application'; + +export const coreActiveColumnsAtom = atomFamily( + ({ dataType }: { key: string; dataType: EntityCoreExtendedType }) => + atomWithDefault | string[]>(async () => { + const { columns } = { ...ViewsDefinitionRegistry[dataType] }; + return ['index', ...(columns || [])]; + }), + (a, b) => a.key === b.key +); + +export const coreFiltersAtom = atomFamily( + ({ dataType, key }: { key: string; dataType: EntityCoreExtendedType }) => { + const childAtom = atomWithDefault>(() => { + const columns = getViewDefinitionByExtendedType(dataType)?.columns; + const fields = columns ? getFieldsDefinition(columns) : []; + + return [ + ...(columns + ?.filter( + (o) => + _get(fields, o, { isFilterable: false })?.isFilterable === true || + _get(fields, o, { isDisplayable: false })?.isDisplayable === true + ) + ?.map((colKey) => columnKeyToFilter(colKey, dataType)) ?? []), + ]; + }); + childAtom.debugLabel = `filter-atom/${key}`; + return childAtom; + }, + (a, b) => a.key === b.key +); + +export const coreSearchStringAtom = atomFamily((key: string) => { + const childAtom = atom(''); + childAtom.debugLabel = `search/${key}`; + return childAtom; +}); + +export const coreSortStateAtom = atomFamily( + (_ctx: { key: string }) => { + const initialState: SortState = { + field: EntityCoreFields.CreationDate, + backendField: EntityCoreFields.CreationDate, + order: 'desc', + }; + + const writableAtom = atom(initialState, (_, set, update) => { + set(writableAtom, update); + }); + + return writableAtom; + }, + (a, b) => a.key === b.key +); + +export const corePageNumberAtom = atomFamily((key: string) => { + const childAtom = atom(DEFAULT_PAGE_NUMBER); + childAtom.debugLabel = `page-number/${key}`; + return childAtom; +}); + +export const coreSelectedRowsAtom = atomFamily( + (_key: string) => atom>([]) // FIXME: get the right type +); diff --git a/src/ui/segments/data-table/elements/controls.tsx b/src/ui/segments/data-table/elements/controls.tsx new file mode 100644 index 000000000..d2e07a1e6 --- /dev/null +++ b/src/ui/segments/data-table/elements/controls.tsx @@ -0,0 +1,77 @@ +'use client'; + +import { ReactNode } from 'react'; + +import { RenderButtonProps } from '@/components/explore-section/ExploreSectionListingView/useRowSelection'; +import { ExploreDownloadButton } from '@/components/explore-section/ExploreSectionListingView/DownloadButton'; +import { useScrollNav } from '@/components/explore-section/ExploreSectionListingView/hooks'; +import { TExtendedEntitiesTypeDict } from '@/api/entitycore/types/extended-entity-type'; + +import type { EntityCoreIdentifiable } from '@/api/entitycore/types/shared/global'; + +function DefaultRenderButton({ + children, + clearSelectedRows, + selectedRows, + dataType, +}: RenderButtonProps & { + children?: (props: RenderButtonProps) => ReactNode; +}) { + return children ? ( + children({ selectedRows, clearSelectedRows, dataType }) + ) : ( + + selectedRows={selectedRows} + dataType={dataType} + clearSelectedRows={clearSelectedRows} + data-testid="listing-view-download-button" + > + {`Download ${selectedRows.length === 1 ? 'Resource' : 'Resources'} (${ + selectedRows.length + })`} + + ); +} + +export default function TableControls({ + clearSelectedRows, + children, + renderButton, + selectedRows, + visible, + dataType, +}: { + clearSelectedRows: RenderButtonProps['clearSelectedRows']; + children?: ReactNode; + renderButton?: (props: RenderButtonProps) => ReactNode; + selectedRows: RenderButtonProps['selectedRows']; + visible: boolean; + dataType: TExtendedEntitiesTypeDict; +}) { + const { left, right } = useScrollNav( + typeof document !== 'undefined' + ? (document.querySelector('.ant-table-body') as HTMLDivElement) + : undefined + ); + + if (!visible) return null; + + return ( +
+ {left} +
+
{children}
+
{right}
+
+ {!!selectedRows?.length && clearSelectedRows && ( + + clearSelectedRows={clearSelectedRows} + selectedRows={selectedRows} + dataType={dataType} + > + {renderButton} + + )} +
+ ); +} diff --git a/src/ui/segments/data-table/elements/filter-controls.tsx b/src/ui/segments/data-table/elements/filter-controls.tsx new file mode 100644 index 000000000..b6e8d4f50 --- /dev/null +++ b/src/ui/segments/data-table/elements/filter-controls.tsx @@ -0,0 +1,119 @@ +'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 { filterHasValue } from '@/ui/segments/data-table/elements/listing-filter-panel/util'; +import { coreActiveColumnsAtom } from '@/ui/segments/data-table/elements/context'; +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) { + return ( + + ); +} + +export function FilterControls({ + children, + displayControlPanel, + setDisplayControlPanel, + dataType, + dataKey, + filters, + disabled, + className, +}: { + 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{' '} + {activeColumnsLength === 1 ? ' column' : ' columns'} + + ) : ( + + )} + +
+
+
+
+
+ ); +} diff --git a/src/ui/segments/data-table/elements/hooks.tsx b/src/ui/segments/data-table/elements/hooks.tsx new file mode 100644 index 000000000..2cc4e86e0 --- /dev/null +++ b/src/ui/segments/data-table/elements/hooks.tsx @@ -0,0 +1,159 @@ +'use client'; + +import { MouseEvent, ReactNode, useEffect, useState } from 'react'; +import { ColumnGroupType, ColumnType } from 'antd/es/table'; +import { ConfigProvider, Button } from 'antd'; + +import ChevronLast from '@/components/icons/ChevronLast'; +import usePathname from '@/hooks/pathname'; + +import { EntityCoreFields } from '@/entity-configuration/definitions/fields-defs/enums'; +import { classNames } from '@/util/utils'; + +import type { EntityCoreIdentifiable } from '@/api/entitycore/types/shared/global'; +import type { TExtendedEntitiesTypeDict } from '@/api/entitycore/types/extended-entity-type'; + +type OnCellClick = (basePath: string, record: T, type: TExtendedEntitiesTypeDict) => void; + +export function useOnCellRouteHandler({ + dataType, + onCellClick, +}: { + dataType: TExtendedEntitiesTypeDict; + onCellClick?: OnCellClick; +}) { + const pathname = usePathname(); + + const onCellRouteHandler = (col: ColumnGroupType | ColumnType) => { + return { + onCell: (record: T) => + col.key !== EntityCoreFields.Preview + ? { + onClick: (e: MouseEvent) => { + e.preventDefault(); + onCellClick?.(pathname, record, dataType); + }, + } + : {}, + }; + }; + + return onCellRouteHandler; +} + +function useTheme(children: ReactNode): ReactNode { + return ( + + {children} + + ); +} + +/** + * Checks if the content inside the provided HTMLDivElement is horizontally scrollable to the right. + * Returns true if there is content to the right that is not currently visible, false otherwise. + * @param {HTMLDivElement} node - The HTMLDivElement to check for rightward scrollability. + * @returns {boolean} - True if scrollable to the right, false otherwise. + */ +const isRightScrollable = (node: HTMLElement): boolean => + (node?.scrollLeft ?? 0) + (node?.offsetWidth ?? 0) < (node?.scrollWidth ?? 0); + +/** + * Checks if the content inside the provided HTMLDivElement is horizontally scrollable to the left. + * Returns the number of pixels scrolled to the left if scrollable, otherwise returns false. + * @param {HTMLDivElement} node - The HTMLDivElement to check for leftward scrollability. + * @returns {number | boolean} - Number of pixels scrolled to the left or false if not scrollable. + */ +const isLeftScrollable = (node: HTMLElement): number | boolean => + node?.scrollLeft > 0 ? node.getBoundingClientRect().left : false; + +export function useScrollNav(element?: HTMLDivElement): Record<'left' | 'right', ReactNode> { + const [displayFloatButtons, setDisplayFloatButtons] = useState<{ + right: boolean; + left: number | boolean; + }>({ + right: false, + left: false, + }); + + const updateDisplayControls = (node: HTMLElement) => + setDisplayFloatButtons({ + right: isRightScrollable(node), + left: isLeftScrollable(node), + }); + + const handleScrollToRight = () => + element?.scrollTo({ + behavior: 'smooth', + left: element.scrollWidth, + }); + + const handleScrollToLeft = () => + element?.scrollTo({ + behavior: 'smooth', + left: 0, + }); + + useEffect(() => { + const container = element as HTMLElement; + const handleOnScroll = () => updateDisplayControls(container); + const observer = new ResizeObserver((entries) => { + updateDisplayControls(entries[0].target as HTMLDivElement); + }); + + if (container) { + observer.observe(container); + container.addEventListener('scroll', handleOnScroll); + } + return () => { + if (container) { + observer.unobserve(container); + container.removeEventListener('scroll', handleOnScroll); + } + }; + }, [element]); + + const right = useTheme( + + ); + + const search = () => ( +
+ +
+ ); + + return ( +
+ {options && options.length > 0 ? ( + children({ + options, + search, // Pass the search function to the ListComponent + loadMoreBtn, // Pass the loadMoreBtn function to the ListComponent + handleCheckedChange, + filterField: filter.field, + renderLength: filtersRenderLength, + defaultRenderLength: DEFAULT_CHECKLIST_RENDER_LENGTH, // Pass the defaultRenderLength as a prop + }) + ) : ( +
+ } + message="We could not find any data that matches your selected filters. Please modify your selection to narrow down and retrieve the relevant information" + /> +
+ )} +
+ ); +} diff --git a/src/ui/segments/data-table/elements/listing-filter-panel/checklist/option.tsx b/src/ui/segments/data-table/elements/listing-filter-panel/checklist/option.tsx new file mode 100644 index 000000000..cf058db81 --- /dev/null +++ b/src/ui/segments/data-table/elements/listing-filter-panel/checklist/option.tsx @@ -0,0 +1,93 @@ +'use client'; + +import { memo, ReactNode, useEffect, useState } from 'react'; +import * as Checkbox from '@radix-ui/react-checkbox'; +import { format } from 'date-fns'; + +import { getMtype } from '@/api/entitycore/queries/annotations/mtype'; +import { CheckIcon } from '@/components/icons'; +import { tryCatch } from '@/api/utils'; + +const DisplayLabel = (filterField: string, key: string): string | null => { + switch (filterField) { + case 'updatedAt': + return format(new Date(Number(key)), 'dd.MM.yyyy'); + case 'createdBy': + return key.substring(key.lastIndexOf('/') + 1); + default: + return key; + } +}; + +export function CheckListOption({ + checked, + value, + handleCheckedChange, + id, + filterField, + label, + children, +}: { + children: ReactNode; + checked: string | boolean; + value: string | number | null; + handleCheckedChange: (key: string) => void; + id: string; + filterField: string; + label: string; + // eslint-disable-next-line react/no-unused-prop-types + type?: string | null; +}) { + const onCheckedChange = () => handleCheckedChange(label); + return ( +
  • +
    + {DisplayLabel(filterField, label)} + + {!!value && {`${value} datasets`}} + + + + + + +
    + {children} +
  • + ); +} + +function CheckListDescriptionToMemoize({ + id, +}: { + id: string; + // eslint-disable-next-line react/no-unused-prop-types + filterField: string; + // eslint-disable-next-line react/no-unused-prop-types + label: string; + // eslint-disable-next-line react/no-unused-prop-types + type?: string | null; +}) { + const [definition, setDefinition] = useState(null); + + useEffect(() => { + // TODO: fetch based on the type + async function getDefinition() { + const { data, error } = await tryCatch(getMtype({ id })); + if (error) return null; + setDefinition(data.definition); + } + getDefinition(); + }, [id]); + + return {definition}; +} + +export const CheckListDescription = memo( + CheckListDescriptionToMemoize, + ({ id }, { id: nextId }) => id !== nextId +); diff --git a/src/ui/segments/data-table/elements/listing-filter-panel/checklist/use-options.ts b/src/ui/segments/data-table/elements/listing-filter-panel/checklist/use-options.ts new file mode 100644 index 000000000..4ed32efb4 --- /dev/null +++ b/src/ui/segments/data-table/elements/listing-filter-panel/checklist/use-options.ts @@ -0,0 +1,41 @@ +import { useMemo } from 'react'; + +export type FacetLabelValuePair = { + id: string; + label: string; + value: string; + type?: string | null; + count: number; +}; + +export type FacetOptionsList = + | Array<{ + id: string; + label: string; + value: string; + checked: boolean; + count: number; + type?: string | null; + }> + | undefined; + +export function useOptions( + values: Array, + data?: Array +): FacetOptionsList { + return useMemo(() => { + return ( + data && + data.map(({ id, label, value, type, count }) => { + return { + id, + label, + type, + value, + count, + checked: values?.includes(label), + }; + }) + ); + }, [data, values]); +} diff --git a/src/ui/segments/data-table/elements/listing-filter-panel/clear-filters.tsx b/src/ui/segments/data-table/elements/listing-filter-panel/clear-filters.tsx new file mode 100644 index 000000000..b92294885 --- /dev/null +++ b/src/ui/segments/data-table/elements/listing-filter-panel/clear-filters.tsx @@ -0,0 +1,14 @@ +import ReloadIcon from '@/components/icons/Reload'; + +export default function ClearFilters({ onClick }: { onClick: () => void }) { + return ( + + ); +} diff --git a/src/ui/segments/data-table/elements/listing-filter-panel/date-range.tsx b/src/ui/segments/data-table/elements/listing-filter-panel/date-range.tsx new file mode 100644 index 000000000..3955bcfd7 --- /dev/null +++ b/src/ui/segments/data-table/elements/listing-filter-panel/date-range.tsx @@ -0,0 +1,49 @@ +import { useMemo } from 'react'; +import { ConfigProvider, DatePicker } from 'antd'; +import dateFnsGenerateConfig from 'rc-picker/lib/generate/dateFns'; // eslint-disable-line import/no-extraneous-dependencies + +import type { DateRangeFilter, GteLteValue } from '@/entity-configuration/definitions/types'; + +const DateRangePicker = DatePicker.generatePicker(dateFnsGenerateConfig); + +export default function DateRange({ + filter, + onChange, +}: { + filter: DateRangeFilter; + onChange: (value: GteLteValue) => void; +}) { + const memoizedRender = useMemo( + () => ( + + onChange({ gte: newValues?.[0] ?? null, lte: newValues?.[1] ?? null }) + } + /> + ), + [filter.value.gte, filter.value.lte, onChange] + ); + + return ( +
    + + {memoizedRender} + +
    + ); +} diff --git a/src/ui/segments/data-table/elements/listing-filter-panel/filter-group.tsx b/src/ui/segments/data-table/elements/listing-filter-panel/filter-group.tsx new file mode 100644 index 000000000..15a4fd6e6 --- /dev/null +++ b/src/ui/segments/data-table/elements/listing-filter-panel/filter-group.tsx @@ -0,0 +1,76 @@ +import { EyeOutlined, EyeInvisibleOutlined } from '@ant-design/icons'; +import * as Accordion from '@radix-ui/react-accordion'; +import { ReactElement } from 'react'; + +import { ChevronIcon } from '@/components/icons'; +import { classNames } from '@/util/utils'; + +import type { CoreFilter } from '@/entity-configuration/definitions/types'; + +import styles from '@/ui/segments/data-table/elements/listing-filter-panel/filters.module.css'; + +type ContentProps = { + filters: Array; + setFilters: (filters: Array) => void; +}; + +type FilterGroupProps = { + items: { + content?: (contentProps: ContentProps) => null | ReactElement; + display?: boolean; + label: string; + toggleFunc?: () => void; + }[]; + filters: Array; + setFilters: (filters: Array) => void; +}; + +export function FilterGroup({ items, filters, setFilters }: FilterGroupProps) { + return ( + + {items?.map(({ content, display, label, toggleFunc }) => { + const displayTrigger = display ? ( + + ) : ( + + ); + + return content ? ( + +
    + {toggleFunc && displayTrigger} + + {label} + + +
    + + {content({ filters, setFilters })} + +
    + ) : ( +
    + {toggleFunc && displayTrigger} + {label} +
    + ); + })} +
    + ); +} diff --git a/src/ui/segments/data-table/elements/listing-filter-panel/filters.module.css b/src/ui/segments/data-table/elements/listing-filter-panel/filters.module.css new file mode 100644 index 000000000..9fa64809b --- /dev/null +++ b/src/ui/segments/data-table/elements/listing-filter-panel/filters.module.css @@ -0,0 +1,9 @@ +.accordionTrigger .chevron { + transition-property: transform; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; +} + +.accordionTrigger[data-state='open'] .chevron { + transform: rotate(90deg); +} diff --git a/src/ui/segments/data-table/elements/listing-filter-panel/listing-filter-panel.tsx b/src/ui/segments/data-table/elements/listing-filter-panel/listing-filter-panel.tsx new file mode 100644 index 000000000..c82abac18 --- /dev/null +++ b/src/ui/segments/data-table/elements/listing-filter-panel/listing-filter-panel.tsx @@ -0,0 +1,329 @@ +/* eslint-disable no-case-declarations */ + +import { + ChangeEvent, + Dispatch, + ReactNode, + SetStateAction, + useCallback, + useEffect, + useMemo, + useState, +} from 'react'; +import { CloseOutlined } from '@ant-design/icons'; +import { unwrap, useResetAtom } from 'jotai/utils'; +import { useAtom, useSetAtom } from 'jotai'; +import { Input } from 'antd'; + +import isNil from 'lodash/isNil'; +import map from 'lodash/map'; + +import ValueOrRange from '@/ui/segments/data-table/elements/listing-filter-panel/value-or-range'; +import ClearFilters from '@/ui/segments/data-table/elements/listing-filter-panel/clear-filters'; +import DateRange from '@/ui/segments/data-table/elements/listing-filter-panel/date-range'; +import CheckList from '@/ui/segments/data-table/elements/listing-filter-panel/checklist'; + +import { defaultList } from '@/ui/segments/data-table/elements/listing-filter-panel/checklist/default-checklist'; +import { FilterGroup } from '@/ui/segments/data-table/elements/listing-filter-panel/filter-group'; +import { CoreFieldFilterTypeEnum } from '@/entity-configuration/definitions/fields-defs/enums'; +import { getViewDefinitionByExtendedType } from '@/entity-configuration/definitions/view-defs'; +import { getFieldDefinition } from '@/entity-configuration/definitions'; +import { fieldTitleSentenceCase } from '@/util/utils'; +import { + coreActiveColumnsAtom, + coreFiltersAtom, + coreSearchStringAtom, +} from '@/ui/segments/data-table/elements/context'; + +import type { TExtendedEntitiesTypeDict } from '@/api/entitycore/types/extended-entity-type'; +import type { Facets } from '@/api/entitycore/types/shared/response'; +import type { WorkspaceContext } from '@/types/common'; +import type { + ValueOrRangeFilter, + CoreFilterValues, + GteLteValue, + CoreFilter, +} from '@/entity-configuration/definitions/types'; +import { TWorkspaceScope } from '@/constants'; + +type Props = { + children?: ReactNode; + toggleDisplay: () => void; + dataType: TExtendedEntitiesTypeDict; + // eslint-disable-next-line react/no-unused-prop-types + dataScope?: TWorkspaceScope; + dataKey: string; + filters: CoreFilter[]; + facets: Facets | undefined; + setFilters: any; + showDisplayTrigger?: boolean; + // eslint-disable-next-line react/no-unused-prop-types + workspace?: WorkspaceContext; +}; + +function createFilterItemComponent( + filter: CoreFilter, + facets: Facets | undefined, + filterValues: CoreFilterValues, + setFilterValues: Dispatch> +) { + return function FilterItemComponent() { + const { type } = filter; + + const updateFilterValues = (field: string, values: CoreFilter['value']) => { + setFilterValues((prevState) => ({ + ...prevState, + [field]: values, + })); + }; + + const emptyFilter = ( +
    + No filter available for this property yet +
    + ); + + switch (type) { + case CoreFieldFilterTypeEnum.DateRange: + return ( + updateFilterValues(filter.field, values)} + /> + ); + + case CoreFieldFilterTypeEnum.ValueRange: + if (!facets) return emptyFilter; + + // if (esConfig?.nested) { + // const nestedAgg = facets[filter.field] as NestedStatsAggregation; + // facet = nestedAgg[filter.field][esConfig?.nested.aggregationName]; + // } else { + // facet = facets[filter.field] as Statistics; + // } + + // return ( + // updateFilterValues(filter.field, values)} + // /> + // ); + return null; + + case CoreFieldFilterTypeEnum.CheckList: + if (!facets || !facets[filter.field]) return emptyFilter; + const facetItems = map(facets[filter.field], ({ id, label, count, type: facetType }) => ({ + id, + label, + type: facetType, + count, + value: label, + })); + return ( + updateFilterValues(filter.field, values)} + > + {defaultList} + + ); + + case CoreFieldFilterTypeEnum.ValueOrRange: + return ( + + updateFilterValues(filter.field, value) + } + /> + ); + + case CoreFieldFilterTypeEnum.Text: + return ( +
    + ) => + updateFilterValues(filter.field, event.target.value) + } + /> + + Use the asterix character (*) to + specify a "wildcard" for your search. For example; to search for names{' '} + beginning with "AA11", specify{' '} + AA11*. To search for names{' '} + containing "L5-2", specify{' '} + *L5-2*. + +
    + ); + + default: + return null; + } + }; +} + +export function ListingFilterPanel({ + children, + toggleDisplay, + dataType, + dataKey, + filters, + setFilters, + facets, + showDisplayTrigger = true, +}: Props) { + const [filterValues, setFilterValues] = useState({}); + const resetFilters = useResetAtom( + coreFiltersAtom({ + dataType, + key: dataKey, + }) + ); + const setSearchString = useSetAtom(coreSearchStringAtom(dataKey)); + + const [activeColumns, setActiveColumns] = useAtom( + useMemo( + () => + unwrap( + coreActiveColumnsAtom({ + dataType, + key: dataKey, + }) + ), + [dataType, dataKey] + ) + ); + + const onToggleActive = useCallback( + (key: string) => { + if (!activeColumns) return; + const existingIndex = activeColumns.findIndex((existingKey) => existingKey === key); + + if (existingIndex === -1) { + setActiveColumns([...activeColumns, key]); + } else { + setActiveColumns([ + ...activeColumns.slice(0, existingIndex), + ...activeColumns.slice(existingIndex + 1), + ]); + } + }, + + [activeColumns, setActiveColumns] + ); + + useEffect(() => { + const values: CoreFilterValues = {}; + + filters?.forEach((filter: CoreFilter) => { + values[filter.field] = filter.value; + }); + + setFilterValues(values); + }, [filters]); + + const submitValues = () => { + setFilters(filters?.map((fil: CoreFilter) => ({ ...fil, value: filterValues[fil.field] }))); + }; + + const Entity = getViewDefinitionByExtendedType(dataType); + const filterItems = useMemo( + () => + filters + .filter((o) => o.field !== 'id') + ?.map((filter) => { + const item = getFieldDefinition(filter.field); + return { + content: + filter.type && + item?.isFilterable && + (Entity?.filterableFields ? Entity?.filterableFields.includes(filter.field) : true) + ? createFilterItemComponent(filter, facets, filterValues, setFilterValues) + : undefined, + display: item?.isDisplayable && activeColumns?.includes(filter.field), + label: fieldTitleSentenceCase(item?.title ?? ''), + type: filter.type, + toggleFunc: showDisplayTrigger + ? () => onToggleActive && onToggleActive(filter.field) + : undefined, // There are cases where we don't want to show the display trigger. Undefined toggleFunc achieves this. + }; + }) + .filter((item) => showDisplayTrigger || !isNil(item.content)), // If showDisplayTrigger is false and content is undefined that filter is not needed. + [ + filters, + facets, + filterValues, + setFilterValues, + activeColumns, + showDisplayTrigger, + onToggleActive, + Entity, + ] + ); + + if (!activeColumns) return null; + + const activeColumnsLength = activeColumns.length ? activeColumns.length - 1 : 0; + const activeColumnsText = `${activeColumnsLength} active ${ + activeColumnsLength === 1 ? 'column' : 'columns' + }`; + + // The columnKeyToFilter method receives a string (key) + // and in this case it is the equivalent to a filters[x].field + const clearFilters = () => { + resetFilters(); + setSearchString(''); + }; + + return ( +
    +
    +
    + + Filters + {activeColumnsText} + + +
    + +

    + Use the eye icon to hide/show columns. Select the column titles and tick the checkbox of + the option(s). +

    + +
    + + {children} +
    +
    + +
    + + +
    +
    + ); +} diff --git a/src/ui/segments/data-table/elements/listing-filter-panel/numeric-results-info.tsx b/src/ui/segments/data-table/elements/listing-filter-panel/numeric-results-info.tsx new file mode 100644 index 000000000..536ed342c --- /dev/null +++ b/src/ui/segments/data-table/elements/listing-filter-panel/numeric-results-info.tsx @@ -0,0 +1,94 @@ +'use client'; + +import { LoadingOutlined } from '@ant-design/icons'; +import { JSX, useEffect, useState } from 'react'; +import { match, P } from 'ts-pattern'; + +import type { Pagination } from '@/api/entitycore/types/shared/response'; + +const generateDisplayComponent = ( + data: + | { + pagination: Pagination; + totalData: number; + } + | undefined +): JSX.Element | null => { + return match(data) + .with(P.nullish, () => 0) + .with({ pagination: P.nullish }, () => 0) + .with( + { + totalData: P.nonNullable.select('totalData'), + pagination: P.nonNullable.select('pagination'), + }, + ({ totalData, pagination }) => { + let currentCount = + pagination?.page && pagination.page_size + ? (pagination.page - 1) * pagination.page_size + (totalData ?? 0) + : totalData; + const totalCount = pagination.total_items || 0; + + currentCount = totalCount > 0 ? currentCount : 0; + + const currentFormatted = currentCount?.toLocaleString('en-US'); + const totalFormatted = totalCount.toLocaleString('en-US'); + + return match({ currentCount, totalCount }) + .with({ currentCount: P.when((c) => c > 0), totalCount: P.when((t) => t > 0) }, () => ( + + {currentFormatted} of {totalFormatted} + + )) + .otherwise(() => 0); + } + ) + .otherwise(() => null); +}; + +export function ResultsCount({ + isLoading, + resultPagination, +}: { + isLoading?: boolean; + resultPagination?: { + pagination: Pagination; + totalData: number; + }; +}) { + const [persistedDisplay, setPersistedDisplay] = useState(null); + + const content = match({ isLoading, resultPagination }) + .with({ isLoading: true }, () => + persistedDisplay ? ( + {persistedDisplay} + ) : ( + + ) + ) + .with({ resultPagination: P.not(P.nullish) }, () => generateDisplayComponent(resultPagination)) + .otherwise(() => null); + + useEffect(() => { + if (!isLoading && resultPagination) { + setPersistedDisplay(generateDisplayComponent(resultPagination)); + } + }, [isLoading, resultPagination]); + + return ( +
    +
    + Results + {content} +
    +
    + ); +} diff --git a/src/ui/segments/data-table/elements/listing-filter-panel/search-filter.tsx b/src/ui/segments/data-table/elements/listing-filter-panel/search-filter.tsx new file mode 100644 index 000000000..c65c441fa --- /dev/null +++ b/src/ui/segments/data-table/elements/listing-filter-panel/search-filter.tsx @@ -0,0 +1,85 @@ +import { CloseOutlined } from '@ant-design/icons'; +import { ConfigProvider, Tag } from 'antd'; +import type { CustomTagProps } from 'rc-select/lib/BaseSelect'; +import type { DefaultOptionType } from 'antd/es/select'; + +import { useOptions } from '@/ui/segments/data-table/elements/listing-filter-panel/checklist/use-options'; +import { getFieldDefinition } from '@/entity-configuration/definitions'; +import Search from '@/components/Search'; + +import type { FacetLabelValuePair } from '@/ui/segments/data-table/elements/listing-filter-panel/checklist/use-options'; +import type { CoreFilter } from '@/entity-configuration/definitions/types'; + +export function SearchFilter({ + data, + filter, + values, + onChange, +}: { + data: Array; + filter: CoreFilter; + values: string[]; + onChange: (newValues: string[]) => void; +}) { + const options = useOptions(values, data); + + const handleCheckedChange = (value: string, option: DefaultOptionType) => { + let newValues = [...values]; + if (values.includes(value)) { + newValues = values.filter((val) => val !== String(option.label)); + } else { + newValues.push(String(option.label)); + } + onChange(newValues); + }; + + const tagRender = (tagProps: CustomTagProps) => { + const { label, closable, onClose } = tagProps; + return ( + + } + onClose={onClose} + style={{ margin: '0.125rem 0.125rem 0.125rem auto' }} + > + {label} + + + ); + }; + + return ( + options && + options.length > 0 && ( + onChange([])} + handleSelect={handleCheckedChange} + options={options.map(({ id, label }) => ({ + label, + value: id, + }))} + mode="multiple" + placeholder={`Search for ${getFieldDefinition(filter.field)?.vocabulary?.plural}`} + tagRender={(props) => { + return tagRender(props); + }} + value={options.reduce( + (acc: string[], { checked, label }) => (checked ? [...acc, label] : acc), + [] + )} + /> + ) + ); +} diff --git a/src/ui/segments/data-table/elements/listing-filter-panel/types.ts b/src/ui/segments/data-table/elements/listing-filter-panel/types.ts new file mode 100644 index 000000000..7c03da69f --- /dev/null +++ b/src/ui/segments/data-table/elements/listing-filter-panel/types.ts @@ -0,0 +1,54 @@ +import { CoreFieldFilterTypeEnum } from '@/entity-configuration/definitions/fields-defs/enums'; + +export interface GteLteValue { + gte: Date | number | null; + lte: Date | number | null; +} + +interface BaseFilter { + field: string; + type: null; + value: null; + constraint?: string | Record; +} + +interface CheckListFilter extends Omit { + type: CoreFieldFilterTypeEnum.CheckList; + value: string[]; +} + +interface SearchFilter extends Omit { + type: CoreFieldFilterTypeEnum.Search; + value: string[]; +} + +interface DateRangeFilter extends Omit { + type: CoreFieldFilterTypeEnum.DateRange; + value: GteLteValue; +} + +interface TextFilter extends Omit { + type: CoreFieldFilterTypeEnum.Text; + value: string; +} + +export interface ValueFilter extends Omit { + type: CoreFieldFilterTypeEnum.ValueRange; + value: GteLteValue; +} + +export interface ValueOrRangeFilter extends Omit { + type: CoreFieldFilterTypeEnum.ValueOrRange; + value: number | GteLteValue | null; // "value" | "range" | "all" +} + +export type Filter = + | CheckListFilter + | SearchFilter + | DateRangeFilter + | TextFilter + | ValueFilter + | ValueOrRangeFilter + | BaseFilter; + +export type FilterType = CoreFieldFilterTypeEnum | null; diff --git a/src/ui/segments/data-table/elements/listing-filter-panel/util.ts b/src/ui/segments/data-table/elements/listing-filter-panel/util.ts new file mode 100644 index 000000000..1cfc0fe75 --- /dev/null +++ b/src/ui/segments/data-table/elements/listing-filter-panel/util.ts @@ -0,0 +1,33 @@ +import isNumber from 'lodash/isNumber'; +import isEmpty from 'lodash/isEmpty'; + +import { CoreFieldFilterTypeEnum } from '@/entity-configuration/definitions/fields-defs/enums'; + +import type { CoreFilter } from '@/entity-configuration/definitions/types'; + +/** + * Checks whether the filter has a value assigned + * + * @param filter the filter to check + */ +export function filterHasValue(filter: CoreFilter) { + switch (filter.type) { + case CoreFieldFilterTypeEnum.Text: + return !isEmpty(filter.value); + case CoreFieldFilterTypeEnum.CheckList: + return filter.value.length !== 0; + case CoreFieldFilterTypeEnum.DateRange: + return !isEmpty(filter.value.gte) || !isEmpty(filter.value.lte); + case CoreFieldFilterTypeEnum.ValueRange: + return !isEmpty(filter.value.gte) || !isEmpty(filter.value.lte); + case CoreFieldFilterTypeEnum.WithinList: + return false; // TODO: this is need to be discussed/fixed + case CoreFieldFilterTypeEnum.ValueOrRange: + if (!filter.value) { + return false; + } + return !!(isNumber(filter.value) || filter.value.gte || filter.value.lte); + default: + return !!filter.value; + } +} diff --git a/src/ui/segments/data-table/elements/listing-filter-panel/value-or-range.tsx b/src/ui/segments/data-table/elements/listing-filter-panel/value-or-range.tsx new file mode 100644 index 000000000..6eb89cccb --- /dev/null +++ b/src/ui/segments/data-table/elements/listing-filter-panel/value-or-range.tsx @@ -0,0 +1,156 @@ +'use client'; + +import { ChangeEvent, HTMLProps, useState } from 'react'; + +import { RangeIcon } from '@/components/icons'; + +import type { GteLteValue, ValueOrRangeFilter } from '@/entity-configuration/definitions/types'; +import { getFieldDefinition } from '@/entity-configuration/definitions'; +import { EntityCoreFields } from '@/entity-configuration/definitions/fields-defs/enums'; + +export function getFieldUnit(field: EntityCoreFields) { + const fieldDef = getFieldDefinition(field); + return fieldDef?.unit; +} + +function Radio({ + checked, + children, + id, + label, + onChange, + value, +}: HTMLProps & { label: string }) { + return ( +
    + + {children} +
    + ); +} + +export default function ValueOrRange({ + filter, + setFilter, +}: { + filter: ValueOrRangeFilter; + setFilter: (value: ValueOrRangeFilter['value']) => void; +}) { + const [range, setRange] = useState( + filter.value && Object.prototype.hasOwnProperty.call(filter.value, 'gte') + ? (filter.value as GteLteValue) + : { + gte: null, + lte: null, + } + ); + + const [value, setValue] = useState( + typeof filter.value === 'number' ? filter.value : undefined + ); + + function getInitialRadio() { + if (typeof filter.value === 'number') { + return 'value'; + } + + if ( + !!filter.value && + (Object.prototype.hasOwnProperty.call(filter.value, 'gte') || + Object.prototype.hasOwnProperty.call(filter.value, 'lte')) + ) { + return 'range'; + } + + return 'all'; + } + + type RadioOptions = 'all' | 'value' | 'range'; + + const [selectedRadio, setSelectedRadio] = useState(getInitialRadio()); + + function updateValue(e: ChangeEvent) { + const newValue = Number(e.target.value); + + setValue(newValue); + setFilter(newValue); + } + + function updateRange(newValue: { [x in keyof Partial]: number }) { + setRange({ ...range, ...newValue }); + setFilter({ ...range, ...newValue }); + } + + function updateFilter(newValue: number | GteLteValue | null, radioValue: RadioOptions) { + setFilter(newValue); + setSelectedRadio(radioValue); + } + + return ( +
    + updateFilter(null, 'all')} + value="all" + /> + updateFilter(value ?? null, 'value')} + value="value" + > +
    + + {getFieldUnit(filter.field)} +
    +
    + updateFilter(range, 'range')} + value="range" + > +
    + ) => + updateRange({ gte: Number(e.target.value) }) + } + step={1} + type="number" + value={(range.gte as number | null) ?? undefined} + /> + + ) => + updateRange({ lte: Number(e.target.value) }) + } + step={1} + type="number" + value={(range.lte as number | null) ?? undefined} + /> + {getFieldUnit(filter.field)} +
    +
    +
    + ); +} diff --git a/src/ui/segments/data-table/elements/pagination.tsx b/src/ui/segments/data-table/elements/pagination.tsx new file mode 100644 index 000000000..83ff36020 --- /dev/null +++ b/src/ui/segments/data-table/elements/pagination.tsx @@ -0,0 +1,47 @@ +import { Pagination as AntPagination, type PaginationProps } from 'antd'; +import { useAtom } from 'jotai'; +import type { ComponentProps } from 'react'; + +import { corePageNumberAtom } from '@/ui/segments/data-table/elements/context'; +import { DEFAULT_PAGE_SIZE } from '@/constants'; +import { cn } from '@/utils/css-class'; + +import type { Pagination as EntitycorePagination } from '@/api/entitycore/types/shared/response'; + +type Props = { + dataKey: string; + size?: PaginationProps['size']; + resultPagination?: { + pagination: EntitycorePagination; + totalData: number; + }; + className?: ComponentProps<'ul'>['className']; +}; + +export function Pagination({ dataKey, size, resultPagination, className }: Props) { + const [page, updatePage] = useAtom(corePageNumberAtom(dataKey)); + + return ( + + ); +} diff --git a/src/ui/segments/data-table/elements/table.module.css b/src/ui/segments/data-table/elements/table.module.css new file mode 100644 index 000000000..7f0a5846b --- /dev/null +++ b/src/ui/segments/data-table/elements/table.module.css @@ -0,0 +1,155 @@ +@reference '../../../../styles/globals.css'; + +.table a { + @apply text-primary-7; +} +.table table .ant-table td { + white-space: nowrap; +} + +.headerText, +.tableHeader { + @apply flex w-fit items-center justify-between gap-2 text-left text-gray-500; +} + +.tableHeaderTitle { + @apply cursor-help pl-0 text-left text-lg font-medium text-gray-500; +} + +.tableHeaderUnits { + @apply pl-2 text-left text-sm font-thin text-gray-500; +} + +.dragIcons { + @apply text-neutral-3 absolute right-0 ml-auto rotate-90 cursor-col-resize text-2xl font-light; + opacity: 0; +} + +.alignmentHack > div { + align-items: normal !important; +} + +.tableRow { + border: none !important; + @apply cursor-pointer; +} + +.themeBlack { + @apply bg-black; +} + +.container { + height: 100vh; + width: 100vw; + display: grid; + grid-template-columns: 0.7fr 1.3fr; + grid-template-rows: auto repeat(4, 1fr); + gap: 0px 0px; + grid-auto-flow: row; + grid-template-areas: + 'explore-header content' + 'braincells content' + 'experimental content' + 'reconstructions content' + 'simulations info'; +} + +.content { + grid-area: content; + background-color: black; + -webkit-background-size: contain; + -moz-background-size: contain; + -o-background-size: contain; + background-size: contain; +} + +.exploreHeader { + min-height: 0; + grid-area: explore-header; + min-height: 20%; +} + +.braincells { + min-height: 0; + grid-area: braincells; + @apply bg-primary-8; +} + +.experimental { + min-height: 0; + grid-area: experimental; + @apply bg-primary-7; +} + +.reconstructions { + min-height: 0; + grid-area: reconstructions; + @apply bg-primary-6; +} +.simulations { + min-height: 0; + grid-area: simulations; + @apply bg-primary-5; +} + +.themeBlack, +.info, +.exploreHeader, +.experimental, +.simulations, +.braincells, +.reconstructions { + @apply px-14 py-8 text-white; + > div > span, + > a > div > span { + float: right; + @apply text-xl; + } + h1 { + @apply inline text-xl font-bold; + } + button { + font-size: clamp(1rem, 0.5vw + 5px, 3rem); + @apply mt-4 border border-white px-4 py-2; + } + p { + font-size: clamp(1rem, 0.4vw + 5px, 3rem); + @apply mt-2 font-thin; + } +} + +.exploreHeader { + h1 { + @apply block font-bold uppercase; + font-size: clamp(1rem, 2.5vw, 5rem); + line-height: 0.8; + word-spacing: 100vw; + } + button { + @apply border-primary-4 text-primary-4 border; + > span { + @apply mr-5; + } + } +} +.info { + min-height: 0; + background-color: black; + @apply py-0 text-white; + h1 { + @apply text-xl; + } + p { + @apply w-8/12; + } + button { + @apply bg-white text-black; + } +} + +.columnTitle { + @apply relative pl-2; + &::before { + @apply absolute end-0 top-0 left-0 h-full w-px bg-[#d9d9d9] content-[""]; + } +} diff --git a/src/ui/segments/data-table/elements/use-data-table-columns.tsx b/src/ui/segments/data-table/elements/use-data-table-columns.tsx new file mode 100644 index 000000000..dfd2a44dd --- /dev/null +++ b/src/ui/segments/data-table/elements/use-data-table-columns.tsx @@ -0,0 +1,246 @@ +'use client'; + +import { ReactNode, useCallback, useEffect, useMemo, useState } from 'react'; +import { ColumnProps } from 'antd/lib/table'; +import throttle from 'lodash/throttle'; + +import { fieldsDefinitionRegistry, getFieldDefinition } from '@/entity-configuration/definitions'; +import { EntityCoreFields } from '@/entity-configuration/definitions/fields-defs/enums'; +import { ViewsDefinitionRegistry } from '@/entity-configuration/definitions/view-defs'; +import { classNames, fieldTitleSentenceCase } from '@/util/utils'; + +import type { TExtendedEntitiesTypeDict } from '@/api/entitycore/types/extended-entity-type'; +import type { OrderShape } from '@/entity-configuration/definitions/types'; +import type { SortState } from '@/types/explore-section/application'; + +import styles from '@/ui/segments/data-table/elements/table.module.css'; + +type ResizeInit = { + key: string | null; + start: number | null; +}; + +const COL_SIZING = { + min: 75, + default: 125, +}; + +/** + * This fn will get the exact width that the column title must take in the table + * the returned width will be the Max between the default width and the + * the width of the sum of the title, the sorting arrows and the unit (if present) + * @param title string + * @param unit string + * @returns number + */ +function getProvisionedWidth(title: string, unit?: ReactNode) { + const titleSpan = document.createElement('span'); + titleSpan.textContent = `${title} ${unit ?? ''}`; + // font-{size/weight} must be the same as the column style + titleSpan.style.setProperty('font-size', '1rem'); + document.body.appendChild(titleSpan); + // 56= x-padding (32px) + (sorter icon) 24 + const width = titleSpan.getBoundingClientRect().width + 56; + document.body.removeChild(titleSpan); + return width; +} + +function isOrderObject(order: OrderShape): order is { property: string; value: string } { + return !Array.isArray(order); +} + +/** + * Retrieves the order value from the provided order object or array, optionally filtered by data type. + * + * @param order - The order information, which can be an `OrderShape` object, an array of order objects, or `undefined`. + * @param dataType - (Optional) The data type to filter the order by when `order` is an array. + * @returns The order value as a string if found; otherwise, `undefined`. + */ +export function getOrderValue( + order: OrderShape | undefined, + dataType?: TExtendedEntitiesTypeDict +): string | undefined { + if (!order) return undefined; + + if (isOrderObject(order)) { + return order.value; + } + + if (Array.isArray(order)) { + if (!dataType) return undefined; + + const orderForType = order.find((o) => o.types.includes(dataType)); + + if (orderForType) { + return orderForType.value; + } + } + return undefined; +} + +export function useDataTableColumns({ + dataType, + sortState, + setSortState, + initialColumns = [], +}: { + dataType: TExtendedEntitiesTypeDict; + setSortState: (sortState: SortState) => void; + sortState?: SortState; + initialColumns?: ColumnProps[]; +}): ColumnProps[] { + const keys = useMemo(() => Object.keys(fieldsDefinitionRegistry), []); + + const [columnWidths, setColumnWidths] = useState<{ key: string; width: number }[]>( + [...keys].map((key) => ({ + key, + width: COL_SIZING.default, + })) + ); + + useEffect(() => { + const totalKeys = [...keys]; + setColumnWidths( + totalKeys.map((key) => { + const field = getFieldDefinition(key as EntityCoreFields); + return { + key, + width: field?.style?.width ?? getProvisionedWidth(field!.title, field?.unit), + }; + }) + ); + }, [keys]); + + const columnOrderBy = useCallback( + (field: string, backendField: string) => { + let order: 'asc' | 'desc' | null = 'asc'; + + if (sortState?.order && field === sortState.field) { + order = sortState.order === 'desc' ? 'asc' : 'desc'; + } + + setSortState({ + backendField, + field, + order, + }); + }, + [setSortState, sortState] + ); + + const updateColumnWidths = useCallback( + (resizeInit: ResizeInit, clientX: number) => { + const { key, start } = resizeInit; + + const delta = start ? clientX - start : 0; // No start? No delta. + const colWidthIndex = columnWidths.findIndex(({ key: colKey }) => colKey === key); + + const updatedWidth = { + key: key as string, + width: Math.max(columnWidths[colWidthIndex].width + delta, COL_SIZING.min), + }; + + setColumnWidths([ + ...columnWidths.slice(0, colWidthIndex), + updatedWidth, + ...columnWidths.slice(colWidthIndex + 1), + ]); + }, + [columnWidths] + ); + + const onMouseDown = useCallback( + (mouseDownEvent: React.MouseEvent, key: string) => { + const { clientX } = mouseDownEvent; + const resizeInit = { + key, + start: clientX, + }; + + const handleMouseMove = throttle( + (moveEvent: MouseEvent) => updateColumnWidths(resizeInit, moveEvent.clientX), + 200 + ); + + window.addEventListener('mousemove', handleMouseMove); + window.addEventListener( + 'mouseup', + () => window.removeEventListener('mousemove', handleMouseMove), + { once: true } // Auto-removeEventListener + ); + }, + [updateColumnWidths] + ); + + const getOrderDirection = useCallback( + (key: string) => { + switch (sortState?.order) { + case 'asc': + return sortState?.field === key ? 'ascend' : undefined; + case 'desc': + return sortState?.field === key ? 'descend' : undefined; + default: + return undefined; + } + }, + [sortState?.field, sortState?.order] + ); + + const columns: ColumnProps[] = useMemo( + () => + keys.reduce((acc, key) => { + const term = getFieldDefinition(key as EntityCoreFields); + const isSortable = term?.isSortable && !!getOrderValue(term?.order, dataType); + + acc.push({ + key, + title: ( +
    +
    {fieldTitleSentenceCase(term?.title!)}
    + {term?.unit && [{term?.unit}]} +
    + ), + className: classNames( + 'text-primary-7 cursor-pointer before:!content-none', + term?.className + ), + sorter: isSortable, + ellipsis: true, + width: columnWidths.find(({ key: colKey }) => colKey === key)?.width, + render: (r) => term?.render?.(r), + onHeaderCell: () => ({ + handleResizing: (e: React.MouseEvent) => onMouseDown(e, key), + onClick: () => { + if (!isSortable || !term.order) return; + const field = getOrderValue(term.order, dataType); + if (field) { + columnOrderBy(key, field); + } + }, + showsortertooltip: { + title: term?.description ? term.description : term?.title, + }, + }), + defaultSortOrder: 'descend', + sortOrder: getOrderDirection(key), + sortDirections: ['ascend', 'descend', 'descend'], + align: term?.style?.align, + }); + return acc; + }, initialColumns), + [columnWidths, initialColumns, keys, onMouseDown, columnOrderBy, getOrderDirection, dataType] + ); + + if (dataType) { + return columns.sort((a, b) => + a.key && b.key + ? ViewsDefinitionRegistry[dataType].columns.indexOf(a.key as EntityCoreFields) - + ViewsDefinitionRegistry[dataType].columns.indexOf(b.key as EntityCoreFields) + : -1 + ); + } + + return columns; +} + +export default useDataTableColumns; diff --git a/src/ui/segments/data-table/elements/use-row-selection.ts b/src/ui/segments/data-table/elements/use-row-selection.ts new file mode 100644 index 000000000..0cd1246d9 --- /dev/null +++ b/src/ui/segments/data-table/elements/use-row-selection.ts @@ -0,0 +1,45 @@ +import { Key } from 'react'; +import { useAtom } from 'jotai'; +import { RowSelectionType, TableRowSelection } from 'antd/es/table/interface'; + +import { coreSelectedRowsAtom } from '@/ui/segments/data-table/elements/context'; + +import type { TExtendedEntitiesTypeDict } from '@/api/entitycore/types/extended-entity-type'; + +type RowSelection = Pick, 'selectedRowKeys' | 'onChange' | 'type'>; + +export type RenderButtonProps = { + selectedRows: Array; + clearSelectedRows: () => void; + dataType: TExtendedEntitiesTypeDict; +}; + +export function useRowSelection({ + dataKey, + selectionType = 'checkbox', + onRowsSelected, +}: { + dataKey: string; + selectionType?: RowSelectionType; + onRowsSelected?: (rows: Array) => void; +}): { + rowSelection: RowSelection; + selectedRows: Array; + clearSelectedRows: () => void; +} { + const [selectedRows, setSelectedRows] = useAtom(coreSelectedRowsAtom(dataKey)); + const clearSelectedRows = () => setSelectedRows([]); + + return { + rowSelection: { + selectedRowKeys: selectedRows.map((row: T) => row.id), + onChange: (_keys: Key[], rows: Array) => { + setSelectedRows(() => rows); + onRowsSelected?.(rows); + }, + type: selectionType, + }, + selectedRows, + clearSelectedRows, + }; +} diff --git a/src/ui/segments/data-table/index.tsx b/src/ui/segments/data-table/index.tsx new file mode 100644 index 000000000..fa1cdd546 --- /dev/null +++ b/src/ui/segments/data-table/index.tsx @@ -0,0 +1,161 @@ +'use client'; + +import { type ComponentProps, type CSSProperties, type ReactNode, useMemo, useState } from 'react'; +import { unwrap } from 'jotai/utils'; +import { useAtom } from 'jotai'; + +import type { RowSelectionType } from 'antd/es/table/interface'; +import type { ColumnProps, TableProps } from 'antd/es/table'; + +import { ListingFilterPanel } from '@/ui/segments/data-table/elements/listing-filter-panel/listing-filter-panel'; +// import { ResultsCount } from '@/ui/segments/data-table/elements/listing-filter-panel/numeric-results-info'; +import { FilterControls } from '@/ui/segments/data-table/elements/filter-controls'; +import { coreFiltersAtom } from '@/ui/segments/data-table/elements/context'; +import { OnCellClick, WrapperTable } from '@/ui/segments/data-table/table'; +import { Pagination } from '@/ui/segments/data-table/elements/pagination'; +import { Search } from '@/ui/segments/data-table/search'; +import { cn } from '@/utils/css-class'; + +import type { TExtendedEntitiesTypeDict } from '@/api/entitycore/types/extended-entity-type'; +import type { RenderButtonProps } from '@/ui/segments/data-table/elements/use-row-selection'; +import type { EntityCoreIdentifiableNamed } from '@/api/entitycore/types/shared/global'; +import type { + Facets, + Pagination as EntitycorePagination, +} from '@/api/entitycore/types/shared/response'; +import type { WorkspaceContext } from '@/types/common'; +import type { TWorkspaceScope } from '@/constants'; + +export function MainTable({ + dataKey, + dataScope, + dataType, + workspace, + cls, + facets, + controlsVisible, + renderButton, + showLoadingState, + isLoading, + resultPagination, + // from antd + rowClassName, + columns, + dataSource, + sticky, + selectionType, + onRow, + onRowsSelected, + onCellClick, + tableStyle, +}: { + facets: Facets | undefined; + resultPagination?: { + pagination: EntitycorePagination; + totalData: number; + }; + dataScope?: TWorkspaceScope; + columns: ColumnProps[]; + controlsVisible: boolean; + dataType: TExtendedEntitiesTypeDict; + workspace?: WorkspaceContext; + cls?: { + container?: ComponentProps<'div'>['className']; // this is for the section + table?: ComponentProps<'div'>['className']; // this is for ant-table-wrapper + }; + dataKey: string; + selectionType?: RowSelectionType; + onRow?: TableProps['onRow']; + sticky?: TableProps['sticky']; + onRowsSelected?: ((rows: T[]) => void) | undefined; + renderButton?: ((props: RenderButtonProps) => ReactNode) | undefined; + onCellClick?: OnCellClick | undefined; + showLoadingState?: boolean; + isLoading?: boolean; + dataSource: Array; + rowClassName?: ComponentProps<'td'>['className']; + tableStyle?: CSSProperties | undefined; +}) { + const [displayControlPanel, setDisplayControlPanel] = useState(false); + const onDisplayControlPanel = (value: boolean) => setDisplayControlPanel(value); + + const [filters, setFilters] = useAtom( + useMemo( + () => + unwrap( + coreFiltersAtom({ + dataType, + key: dataKey, + }) + ), + [dataType, dataKey] + ) + ); + + return ( + <> +
    +
    +
    + +
    +
    + +
    +
    + +
    +
    + + dataType={dataType} + columns={columns} + dataSource={dataSource} + loading={showLoadingState && isLoading} + onCellClick={onCellClick} + renderButton={renderButton} + selectionType={selectionType} + controlsVisible={controlsVisible} + onRowsSelected={onRowsSelected} + dataKey={dataKey} + rowClassName={rowClassName} + tableStyle={tableStyle} + onRow={onRow} + sticky={sticky} + className={cls?.table} + /> +
    + {displayControlPanel && filters && ( + setDisplayControlPanel(false)} + dataType={dataType} + dataKey={dataKey} + facets={facets} + workspace={workspace} + /> + )} + + ); +} diff --git a/src/ui/segments/data-table/search.tsx b/src/ui/segments/data-table/search.tsx new file mode 100644 index 000000000..09a9bac12 --- /dev/null +++ b/src/ui/segments/data-table/search.tsx @@ -0,0 +1,117 @@ +import { ChangeEvent, ComponentProps, useDeferredValue, useEffect, useRef, useState } from 'react'; +import { SearchOutlined, CloseOutlined } from '@ant-design/icons'; +import { useAtom, useSetAtom } from 'jotai'; + +import { + corePageNumberAtom, + coreSearchStringAtom, +} from '@/ui/segments/data-table/elements/context'; +import { DEFAULT_PAGE_NUMBER } from '@/constants'; +import { cn } from '@/utils/css-class'; + +type SearchProps = { + dataKey: string; + className?: ComponentProps<'div'>['className']; +}; + +export function Search({ dataKey, className }: SearchProps) { + const [isSearchOpen, setIsSearchOpen] = useState(false); + const [searchInput, setSearchInput] = useState(''); + const deferredSearchInput = useDeferredValue(searchInput); + + const [searchString, setSearchString] = useAtom(coreSearchStringAtom(dataKey)); + const searchInputRef = useRef(null); + const setPageNumber = useSetAtom(corePageNumberAtom(dataKey)); + + useEffect(() => { + setPageNumber(DEFAULT_PAGE_NUMBER); + setSearchString(deferredSearchInput); + }, [deferredSearchInput]); // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { + setSearchInput(searchString); + if (searchString && !isSearchOpen) { + setIsSearchOpen(true); + } + }, [searchString, isSearchOpen]); + + const handleToggleSearch = (): void => { + if (isSearchOpen) { + setSearchInput(''); + setIsSearchOpen(false); + } else { + setIsSearchOpen(true); + setTimeout(() => searchInputRef.current?.focus(), 100); + } + }; + + const handleInputChange = (e: ChangeEvent): void => { + setSearchInput(e.target.value); + }; + + const handleClearSearch = (): void => { + setSearchInput(''); + setPageNumber(DEFAULT_PAGE_NUMBER); + searchInputRef.current?.focus(); + }; + + const handleKeyDown = (e: React.KeyboardEvent): void => { + if (e.key === 'Escape') { + handleToggleSearch(); + } + }; + + return ( +
    +
    + + +
    +
    + + + {searchInput && ( + + )} +
    +
    +
    +
    + ); +} diff --git a/src/ui/segments/data-table/table.tsx b/src/ui/segments/data-table/table.tsx new file mode 100644 index 000000000..7109ee145 --- /dev/null +++ b/src/ui/segments/data-table/table.tsx @@ -0,0 +1,279 @@ +'use client'; + +import { CSSProperties, ReactNode, useCallback, useRef, useState } from 'react'; +import { VerticalAlignMiddleOutlined } from '@ant-design/icons'; +import { ConfigProvider, Table, TableProps } from 'antd'; +import isString from 'lodash/isString'; + +import type { ExpandableConfig, RowSelectionType } from 'antd/es/table/interface'; +import type { TableRef } from 'antd/es/table'; + +import { + useRowSelection, + type RenderButtonProps, +} from '@/ui/segments/data-table/elements/use-row-selection'; +import { useOnCellRouteHandler } from '@/ui/segments/data-table/elements/hooks'; +import { classNames } from '@/util/utils'; +import { cn } from '@/utils/css-class'; + +import TableControls from '@/ui/segments/data-table/elements/controls'; +import useResizeObserver from '@/hooks/useResizeObserver'; +import useScrollComplete from '@/hooks/useScrollComplete'; + +import type { TExtendedEntitiesTypeDict } from '@/api/entitycore/types/extended-entity-type'; +import type { EntityCoreIdentifiable } from '@/api/entitycore/types/shared/global'; + +import styles from '@/ui/segments/data-table/elements/table.module.css'; + +export type OnCellClick = (basePath: string, record: T, type: TExtendedEntitiesTypeDict) => void; + +function CustomTH({ + children, + style, + onClick, + handleResizing, + ...props +}: { + children: ReactNode; + style: CSSProperties; + onClick: () => void; + handleResizing: () => void; +}) { + const modifiedStyle: CSSProperties = { + ...style, + fontWeight: '500', + color: '#434343', + verticalAlign: 'baseline', + boxSizing: 'border-box', + backgroundColor: 'white', + }; + + return handleResizing ? ( + +
    + + +
    + + ) : ( + + {children} + + ); +} + +function CustomCell({ children, style, ...props }: { children: ReactNode; style: CSSProperties }) { + const modifiedStyle = { + ...style, + padding: '14px 6pX', + }; + + return ( + + {children} + + ); +} + +type AdditionalTableProps = { + hasError?: boolean; + onCellClick?: OnCellClick; +}; + +function BaseTable({ + columns, + dataSource, + hasError, + loading, + onCellClick, + rowSelection, + showLoadMore, + scrollable = true, + sticky, + expandableConfig, + rowClassName, + tableStyle, + className, + onRow, + dataType, +}: TableProps & + AdditionalTableProps & { + showLoadMore?: (value?: boolean) => void; + scrollable?: boolean; + expandableConfig?: ExpandableConfig; + tableStyle?: CSSProperties | undefined; + dataType: TExtendedEntitiesTypeDict; + }) { + const [containerDimension, setContainerDimension] = useState<{ height: number; width: number }>({ + height: 0, + width: 0, + }); + const tableRef = useRef(null); + const wrapperRef = useRef(null); + const tableElement: HTMLElement | null | undefined = + tableRef.current?.nativeElement.querySelector('.ant-table-body'); + const headerHeight = + (tableElement?.getBoundingClientRect()?.y ?? 0) - + (wrapperRef.current?.getBoundingClientRect()?.y ?? 0); + + const onResize = useCallback((target: HTMLElement) => { + setContainerDimension(target.getBoundingClientRect()); + }, []); + + // added new id explore-table-container-for-observable because we are using this component + // outside of the explore and we want to resize the table according to the screen size as well + useResizeObserver({ + element: wrapperRef.current ?? undefined, + callback: onResize, + }); + + useScrollComplete({ + element: tableElement, + callback: showLoadMore, + }); + + const onCellRouteHandler = useOnCellRouteHandler({ + dataType, + onCellClick, + }); + + if (hasError) return
    Something went wrong
    ; + + if (!columns?.length) return null; + return ( +
    + + ({ + ...col, + ...onCellRouteHandler(col), + })) + } + components={{ + header: { + cell: CustomTH, + }, + body: { + cell: CustomCell, + }, + }} + dataSource={dataSource} + loading={loading} + pagination={false} + rowClassName={(row: T, index: number, indent: number) => + classNames( + styles.tableRow, + isString(rowClassName) ? rowClassName : rowClassName?.(row, index, indent) + ) + } + onRow={onRow} + rowKey={(row) => row.id} + rowSelection={rowSelection} + scroll={ + scrollable + ? { + x: 'fit-content', + y: Math.max(containerDimension.height - headerHeight, 0), + } + : { x: 'fit-content' } + } + expandable={expandableConfig} + /> + + + ); +} + +export function WrapperTable({ + columns, + dataSource, + hasError, + loading, + onCellClick, + renderButton, + selectionType, + onRowsSelected, + scrollable = true, + controlsVisible = true, + autohideControls = false, + dataKey, + expandableConfig, + rowClassName, + tableStyle, + onRow, + className, + dataType, +}: 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; + }) { + const { rowSelection, selectedRows, clearSelectedRows } = useRowSelection({ + dataKey, + selectionType, + onRowsSelected, + }); + + return ( + <> + + dataType={dataType} + columns={columns} + dataSource={dataSource} + hasError={hasError} + loading={loading} + onCellClick={onCellClick} + rowKey={(row) => row.id} + rowSelection={rowSelection} + scrollable={scrollable} + expandableConfig={expandableConfig} + rowClassName={rowClassName} + style={tableStyle} + className={className} + onRow={onRow} + /> + {(!autohideControls || (autohideControls && selectedRows.length > 0)) && ( + + )} + + ); +} diff --git a/src/ui/segments/explore/default-content.tsx b/src/ui/segments/explore/default-content.tsx new file mode 100644 index 000000000..1e5c6b7b1 --- /dev/null +++ b/src/ui/segments/explore/default-content.tsx @@ -0,0 +1,52 @@ +'use client'; + +import { parseAsString, useQueryState, type Parser } from 'nuqs'; +import { useState, type ReactNode } from 'react'; +import { match, P } from 'ts-pattern'; + +import { useSelectEntityClickEvent } from '@/ui/segments/mini-detail-view/event'; +import { ExploreMenu } from '@/ui/segments/explore/scope-left-menu'; +import { LibraryLeftMenu } from '@/ui/segments/explore/library-left-menu'; +import { Card } from '@/ui/molecules/card'; +import { cn } from '@/utils/css-class'; + +import { WorkspaceScope, type TWorkspaceScope } from '@/constants'; + +type Props = { dataKey: string; children: ReactNode }; + +export function DefaultContent({ children, dataKey }: Props) { + const [miniViewPresent, setMiniViewPresent] = useState(false); + const [scope] = useQueryState( + 'scope', + parseAsString.withOptions({ clearOnDefault: false, shallow: true }) as Parser + ); + + useSelectEntityClickEvent((ev) => { + setMiniViewPresent(ev.detail.display); + }); + + const menu = match(scope) + .with(P.union(WorkspaceScope.Project, WorkspaceScope.Public), () => ( + + )) + .with('bookmarks', () => ) + .otherwise(() => ); + + return ( + <> +
    + + {menu} + +
    + {children} + + ); +} diff --git a/src/ui/segments/explore/entity-count.tsx b/src/ui/segments/explore/entity-count.tsx index 9a9dbd5f1..c2eb29c4c 100644 --- a/src/ui/segments/explore/entity-count.tsx +++ b/src/ui/segments/explore/entity-count.tsx @@ -1,75 +1,34 @@ import { LoadingOutlined } from '@ant-design/icons'; import { useQueries } from '@tanstack/react-query'; -import { useMemo } from 'react'; +import { usePathname, useSearchParams } from 'next/navigation'; import { match } from 'ts-pattern'; +import { useMemo } from 'react'; + +import snakeCase from 'lodash/snakeCase'; +import kebabCase from 'lodash/kebabCase'; +import Link from 'next/link'; import get from 'lodash/get'; -import { ElectricalRecordingOriginDictionary } from '@/api/entitycore/types/entities/electrical-cell-recording'; -import { getElectricalCellRecordings } from '@/api/entitycore/queries/experimental/electrical-cell-recording'; 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 { PillTabs, PillTabsList, PillTabsTrigger } from '@/ui/molecules/tabs'; -import { getEntitiesCount } from '@/api/entitycore/queries/general/entity'; import { useDefaultBreakpoint } from '@/ui/hooks/create-break-point'; import { EntityTypeDict } from '@/api/entitycore/types/entity-type'; +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 { Button } from '@/ui/molecules/button'; import { + getElectricalCellRecordingsCount, ExperimentalEntitiesTileTypes, ModelEntitiesTileTypes, + getEntityTypeFromUrl, + getAllEntitiesCount, } from '@/ui/segments/explore/helpers'; -import { - DEFAULT_BRAIN_REGION_HIERARCHY_ID, - useGetSelectedBrainRegion, -} from '@/features/brain-region-hierarchy/context'; import { cn } from '@/utils/css-class'; -import type { WorkspaceContext } from '@/types/common'; - -function getAllEntitiesCount({ - virtualLabId, - projectId, - brainRegionId, -}: WorkspaceContext & { brainRegionId: string }) { - return getEntitiesCount({ - context: virtualLabId && projectId ? { virtualLabId, projectId } : undefined, - types: [ - 'experimental_synapses_per_connection', - 'experimental_neuron_density', - 'experimental_bouton_density', - 'reconstruction_morphology', - 'single_neuron_synaptome', - 'memodel', - 'emodel', - ], - brainRegion: { - within_brain_region_hierarchy_id: DEFAULT_BRAIN_REGION_HIERARCHY_ID, - within_brain_region_brain_region_id: brainRegionId ?? null, - within_brain_region_ascendants: false, - }, - }); -} - -function getElectricalCellRecordingsCount({ - virtualLabId, - projectId, - brainRegionId, -}: WorkspaceContext & { brainRegionId: string }) { - return getElectricalCellRecordings({ - withFacets: false, - context: virtualLabId && projectId ? { virtualLabId, projectId } : undefined, - filters: { - recording_origin: ElectricalRecordingOriginDictionary.InVitro, - page: 1, - page_size: 1, - within_brain_region_hierarchy_id: DEFAULT_BRAIN_REGION_HIERARCHY_ID, - within_brain_region_brain_region_id: brainRegionId ?? null, - within_brain_region_ascendants: false, - }, - }); -} - const ExploreDataTypeTabs = { Experimental: 'experimental', Model: 'model', @@ -97,8 +56,51 @@ type Props = { dataKey: string; }; +function BrowseLink({ + isLoading, + type, + title, + count, + href, +}: { + isLoading: boolean; + type: string; + title: string; + count: number | null; + href: string; +}) { + const searchParams = useSearchParams(); + const pathname = usePathname(); + const entityType = snakeCase(getEntityTypeFromUrl(pathname) ?? ''); + return ( + + ); +} + export function EntityCount({ dataKey }: Props) { const breakpoint = useDefaultBreakpoint(); + const { virtualLabId, projectId } = useWorkspace(); const { selectedBrainRegion } = useGetSelectedBrainRegion(); const { activeTab, onChangeTab } = useTabs({ @@ -155,7 +157,6 @@ export function EntityCount({ dataKey }: Props) { ], [allLoading] ); - const content = match(activeTab) .with('experimental', () => ( <> @@ -164,22 +165,16 @@ export function EntityCount({ dataKey }: Props) { if (value.type === EntityTypeDict.ElectricalCellRecording) { count = ephysData?.pagination.total_items ?? null; } - + const link = `${V2_MIGRATION_TEMPORARY_BASE_PATH}/${virtualLabId}/${projectId}/explore/browse/${kebabCase(value.type)}`; return ( - + ); })} @@ -188,32 +183,26 @@ export function EntityCount({ dataKey }: Props) { <> {modelState.map((value) => { const count = get(allData, value.type, null); - + const link = `${V2_MIGRATION_TEMPORARY_BASE_PATH}/${virtualLabId}/${projectId}/explore/browse/${kebabCase(value.type)}`; return ( - + ); })} - + )) .otherwise(() => null); diff --git a/src/ui/segments/explore/header.tsx b/src/ui/segments/explore/header.tsx index 76515b7e2..31bddc802 100644 --- a/src/ui/segments/explore/header.tsx +++ b/src/ui/segments/explore/header.tsx @@ -34,42 +34,63 @@ const tabsConfigItems: Array<{ function ExploreTabs() { const breakpoint = useDefaultBreakpoint(); - const { activeTab, onChangeTab } = useTabs({ + const { activeTab, onChangeTab } = useTabs({ + // @ts-ignore tabsConfig: tabsConfigItems, - tabKey: 'section', - shallow: true, + tabKey: 'scope', + shallow: false, + clearOnDefault: false, + defaultKey: 'public', }); + const onBookmarkClick = () => onChangeTab('bookmarks'); + return ( - { - onChangeTab(value as ExploreSectionsKeys)(); - }} - > - + { + onChangeTab(value as ExploreSectionsKeys)(); + }} + > + + {tabsConfigItems.map((tab) => ( + + {tab.title} + + ))} + + + + ); } @@ -80,18 +101,6 @@ export function ExploreHeader() {
    -
    ; +} diff --git a/src/ui/segments/explore/left-menu.tsx b/src/ui/segments/explore/scope-left-menu.tsx similarity index 67% rename from src/ui/segments/explore/left-menu.tsx rename to src/ui/segments/explore/scope-left-menu.tsx index 2fdbecd46..c92c514a0 100644 --- a/src/ui/segments/explore/left-menu.tsx +++ b/src/ui/segments/explore/scope-left-menu.tsx @@ -1,7 +1,8 @@ 'use client'; import { AnimatePresence, motion } from 'motion/react'; -import React, { Suspense, useState } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { Suspense, useState } from 'react'; import { TreeSkeleton } from '@/features/brain-region-hierarchy/brain-region-skeleton'; import { BrainRegionHierarchy } from '@/features/brain-region-hierarchy'; @@ -11,16 +12,48 @@ import { RegionBanner, TExploreLeftMenuContext, } from '@/features/brain-region-hierarchy/region-banner'; +import { + getAllEntitiesCount, + getElectricalCellRecordingsCount, +} from '@/ui/segments/explore/helpers'; +import { useWorkspace } from '@/ui/hooks/use-workspace'; +import { keyBuilder } from '@/ui/use-query-keys/data'; + +import type { TTreeNode } from '@/components/tree/types'; type Props = { dataKey: string }; export function ExploreMenu({ dataKey }: Props) { + const queryClient = useQueryClient(); + const { virtualLabId, projectId } = useWorkspace(); + const [view, updateView] = useState( ExploreLeftMenuContext.BrainRegionHierarchy ); 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, + }), + }); + }; + return (
    @@ -40,7 +73,7 @@ export function ExploreMenu({ dataKey }: Props) {
    Brain region
    - +
    diff --git a/src/ui/segments/mini-detail-view/event.ts b/src/ui/segments/mini-detail-view/event.ts new file mode 100644 index 000000000..2d17c5b5d --- /dev/null +++ b/src/ui/segments/mini-detail-view/event.ts @@ -0,0 +1,53 @@ +import { useEffect } from 'react'; +import noop from 'lodash/noop'; + +import { isBrowser } from '@/utils/environment'; + +export type TSelectedExploreEntity = { + display: boolean; + data: T | null; +}; + +const SelectEntityClickEvent = 'SelectEntityClickEvent' as const; + +export const makeSelectEntityClickEvent = (detail: TSelectedExploreEntity) => { + const event = new CustomEvent>(SelectEntityClickEvent, { + detail, + }); + if (isBrowser()) window.dispatchEvent(event); +}; + +const isVirtualLabClickEvent = ( + event: Event +): event is CustomEvent> => { + return event instanceof CustomEvent && event.type === SelectEntityClickEvent; +}; + +export const virtualLabClickEventListener = ( + cb: (event: CustomEvent>) => void +) => { + const abortController = new AbortController(); + const { signal } = abortController; + + if (isBrowser()) { + const handler: EventListener = (event: Event) => { + if (isVirtualLabClickEvent(event)) { + cb(event); + } + }; + window.addEventListener(SelectEntityClickEvent, handler, { signal }); + + return () => abortController.abort(); + } + + return noop; +}; + +export const useSelectEntityClickEvent = ( + cb: (event: CustomEvent>) => void +) => { + useEffect(() => { + const unsubscribe = virtualLabClickEventListener(cb); + return () => unsubscribe(); + }, [cb]); +}; diff --git a/src/ui/segments/mini-detail-view/index.tsx b/src/ui/segments/mini-detail-view/index.tsx new file mode 100644 index 000000000..039a7072d --- /dev/null +++ b/src/ui/segments/mini-detail-view/index.tsx @@ -0,0 +1,39 @@ +import { CloseOutlined } from '@ant-design/icons'; +import { useState } from 'react'; + +import { EntityCoreIdentifiableNamed } from '@/api/entitycore/types/shared/global'; +import { Card, CardTitle } from '@/ui/molecules/card'; +import { + makeSelectEntityClickEvent, + useSelectEntityClickEvent, +} from '@/ui/segments/mini-detail-view/event'; + +export function MiniDetailView() { + const [record, setRecord] = useState(null); + + useSelectEntityClickEvent((event) => { + setRecord(event.detail.data); + }); + + const onClose = () => { + makeSelectEntityClickEvent({ data: null, display: false }); + }; + + if (!record) return null; + + return ( + + + {record.name} + + +
    {JSON.stringify(record, null, 2)}
    +
    + ); +} + +export default MiniDetailView; diff --git a/src/ui/segments/project/activities/elements/helpers.tsx b/src/ui/segments/project/activities/elements/helpers.tsx index bbb33a523..0226e8816 100644 --- a/src/ui/segments/project/activities/elements/helpers.tsx +++ b/src/ui/segments/project/activities/elements/helpers.tsx @@ -83,19 +83,19 @@ export const Scales: Partial< link: 'explore', }, [ExtendedEntitiesTypeDict.SingleNeuronSynaptome]: { - title: 'single neuron synaptome', + title: 'Single neuron synaptome', build: ExtendedEntitiesTypeDict.SingleNeuronSynaptome, simulate: ExtendedEntitiesTypeDict.SingleNeuronSynaptomeSimulation, link: 'explore', }, [ExtendedEntitiesTypeDict.SmallMicrocircuit]: { - title: 'small circuit', + title: 'Small microcircuits', build: null, simulate: ExtendedEntitiesTypeDict.SmallMicrocircuitSimulation, link: 'workflows', }, [ExtendedEntitiesTypeDict.PairedNeuronCircuit]: { - title: 'paired neuron circuit', + title: 'Paired neuron circuits', build: null, simulate: ExtendedEntitiesTypeDict.PairedNeuronCircuitSimulation, link: 'workflows', diff --git a/src/ui/segments/project/activities/index.tsx b/src/ui/segments/project/activities/index.tsx index cc6e45054..c0923eb52 100644 --- a/src/ui/segments/project/activities/index.tsx +++ b/src/ui/segments/project/activities/index.tsx @@ -134,7 +134,6 @@ export function ProjectActivities() { current: page, hideOnSinglePage: true, align: 'end', - simple: true, size: 'default', responsive: true, role: 'button', @@ -142,6 +141,10 @@ export function ProjectActivities() { onChange: (_page, _pageSize) => { 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: ( diff --git a/src/ui/segments/project/preview/index.tsx b/src/ui/segments/project/preview/index.tsx index 423cf6547..10dded2b7 100644 --- a/src/ui/segments/project/preview/index.tsx +++ b/src/ui/segments/project/preview/index.tsx @@ -4,18 +4,19 @@ import { CloseOutlined, RightOutlined } from '@ant-design/icons'; import { useQuery } from '@tanstack/react-query'; import Link from 'next/link'; -import { LATEST_VISITED_PROJECT_KEY, V2_MIGRATION_TEMPORARY_BASE_PATH } from '@/config'; +import { makeTriggerWorkspaceConfigurationClickEvent } from '@/ui/segments/workspaces/space-manager/event'; import { Bar, MetricsSkeleton } from '@/ui/segments/project/metrics/metrics-skeleton'; import { listProjectMembers } from '@/api/virtual-lab-svc/queries/member'; +import { Metrics } from '@/ui/segments/project/metrics/metrics'; import { ExpandableText } from '@/ui/molecules/more-less-text'; import { PeopleCommunity } from '@/components/icons/buttons'; import { useLocalStorage } from '@/hooks/use-local-storage'; -import { Metrics } from '@/ui/segments/project/metrics/metrics'; +import { V2_MIGRATION_TEMPORARY_BASE_PATH } from '@/config'; import { keyBuilder } from '@/ui/use-query-keys/workspace'; +import { LATEST_VISITED_PROJECT_KEY } from '@/constants'; import { Button } from '@/ui/molecules/button'; import type { Member, Project } from '@/api/virtual-lab-svc/queries/types'; -import { makeTriggerWorkspaceConfigurationClickEvent } from '@/ui/segments/workspaces/space-manager/event'; function Header({ onClose, project }: { onClose: () => void; project?: Project | null }) { if (!project) return null; diff --git a/src/ui/segments/virtual-lab-settings/elements/credits-management.tsx b/src/ui/segments/virtual-lab-settings/elements/credits-management.tsx index e8c41cb87..00bb3c5be 100644 --- a/src/ui/segments/virtual-lab-settings/elements/credits-management.tsx +++ b/src/ui/segments/virtual-lab-settings/elements/credits-management.tsx @@ -122,8 +122,8 @@ export function CreditsManagement({ showSizeChanger: false, hideOnSinglePage: true, className: cn( - '[&_.ant-pagination-item_a]:text-white! [&_.ant-pagination-item_a]:bg-primary-9! [&_.ant-pagination-item-active]:bg-primary-7 [&_.ant-pagination-item-active]:text-white! [&_.ant-pagination-item-link]:text-white!', - 'flex items-center gap-2 [&_.ant-pagination-item]:rounded-sm [&_.ant-pagination-item_a]:rounded-sm' + '[&_.ant-pagination-item]:bg-primary-9! [&_.ant-pagination-item_a]:text-white! [&_.ant-pagination-item-active]:bg-white! [&_.ant-pagination-item-active_a]:text-primary-9!', + '[&_.ant-pagination-disabled_button]:text-neutral-1 [&_button.ant-pagination-item-link]:text-white' ), }} renderItem={(item) => ( diff --git a/src/ui/segments/virtual-lab-settings/elements/manage-credits.tsx b/src/ui/segments/virtual-lab-settings/elements/manage-credits.tsx index c54e0daa8..066d36ae3 100644 --- a/src/ui/segments/virtual-lab-settings/elements/manage-credits.tsx +++ b/src/ui/segments/virtual-lab-settings/elements/manage-credits.tsx @@ -254,13 +254,15 @@ export function ManageCreditsStep({ onBack, virtualLabId }: ManageCreditsStepPro className={cn( 'w-full bg-transparent [&_.ant-select-arrow]:!text-white [&_.ant-select-selection-item]:!text-xl', '[&_.ant-select-selection-item]:!font-semibold [&_.ant-select-selection-item]:text-white!', - '[&_.ant-select-selector]:!border-0 [&_.ant-select-selector]:!bg-transparent [&_.ant-select-selector]:!shadow-none' + '[&_.ant-select-selector]:!border-0 [&_.ant-select-selector]:!bg-transparent [&_.ant-select-selector]:!shadow-none', + '[&_.ant-select-selection-search]:text-white' )} options={projects} popupClassName={cn( '!bg-[#0a3a76] !text-white', '[&_.ant-select-item-option-content]:text-white!', - '[&_.ant-select-item-option-selected:not(.ant-select-item-option-disabled)]:bg-primary-7/50! [&_.ant-select-item-option-selected]:!text-white!' + '[&_.ant-select-item-option-selected:not(.ant-select-item-option-disabled)]:bg-primary-7/50! [&_.ant-select-item-option-selected]:!text-white!', + '[&_.ant-empty-description]:text-white!' )} optionFilterProp="label" disabled={isPending} @@ -317,13 +319,15 @@ export function ManageCreditsStep({ onBack, virtualLabId }: ManageCreditsStepPro className={cn( 'w-full bg-transparent [&_.ant-select-arrow]:!text-white [&_.ant-select-selection-item]:!text-xl', '[&_.ant-select-selection-item]:!font-semibold [&_.ant-select-selection-item]:text-white!', - '[&_.ant-select-selector]:!border-0 [&_.ant-select-selector]:!bg-transparent [&_.ant-select-selector]:!shadow-none' + '[&_.ant-select-selector]:!border-0 [&_.ant-select-selector]:!bg-transparent [&_.ant-select-selector]:!shadow-none', + '[&_.ant-select-selection-search]:text-white' )} options={projects} popupClassName={cn( '!bg-[#0a3a76] !text-white', '[&_.ant-select-item-option-content]:text-white!', - '[&_.ant-select-item-option-selected:not(.ant-select-item-option-disabled)]:bg-primary-7/50! [&_.ant-select-item-option-selected]:!text-white!' + '[&_.ant-select-item-option-selected:not(.ant-select-item-option-disabled)]:bg-primary-7/50! [&_.ant-select-item-option-selected]:!text-white!', + '[&_.ant-empty-description]:text-white!' )} optionFilterProp="label" disabled={isPending} diff --git a/src/ui/segments/virtual-lab-settings/sections/team.tsx b/src/ui/segments/virtual-lab-settings/sections/team.tsx index a8de65f0b..40455afdb 100644 --- a/src/ui/segments/virtual-lab-settings/sections/team.tsx +++ b/src/ui/segments/virtual-lab-settings/sections/team.tsx @@ -698,7 +698,8 @@ function ListingStep({ onInviteMemberClick, virtualLabId }: ListingStepProps) { 'h-full', '[&_.ant-table-tbody>tr]:transition-all [&_.ant-table-tbody>tr]:duration-1000', '[&_.ant-table-tbody>tr.ant-table-row-remove]:h-0 [&_.ant-table-tbody>tr.ant-table-row-remove]:opacity-40', - '[&_.ant-table-body]:primary-scrollbar [&_.ant-table-body]:max-h-full [&_.ant-table-body]:overflow-auto [&_.ant-table-container]:h-full' + '[&_.ant-table-body]:primary-scrollbar [&_.ant-table-body]:max-h-full [&_.ant-table-body]:overflow-auto [&_.ant-table-container]:h-full', + '[&_.ant-empty-description]:text-white!' )} scroll={{ y: 'calc(100vh - 180px)' }} /> diff --git a/src/utils/type.ts b/src/utils/type.ts index a698bbc83..d7336e8d6 100644 --- a/src/utils/type.ts +++ b/src/utils/type.ts @@ -8,8 +8,10 @@ export type Prettify = { } & {}; type KebabCaseHelper = S extends `${infer First}${infer Rest}` - ? First extends Lowercase | '-' | '_' | ' ' - ? `${First}${KebabCaseHelper}` + ? First extends Lowercase + ? First extends '_' | ' ' | '-' + ? `-${KebabCaseHelper}` + : `${First}${KebabCaseHelper}` : `-${Lowercase}${KebabCaseHelper}` : ''; @@ -19,8 +21,11 @@ export type NormalizeChars = S extends `${infer Head}${infer T : `${Head}${NormalizeChars}` : S; -export type KebabCase = S extends `${infer First}${infer Rest}` - ? First extends Lowercase - ? `${First}${KebabCaseHelper}` +type KebabCaseCore = S extends `${infer First}${infer Rest}` + ? First extends '_' | ' ' | '-' + ? KebabCaseCore : `${Lowercase}${KebabCaseHelper}` : S; + +// Strict variant: rejects widened string types. Ensures hover shows concrete transformed literal unions. +export type KebabCase = string extends S ? never : KebabCaseCore;