Skip to content

Commit

Permalink
Merge pull request #17 from paritytech/feat/admin-dashboard
Browse files Browse the repository at this point in the history
New admin layout & "admin-dashboard" module
  • Loading branch information
ba1uev committed Mar 11, 2024
2 parents 2d7fa49 + 8e97e99 commit 623dcbd
Show file tree
Hide file tree
Showing 67 changed files with 2,525 additions and 174 deletions.
1 change: 1 addition & 0 deletions package.json
Expand Up @@ -79,6 +79,7 @@
},
"devDependencies": {
"@tailwindcss/forms": "^0.5.2",
"@types/d3": "^7.4.3",
"@types/jsonwebtoken": "^8.5.8",
"@types/marked": "^4.0.3",
"@types/mustache": "^4.2.4",
Expand Down
1 change: 1 addition & 0 deletions scripts/client.build.ts
Expand Up @@ -38,6 +38,7 @@ function getBuildConfig(): BuildOptions {
router: m.manifest.clientRouter || {},
name: m.manifest.name,
portals: m.portals,
adminLinkCounter: m.manifest.adminLinkCounter,
}))
),
// TODO: add .env.COMPONENT_ID_BY_ROUTE
Expand Down
5 changes: 4 additions & 1 deletion src/client/App.tsx
Expand Up @@ -16,6 +16,7 @@ import { getComponentInstance } from '#client/utils/portal'
import { PermissionsSet } from '#shared/utils'
import { PolkadotProvider } from '#client/components/auth/PolkadotProvider'
import { Welcome } from '#modules/users/client/components'
import { WidgetWrapper } from './components/ui'

const routeGroups: Record<
'admin' | 'public' | 'extra' | 'extraLayout',
Expand Down Expand Up @@ -160,7 +161,9 @@ export const App = () => {
// @todo implement redirect to the latest opened and available module tab
return (
<Layout>
<AdminHome>Select a module to work with</AdminHome>
<AdminHome>
<WidgetWrapper>Select a module to work with</WidgetWrapper>
</AdminHome>
</Layout>
)
}
Expand Down
100 changes: 65 additions & 35 deletions src/client/components/AdminHome.tsx
@@ -1,19 +1,27 @@
import * as React from 'react'
import { useStore } from '@nanostores/react'
import { useQuery } from 'react-query'
import * as stores from '#client/stores'
import config from '#client/config'
import {
ADMIN_ACCESS_PERMISSION_RE,
ADMIN_ACCESS_PERMISSION_POSTFIX,
} from '#client/constants'
import { Button, ComponentWrapper } from '#client/components/ui'
import {
Button,
ComponentWrapper,
WidgetWrapper,
} from '#client/components/ui'
import Permissions from '#shared/permissions'
import { PermissionsSet } from '#shared/utils'
import { cn } from '#client/utils'
import { api } from '#client/utils/api'

type ModuleWithAdminComponents = {
id: string
name: string
routes: string[]
counter: boolean
}
const modulesWithAdminComponents: ModuleWithAdminComponents[] =
config.modules.reduce((acc, m) => {
Expand All @@ -24,6 +32,7 @@ const modulesWithAdminComponents: ModuleWithAdminComponents[] =
id: m.id,
name: m.name,
routes,
counter: m.adminLinkCounter,
}
return [...acc, moduleInfo]
}
Expand Down Expand Up @@ -67,42 +76,63 @@ const _AdminHome: React.FC<Props> = ({ children }) => {
})
}, [permissions, officeId])

return filteredModules.length ? (
<div className="grid grid-cols-[240px_minmax(0,auto)] gap-x-4">
<div>
<WidgetWrapper className="p-2 sticky top-2">
{filteredModules.map((x, i) => {
const isActive = !!(page && x.routes.includes(page.route))
return <ModuleLink key={x.id} isActive={isActive} module={x} />
})}
</WidgetWrapper>
</div>
<div>
<div>{children}</div>
</div>
</div>
) : (
<WidgetWrapper>
Please select an office that you can work with.
</WidgetWrapper>
)
}

