generated from TBD54566975/tbd-project-template
-
Notifications
You must be signed in to change notification settings - Fork 7
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(client): renders and laysout the module (#391)
This PR just handles formatting module data, and laying out dom nodes for the module visualization from the wireframes. It mostly create a new data type `Item[]` that's used to render the modules for properly spaced for the module page. ```ts interface Call { module: string name: string } interface VerbItem { name?: string 'data-id': string calls: Call[] } interface Item { 'data-id': string name: string style: { marginLeft: number } verbs: VerbItem[] } ``` It also sorts them based on depth. It take the first found ancestor and places a dependent module under it. It should handle cycles and recursion correctly. In the next PR I submit I'll be drawing directional lines from the ancestors to dependents and also from from low depth nodes high depth modules and from high depth modules to any low depth modules (verbs) they call. That's outside of the scope of this PR. However what it should do is show for example as seen in the attached video a line being drawn from `recommendation` to `"productcatalog"` and from `checkout` to `"productcatalog"` even though shipping is indented under checkout. This PR mostly tackled the initial styles of the modules in the viz and the sorting algorithm to lay them out correctly. I still think that could use some work but It's generating the structure correctly with our test data right now. Note in the types in this comment I'm including data-id those are going to be used to target the correct node to draw a line with. I'll be using at transparent no pointer event canvas to reduce node count and allow interactions.
- Loading branch information
1 parent
aaef01c
commit b3c9c63
Showing
2 changed files
with
194 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,11 +1,52 @@ | ||
import React from 'react' | ||
import { Square3Stack3DIcon } from '@heroicons/react/24/outline' | ||
import { PageHeader } from '../../components/PageHeader' | ||
import { modulesContext } from '../../providers/modules-provider' | ||
import { Disclosure } from '@headlessui/react' | ||
import { createLayoutDataStructure } from './create-layout-data-structure' | ||
|
||
export const ModulesPage = () => { | ||
const modules = React.useContext(modulesContext) | ||
const data = createLayoutDataStructure(modules) | ||
|
||
return ( | ||
<> | ||
<PageHeader icon={<Square3Stack3DIcon />} title='Modules' /> | ||
<div className='flex h-full'>Modules</div> | ||
<div role='list' className='flex flex-col space-y-3 p-2'> | ||
{data?.map(({ name, style, verbs, 'data-id': dataId }) => ( | ||
<Disclosure | ||
as='div' | ||
key={name} | ||
style={{ ...style }} | ||
data-id={dataId} | ||
className='min-w-fit w-44 border border-gray-100 dark:border-slate-700 rounded overflow-hidden inline-block' | ||
> | ||
<Disclosure.Button | ||
as='button' | ||
className='text-gray-600 dark:text-gray-300 p-1 w-full text-left flex justify-between items-center' | ||
> | ||
{name} | ||
</Disclosure.Button> | ||
<Disclosure.Panel as='ul' className='text-gray-400 dark:text-gray-400 text-xs p-1 space-y-1 list-inside'> | ||
{verbs.map(({ name, 'data-id': dataId }) => ( | ||
<li key={name} className='flex items-center text-gray-900 dark:text-gray-400'> | ||
<svg | ||
data-id={dataId} | ||
className='w-3.5 h-3.5 mr-2 text-gray-500 dark:text-gray-400 flex-shrink-0' | ||
aria-hidden='true' | ||
xmlns='http://www.w3.org/2000/svg' | ||
fill='currentColor' | ||
viewBox='0 0 20 20' | ||
> | ||
<circle cx='10' cy='10' r='4.5' /> | ||
</svg> | ||
{name} | ||
</li> | ||
))} | ||
</Disclosure.Panel> | ||
</Disclosure> | ||
))} | ||
</div> | ||
</> | ||
) | ||
} |
152 changes: 152 additions & 0 deletions
152
console/client/src/features/modules/create-layout-data-structure.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,152 @@ | ||
import { GetModulesResponse } from '../../protos/xyz/block/ftl/v1/console/console_pb' | ||
|
||
interface Call { | ||
module: string | ||
name: string | ||
} | ||
|
||
interface VerbItem { | ||
name?: string | ||
'data-id': string | ||
calls: Call[] | ||
} | ||
|
||
interface Item { | ||
'data-id': string | ||
name: string | ||
style: { marginLeft: number } | ||
verbs: VerbItem[] | ||
} | ||
|
||
type ModuleMap = Map<number, Set<Item>> | ||
interface Graph { | ||
[key: string]: Set<string> | ||
} | ||
|
||
const flattenMap = (map: ModuleMap, graph: Graph): Item[] => { | ||
const sortedKeys = Array.from(map.keys()).sort((a, b) => a - b) | ||
const flattenedList: Item[] = [] | ||
|
||
for (const key of sortedKeys) { | ||
for (const item of map.get(key)!) { | ||
if (key === 0) { | ||
// Items with key 0 have no ancestors, so we just add them directly to the list | ||
flattenedList.push(item) | ||
} else if (graph[item.name]) { | ||
// Find the ancestor for the current item | ||
const ancestorName = Array.from(graph[item.name])[0] | ||
|
||
// Find the index of the ancestor in the flattenedList | ||
let insertionIndex = flattenedList.findIndex((i) => i.name === ancestorName) | ||
|
||
// If ancestor is found, find the position after the last dependent of the ancestor | ||
if (insertionIndex !== -1) { | ||
while ( | ||
insertionIndex + 1 < flattenedList.length && | ||
graph[flattenedList[insertionIndex + 1].name] && | ||
Array.from(graph[flattenedList[insertionIndex + 1].name])[0] === ancestorName | ||
) { | ||
insertionIndex++ | ||
} | ||
flattenedList.splice(insertionIndex + 1, 0, item) | ||
} else { | ||
// If ancestor is not found, this is a fallback, though ideally this shouldn't happen | ||
flattenedList.push(item) | ||
} | ||
} else { | ||
// If no ancestor is found in the graph, simply push the item to the list | ||
flattenedList.push(item) | ||
} | ||
} | ||
} | ||
|
||
return flattenedList | ||
} | ||
|
||
export const createLayoutDataStructure = (data: GetModulesResponse): Item[] => { | ||
const graph: { [key: string]: Set<string> } = {} | ||
|
||
// Initialize graph with all module names | ||
data.modules.forEach((module) => { | ||
graph[module.name] = new Set() | ||
}) | ||
|
||
// Populate graph with relationships based on verbs' metadata | ||
data.modules.forEach((module) => { | ||
module.verbs.forEach((verbEntry) => { | ||
const verb = verbEntry.verb | ||
verb?.metadata.forEach((metadataEntry) => { | ||
if (metadataEntry.value.case === 'calls') { | ||
metadataEntry.value.value.calls.forEach((call) => { | ||
if (call.module) { | ||
graph[call.module].add(module.name) | ||
} | ||
}) | ||
} | ||
}) | ||
}) | ||
}) | ||
|
||
// Helper function to determine depth of a node in the graph | ||
const determineDepth = ( | ||
node: string, | ||
visited: Set<string> = new Set(), | ||
ancestors: Set<string> = new Set(), | ||
): number => { | ||
if (ancestors.has(node)) { | ||
// Cycle detected | ||
return 0 | ||
} | ||
|
||
let depth = 0 | ||
ancestors.add(node) | ||
graph[node].forEach((neighbor) => { | ||
if (!visited.has(neighbor)) { | ||
visited.add(neighbor) | ||
depth = Math.max(depth, 1 + determineDepth(neighbor, visited, ancestors)) | ||
} | ||
}) | ||
ancestors.delete(node) | ||
|
||
return depth | ||
} | ||
|
||
const sortedKeys = Object.keys(graph).sort(new Intl.Collator().compare) | ||
const map: Map<number, Set<Item>> = new Map() | ||
|
||
sortedKeys.forEach((moduleName) => { | ||
const moduleData = data.modules.find((mod) => mod.name === moduleName) | ||
if (!moduleData) return | ||
|
||
const depth = determineDepth(moduleName) | ||
const item: Item = { | ||
'data-id': moduleName, | ||
name: moduleName, | ||
style: { marginLeft: 20 * (depth + 1) }, | ||
verbs: [], | ||
} | ||
|
||
moduleData.verbs.forEach((verbEntry) => { | ||
const verb = verbEntry.verb | ||
const verbItem: VerbItem = { | ||
name: verb?.name, | ||
'data-id': `${moduleName}.${verb?.name}`, | ||
calls: [], | ||
} | ||
verb?.metadata.forEach((metadataEntry) => { | ||
if (metadataEntry.value.case === 'calls') { | ||
metadataEntry.value.value.calls.forEach((call) => { | ||
verbItem.calls.push({ | ||
module: call.module, | ||
name: call.name, | ||
}) | ||
}) | ||
} | ||
}) | ||
item.verbs.push(verbItem) | ||
}) | ||
map.has(depth) ? map.get(depth)?.add(item) : map.set(depth, new Set([item])) | ||
}) | ||
|
||
return flattenMap(map, graph) | ||
} |