diff --git a/.github/workflows/ui-patterns-tests.yml b/.github/workflows/ui-patterns-tests.yml new file mode 100644 index 00000000000..a2fe4dc775f --- /dev/null +++ b/.github/workflows/ui-patterns-tests.yml @@ -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 diff --git a/package-lock.json b/package-lock.json index 30964ca48e5..bccfec2311a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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": "*", diff --git a/package.json b/package.json index fabe54eaa71..dc424273316 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/ui-patterns/CommandMenu/internal/Command.tsx b/packages/ui-patterns/CommandMenu/internal/Command.tsx new file mode 100644 index 00000000000..7d09e407635 --- /dev/null +++ b/packages/ui-patterns/CommandMenu/internal/Command.tsx @@ -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 } diff --git a/packages/ui-patterns/CommandMenu/internal/CommandSection.tsx b/packages/ui-patterns/CommandMenu/internal/CommandSection.tsx new file mode 100644 index 00000000000..7fad0501156 --- /dev/null +++ b/packages/ui-patterns/CommandMenu/internal/CommandSection.tsx @@ -0,0 +1,23 @@ +import { type ICommand } from './Command' + +type ICommandSection = { + id: string + name: string + forceMount: boolean + commands: Array +} + +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 } diff --git a/packages/ui-patterns/CommandMenu/internal/state/commandsState.ts b/packages/ui-patterns/CommandMenu/internal/state/commandsState.ts new file mode 100644 index 00000000000..ad91bfa268c --- /dev/null +++ b/packages/ui-patterns/CommandMenu/internal/state/commandsState.ts @@ -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 +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 } diff --git a/packages/ui-patterns/CommandMenu/internal/state/commandsStates.test.ts b/packages/ui-patterns/CommandMenu/internal/state/commandsStates.test.ts new file mode 100644 index 00000000000..28f5121adb8 --- /dev/null +++ b/packages/ui-patterns/CommandMenu/internal/state/commandsStates.test.ts @@ -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) + }) +}) diff --git a/packages/ui-patterns/jest.config.js b/packages/ui-patterns/jest.config.js new file mode 100644 index 00000000000..8bb37208d7a --- /dev/null +++ b/packages/ui-patterns/jest.config.js @@ -0,0 +1,5 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', +} diff --git a/packages/ui-patterns/package.json b/packages/ui-patterns/package.json index 37149976f0d..2561c1f3eb6 100644 --- a/packages/ui-patterns/package.json +++ b/packages/ui-patterns/package.json @@ -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": "*",