Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(client): renders and laysout the module #391

Merged
merged 4 commits into from
Sep 18, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 42 additions & 1 deletion console/client/src/features/modules/ModulesPage.tsx
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)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Screenshot 2023-09-16 at 7 51 14 AM

I like the idea of this layout. I'm guessing this is intended to display the dependency graph similar to our Graph page.

Screenshot 2023-09-16 at 7 58 37 AM

This graph helps me view the dependencies (even though our layout algorithm isn't great :)) I see with this graph that productCatalog depends on recommendation too. Maybe recommendation should be under productCatalog.

Given that multiple modules can depend on the same module (maybe that's not the case with our boutique demo but it's possible) we might end up with duplicate module nodes here. An example that comes to mind is, what if cart also used recommendation? Then would we have a recommendation node below cart and one below productCatalog?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated description on this PR please see that should address questions


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 console/client/src/features/modules/create-layout-data-structure.ts
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)
}