UNPKG

@servicenow/sdk

Version:
571 lines (465 loc) 16.6 kB
--- tags: [ui page patterns, dirty state, field extraction, service layer, css styling, build system, file guidelines, react patterns] --- # UI Page Patterns Development patterns and guidelines for ServiceNow UI Pages, covering dirty state management, field extraction, service layer architecture, CSS styling, build constraints, and file organization. ## Dirty State Management MANDATORY for ANY view that creates, edits, or views records with a form. If a form exists, dirty state tracking MUST exist. `RecordProvider` tracks dirty state internally -- do NOT implement manual field diffing with `JSON.stringify`. The ONLY way to check dirty state is `useRecord().form.isDirty`. NEVER use `window.confirm()` or `window.alert()` for dirty state warnings -- use the ServiceNow `Modal` component instead. ### Standard Pattern ```tsx import React, { useEffect } from "react"; import { RecordProvider } from "@servicenow/react-components/RecordContext"; import { FormActionBar } from "@servicenow/react-components/FormActionBar"; import { FormColumnLayout } from "@servicenow/react-components/FormColumnLayout"; import { Alert } from "@servicenow/react-components/Alert"; import { useRecord } from "@servicenow/react-components"; function FormWithDirtyTracking() { const { form } = useRecord(); useEffect(() => { if (!form.isDirty) return; const handler = (e: BeforeUnloadEvent) => { e.preventDefault(); e.returnValue = ""; }; window.addEventListener("beforeunload", handler); return () => window.removeEventListener("beforeunload", handler); }, [form.isDirty]); return ( <> {form.isDirty && ( <Alert status="warning" content="Unsaved changes" /> )} <FormActionBar /> <FormColumnLayout /> </> ); } export default function RecordForm({ sysId }: { sysId: string }) { return ( <RecordProvider table="incident" sysId={sysId} isReadOnly={false}> <FormWithDirtyTracking /> </RecordProvider> ); } ``` ### Warn on In-App Navigation (Modal) ```tsx import React, { useState, useCallback } from "react"; import { Modal, ModalOpenedSet, ModalFooterActionClicked } from "@servicenow/react-components/Modal"; interface UnsavedChangesModalProps { opened: boolean; onDiscard: () => void; onCancel: () => void; } function UnsavedChangesModal({ opened, onDiscard, onCancel }: UnsavedChangesModalProps) { const handleOpenedSet = useCallback<ModalOpenedSet>(() => { onCancel(); }, [onCancel]); const handleFooterAction = useCallback<ModalFooterActionClicked>( e => { if (e.detail.payload.action.label === "Discard") { onDiscard(); } else { onCancel(); } }, [onDiscard, onCancel] ); return ( <Modal opened={opened} size="sm" headerLabel="Unsaved Changes" content="You have unsaved changes. Are you sure you want to leave? Your changes will be lost." footerActions={[ { label: "Cancel", variant: "secondary" }, { label: "Discard", variant: "primary-negative" } ]} onOpenedSet={handleOpenedSet} onFooterActionClicked={handleFooterAction} /> ); } ``` Integrate with the navigation pattern. Wrap the actual `navigateToView` call with a dirty check: ```tsx const { form } = useRecord(); const [pendingNavigation, setPendingNavigation] = useState<(() => void) | null>( null ); const safeNavigate = useCallback( ( viewName: string, recordId?: string | null, options?: { title?: string } ) => { if (form.isDirty) { setPendingNavigation( () => () => navigateToView(viewName, recordId, options) ); return; } navigateToView(viewName, recordId, options); }, [form.isDirty, navigateToView] ); // In JSX -- use safeNavigate instead of navigateToView for all user-triggered navigation: <UnsavedChangesModal opened={pendingNavigation !== null} onDiscard={() => { pendingNavigation?.(); setPendingNavigation(null); }} onCancel={() => setPendingNavigation(null)} />; ``` ### Dirty State Key Points - ONLY use `useRecord().form.isDirty` to check dirty state - NEVER use `window.confirm()` or `window.alert()` -- use the ServiceNow `Modal` component - `FormActionBar` handles save/submit/cancel automatically -- dirty state resets on successful save - Use `key` prop on `RecordProvider` when switching records to fully remount the form - For new records: pass `sysId="-1"` -- NEVER `null` or `undefined` ## Field Extraction Pattern When using `sysparm_display_value=all` (recommended), ServiceNow reference, choice, and sys_id fields become objects. React cannot render objects directly, so you must extract primitive values. ### Required Utility Functions ALWAYS use `sysparm_display_value=all` in ALL Table API calls. Create these utility functions in EVERY project: ```ts fluent // src/client/utils/fields.ts export const display = field => { if (typeof field === "string") { return field; } return field?.display_value || ""; }; export const value = field => { if (typeof field === "string") { return field; } return field?.value || ""; }; ``` ### Usage ```tsx import { display, value } from "./utils/fields"; // For UI display: <td>{display(record.short_description)}</td> <td>{display(record.assigned_to)}</td> // For operations/keys: await updateRecord(value(record.sys_id), data); {records.map(r => <li key={value(r.sys_id)}>)} ``` ### Common Mistakes ```tsx // WRONG - accessing object directly <span>{record.assigned_to}</span> // WRONG - assuming string type <span>{record.assigned_to.toString()}</span> // CORRECT - using display helper <span>{display(record.assigned_to)}</span> // WRONG - using value for display <span>{value(record.state)}</span> // Shows "2" instead of "In Progress" // CORRECT - display for UI, value for operations <span>{display(record.state)}</span> // Shows "In Progress" await api.update(value(record.sys_id), data); // Uses sys_id value ``` ## Service Layer Pattern Centralize all API calls in a service layer to keep components focused on UI logic, enable easy testing and mocking, standardize error handling, and maintain consistent authentication. ### Basic Service Class ```ts fluent // src/client/services/TodoService.ts export class TodoService { constructor() { this.tableName = "x_app_todo"; } async list() { const response = await fetch( `/api/now/table/${this.tableName}?sysparm_display_value=all`, { headers: { Accept: "application/json", "X-UserToken": window.g_ck } } ); const { result } = await response.json(); return result || []; } async create(data) { const response = await fetch( `/api/now/table/${this.tableName}?sysparm_display_value=all`, { method: "POST", headers: { "Content-Type": "application/json", Accept: "application/json", "X-UserToken": window.g_ck }, body: JSON.stringify(data) } ); return response.json(); } async update(sysId, data) { const response = await fetch( `/api/now/table/${this.tableName}/${sysId}?sysparm_display_value=all`, { method: "PATCH", headers: { "Content-Type": "application/json", Accept: "application/json", "X-UserToken": window.g_ck }, body: JSON.stringify(data) } ); return response.json(); } async delete(sysId) { const response = await fetch(`/api/now/table/${this.tableName}/${sysId}`, { method: "DELETE", headers: { Accept: "application/json", "X-UserToken": window.g_ck } }); return response.ok; } } ``` ### Service with Error Handling ```ts fluent // src/client/services/ApiService.ts export class ApiService { constructor(tableName) { this.tableName = tableName; this.baseUrl = `/api/now/table/${tableName}`; } async request(url, options = {}) { try { const response = await fetch(url, { ...options, headers: { Accept: "application/json", "X-UserToken": window.g_ck, ...options.headers } }); if (!response.ok) { const error = await response.json().catch(() => ({})); throw new Error( error.error?.message || `Request failed: ${response.status}` ); } if (response.status === 204) { return true; } return await response.json(); } catch (error) { console.error("API Error:", error); throw error; } } async list(query = "") { const params = new URLSearchParams({ sysparm_display_value: "all" }); if (query) params.set("sysparm_query", query); const { result } = await this.request(`${this.baseUrl}?${params}`); return result || []; } async get(sysId) { const { result } = await this.request( `${this.baseUrl}/${sysId}?sysparm_display_value=all` ); return result; } async create(data) { const { result } = await this.request( `${this.baseUrl}?sysparm_display_value=all`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data) } ); return result; } async update(sysId, data) { const { result } = await this.request( `${this.baseUrl}/${sysId}?sysparm_display_value=all`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data) } ); return result; } async delete(sysId) { return this.request(`${this.baseUrl}/${sysId}`, { method: "DELETE" }); } } ``` ### Service Key Points 1. Always include `X-UserToken: window.g_ck` -- required for authentication 2. Always use `sysparm_display_value=all` -- returns both display and raw values 3. Centralize error handling -- parse JSON errors from ServiceNow responses 4. Use `useMemo` for service instances -- prevents recreation on re-renders 5. Keep services under 60 lines -- split into multiple services if needed ## CSS Styling Guidelines ### Supported CSS Patterns Import CSS files directly in TSX/TS files using ESM syntax: ```tsx import "./filename.css"; import "./app.css"; import "./components/TodoItem.css"; ``` ### Not Supported - **CSS Modules**: `import styles from './file.module.css'` -- NOT supported - **@import statements**: Within CSS files -- NOT supported - **Link tags**: `<link rel="stylesheet" href="...">` in HTML -- NOT supported - **CSS-in-CSS imports**: Relative stylesheet references -- NOT supported ### File Organization Place CSS files in `src/client` alongside their components. Each component can have its own CSS file. The build system automatically bundles all imported CSS. ``` src/client/ app.tsx app.css components/ TodoList.tsx TodoList.css TodoItem.tsx TodoItem.css ``` ### Naming Conventions Since CSS Modules aren't supported, use BEM naming conventions to avoid conflicts: ```css /* TodoItem.css */ .todo-item { display: flex; align-items: center; padding: 10px; border-bottom: 1px solid #eee; } .todo-item__text { flex: 1; } .todo-item__text--done { text-decoration: line-through; opacity: 0.6; } .todo-item__delete { margin-left: auto; } ``` ### ServiceNow Theming Integration Use CSS variables from the Horizon Design System to allow customer-authored themes to apply to your UI Page. Including the `<sdk:now-ux-globals></sdk:now-ux-globals>` tag in your HTML brings in support for theming. ```css .my-card { background-color: var(--now-color-background-primary); border: 1px solid var(--now-color-border-primary); border-radius: var(--now-border-radius-md); padding: var(--now-spacing-lg); color: var(--now-color-text-primary); } .my-button { background-color: var(--now-color-interactive-primary); color: var(--now-color-text-inverse); padding: var(--now-spacing-sm) var(--now-spacing-md); border-radius: var(--now-border-radius-sm); } .my-button:hover { background-color: var(--now-color-interactive-primary-hover); } ``` ## Build System Constraints The build system handles ALL build processes automatically. **MUST NEVER:** - Create webpack.config.js, vite.config.js, or any build configs - Add build scripts to package.json - Configure babel, typescript compiler, or bundlers - Attempt to modify the build pipeline - Add build tools as dependencies **MUST ALWAYS:** - Trust the IDE build system to handle everything - Use only the file patterns shown in templates - Place files in exact locations specified - Use ESM imports (the IDE handles transformation) ### What the IDE Handles Automatically - TSX transformation - Module bundling - CSS processing - Import resolution - Development server - Production builds ### Package.json Restrictions When adding dependencies, preserve existing versions -- never modify them. No "scripts" section, no build configurations. After modifying package.json, always install the dependencies. ### HTML Entry Point Requirements ```html <!-- src/client/index.html --> <html class="-polaris"> <head> <title>My Page</title> <sdk:now-ux-globals></sdk:now-ux-globals> <script src="main.tsx?uxpcb=$[UxFrameworkScriptables.getFlushTimestamp()]" type="module" ></script> </head> <body> <div id="root"></div> </body> </html> ``` The `uxpcb` parameter is required to ensure that stale UI Page contents are not mistakenly cached. The `<sdk:now-ux-globals></sdk:now-ux-globals>` tag brings in support for theming and other platform support. ## File Size Guidelines ### Optimal Ranges by File Type - **Components** (50-80 lines ideal, 100 max): Simple display 30-50, Forms 50-80, Complex with state 60-100 max - **Service Modules** (30-60 lines): API service 40-60, Single responsibility 30-50 - **Hooks** (20-50 lines): Simple 20-30, Complex with cleanup 40-50 - **Utility Functions** (20-40 lines): 3-5 related functions per file - **Main App Component** (50-100 lines): Composition and routing logic ### When to Split Files Split when: - File exceeds 100 lines - Multiple unrelated responsibilities - Component has 3+ useEffect hooks - Service has 5+ API methods ## Essential Requirements and Limitations ### Core Requirements 1. **UiPage API Usage**: UI Pages must be created using the `UiPage` API from `@servicenow/sdk/core`. 2. **HTML Reference**: Always use imports (not `Now.include()`) to reference HTML files in UI Page definitions. HTML files should only be placed in the `src/client` directory. 3. **Script Management**: TypeScript/TSX code in separate files loaded via script tags with `type="module"`. Do not embed or inline TypeScript directly in HTML. 4. **Use of HTML**: Ensure HTML files are valid HTML with self-closing tags for void elements. 5. **No DOCTYPE**: Never add `<!DOCTYPE html>` declarations. Never add XML preamble. 6. **No Jelly**: Do not include Jelly elements in HTML files. 7. **No Client Script**: Do not include `client_script` or `processing_script` fields. 8. **No Script Includes**: Use `<script src="..."></script>` instead of `<g:script>` or `<g:include>`. 9. **No g_form**: Do not reference g_form in UI Pages. 10. **Use React**: Always use React. Do not use pure HTML or other frameworks. 11. **Event Handling**: Use event listeners in TypeScript, not inline handlers like `onclick="function()"`. 12. **Authentication**: Include `X-UserToken: window.g_ck` header in all fetch requests. 13. **Accessibility**: Follow WCAG 2.1 AA standards, use semantic HTML, ensure keyboard navigation. 14. **Ampersand Character**: Use `$[AMP]` instead of `&` in text content within HTML files. ### Technology Stack (Mandatory) - **React 18.2.0** -- All UI Pages MUST use React exclusively - **@servicenow/react-components ^0.1.0** -- MUST install with caret `^` - **ServiceNow Table API** -- Primary integration method for CRUD operations - **Fluent DSL** -- TypeScript-based configuration language - **Build System** -- Platform handles ALL build processes automatically ### Limitations - **No media support**: Audio, video, and WASM files are not supported - **Deterministic paths**: No hashed output paths; file paths must be predictable - **No preloading**: `<link rel="preload">` is not supported - **CSS limitations**: No CSS Modules, no @import, no CSS-in-CSS imports, ESM imports only - **Routing**: Use URLSearchParams, NOT hash routing - **No server-side rendering**: React server components and SSR are not available