Skip to content

Commit 2e111eb

Browse files
antfubotantfu
andauthored
feat(hub): reinterpret grouped dock entry category as in-group sub-category (#130)
Co-authored-by: Anthony Fu <github@antfu.me>
1 parent caf318b commit 2e111eb

14 files changed

Lines changed: 164 additions & 11 deletions

File tree

docs/guide/hub.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,8 +199,42 @@ ctx.docks.register({
199199

200200
`groupId` lives on every entry kind, so iframes, launchers, custom-render views, and integration-contributed types (e.g. json-render panels) all join groups the same way. The group and its members stay independent top-level entries in `devframe:docks`; a downstream UI derives the visual collapse by matching each member's `groupId` to the group's `id` and renders members in a popover or sub-navigation. `defaultChildId` names the member opened when the group button is activated.
201201

202+
### The dual role of `category`
203+
204+
`category` decides an entry's outer bucket on the dock bar (ordered by `DEFAULT_CATEGORIES_ORDER`) — but its meaning shifts once an entry joins a group:
205+
206+
- **Ungrouped entry**`category` is the entry's outer dock-bar bucket, defaulting to `'default'`.
207+
- **Grouped entry** (a `groupId` that resolves to a registered group) — the outer bucket comes from the **group's** own `category` instead. The member's own `category` is reinterpreted as an **in-group sub-category** that sub-divides and sorts members inside the group's popover / sub-navigation.
208+
209+
The group's `category` is therefore the single outer bucket shared by the group button and all its members. Fallbacks keep the rule total:
210+
211+
- A **group** with no `category` buckets itself and its members under `'default'`.
212+
- A grouped **member** with no `category` defaults its in-group sub-category to `'default'`.
213+
- **Orphan tolerance** — a member whose `groupId` never resolves to a registered group renders as a normal top-level entry and uses its **own** `category` as the outer bucket, exactly as an ungrouped entry does.
214+
215+
In the example above, `nuxt:overview` renders under the `framework` bucket (from the `nuxt` group) even though it could carry its own `category`; that own `category` would only sort it against sibling members inside the Nuxt group.
216+
202217
Grouping is one level deep: members join a group, and a group is always a top-level button. A member whose group is never registered renders as a normal top-level entry, so registration order is free.
203218

219+
#### Known categories
220+
221+
`DEFAULT_CATEGORIES_ORDER` (exported from `@devframes/hub`, `@devframes/hub/node`, `@devframes/hub/client` and `@devframes/hub/constants`) names the buckets the hub orders by default, reading from "closest to your app" toward "peripheral":
222+
223+
| Category | Weight | Typical use |
224+
|---|---|---|
225+
| `framework` | `-100` | Framework internals (Vue / Nuxt / Vite). |
226+
| `default` | `0` | Uncategorized entries. |
227+
| `app` | `100` | The user's own app tools. |
228+
| `ui` | `150` | Components, design system, styling. |
229+
| `data` | `250` | State, storage, queries, database. |
230+
| `web` | `300` | Network, platform, accessibility. |
231+
| `performance` | `350` | Profiling, metrics, budgets. |
232+
| `advanced` | `400` | Power-user / low-level tools. |
233+
| `docs` | `500` | Documentation, references. |
234+
| `~builtin` | `1000` | The viewer's own built-in views; always last. |
235+
236+
The gaps are intentional — a kit can interleave its own category ids (or override any weight) without editing this table. An unknown category sorts as weight `0`.
237+
204238
## The protocol — what the UI sees
205239

206240
A hub-aware UI doesn't import any hub classes; it reads three shared-state keys and one RPC method:

packages/hub/src/client/__tests__/host.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,10 @@ function iframeEntry(id: string, extra?: Record<string, unknown>): DevframeDockE
6262
return { id, title: id, icon: 'ph:cube', type: 'iframe', url: `/__${id}/`, ...extra } as DevframeDockEntry
6363
}
6464

65+
function groupEntry(id: string, extra?: Record<string, unknown>): DevframeDockEntry {
66+
return { id, title: id, icon: 'ph:folder', type: 'group', ...extra } as DevframeDockEntry
67+
}
68+
6569
describe('createDevframeClientHost', () => {
6670
it('publishes the global client context with the full surface', async () => {
6771
const { rpc } = createStubRpc()
@@ -95,6 +99,46 @@ describe('createDevframeClientHost', () => {
9599
host.dispose()
96100
})
97101

102+
it('groups entries by category — grouped members bucket under their group, orphans by their own', async () => {
103+
const { rpc, states } = createStubRpc()
104+
const host = await createDevframeClientHost({ rpc })
105+
106+
states.get('devframe:docks')!.push([
107+
// (a) member with groupId + its own category → outer bucket = group's category ('framework'),
108+
// and its own 'app' category is reinterpreted as an in-group sub-category.
109+
groupEntry('nuxt', { category: 'framework' }),
110+
iframeEntry('nuxt:overview', { groupId: 'nuxt', category: 'app' }),
111+
// (b) group with no category → members bucket to 'default'.
112+
groupEntry('misc'),
113+
iframeEntry('misc:one', { groupId: 'misc', category: 'web' }),
114+
// (c) orphan member (groupId with no registered group) → its own category ('web').
115+
iframeEntry('orphan', { groupId: 'ghost', category: 'web' }),
116+
// plain ungrouped entry keeps its own category.
117+
iframeEntry('plain', { category: 'app' }),
118+
])
119+
120+
const grouped = Object.fromEntries(
121+
host.context.docks.groupedEntries.map(([cat, entries]) => [cat, entries.map(e => e.id)]),
122+
)
123+
124+
// (a) grouped member lands under the group's category, alongside the group button.
125+
expect(grouped.framework).toEqual(['nuxt', 'nuxt:overview'])
126+
// (b) group without a category, and its member, bucket to 'default'.
127+
expect(grouped.default).toEqual(['misc', 'misc:one'])
128+
// (c) orphan + plain keep their own categories.
129+
expect(grouped.web).toEqual(['orphan'])
130+
expect(grouped.app).toEqual(['plain'])
131+
132+
// Categories sort by DEFAULT_CATEGORIES_ORDER — framework first.
133+
expect(host.context.docks.groupedEntries.map(([cat]) => cat)).toEqual([
134+
'framework',
135+
'default',
136+
'app',
137+
'web',
138+
])
139+
host.dispose()
140+
})
141+
98142
it('switches entries with activation/deactivation events and when-context updates', async () => {
99143
const { rpc, states } = createStubRpc()
100144
const host = await createDevframeClientHost({ rpc })

packages/hub/src/client/host.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -479,9 +479,22 @@ function createPanelContext(clientType: DockClientType): DocksPanelContext {
479479
}
480480

481481
function groupByCategory(entries: DevframeDockEntry[]): DevframeDockEntriesGrouped {
482+
// Index registered groups so a member whose `groupId` resolves takes its
483+
// OUTER bucket from the group's category, not its own (which becomes the
484+
// member's in-group sub-category). Orphan members — a `groupId` with no
485+
// registered group — fall back to their own category.
486+
const groupsById = new Map<string, DevframeDockEntry>()
487+
for (const entry of entries) {
488+
if (entry.type === 'group')
489+
groupsById.set(entry.id, entry)
490+
}
491+
482492
const groups = new Map<string, DevframeDockEntry[]>()
483493
for (const entry of entries) {
484-
const category = entry.category ?? 'default'
494+
const resolvedGroup = entry.groupId ? groupsById.get(entry.groupId) : undefined
495+
const category = resolvedGroup
496+
? resolvedGroup.category ?? 'default'
497+
: entry.category ?? 'default'
485498
let list = groups.get(category)
486499
if (!list) {
487500
list = []

packages/hub/src/client/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
// Re-export the category-order table so client-side viewers can import the
2+
// single source of truth from `@devframes/hub/client`.
3+
export { DEFAULT_CATEGORIES_ORDER } from '../constants'
14
export * from './client-script'
25
export * from './context'
36
export * from './docks'

packages/hub/src/constants.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,26 @@ import type { DevframeDocksUserSettings } from './types/settings'
22

33
export * from 'devframe/constants'
44

5+
/**
6+
* The default ordering weight for each known dock category — lower sorts
7+
* earlier. Downstream viewers (e.g. `@vitejs/devtools-kit`) import this as the
8+
* single source of truth so the hub and its viewers agree on category order.
9+
* `framework` sorts first; `~builtin` (the viewer's own built-in views) last.
10+
*
11+
* The buckets read from "closest to your app" → "platform / analysis" →
12+
* "peripheral". Gaps between the weights are intentional: a kit can interleave
13+
* its own categories (or override these) without editing this table.
14+
*/
515
export const DEFAULT_CATEGORIES_ORDER: Record<string, number> = {
16+
'framework': -100,
617
'default': 0,
718
'app': 100,
8-
'framework': 200,
19+
'ui': 150,
20+
'data': 250,
921
'web': 300,
22+
'performance': 350,
1023
'advanced': 400,
24+
'docs': 500,
1125
'~builtin': 1000,
1226
}
1327

packages/hub/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
1+
export { DEFAULT_CATEGORIES_ORDER } from './constants'
12
export * from './define'
23
export type * from './types'

packages/hub/src/node/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
// Re-export the category-order table so node-side consumers can import the
2+
// single source of truth from `@devframes/hub/node`.
3+
export { DEFAULT_CATEGORIES_ORDER } from '../constants'
14
export * from './context'
25
export * from './host-commands'
36
export * from './host-docks'

packages/hub/src/types/docks.ts

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -50,15 +50,20 @@ export interface DevframeDocksActiveState {
5050
activation: DevframeDockActivation | null
5151
}
5252

53-
// Known categories the hub orders by default. Kits may pass their own
54-
// category ids; `(string & {})` keeps autocomplete on the known set while
53+
// Known categories the hub orders by default (see
54+
// {@link import('../constants').DEFAULT_CATEGORIES_ORDER}). Kits may pass their
55+
// own category ids; `(string & {})` keeps autocomplete on the known set while
5556
// allowing arbitrary string values. `~builtin` is reserved for the viewer's
5657
// own built-in views (see {@link DevframeViewBuiltin}) and always sorts last.
5758
export type DevframeDockEntryCategory
58-
= | 'app'
59-
| 'framework'
60-
| 'web'
61-
| 'advanced'
59+
= | 'framework' // framework internals (Vue / Nuxt / Vite)
60+
| 'app' // the user's own app tools
61+
| 'ui' // components, design system, styling
62+
| 'data' // state, storage, queries, database
63+
| 'web' // network, platform, accessibility
64+
| 'performance' // profiling, metrics, budgets
65+
| 'advanced' // power-user / low-level tools
66+
| 'docs' // documentation, references
6267
| 'default'
6368
| '~builtin'
6469
| (string & {})
@@ -76,7 +81,19 @@ export interface DevframeDockEntryBase {
7681
*/
7782
defaultOrder?: number
7883
/**
79-
* The category of the entry
84+
* The category of the entry — a field with a dual role that depends on
85+
* whether {@link groupId} resolves to a registered {@link DevframeViewGroup}:
86+
*
87+
* - **Ungrouped (or orphan) entry** — `category` is the entry's OUTER bucket
88+
* on the dock bar, ordered by {@link import('../constants').DEFAULT_CATEGORIES_ORDER}.
89+
* - **Grouped entry** (a `groupId` that resolves to a registered group) —
90+
* the OUTER bucket is instead the group's own `category`, and this field is
91+
* reinterpreted as the entry's IN-GROUP sub-category, used to sub-divide and
92+
* sort members inside the group's popover / sub-navigation.
93+
*
94+
* Falls back to `'default'` when omitted — both as an outer bucket and, for a
95+
* grouped member, as its in-group sub-bucket.
96+
*
8097
* @default 'default'
8198
*/
8299
category?: DevframeDockEntryCategory
@@ -102,8 +119,14 @@ export interface DevframeDockEntryBase {
102119
*
103120
* This is a flat pointer — membership, not containment. The entry stays an
104121
* independently-registered, top-level entry; only its rendering is grouped
105-
* downstream. If the referenced group is never registered, the entry renders
106-
* as a normal top-level entry (orphan tolerance).
122+
* downstream.
123+
*
124+
* When the referenced group **is** registered, it supplies the entry's OUTER
125+
* dock-bar category (the group's own {@link category}), and this entry's own
126+
* {@link category} is reinterpreted as its IN-GROUP sub-category. When the
127+
* referenced group is **never** registered, the entry renders as a normal
128+
* top-level entry and falls back to using its own {@link category} as the
129+
* outer bucket (orphan tolerance).
107130
*
108131
* @see {@link DevframeViewGroup}
109132
*/
@@ -277,6 +300,12 @@ export interface DevframeViewJsonRender extends DevframeDockEntryBase {
277300
* through the same `register`/`update`/`values` machinery as every other entry,
278301
* keyed by `id`.
279302
*
303+
* The group's `category` is the OUTER bucket for the group button itself AND
304+
* for every one of its members — a member's own `category` no longer decides
305+
* its outer bucket, but is reinterpreted as an in-group sub-category that
306+
* sub-divides and sorts members inside this group. A group with no `category`
307+
* buckets itself and its members under `'default'`.
308+
*
280309
* Grouping is one level deep: a group entry must not itself set `groupId`.
281310
*/
282311
export interface DevframeViewGroup extends DevframeDockEntryBase {

tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ export * from "devframe/client";
136136
// #endregion
137137

138138
// #region Other
139+
export { DEFAULT_CATEGORIES_ORDER }
139140
export { DevframeClientRpcHost }
140141
export { RpcClientEvents }
141142
// #endregion

tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,8 @@ export var CLIENT_CONTEXT_KEY /* const */
1616

1717
// #region Re-exports
1818
export * from "devframe/client";
19+
// #endregion
20+
21+
// #region Other
22+
export { DEFAULT_CATEGORIES_ORDER }
1923
// #endregion

0 commit comments

Comments
 (0)