feat: new command menu pt 1 (#23699)
Introduces the first (very small) slice of the new command menu as a `ui-pattern`. This slice just handles state for available commands. It isn't hooked up to anything yet.
This commit is contained in:
34
.github/workflows/ui-patterns-tests.yml
vendored
Normal file
34
.github/workflows/ui-patterns-tests.yml
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
name: UI Patterns Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [master]
|
||||
paths:
|
||||
- 'packages/ui-patterns/**'
|
||||
|
||||
# Cancel old builds on new commit for same workflow + branch/PR
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: |
|
||||
packages
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install deps
|
||||
run: npm ci
|
||||
|
||||
- name: Run tests
|
||||
run: npm run test:ui-patterns
|
||||
18
package-lock.json
generated
18
package-lock.json
generated
@@ -19120,6 +19120,7 @@
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
@@ -31674,8 +31675,9 @@
|
||||
},
|
||||
"node_modules/jest": {
|
||||
"version": "29.7.0",
|
||||
"resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz",
|
||||
"integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jest/core": "^29.7.0",
|
||||
"@jest/types": "^29.6.3",
|
||||
@@ -48884,9 +48886,10 @@
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/ts-jest": {
|
||||
"version": "29.1.1",
|
||||
"version": "29.1.2",
|
||||
"resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.1.2.tgz",
|
||||
"integrity": "sha512-br6GJoH/WUX4pu7FbZXuWGKGNDuU7b8Uj77g/Sp7puZV6EXzuByl6JrECvm0MzVzSTkSHWTihsXt+5XYER5b+g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bs-logger": "0.x",
|
||||
"fast-json-stable-stringify": "2.x",
|
||||
@@ -48901,7 +48904,7 @@
|
||||
"ts-jest": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
|
||||
"node": "^16.10.0 || ^18.0.0 || >=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@babel/core": ">=7.0.0-beta.0 <8",
|
||||
@@ -52554,11 +52557,14 @@
|
||||
"remark-gfm": "^4.0.0",
|
||||
"sql-formatter": "^15.3.1",
|
||||
"tsconfig": "*",
|
||||
"ui": "*"
|
||||
"ui": "*",
|
||||
"valtio": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/common-tags": "^1.8.4",
|
||||
"api-types": "*"
|
||||
"api-types": "*",
|
||||
"jest": "^29.7.0",
|
||||
"ts-jest": "^29.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"next": "*",
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"docker:remove": "cd docker && docker compose -f docker-compose.yml -f ./dev/docker-compose.dev.yml rm -vfs",
|
||||
"test:docs": "turbo run test --filter=docs",
|
||||
"test:ui": "turbo run test --filter=ui",
|
||||
"test:ui-patterns": "turbo run test --filter=ui-patterns",
|
||||
"test:studio": "turbo run test --filter=studio",
|
||||
"test:studio:watch": "turbo run test --filter=studio -- watch",
|
||||
"test:playwright": "npm --prefix playwright-tests run test",
|
||||
|
||||
35
packages/ui-patterns/CommandMenu/internal/Command.tsx
Normal file
35
packages/ui-patterns/CommandMenu/internal/Command.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { type ReactNode } from 'react'
|
||||
|
||||
type ICommand = IActionCommand | IRouteCommand
|
||||
|
||||
interface IBaseCommand {
|
||||
id: string
|
||||
name: string
|
||||
value?: string
|
||||
className?: string
|
||||
forceMount?: boolean
|
||||
badge?: () => ReactNode
|
||||
icon?: () => ReactNode
|
||||
/**
|
||||
* Whether the item should be hidden until searched
|
||||
*/
|
||||
defaultHidden?: boolean
|
||||
/**
|
||||
* Curerntly unused
|
||||
*/
|
||||
keywords?: string[]
|
||||
/**
|
||||
* Currently unused
|
||||
*/
|
||||
shortcut?: string
|
||||
}
|
||||
|
||||
interface IActionCommand extends IBaseCommand {
|
||||
action: () => void
|
||||
}
|
||||
|
||||
interface IRouteCommand extends IBaseCommand {
|
||||
route: `/${string}` | `http${string}`
|
||||
}
|
||||
|
||||
export type { ICommand }
|
||||
23
packages/ui-patterns/CommandMenu/internal/CommandSection.tsx
Normal file
23
packages/ui-patterns/CommandMenu/internal/CommandSection.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { type ICommand } from './Command'
|
||||
|
||||
type ICommandSection = {
|
||||
id: string
|
||||
name: string
|
||||
forceMount: boolean
|
||||
commands: Array<ICommand>
|
||||
}
|
||||
|
||||
const toSectionId = (str: string) => str.toLowerCase().replace(/\s+/g, '-')
|
||||
|
||||
const section$new = (
|
||||
name: string,
|
||||
{ forceMount = false, id }: { forceMount?: boolean; id?: string } = {}
|
||||
): ICommandSection => ({
|
||||
id: id ?? toSectionId(name),
|
||||
name,
|
||||
forceMount,
|
||||
commands: [],
|
||||
})
|
||||
|
||||
export { section$new }
|
||||
export type { ICommandSection }
|
||||
@@ -0,0 +1,76 @@
|
||||
import { proxy } from 'valtio'
|
||||
import { type ICommand } from '../Command'
|
||||
import { type ICommandSection, section$new } from '../CommandSection'
|
||||
|
||||
type OrderSectionInstruction = (sections: ICommandSection[], idx: number) => ICommandSection[]
|
||||
type OrderCommandsInstruction = (
|
||||
commands: ICommand[],
|
||||
commandsToInsert: ICommand[]
|
||||
) => Array<ICommand>
|
||||
type CommandOptions = {
|
||||
deps?: any[]
|
||||
enabled?: boolean
|
||||
forceMountSection?: boolean
|
||||
orderSection?: OrderSectionInstruction
|
||||
orderCommands?: OrderCommandsInstruction
|
||||
}
|
||||
|
||||
type ICommandsState = {
|
||||
commandSections: ICommandSection[]
|
||||
registerSection: (
|
||||
sectionName: string,
|
||||
commands: ICommand[],
|
||||
options?: CommandOptions
|
||||
) => () => void
|
||||
}
|
||||
|
||||
const initCommandsState = () => {
|
||||
const state: ICommandsState = proxy({
|
||||
commandSections: [],
|
||||
registerSection: (sectionName, commands, options) => {
|
||||
let editIndex = state.commandSections.findIndex((section) => section.name === sectionName)
|
||||
if (editIndex === -1) editIndex = state.commandSections.length
|
||||
|
||||
state.commandSections[editIndex] ??= section$new(sectionName)
|
||||
|
||||
if (options?.forceMountSection) state.commandSections[editIndex].forceMount = true
|
||||
|
||||
if (options?.orderCommands) {
|
||||
state.commandSections[editIndex].commands = options.orderCommands(
|
||||
state.commandSections[editIndex].commands,
|
||||
commands
|
||||
)
|
||||
} else {
|
||||
state.commandSections[editIndex].commands.push(...commands)
|
||||
}
|
||||
|
||||
state.commandSections =
|
||||
options?.orderSection?.(state.commandSections, editIndex) ?? state.commandSections
|
||||
|
||||
return () => {
|
||||
const idx = state.commandSections.findIndex((section) => section.name === sectionName)
|
||||
if (idx !== -1) {
|
||||
const filteredCommands = state.commandSections[idx].commands.filter(
|
||||
(command) => !commands.map((cmd) => cmd.id).includes(command.id)
|
||||
)
|
||||
if (!filteredCommands.length) {
|
||||
state.commandSections.splice(idx, 1)
|
||||
} else {
|
||||
state.commandSections[idx].commands = filteredCommands
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return state
|
||||
}
|
||||
|
||||
const orderSectionFirst = (sections: ICommandSection[], idx: number) => [
|
||||
sections[idx],
|
||||
...sections.slice(0, idx),
|
||||
...sections.slice(idx + 1),
|
||||
]
|
||||
|
||||
export { initCommandsState, orderSectionFirst }
|
||||
export type { ICommandsState, CommandOptions }
|
||||
@@ -0,0 +1,164 @@
|
||||
import { type ICommandSection, section$new } from '../CommandSection'
|
||||
import { initCommandsState, orderSectionFirst, type ICommandsState } from './commandsState'
|
||||
|
||||
describe('orderSectionFirst', () => {
|
||||
it('Orders newly created section first', () => {
|
||||
const first = section$new('First')
|
||||
const second = section$new('Second')
|
||||
const third = section$new('Third')
|
||||
const sections = [first, second, third]
|
||||
|
||||
const expected = [third, first, second]
|
||||
const actual = orderSectionFirst(sections, 2)
|
||||
expect(actual).toEqual(expected)
|
||||
})
|
||||
|
||||
it('Orders sole section first', () => {
|
||||
const only = section$new('Only')
|
||||
const sections = [only]
|
||||
|
||||
const actual = orderSectionFirst(sections, 0)
|
||||
expect(actual).toEqual(sections)
|
||||
})
|
||||
|
||||
it('Orders existing section first', () => {
|
||||
const first = section$new('First')
|
||||
const second = section$new('Second')
|
||||
const third = section$new('Third')
|
||||
const sections = [first, second, third]
|
||||
|
||||
const expected = [second, first, third]
|
||||
const actual = orderSectionFirst(sections, 1)
|
||||
expect(actual).toEqual(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('commandState', () => {
|
||||
let commandsState: ICommandsState
|
||||
|
||||
beforeEach(() => {
|
||||
commandsState = initCommandsState()
|
||||
})
|
||||
|
||||
it('Registers commands in new section', () => {
|
||||
const sectionName = 'Section'
|
||||
const commands = [
|
||||
{ id: 'first', name: 'First', action: () => {} },
|
||||
{ id: 'second', name: 'Second', action: () => {} },
|
||||
]
|
||||
|
||||
const expected = [section$new(sectionName)]
|
||||
expected[0].commands = commands
|
||||
|
||||
commandsState.registerSection(sectionName, commands)
|
||||
|
||||
expect(commandsState.commandSections).toEqual(expected)
|
||||
})
|
||||
|
||||
it('Registers command in pre-existing section', () => {
|
||||
const sectionA = 'A'
|
||||
const sectionB = 'B'
|
||||
|
||||
const existingCommands = [
|
||||
{ id: 'first', name: 'First', action: () => {} },
|
||||
{ id: 'second', name: 'Second', action: () => {} },
|
||||
]
|
||||
|
||||
const commandsToAdd = [{ id: 'third', name: 'Third', action: () => {} }]
|
||||
|
||||
const sections = [section$new(sectionA), section$new(sectionB)]
|
||||
sections[0].commands = existingCommands
|
||||
commandsState.commandSections = sections
|
||||
|
||||
const expected = [section$new(sectionA), section$new(sectionB)]
|
||||
expected[0].commands = existingCommands
|
||||
expected[0].commands = expected[0].commands.concat(commandsToAdd)
|
||||
|
||||
commandsState.registerSection('A', commandsToAdd)
|
||||
expect(commandsState.commandSections).toEqual(expected)
|
||||
})
|
||||
|
||||
it('Reorders sections if orderSection is given', () => {
|
||||
const sectionA = 'A'
|
||||
const sectionB = 'B'
|
||||
|
||||
const commands = [{ id: 'first', name: 'First', action: () => {} }]
|
||||
|
||||
const sections = [section$new(sectionA), section$new(sectionB)]
|
||||
commandsState.commandSections = sections
|
||||
|
||||
const expected = [section$new(sectionB), section$new(sectionA)]
|
||||
expected[0].commands = commands
|
||||
|
||||
commandsState.registerSection('B', commands, { orderSection: orderSectionFirst })
|
||||
expect(commandsState.commandSections).toEqual(expected)
|
||||
})
|
||||
|
||||
it('Reorders commands if orderCommands is given', () => {
|
||||
const sectionName = 'Section'
|
||||
|
||||
const existingCommands = [
|
||||
{ id: 'first', name: 'First', action: () => {} },
|
||||
{ id: 'second', name: 'Second', action: () => {} },
|
||||
]
|
||||
|
||||
const sections = [section$new(sectionName)]
|
||||
sections[0].commands = existingCommands
|
||||
commandsState.commandSections = sections
|
||||
|
||||
const commandsToAdd = [{ id: 'third', name: 'Third', action: () => {} }]
|
||||
|
||||
const expected = [section$new(sectionName)]
|
||||
expected[0].commands = [existingCommands[0], commandsToAdd[0], existingCommands[1]]
|
||||
|
||||
commandsState.registerSection('Section', commandsToAdd, {
|
||||
orderCommands: (oldCommands, newCommands) => {
|
||||
const commands = [...oldCommands]
|
||||
commands.splice(1, 0, ...newCommands)
|
||||
return commands
|
||||
},
|
||||
})
|
||||
|
||||
expect(commandsState.commandSections).toEqual(expected)
|
||||
})
|
||||
|
||||
it('Unregisters commands when returned function is called', () => {
|
||||
const sectionName = 'Section'
|
||||
const firstCommands = [{ id: 'command', name: 'Command', action: () => {} }]
|
||||
const secondCommands = [{ id: 'second', name: 'Second', action: () => {} }]
|
||||
|
||||
const expected = [section$new(sectionName)]
|
||||
expected[0].commands = [...firstCommands, ...secondCommands]
|
||||
|
||||
commandsState.registerSection(sectionName, firstCommands)
|
||||
const unregister = commandsState.registerSection(sectionName, secondCommands)
|
||||
|
||||
expect(commandsState.commandSections).toEqual(expected)
|
||||
|
||||
const newExpected = [section$new(sectionName)]
|
||||
newExpected[0].commands = firstCommands
|
||||
|
||||
unregister()
|
||||
expect(commandsState.commandSections).toHaveLength(1)
|
||||
expect(commandsState.commandSections[0].commands).toHaveLength(1)
|
||||
expect(commandsState.commandSections).toEqual(newExpected)
|
||||
})
|
||||
|
||||
it('Unregisters entire section if no commands remain', () => {
|
||||
const sectionName = 'Section'
|
||||
const commands = [{ id: 'command', name: 'Command', action: () => {} }]
|
||||
|
||||
const expected = [section$new(sectionName)]
|
||||
expected[0].commands = commands
|
||||
|
||||
const unregister = commandsState.registerSection(sectionName, commands)
|
||||
|
||||
expect(commandsState.commandSections).toEqual(expected)
|
||||
|
||||
const newExpected: ICommandSection[] = []
|
||||
|
||||
unregister()
|
||||
expect(commandsState.commandSections).toHaveLength(0)
|
||||
expect(commandsState.commandSections).toEqual(newExpected)
|
||||
})
|
||||
})
|
||||
5
packages/ui-patterns/jest.config.js
Normal file
5
packages/ui-patterns/jest.config.js
Normal file
@@ -0,0 +1,5 @@
|
||||
/** @type {import('ts-jest').JestConfigWithTsJest} */
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
"license": "MIT",
|
||||
"sideEffects": false,
|
||||
"scripts": {
|
||||
"test": "jest",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -27,11 +28,14 @@
|
||||
"remark-gfm": "^4.0.0",
|
||||
"sql-formatter": "^15.3.1",
|
||||
"tsconfig": "*",
|
||||
"ui": "*"
|
||||
"ui": "*",
|
||||
"valtio": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/common-tags": "^1.8.4",
|
||||
"api-types": "*"
|
||||
"api-types": "*",
|
||||
"jest": "^29.7.0",
|
||||
"ts-jest": "^29.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"next": "*",
|
||||
|
||||
Reference in New Issue
Block a user