@servicenow/sdk
Version:
ServiceNow SDK
571 lines (465 loc) • 16.6 kB
Markdown
---
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