const ModuleLink: React.FC<{
isActive: boolean
module: ModuleWithAdminComponents
}> = (props) => {
const officeId = useStore(stores.officeId)
const counterApiUri = `/admin-api/${props.module.id}/counter`

const { data: count = 0 } = useQuery<number>(
[counterApiUri, { office: officeId }],
async ({ queryKey }) =>
(await api.get<number>(counterApiUri, { params: queryKey[1] })).data,
{ retry: false, enabled: props.module.counter }
)

return (
<ComponentWrapper wide>
{filteredModules.length ? (
<>
<div className="-mx-8 -mt-4 sm:mt-0 px-2 sm:px-8 mb-6 pb-2 sm:pb-4 border-b border-gray-200">
{filteredModules.map((x) => {
return (
<Button
key={x.id}
kind={
page && x.routes.includes(page.route)
? 'primary'
: 'secondary'
}
href={`/admin/${x.id}`}
className="mb-2 sm:mb-4 mr-2 rounded-[24px] relative focus:ring-0"
>
{x.name}
{/* {!!counter && <CounterBadge count={counter} />} */}
</Button>
)
})}
</div>
{children}
</>
) : (
<div>Please select an office that you can work with.</div>
<a
href={`/admin/${props.module.id}`}
className={cn(
'relative flex items-center px-4 py-4 rounded-tiny hover:bg-gray-50',
props.isActive &&
'bg-purple-50 hover:bg-purple-50 bg-opacity-40 hover:bg-opacity-40 text-purple-500'
)}
</ComponentWrapper>
>
<span className="flex-1 text-ellipsis overflow-hidden mr-1 whitespace-nowrap">
{props.module.name}
</span>
<CounterBadge count={count} />
</a>
)
}

// const CounterBadge: React.FC<{ count: number }> = ({ count }) => {
// return !!count ? (
// <span className="absolute -top-2 right-0 border-2 border-white bg-red-500 text-white rounded-[999px] text-xs flex items-center justify-center h-[22px] min-w-[22px] px-1">
// {count}
// </span>
// ) : null
// }
const CounterBadge: React.FC<{ count: number }> = ({ count }) => {
if (!count) return null
return (
<span className="bg-purple-500 text-white rounded-[999px] text-xs inline-flex items-center justify-center h-[18px] min-w-[18px] px-1">
{count}
</span>
)
}
16 changes: 16 additions & 0 deletions src/client/components/charts/Card.tsx
@@ -0,0 +1,16 @@
import React from 'react'
import { WidgetWrapper } from '#client/components/ui'

export const Card: React.FC<{
title: string | React.ReactNode
subtitle: string | React.ReactNode
}> = (props) => {
return (
<WidgetWrapper className="bg-blue-50">
<div className="text-2xl font-medium">{props.title}</div>
<div className="text-text-secondary whitespace-nowrap text-sm">
{props.subtitle}
</div>
</WidgetWrapper>
)
}
197 changes: 197 additions & 0 deletions src/client/components/charts/StackedBarChart.tsx
@@ -0,0 +1,197 @@
import React from 'react'
import { useExternalScript, useResizeObserver } from '#client/utils/hooks'
import { cn } from '#client/utils'

type Color =
| 'green'
| 'yellow'
| 'red'
| 'blue'
| 'gray'
| 'purple'
| 'brown'
| 'pink'
| 'teal'
| 'lime'

const COLORS: Record<Color, { faded: string; normal: string }> = {
green: { faded: '#35d54860', normal: '#30d043' },
yellow: { faded: '#FBBF2460', normal: '#F59E0B' },
red: { faded: '#F8717160', normal: '#EF4444' },
blue: { faded: '#60A5FA60', normal: '#3B82F6' },
gray: { faded: '#9CA3AF60', normal: '#6B7280' },
purple: { faded: '#A78BFA60', normal: '#8B5CF6' },
brown: { faded: '#C39F4460', normal: '#A06401' },
pink: { faded: '#FF84B860', normal: '#F9559A' },
teal: { faded: '#3A9DAB60', normal: '#3A9DAB' },
lime: { faded: '#E9F93960', normal: '#B0C000' },
}
const DEFAULT_HEIGHT = 400
const DEFAULT_COLORS = Object.keys(COLORS) as Color[]

export interface Props<XKey extends string, YKey extends string> {
xKey: XKey
yKeys: YKey[]
colors?: Color[]
data: Array<{ [key in XKey]: string } & Record<YKey, number>>
height?: number
barTitle?: (datum: Record<XKey | YKey, any>) => string
xTickFormat?: (xKey: XKey) => string
className?: string
legendItem?: (key: YKey) => string
}

export const StackedBarChart = <X extends string, Y extends string>(
props: Props<X, Y>
) => {
const ref = React.useRef<HTMLDivElement>(null)

const colors = React.useMemo(
() => props.colors || DEFAULT_COLORS,
[props.colors]
)

const data = React.useMemo(() => {
return props.data
}, [props.data])

const render = React.useCallback(() => {
const d3 = window.d3
const container = ref.current
if (!d3 || !container) return
d3.select(ref.current).select('svg').remove()

const margin = { top: 4, right: 14, bottom: 14, left: 24 }
const width = ref.current.clientWidth - margin.left - margin.right
const height = (props.height || DEFAULT_HEIGHT) - margin.top - margin.bottom
const barPadding = 2
const numberOfBars = data.length
const totalPadding = barPadding * numberOfBars
const rangeWidth = width - margin.left - margin.right - totalPadding
const barWidth = rangeWidth / numberOfBars
const barPaddingRelative = barPadding / barWidth

const svg = d3
.select(ref.current)
.append('svg')
.attr('width', width + margin.left + margin.right)
.attr('height', height + margin.top + margin.bottom)
.append('g')
.attr('transform', `translate(${margin.left},${margin.top})`)

const subgroups = props.yKeys
const xScale = d3
.scaleBand(
data.map((d) => d[props.xKey]),
[margin.left, width - margin.right]
)
.paddingInner(barPaddingRelative)
const yScale = d3.scaleLinear(
[0, d3.max(data, (d) => sumValues(d, props.yKeys))],
[height - margin.bottom, margin.top]
)
const xAxis = d3
.axisBottom(xScale)
.tickSizeOuter(0)
.tickFormat((d) => {
return props.xTickFormat ? props.xTickFormat(d) : d
})
const yAxis = d3.axisLeft(yScale)
const stack = d3.stack().keys(subgroups)
const stackedData = stack(data)

const groups = svg
.append('g')
.selectAll('g')
.data(stackedData)
.join('g')
.style('fill', (_, i) => {
const colors = props.colors || DEFAULT_COLORS
const colorId = colors[i % colors.length]
return COLORS[colorId]?.faded
})

groups
.selectAll('rect')
.data((d) => d)
.join('rect')
.attr('x', (d) => xScale(d.data[props.xKey]))
.attr('y', (d) => yScale(d[1]))
.attr('height', (d) => yScale(d[0]) - yScale(d[1]))
.attr('width', xScale.bandwidth())
.append('title')
.text((d) => {
return props.barTitle ? props.barTitle(d.data) : d.data[props.xKey]
})

groups
.selectAll('rect.main')
.data((d) => d)
.join('rect')
.attr('x', (d) => xScale(d.data[props.xKey]))
.attr('y', (d) => yScale(d[1]))
.attr('height', 2)
.attr('width', xScale.bandwidth())
.style('fill', (d, i, nodes) => {
const stackKey = d3.select(nodes[i].parentNode).datum().key
const value = d.data[stackKey]
if (!value) return 'transparent'
const colorIndex = props.yKeys.indexOf(stackKey)
const colors = props.colors || DEFAULT_COLORS
const colorId = colors[colorIndex % colors.length]
return COLORS[colorId].normal
})

const xAxisGroup = svg
.append('g')
.attr('transform', `translate(0,${height - margin.bottom})`)
.call(xAxis)
xAxisGroup.selectAll('path, .tick line').attr('stroke', '#ccc')
xAxisGroup.selectAll('text').attr('fill', '#888')

const yAxisGroup = svg
.append('g')
.attr('transform', `translate(${margin.left},0)`)
.call(yAxis)
yAxisGroup.selectAll('path, .tick line').attr('stroke', '#ccc')
yAxisGroup.selectAll('text').attr('fill', '#888')
}, [data, props])

useExternalScript('https://cdn.jsdelivr.net/npm/d3@7', render)
useResizeObserver(ref, render)

return (
<div>
<div className={cn(props.className)} ref={ref} />
{!!props.legendItem && (
<div className="flex justify-center">
<div className="flex justify-center max-w-[800px] gap-x-4 flex-wrap mt-4">
{props.yKeys.map((key) => {
const colorIndex = props.yKeys.indexOf(key)
const colorId = colors[colorIndex % colors.length]
const color = COLORS[colorId] || COLORS.gray
return (
<div key={key} className="flex items-center gap-x-1">
<div
className="w-3 h-3 border rounded-md"
style={{
backgroundColor: color.faded,
borderColor: color.normal,
}}
/>
<span className="text-text-secondary">
{props.legendItem!(key)}
</span>
</div>
)
})}
</div>
</div>
)}
</div>
)
}

function sumValues(datum: Record<string, number>, keys: string[]): number {
return keys.reduce((sum, key) => sum + datum[key], 0)
}

0 comments on commit 623dcbd

Please sign in to comment.