diff --git a/.gitignore b/.gitignore index 2316e42..26323fa 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ dist/ cli/lib/ cli/node_modules/ cli/oclif.manifest.json +.data/ +everywhere/.config.json diff --git a/cli/src/codegen/generator.ts b/cli/src/codegen/generator.ts index 7be1ad9..2da2f84 100644 --- a/cli/src/codegen/generator.ts +++ b/cli/src/codegen/generator.ts @@ -85,14 +85,19 @@ export function generateModelHooks(schema: ModelSchema): string { const lines = [HEADER]; lines.push("import { useQuery, useMutation } from '@workday/everywhere';"); + lines.push(`import type { QueryResult } from '@workday/everywhere';`); lines.push(`import type { ${name} } from './models.js';`); lines.push(''); lines.push(`export function use${plural}(options?: Parameters[1]) {`); - lines.push(` return useQuery<${name}>('${name}', options);`); + lines.push( + ` return useQuery<${name}>('${name}', options) as QueryResult<${name}> & { data: ${name}[] | null };` + ); lines.push('}'); lines.push(''); lines.push(`export function use${name}(id: string) {`); - lines.push(` return useQuery<${name}>('${name}', { id });`); + lines.push( + ` return useQuery<${name}>('${name}', { id }) as QueryResult<${name}> & { data: ${name} | null };` + ); lines.push('}'); lines.push(''); lines.push(`export function use${name}Mutation() {`); diff --git a/examples/.justfile b/examples/.justfile index 203796f..ce40b1f 100644 --- a/examples/.justfile +++ b/examples/.justfile @@ -3,6 +3,7 @@ # Install example plugin dependencies setup: cd hello && npm install + cd directory && npm install # Format example source files tidy: diff --git a/examples/directory/everywhere/data/Department.ts b/examples/directory/everywhere/data/Department.ts new file mode 100644 index 0000000..7b2aa0c --- /dev/null +++ b/examples/directory/everywhere/data/Department.ts @@ -0,0 +1,16 @@ +// AUTO-GENERATED by `everywhere bind` -- do not edit manually. + +import { useQuery, useMutation } from '@workday/everywhere'; +import type { Department } from './models.js'; + +export function useDepartments(options?: Parameters[1]) { + return useQuery('Department', options); +} + +export function useDepartment(id: string) { + return useQuery('Department', { id }); +} + +export function useDepartmentMutation() { + return useMutation('Department'); +} diff --git a/examples/directory/everywhere/data/Employee.ts b/examples/directory/everywhere/data/Employee.ts new file mode 100644 index 0000000..23eafc5 --- /dev/null +++ b/examples/directory/everywhere/data/Employee.ts @@ -0,0 +1,16 @@ +// AUTO-GENERATED by `everywhere bind` -- do not edit manually. + +import { useQuery, useMutation } from '@workday/everywhere'; +import type { Employee } from './models.js'; + +export function useEmployees(options?: Parameters[1]) { + return useQuery('Employee', options); +} + +export function useEmployee(id: string) { + return useQuery('Employee', { id }); +} + +export function useEmployeeMutation() { + return useMutation('Employee'); +} diff --git a/examples/directory/everywhere/data/index.ts b/examples/directory/everywhere/data/index.ts new file mode 100644 index 0000000..71fb73d --- /dev/null +++ b/examples/directory/everywhere/data/index.ts @@ -0,0 +1,6 @@ +// AUTO-GENERATED by `everywhere bind` -- do not edit manually. + +export * from './models.js'; +export * from './schema.js'; +export * from './Department.js'; +export * from './Employee.js'; diff --git a/examples/directory/everywhere/data/models.ts b/examples/directory/everywhere/data/models.ts new file mode 100644 index 0000000..b1da92e --- /dev/null +++ b/examples/directory/everywhere/data/models.ts @@ -0,0 +1,21 @@ +// AUTO-GENERATED by `everywhere bind` -- do not edit manually. + +export interface Department { + id: string; + name: string; + manager: string; + headcount: string; +} + +export interface Employee { + id: string; + name: string; + email: string; + department: string; + title: string; + startDate: string; + photoUrl: string; + isActive: boolean; + isRemote: boolean; + bio: string; +} diff --git a/examples/directory/everywhere/data/schema.ts b/examples/directory/everywhere/data/schema.ts new file mode 100644 index 0000000..9837887 --- /dev/null +++ b/examples/directory/everywhere/data/schema.ts @@ -0,0 +1,32 @@ +// AUTO-GENERATED by `everywhere bind` -- do not edit manually. + +import type { ModelSchema } from '@workday/everywhere'; + +export const schemas: Record = { + Department: { + name: 'Department', + label: 'Department', + collection: 'departments', + fields: [ + { name: 'name', type: 'TEXT' }, + { name: 'manager', type: 'SINGLE_INSTANCE', target: 'Employee' }, + { name: 'headcount', type: 'TEXT' }, + ], + }, + Employee: { + name: 'Employee', + label: 'Employee', + collection: 'employees', + fields: [ + { name: 'name', type: 'TEXT' }, + { name: 'email', type: 'TEXT' }, + { name: 'department', type: 'TEXT' }, + { name: 'title', type: 'TEXT' }, + { name: 'startDate', type: 'DATE', precision: 'DAY' }, + { name: 'photoUrl', type: 'TEXT' }, + { name: 'isActive', type: 'BOOLEAN' }, + { name: 'isRemote', type: 'BOOLEAN' }, + { name: 'bio', type: 'TEXT' }, + ], + }, +}; diff --git a/examples/directory/model/Department.businessobject b/examples/directory/model/Department.businessobject new file mode 100644 index 0000000..11499be --- /dev/null +++ b/examples/directory/model/Department.businessobject @@ -0,0 +1,14 @@ +{ + "id": 2, + "name": "Department", + "label": "Department", + "defaultCollection": { + "name": "departments", + "label": "Departments" + }, + "fields": [ + { "id": 1, "name": "name", "type": "TEXT", "useForDisplay": true }, + { "id": 2, "name": "manager", "type": "SINGLE_INSTANCE", "target": "Employee" }, + { "id": 3, "name": "headcount", "type": "TEXT" } + ] +} diff --git a/examples/directory/model/Employee.businessobject b/examples/directory/model/Employee.businessobject new file mode 100644 index 0000000..a310588 --- /dev/null +++ b/examples/directory/model/Employee.businessobject @@ -0,0 +1,20 @@ +{ + "id": 1, + "name": "Employee", + "label": "Employee", + "defaultCollection": { + "name": "employees", + "label": "Employees" + }, + "fields": [ + { "id": 1, "name": "name", "type": "TEXT", "useForDisplay": true }, + { "id": 2, "name": "email", "type": "TEXT" }, + { "id": 3, "name": "department", "type": "TEXT" }, + { "id": 4, "name": "title", "type": "TEXT" }, + { "id": 5, "name": "startDate", "type": "DATE", "precision": "DAY" }, + { "id": 6, "name": "photoUrl", "type": "TEXT" }, + { "id": 7, "name": "isActive", "type": "BOOLEAN" }, + { "id": 8, "name": "isRemote", "type": "BOOLEAN" }, + { "id": 9, "name": "bio", "type": "TEXT" } + ] +} diff --git a/examples/directory/package.json b/examples/directory/package.json new file mode 100644 index 0000000..13c5cf9 --- /dev/null +++ b/examples/directory/package.json @@ -0,0 +1,16 @@ +{ + "name": "employee-directory", + "version": "1.0.0", + "description": "Search and manage employee records.", + "private": true, + "scripts": { + "preview": "everywhere view", + "clean": "rm -rf node_modules" + }, + "dependencies": { + "@workday/canvas-kit-react": "^11", + "@workday/everywhere": "file:../..", + "react": "^19", + "react-dom": "^19" + } +} diff --git a/examples/directory/pages/EmployeeList.tsx b/examples/directory/pages/EmployeeList.tsx new file mode 100644 index 0000000..fd33c3e --- /dev/null +++ b/examples/directory/pages/EmployeeList.tsx @@ -0,0 +1,118 @@ +import { useNavigate, useParams } from '@workday/everywhere'; +import { Card, Flex, Heading, SecondaryButton, Text } from '@workday/canvas-kit-react'; +import { useEmployees, useEmployee } from '../everywhere/data/Employee.js'; +import type { Employee } from '../everywhere/data/models.js'; + +function EmployeeDetail() { + const { id } = useParams(); + const navigate = useNavigate(); + const { data: employee } = useEmployee(id ?? ''); + + return ( + + navigate('employees')}> + Back to list + + + {employee?.name ?? 'Employee Detail'} + + {employee ? ( + + {employee.name} + + + {employee.title} + + + {employee.department} + + {employee.email} + + Started {employee.startDate} + + {employee.isRemote && ( + + Remote + + )} + {employee.bio && ( + + {employee.bio} + + )} + + + ) : ( + Employee not found: {id} + )} + + + + ); +} + +function EmployeeRow({ employee, onClick }: { employee: Employee; onClick: () => void }) { + return ( + + {employee.name} + + + {employee.name} + + + {employee.title} + + + + {employee.department} + + + ); +} + +export default function EmployeeListPage() { + const navigate = useNavigate(); + const { id } = useParams(); + const { data: employees } = useEmployees(); + + if (id) { + return ; + } + + return ( + + Employees + + {Array.isArray(employees) ? `${employees.length} employees` : 'Loading...'} + + + + + {Array.isArray(employees) && + employees.map((emp) => ( + navigate('employees/detail', { id: emp.id })} + /> + ))} + + + + + ); +} diff --git a/examples/directory/pages/Home.tsx b/examples/directory/pages/Home.tsx new file mode 100644 index 0000000..ebe39af --- /dev/null +++ b/examples/directory/pages/Home.tsx @@ -0,0 +1,90 @@ +import { useMemo } from 'react'; +import { useNavigate } from '@workday/everywhere'; +import { Card, Flex, Grid, Heading, SecondaryButton, Text } from '@workday/canvas-kit-react'; +import { useEmployees } from '../everywhere/data/Employee.js'; +import { useDepartments } from '../everywhere/data/Department.js'; + +function StatCard({ label, value, subtitle }: { label: string; value: string; subtitle: string }) { + return ( + + + + + {label} + + + {value} + + + {subtitle} + + + + + ); +} + +export default function HomePage() { + const navigate = useNavigate(); + const { data: employees } = useEmployees(); + const { data: departments } = useDepartments(); + + const totalEmployees = employees?.length ?? 0; + + const deptStats = useMemo(() => { + if (!departments || !Array.isArray(departments)) return []; + return departments.map((dept) => { + const count = Array.isArray(employees) + ? employees.filter((e) => e.department === dept.name).length + : Number(dept.headcount); + return { ...dept, count }; + }); + }, [employees, departments, totalEmployees]); + + return ( + + Employee Directory + + + Welcome to the Employee Directory. Search for colleagues, view team information, and manage + employee records. + + + + + + Team Overview + + + + {deptStats.slice(0, 3).map((dept) => ( + + ))} + + + + + + + + Quick Actions + + + navigate('employees')}> + View All Employees + + + + + + ); +} diff --git a/examples/directory/pages/Spotlight.tsx b/examples/directory/pages/Spotlight.tsx new file mode 100644 index 0000000..a8dae0b --- /dev/null +++ b/examples/directory/pages/Spotlight.tsx @@ -0,0 +1,93 @@ +import { useCallback, useMemo, useState } from 'react'; +import { Card, Flex, Heading, PrimaryButton, Text } from '@workday/canvas-kit-react'; +import { useEmployees } from '../everywhere/data/Employee.js'; + +export default function SpotlightPage() { + const { data: employees } = useEmployees(); + const [index, setIndex] = useState(() => Math.floor(Math.random() * 1000)); + + const employee = useMemo(() => { + if (!Array.isArray(employees) || employees.length === 0) return undefined; + return employees[index % employees.length]; + }, [employees, index]); + + const reroll = useCallback(() => { + setIndex((prev) => { + let next; + do { + next = Math.floor(Math.random() * 1000); + } while ( + Array.isArray(employees) && + employees.length > 1 && + next % employees.length === prev % employees.length + ); + return next; + }); + }, [employees]); + + if (!Array.isArray(employees)) { + return ( + + Spotlight + + Loading... + + + ); + } + + if (!employee) { + return ( + + Spotlight + + No employees found. + + + ); + } + + return ( + + Spotlight + + Meet your coworker! + + + {employee.name} + + + {employee.name} + + + {employee.title} + + + {employee.department} + + {employee.email} + + Started {employee.startDate} + + {employee.isRemote && ( + + Remote + + )} + {employee.bio && ( + + {employee.bio} + + )} + + + + + Meet Someone New + + ); +} diff --git a/examples/directory/plugin.tsx b/examples/directory/plugin.tsx new file mode 100644 index 0000000..ac192f8 --- /dev/null +++ b/examples/directory/plugin.tsx @@ -0,0 +1,28 @@ +import { type ReactNode } from 'react'; +import { plugin, DataProvider, HttpResolver } from '@workday/everywhere'; +import { CanvasProvider } from '@workday/canvas-kit-react'; +import '@workday/canvas-tokens-web/css/base/_variables.css'; +import '@workday/canvas-tokens-web/css/system/_variables.css'; +import './styles.css'; +import HomePage from './pages/Home.js'; +import EmployeeListPage from './pages/EmployeeList.js'; +import SpotlightPage from './pages/Spotlight.js'; + +const resolver = new HttpResolver('/api/data'); + +function DirectoryProvider({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +export default plugin({ + provider: DirectoryProvider, + pages: [ + { id: 'home', title: 'Home', component: HomePage }, + { id: 'employees', title: 'Employees', component: EmployeeListPage }, + { id: 'spotlight', title: 'Spotlight', component: SpotlightPage }, + ], +}); diff --git a/examples/directory/styles.css b/examples/directory/styles.css new file mode 100644 index 0000000..612e912 --- /dev/null +++ b/examples/directory/styles.css @@ -0,0 +1 @@ +@import url('https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap'); diff --git a/src/components/StyleBoundary.tsx b/src/components/StyleBoundary.tsx index fa1ab63..99366af 100644 --- a/src/components/StyleBoundary.tsx +++ b/src/components/StyleBoundary.tsx @@ -7,5 +7,5 @@ export interface StyleBoundaryProps { export function StyleBoundary({ children, provider: Provider }: StyleBoundaryProps) { const content = Provider ? {children} : children; - return
{content}
; + return
{content}
; } diff --git a/tests/components/StyleBoundary.test.tsx b/tests/components/StyleBoundary.test.tsx index ff33b83..3fa545f 100644 --- a/tests/components/StyleBoundary.test.tsx +++ b/tests/components/StyleBoundary.test.tsx @@ -14,14 +14,14 @@ describe('StyleBoundary', () => { expect(html).toContain('hello'); }); - it('wraps content in a div with contain:style', () => { + it('wraps content in a div', () => { const html = renderToStaticMarkup( content ); - expect(html).toContain('style="contain:style"'); + expect(html).toContain('
content
'); }); });