diff --git a/frontend/components/ui/index.ts b/frontend/components/ui/index.ts index 32e69e2..f29a8ca 100644 --- a/frontend/components/ui/index.ts +++ b/frontend/components/ui/index.ts @@ -5,6 +5,7 @@ export { Base, Button, CheckBox, + Chip, Cluster, DefinitionList, EmptyTableBody, @@ -30,6 +31,8 @@ export { Sidebar, SingleComboBox, Stack, + TabBar, + TabItem, Table, TableReel, Td, diff --git a/frontend/hooks/useSearchParams.ts b/frontend/hooks/useSearchParams.ts new file mode 100644 index 0000000..5e9f799 --- /dev/null +++ b/frontend/hooks/useSearchParams.ts @@ -0,0 +1,25 @@ +import { parse, stringify } from '@/utils/queryString' +import { useEffect, useState } from 'react' +import { useSearchParams } from 'react-router-dom' + +type DeserializeSearchParams = { [urlParam in keyof T]: (v: T[urlParam] | null) => T[urlParam] } + +const getInitialState = >( + searchParams: Record, + deserializeSearchParams: DeserializeSearchParams, +): T => { + return Object.entries(deserializeSearchParams).reduce((acc, [key, deserialize]) => { + return { ...acc, [key]: deserialize(searchParams[key]) } + }, {} as T) +} + +export const useSearchParamsState = >(deserializeSearchParams: DeserializeSearchParams) => { + const [searchParams, setSearchParams] = useSearchParams() + const [state, setState] = useState(() => getInitialState(parse(searchParams.toString()), deserializeSearchParams)) + + useEffect(() => { + setSearchParams(new URLSearchParams(stringify(state))) + }, [state, setSearchParams]) + + return [state, setState] as const +} diff --git a/frontend/models/module.ts b/frontend/models/module.ts index 538ff89..5ef4de2 100644 --- a/frontend/models/module.ts +++ b/frontend/models/module.ts @@ -2,28 +2,23 @@ import { MethodId } from './methodId' export type Module = string +export type SpecificModuleDependency = { + sourceName: string + module: Module | null + methodIds: MethodId[] +} + +export type SpecificModuleSource = { + sourceName: string + module: Module | null + memo: string + dependencies: SpecificModuleDependency[] +} + export type SpecificModule = { module: Module moduleDependencies: Module[] moduleReverseDependencies: Module[] - sources: Array<{ - sourceName: string - module: Module - memo: string - dependencies: Array<{ - sourceName: string - module: Module | null - methodIds: MethodId[] - }> - }> - sourceReverseDependencies: Array<{ - sourceName: string - module: Module | null - memo: string - dependencies: Array<{ - sourceName: string - module: Module - methodIds: MethodId[] - }> - }> + sources: SpecificModuleSource[] + sourceReverseDependencies: SpecificModuleSource[] } diff --git a/frontend/models/source.ts b/frontend/models/source.ts index 16e516f..89cd59c 100644 --- a/frontend/models/source.ts +++ b/frontend/models/source.ts @@ -1,3 +1,4 @@ +import { ascString } from '@/utils/sort' import { MethodId } from './methodId' import { Module } from './module' @@ -40,12 +41,6 @@ export const sortSources = (sources: Source[], key: 'sourceName' | 'module', sor let sorted = [...sources] - const ascString = (a: string, b: string) => { - if (a > b) return 1 - if (a < b) return -1 - return 0 - } - switch (key) { case 'sourceName': { sorted = sorted.sort((a, b) => ascString(a.sourceName, b.sourceName)) diff --git a/frontend/pages/Modules/Show.tsx b/frontend/pages/Modules/Show.tsx index 0b3c161..c1b0481 100644 --- a/frontend/pages/Modules/Show.tsx +++ b/frontend/pages/Modules/Show.tsx @@ -1,17 +1,77 @@ -import React from 'react' +import React, { useMemo } from 'react' import { useParams } from 'react-router-dom' import styled from 'styled-components' import { Link } from '@/components/Link' import { Loading } from '@/components/Loading' -import { Cluster, EmptyTableBody, Heading, Section, Stack, Table, Td, Text, Th } from '@/components/ui' +import { Chip, Cluster, Heading, Section, Stack, TabBar, TabItem } from '@/components/ui' import { path } from '@/constants/path' -import { spacing } from '@/constants/theme' +import { color, spacing } from '@/constants/theme' import { useModule } from '@/repositories/moduleRepository' +import { SourcesContent } from './components/SourcesContent/SourcesContent' +import { ModuleDependenciesContent } from './components/ModuleDependenciesContent' +import { SourceReverseDependenciesContent } from './components/SourceReverseDependenciesContent' +import { useSearchParamsState } from '@/hooks/useSearchParams' +import { Module } from '@/models/module' + +const validTabs = ['sources', 'sourceReverseDependencies', 'moduleDependencies', 'moduleReverseDependencies'] as const +type ValidTab = (typeof validTabs)[number] + +type Query = { + module: Module | null +} export const Show: React.FC = () => { const pathModule = useParams()['*'] ?? '' const { data, isLoading } = useModule(pathModule) + const [params, setParams] = useSearchParamsState<{ tab: ValidTab; q: Query }>({ + tab: (val: any) => (validTabs.includes(String(val) as ValidTab) ? (String(val) as ValidTab) : 'sources'), + q: (val: any) => { + const q: Query = { module: null } + + if (val && val.module) { + q.module = val.module + } + + return q + }, + }) + + const content = useMemo(() => { + if (isLoading || data === undefined) { + return + } + + switch (params.tab) { + case 'sources': { + return + } + case 'moduleDependencies': { + return ( + + ) + } + case 'moduleReverseDependencies': { + return ( + + ) + } + case 'sourceReverseDependencies': { + return + } + default: { + throw new Error(`Invalid tab: ${params.tab}`) + } + } + }, [data, pathModule, isLoading, params]) return ( @@ -26,202 +86,38 @@ export const Show: React.FC = () => {
-
- - Links - Graph - -
- - {data && !isLoading ? ( - <> -
- - Module Dependencies ({data.moduleDependencies.length}) -
- - - - - - - {data.sources.length === 0 ? ( - - No module dependencies - - ) : ( - - {data.moduleDependencies.map((module) => ( - - - - ))} - - )} -
Module
- - {module} - -
-
-
-
- -
- - Module Reverse Dependencies ({data.moduleReverseDependencies.length}) -
- - - - - - - {data.sources.length === 0 ? ( - - No module reverse dependencies - - ) : ( - - {data.moduleReverseDependencies.map((module) => ( - - - - ))} - - )} -
Module
- - {module} - -
-
-
-
+ + setParams((prev) => ({ ...prev, tab: 'sources' }))} + selected={params.tab === 'sources'} + > + Sources{data ? ` (${data.sources.length})` : ''} + + setParams((prev) => ({ ...prev, tab: 'moduleDependencies' }))} + selected={params.tab === 'moduleDependencies'} + > + Module Dependencies{data ? ` (${data.moduleDependencies.length})` : ''} + + setParams((prev) => ({ ...prev, tab: 'moduleReverseDependencies' }))} + selected={params.tab === 'moduleReverseDependencies'} + > + Module Reverse Dependencies{data ? ` (${data.moduleReverseDependencies.length})` : ''} + + setParams((prev) => ({ ...prev, tab: 'sourceReverseDependencies' }))} + selected={params.tab === 'sourceReverseDependencies'} + > + Source Reverse Dependencies{data ? ` (${data.sourceReverseDependencies.length})` : ''} + + -
- - Sources ({data.sources.length}) -
- - - - - - - - - - - {data.sources.length === 0 ? ( - - No sources - - ) : ( - - {data.sources.map((source) => - source.dependencies.map((dependency) => - dependency.methodIds.map((methodId) => ( - - - - - - - - )), - ), - )} - - )} -
SourceDependency ModuleDependencyMethod IdPath
- - {source.sourceName} - - - {dependency.module && ( - - {dependency.module} - - )} - - - {dependency.sourceName} - - {`${methodId.context === 'class' ? '.' : '#'}${methodId.name}`} - {methodId.paths.map((methodIdPath) => ( -
- {methodIdPath} -
- ))} -
-
-
-
- -
- - Source Reverse Dependencies ({data.sourceReverseDependencies.length}) -
- - - - - - - - - - - {data.sourceReverseDependencies.length === 0 ? ( - - No sources - - ) : ( - - {data.sourceReverseDependencies.map((source) => - source.dependencies.map((dependency) => - dependency.methodIds.map((methodId) => ( - - - - - - - - )), - ), - )} - - )} -
SourceDependency ModuleDependencyMethod IdPath
- - {source.sourceName} - - - {dependency.module && ( - - {dependency.module} - - )} - - - {dependency.sourceName} - - {`${methodId.context === 'class' ? '.' : '#'}${methodId.name}`} - {methodId.paths.map((methodIdPath) => ( -
- {methodIdPath} -
- ))} -
-
-
-
- - ) : ( - - )} + {content}
@@ -229,6 +125,13 @@ export const Show: React.FC = () => { ) } +const StickyTabBar = styled(TabBar)` + position: sticky; + top: 0; + z-index: 1; + background: ${color.BACKGROUND}; +` + const StyledSection = styled(Section)` padding: ${spacing.XS}; ` diff --git a/frontend/pages/Modules/components/ModuleDependenciesContent/ModuleDependenciesContent.tsx b/frontend/pages/Modules/components/ModuleDependenciesContent/ModuleDependenciesContent.tsx new file mode 100644 index 0000000..21c6cb3 --- /dev/null +++ b/frontend/pages/Modules/components/ModuleDependenciesContent/ModuleDependenciesContent.tsx @@ -0,0 +1,78 @@ +import { Link } from '@/components/Link' +import { EmptyTableBody, Section, Stack, Text, Table, Th, Td } from '@/components/ui' +import { path } from '@/constants/path' +import { Module, SpecificModule } from '@/models/module' +import { FC, useMemo } from 'react' +import { StickyThead } from '../StickyThead' +import { stringify } from '@/utils/queryString' + +type Props = { + pathModule: Module + sources: SpecificModule['sources'] + moduleDependencies: SpecificModule['moduleDependencies'] +} + +export const ModuleDependenciesContent: FC = ({ pathModule, sources, moduleDependencies }) => { + const dependenciesMap = useMemo(() => { + const map = new Map>() + + sources.forEach((source) => { + source.dependencies.forEach((dependency) => { + if (dependency.module) { + if (!map.has(dependency.module)) { + map.set(dependency.module, new Set()) + } + + const set = map.get(dependency.module)! + set.add(dependency.sourceName) + } + }) + }) + + return map + }, [sources, moduleDependencies]) + + return ( +
+ +
+ + + + + + + + {moduleDependencies.length === 0 ? ( + + No module dependencies + + ) : ( + + {moduleDependencies.map((module) => ( + + + + + ))} + + )} +
ModuleSources
+ + {module} + + + + + {dependenciesMap.get(module)?.size ?? 0} + + +
+
+
+
+ ) +} diff --git a/frontend/pages/Modules/components/ModuleDependenciesContent/index.ts b/frontend/pages/Modules/components/ModuleDependenciesContent/index.ts new file mode 100644 index 0000000..5068013 --- /dev/null +++ b/frontend/pages/Modules/components/ModuleDependenciesContent/index.ts @@ -0,0 +1 @@ +export { ModuleDependenciesContent } from './ModuleDependenciesContent' diff --git a/frontend/pages/Modules/components/SourceReverseDependenciesContent/SourceReverseDependenciesContent.tsx b/frontend/pages/Modules/components/SourceReverseDependenciesContent/SourceReverseDependenciesContent.tsx new file mode 100644 index 0000000..1d60d7f --- /dev/null +++ b/frontend/pages/Modules/components/SourceReverseDependenciesContent/SourceReverseDependenciesContent.tsx @@ -0,0 +1,195 @@ +import { Link } from '@/components/Link' +import { EmptyTableBody, Table, Th, Text, Td, Button, Cluster, Chip } from '@/components/ui' +import { path } from '@/constants/path' +import { Module, SpecificModule, SpecificModuleSource } from '@/models/module' +import { FC, useCallback, useMemo, useState } from 'react' +import { StickyThead } from '../StickyThead' +import { SortTypes, ascNumber, ascString, sortTypes } from '@/utils/sort' + +const SourceTr: FC<{ source: SpecificModuleSource; filteredModule: Module | null }> = ({ filteredModule, source }) => { + const [expanded, setExpanded] = useState(false) + + const modules = useMemo(() => { + const modules = new Set() + + source.dependencies.forEach((dependency) => { + if (dependency.module && (!filteredModule || dependency.module === filteredModule)) { + modules.add(dependency.module) + } + }) + + return [...modules].sort() + }, [source]) + + const dependencies = useMemo(() => { + return source.dependencies + .filter((dependency) => !filteredModule || dependency.module === filteredModule) + .toSorted((a, b) => ascString(String(a.module), String(b.module)) || ascString(String(a.sourceName), String(b.sourceName))) + }, [source, filteredModule]) + + return ( + <> + + + {dependencies.length > 0 && ( + + )} + + + + {source.sourceName} + + + + {modules.map((module) => ( + + {module} + + ))} + + {dependencies.length} + + + + + {expanded && + dependencies.map((dependency) => + dependency.methodIds.map((methodId, index) => ( + + + + + {index === 0 && dependency.module && ( + + {dependency.module} + + )} + + + {index === 0 && ( + + {dependency.sourceName} + + )} + + {`${methodId.context === 'class' ? '.' : '#'}${methodId.name}`} + + {methodId.paths.map((methodIdPath) => ( +
+ {methodIdPath} +
+ ))} + + + )), + )} + + ) +} + +const sortSources = ( + sources: SpecificModuleSource[], + key: 'sourceName' | 'dependency', + sort: 'none' | 'asc' | 'desc', +): SpecificModuleSource[] => { + if (sort === 'none') { + return sources + } + + let sorted: SpecificModuleSource[] + + switch (key) { + case 'sourceName': { + sorted = sources.toSorted((a, b) => ascString(a.sourceName, b.sourceName)) + break + } + case 'dependency': { + sorted = sources.toSorted((a, b) => ascNumber(a.dependencies.length, b.dependencies.length)) + } + } + + if (sort === 'desc') { + sorted = sorted.reverse() + } + + return sorted +} + +type SortType = { + key: 'sourceName' | 'dependency' + sort: SortTypes +} + +type Props = { + sources: SpecificModule['sourceReverseDependencies'] + filteredModule: Module | null +} + +export const SourceReverseDependenciesContent: FC = ({ filteredModule, sources }) => { + const [sort, setSort] = useState({ key: 'sourceName', sort: 'none' }) + + const sortedSources = useMemo(() => { + let sorted = sortSources(sources, sort.key, sort.sort) + + if (filteredModule) { + sorted = sorted.filter((source) => source.dependencies.some((dependency) => dependency.module === filteredModule)) + } + + return sorted + }, [sort, filteredModule, sources]) + + const setNextSort = useCallback( + (key: SortType['key']) => { + setSort((prev) => { + if (prev.key === key) { + return { + key, + sort: sortTypes[(sortTypes.indexOf(prev.sort) + 1) % sortTypes.length], + } + } else { + return { key, sort: 'asc' } + } + }) + }, + [setSort], + ) + + return ( + <> + {filteredModule && ( + + Filter: {filteredModule} + + )} + + + + + + + + + + + + {sortedSources.length === 0 ? ( + + No sources + + ) : ( + + {sortedSources.map((source) => ( + + ))} + + )} +
setNextSort('sourceName')}> + Source + Dependency Module setNextSort('dependency')}> + Dependency + Method IdPath
+ + ) +} diff --git a/frontend/pages/Modules/components/SourceReverseDependenciesContent/index.ts b/frontend/pages/Modules/components/SourceReverseDependenciesContent/index.ts new file mode 100644 index 0000000..90eadb1 --- /dev/null +++ b/frontend/pages/Modules/components/SourceReverseDependenciesContent/index.ts @@ -0,0 +1 @@ +export { SourceReverseDependenciesContent } from './SourceReverseDependenciesContent' diff --git a/frontend/pages/Modules/components/SourcesContent/SourcesContent.tsx b/frontend/pages/Modules/components/SourcesContent/SourcesContent.tsx new file mode 100644 index 0000000..edc0dab --- /dev/null +++ b/frontend/pages/Modules/components/SourcesContent/SourcesContent.tsx @@ -0,0 +1,197 @@ +import { Link } from '@/components/Link' +import { EmptyTableBody, Table, Th, Text, Td, Button, Chip, Cluster } from '@/components/ui' +import { path } from '@/constants/path' +import { Module, SpecificModule, SpecificModuleSource } from '@/models/module' +import { FC, useCallback, useMemo, useState } from 'react' +import { StickyThead } from '../StickyThead' +import { SortTypes, ascNumber, ascString, sortTypes } from '@/utils/sort' + +const SourceTr: FC<{ source: SpecificModuleSource; filteredModule: Module | null }> = ({ source, filteredModule }) => { + const [expanded, setExpanded] = useState(false) + + const modules = useMemo(() => { + const modules = new Set() + + source.dependencies.forEach((dependency) => { + if (dependency.module && (!filteredModule || dependency.module === filteredModule)) { + modules.add(dependency.module) + } + }) + + return [...modules].sort() + }, [source]) + + const dependencies = useMemo(() => { + return source.dependencies + .filter((dependency) => !filteredModule || dependency.module === filteredModule) + .toSorted((a, b) => ascString(String(a.module), String(b.module)) || ascString(String(a.sourceName), String(b.sourceName))) + }, [source, filteredModule]) + + return ( + <> + + + {dependencies.length > 0 && ( + + )} + + + + {source.sourceName} + + + + {modules.map((module) => ( + + {module} + + ))} + + {dependencies.length} + + + + + {expanded && + dependencies.map((dependency) => + dependency.methodIds.map((methodId, index) => ( + + + + + {index === 0 && dependency.module && ( + + {dependency.module} + + )} + + + {index === 0 && ( + + {dependency.sourceName} + + )} + + {`${methodId.context === 'class' ? '.' : '#'}${methodId.name}`} + + {methodId.paths.map((methodIdPath) => ( +
+ {methodIdPath} +
+ ))} + + + )), + )} + + ) +} + +const sortSources = ( + sources: SpecificModuleSource[], + key: 'sourceName' | 'dependency', + sort: 'none' | 'asc' | 'desc', +): SpecificModuleSource[] => { + if (sort === 'none') { + return sources + } + + let sorted: SpecificModuleSource[] + + switch (key) { + case 'sourceName': { + sorted = sources.toSorted((a, b) => ascString(a.sourceName, b.sourceName)) + break + } + case 'dependency': { + sorted = sources.toSorted((a, b) => ascNumber(a.dependencies.length, b.dependencies.length)) + } + } + + if (sort === 'desc') { + sorted = sorted.reverse() + } + + return sorted +} + +type SortType = { + key: 'sourceName' | 'dependency' + sort: SortTypes +} + +type Props = { + sources: SpecificModule['sources'] + filteredModule: Module | null +} + +export const SourcesContent: FC = ({ filteredModule, sources }) => { + const [sort, setSort] = useState({ key: 'sourceName', sort: 'none' }) + + const sortedSources = useMemo(() => { + let sorted = sortSources(sources, sort.key, sort.sort) + + if (filteredModule) { + sorted = sorted.filter((source) => source.dependencies.some((dependency) => dependency.module === filteredModule)) + } + + return sorted + }, [sort, filteredModule, sources]) + + const setNextSort = useCallback( + (key: SortType['key']) => { + setSort((prev) => { + if (prev.key === key) { + return { + key, + sort: sortTypes[(sortTypes.indexOf(prev.sort) + 1) % sortTypes.length], + } + } else { + return { key, sort: 'asc' } + } + }) + }, + [setSort], + ) + + return ( + <> + {filteredModule && ( + + + Filter: {filteredModule} + + + )} + + + + + + + + + + + + {sortedSources.length === 0 ? ( + + No sources + + ) : ( + + {sortedSources.map((source) => ( + + ))} + + )} +
setNextSort('sourceName')}> + Source + Dependency Module setNextSort('dependency')}> + Dependency + Method IdPath
+ + ) +} diff --git a/frontend/pages/Modules/components/SourcesContent/index.ts b/frontend/pages/Modules/components/SourcesContent/index.ts new file mode 100644 index 0000000..732fcd0 --- /dev/null +++ b/frontend/pages/Modules/components/SourcesContent/index.ts @@ -0,0 +1 @@ +export { SourcesContent } from './SourcesContent' diff --git a/frontend/pages/Modules/components/StickyThead/StickyThead.tsx b/frontend/pages/Modules/components/StickyThead/StickyThead.tsx new file mode 100644 index 0000000..cbd914a --- /dev/null +++ b/frontend/pages/Modules/components/StickyThead/StickyThead.tsx @@ -0,0 +1,7 @@ +import styled from 'styled-components' + +export const StickyThead = styled.thead` + &&& { + top: 51px; + } +` diff --git a/frontend/pages/Modules/components/StickyThead/index.ts b/frontend/pages/Modules/components/StickyThead/index.ts new file mode 100644 index 0000000..314e3f9 --- /dev/null +++ b/frontend/pages/Modules/components/StickyThead/index.ts @@ -0,0 +1 @@ +export { StickyThead } from './StickyThead' diff --git a/frontend/pages/Sources/List.tsx b/frontend/pages/Sources/List.tsx index b4eebe6..575871d 100644 --- a/frontend/pages/Sources/List.tsx +++ b/frontend/pages/Sources/List.tsx @@ -27,10 +27,7 @@ import { SourceModuleComboBox } from '@/components/SourceModuleComboBox' import { UpdateSourceModuleButton } from '@/components/UpdateSourceModuleButton' import { SourceMemoInput } from '@/components/SourceMemoInput' import { createSearchParams, useNavigate } from 'react-router-dom' - -const sortTypes = ['asc', 'desc', 'none'] as const - -type SortTypes = (typeof sortTypes)[number] +import { SortTypes, sortTypes } from '@/utils/sort' type SortState = { key: 'sourceName' | 'module' diff --git a/frontend/utils/sort.ts b/frontend/utils/sort.ts new file mode 100644 index 0000000..67bf8a0 --- /dev/null +++ b/frontend/utils/sort.ts @@ -0,0 +1,13 @@ +export const ascString = (a: string, b: string) => { + if (a > b) return 1 + if (a < b) return -1 + return 0 +} + +export const ascNumber = (a: number, b: number) => { + return a - b +} + +export const sortTypes = ['asc', 'desc', 'none'] as const + +export type SortTypes = (typeof sortTypes)[number]