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:
Charis
2024-06-04 14:32:13 -04:00
committed by GitHub
parent 87863d8173
commit 4fbcd61e95
9 changed files with 356 additions and 8 deletions

34
.github/workflows/ui-patterns-tests.yml vendored Normal file
View 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
View File

@@ -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": "*",

View File

@@ -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",

View 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 }

View 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 }

View File

@@ -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 }

View File

@@ -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)
})
})

View File

@@ -0,0 +1,5 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
}

View File

@@ -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": "*",