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

Introduce a terminal UI to explore your Tool Kit setup #325

Draft
wants to merge 16 commits into
base: main
Choose a base branch
from
Draft
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
11 changes: 9 additions & 2 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ module.exports = {
'prettier',
'plugin:import/recommended',
'plugin:import/typescript',
'plugin:@typescript-eslint/recommended'
'plugin:@typescript-eslint/recommended',
'plugin:react/recommended',
'plugin:react-hooks/recommended'
],
rules: {
// We use winston's logging instead
Expand All @@ -19,5 +21,10 @@ module.exports = {
'@typescript-eslint/no-var-requires': 'off'
}
}
]
],
settings: {
react: {
version: 'detect'
}
}
}
1 change: 1 addition & 0 deletions core/cli/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,7 @@ export async function loadConfig(logger: Logger, { validate = true } = {}): Prom

if (validate) {
validateConfig(validPluginConfig)
return validPluginConfig
}

return config
Expand Down
5 changes: 5 additions & 0 deletions core/tool-box/bin/run
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
#!/usr/bin/env node

const { main } = require('../lib/index.js')

main()
40 changes: 40 additions & 0 deletions core/tool-box/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
{
"name": "@dotcom-tool-kit/tool-box",
"version": "0.0.0-development",
"description": "",
"main": "lib",
"bin": {
"dotcom-tool-kit": "./bin/run"
},
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "FT.com Platforms Team <[email protected]>",
"license": "ISC",
"dependencies": {
"@dotcom-tool-kit/logger": "^2.1.2",
"dotcom-tool-kit": "^2.4.0",
"ink": "^3.2.0",
"react": "^17.0.2",
"winston": "^3.6.0"
},
"repository": {
"type": "git",
"url": "https://github.com/financial-times/dotcom-tool-kit.git",
"directory": "core/box"
},
"bugs": "https://github.com/financial-times/dotcom-tool-kit/issues",
"homepage": "https://github.com/financial-times/dotcom-tool-kit/tree/main/core/box",
"files": [
"/lib",
".toolkitrc.yml"
],
"volta": {
"extends": "../../package.json"
},
"devDependencies": {
"@dotcom-tool-kit/types": "^2.7.0",
"@types/react": "^17.0.52"
}
}
8 changes: 8 additions & 0 deletions core/tool-box/src/components/DetailsBox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import React from 'react'
import { SelectableBox, SelectableBoxProps } from './SelectableBox'

export const DetailsBox = (props: React.PropsWithChildren<SelectableBoxProps>): JSX.Element => (
<SelectableBox width={72} flexDirection="column" {...props}>
{props.children}
</SelectableBox>
)
19 changes: 19 additions & 0 deletions core/tool-box/src/components/List.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { Box, Text } from 'ink'
import React from 'react'
import { SelectableBox } from './SelectableBox'

export interface ListProps {
items: string[]
selected: boolean
cursor: number
}

export const List = (props: ListProps): JSX.Element => (
<SelectableBox selected={props.selected} flexDirection="column">
{props.items.map((item, index) => (
<Box key={item}>
<Text bold={props.cursor === index}>{item}</Text>
</Box>
))}
</SelectableBox>
)
15 changes: 15 additions & 0 deletions core/tool-box/src/components/SelectableBox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { Box, BoxProps } from 'ink'
import React from 'react'

export interface SelectableBoxProps extends BoxProps {
selected: boolean
}

export const SelectableBox = (props: React.PropsWithChildren<SelectableBoxProps>): JSX.Element => {
const { selected, children, ...rest } = props
return (
<Box borderStyle={selected ? 'single' : undefined} paddingTop={selected ? 0 : 1} paddingX={1} {...rest}>
{children}
</Box>
)
}
111 changes: 111 additions & 0 deletions core/tool-box/src/components/TabbedView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import type { ValidConfig } from 'dotcom-tool-kit/lib/config'
import { Box, Text, useInput } from 'ink'
import React, { useState } from 'react'
import { HooksPage } from './pages/Hooks'
import { PluginsPage } from './pages/Plugins'
import { TabName, TabPages } from './pages/shared'
import { TasksPage } from './pages/Tasks'

// Our styling uses ansi-colors, whereas ink uses chalk, and there's no nice
// way to convert between the two, so lets just copy the appropriate colours
// as strings.
const tabColours = { plugins: 'cyan', hooks: 'magenta', tasks: 'blueBright' }

interface TabbedViewProps {
config: ValidConfig
}

export const TabbedView = (props: TabbedViewProps): JSX.Element => {
const [activeTab, setActiveTab] = useState(0)
const [pluginsStart, setPluginsStart] = useState<string | undefined>()
const [hooksStart, setHooksStart] = useState<string | undefined>()
const [tasksStart, setTasksStart] = useState<string | undefined>()
const pluginsWithHook: Record<string, string[]> = {}
const pluginsWithTask: Record<string, string[]> = {}
for (const [pluginId, plugin] of Object.entries(props.config.plugins)) {
for (const hookId of Object.keys(plugin.module?.hooks ?? {})) {
pluginsWithHook[hookId] ??= []
pluginsWithHook[hookId].push(pluginId)
}
for (const taskId of Object.keys(plugin.module?.tasks ?? {})) {
pluginsWithTask[taskId] ??= []
pluginsWithTask[taskId].push(pluginId)
}
}
const tasksWithHook = Object.entries(props.config.hookTasks).map(
([hookId, hookTask]) => [hookId, hookTask.tasks] as const
)
const hooksWithTask: Record<string, string[]> = {}
for (const [hookId, tasks] of tasksWithHook) {
for (const task of tasks) {
hooksWithTask[task] ??= []
hooksWithTask[task].push(hookId)
}
}

const handleTabChange = (newTab: TabName, itemId?: string) => {
setActiveTab(TabPages.indexOf(newTab))
if (itemId) {
switch (newTab) {
case 'plugins':
setPluginsStart(itemId)
break
case 'hooks':
setHooksStart(itemId)
break
case 'tasks':
setTasksStart(itemId)
break
}
}
}
useInput((_, key) => {
if (key.tab) {
if (key.shift) {
const prevTab = activeTab - 1
setActiveTab(prevTab < 0 ? TabPages.length - 1 : prevTab)
} else {
setActiveTab((activeTab + 1) % TabPages.length)
}
}
})
return (
<>
<Box>
{TabPages.map((page, index) => (
<React.Fragment key={page}>
{index !== 0 && <Text> | </Text>}
<Text backgroundColor={index === activeTab ? tabColours[page] : undefined}>{page}</Text>
</React.Fragment>
))}
</Box>
<Box>
{TabPages[activeTab] === 'plugins' && (
<PluginsPage
plugins={Object.entries(props.config.plugins)}
startingItem={pluginsStart}
onTabChange={handleTabChange}
/>
)}
{TabPages[activeTab] === 'hooks' && (
<HooksPage
hooks={Object.entries(props.config.hooks)}
taskMap={Object.fromEntries(tasksWithHook)}
pluginMap={pluginsWithHook}
startingItem={hooksStart}
onTabChange={handleTabChange}
/>
)}
{TabPages[activeTab] === 'tasks' && (
<TasksPage
tasks={Object.entries(props.config.tasks)}
pluginMap={pluginsWithTask}
startingItem={tasksStart}
hookMap={hooksWithTask}
onTabChange={handleTabChange}
/>
)}
</Box>
</>
)
}
109 changes: 109 additions & 0 deletions core/tool-box/src/components/pages/Hooks.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { styles } from '@dotcom-tool-kit/logger'
import type { Hook, HookClass } from '@dotcom-tool-kit/types'
import { Box, Text } from 'ink'
import React, { useCallback } from 'react'
import { DetailsBox } from '../DetailsBox'
import { List } from '../List'
import { TabPageProps, ToolEssentials, useNavigation } from './shared'

interface HookDetailsProps extends ToolEssentials {
hook: Hook<unknown>
taskIds: string[]
pluginIds: string[]
}

const HookDetails = (props: HookDetailsProps) => {
return (
<DetailsBox selected={props.selected}>
<Text>{(props.hook.constructor as HookClass).description}</Text>
{props.hook.plugin && (
<Text>
Defined in the{' '}
<Text bold={props.selected && props.cursor === 0}>{styles.plugin(props.hook.plugin.id)}</Text>{' '}
plugin
</Text>
)}
{props.taskIds.length > 0 && (
<>
<Text>Calls the following tasks:</Text>
{props.taskIds.map((taskId, index) => (
<Text key={taskId}>
- <Text bold={props.selected && props.cursor === index + 1}>{styles.plugin(taskId)}</Text>
</Text>
))}
</>
)}
{props.pluginIds.length > 0 && (
<>
<Text>Appears in the following plugins:</Text>
{props.pluginIds.map((pluginId, index) => (
<Text key={pluginId}>
-{' '}
<Text bold={props.selected && props.cursor === index + 1 + props.taskIds.length}>
{styles.plugin(pluginId)}
</Text>
</Text>
))}
</>
)}
</DetailsBox>
)
}

export interface HooksPageProps extends TabPageProps {
hooks: [string, Hook<unknown>][]
taskMap: Record<string, string[]>
pluginMap: Record<string, string[]>
}

export const HooksPage = (props: HooksPageProps): JSX.Element => {
const { listCursor, detailsCursor, detailsSelected } = useNavigation({
listLength: props.hooks.length,
getDetailsLength(cursor) {
const [hookId, hook] = props.hooks[cursor]
return (
(hook.plugin ? 1 : 0) + (props.taskMap[hookId]?.length ?? 0) + (props.pluginMap[hookId]?.length ?? 0)
)
},
getSelectedItem(listCursor, detailsCursor) {
const [hookId, hook] = props.hooks[listCursor]
if (detailsCursor === 0 && hook.plugin) {
return ['plugins', hook.plugin.id]
} else if (detailsCursor <= props.taskMap[hookId]?.length ?? 0) {
return ['tasks', props.taskMap[hookId][detailsCursor - (hook.plugin ? 1 : 0)]]
} else {
return [
'plugins',
props.pluginMap[hookId][
detailsCursor - (props.taskMap[hookId]?.length ?? 0) - (hook.plugin ? 1 : 0)
]
]
}
},
findItem: useCallback(
(itemId) => {
return props.hooks.findIndex(([hookId]) => hookId === itemId)
},
[props.hooks]
),
startingItem: props.startingItem,
changeTab: props.onTabChange
})
const [hookId, selectedHook] = props.hooks[listCursor]
return (
<Box paddingLeft={detailsSelected ? 1 : 0}>
<List
items={props.hooks.map(([id]) => styles.hook(id))}
selected={!detailsSelected}
cursor={listCursor}
/>
<HookDetails
hook={selectedHook}
taskIds={props.taskMap[hookId] ?? []}
pluginIds={props.pluginMap[hookId] ?? []}
selected={detailsSelected}
cursor={detailsCursor}
/>
</Box>
)
}
Loading