Documentation
how-to/migrate-ui-pages.md
title: UI Migration Detailed Guide
Overview
This document captures the proven patterns for migrating AssetsTrack UI pages from the legacy architecture to the modern Dynaplex architecture. These patterns were discovered and validated during the regions page migration.
Source of Truth: The backend API is authoritative. When UI and API conflict, the API wins. Remove UI features/fields that don't exist in the API.
Migration Philosophy:
- API-first: Analyze backend first, make UI conform
- Type-safe: Use constants and generated types
- Standards-based: HTTP status codes, not wrapper objects
- Simplified: Remove unnecessary abstraction layers
Baseline Migration Expectations
CRITICAL CONTEXT: Every UI page migration involves these BASELINE patterns. They are NOT complexities, NOT exceptions, and NOT reasons for concern:
1. Database Migration (SQL Server → PostgreSQL + EF Core)
This is THE NORM: Every migration moves from legacy SQL Server (with stored procedures) to modern PostgreSQL with EF Core entities.
- ✅ Expected for EVERY single migration
- ✅ Not unusual, not complex, not a "dual database system"
- ✅ The old SQL Server backend is being REPLACED entirely
- ❌ DO NOT treat this as exceptional or mention "dual database systems"
2. ID Type Migration (long → Guid)
This is THE NORM: Every migration converts from old long IDs to new Guid IDs with Passport integration.
- ✅ Expected for EVERY single migration
- ✅ Not a complexity, not a "data type mismatch concern"
- ✅ Just update the types:
long→Guid,long?→Guid? - ❌ DO NOT flag "data type mismatch" as a concern
3. No Migration Path Needed
CRITICAL: This is a brand new system with ZERO production clients.
- ✅ ALWAYS replace the old with the new entirely
- ✅ NEVER worry about maintaining old endpoints
- ✅ NEVER create "migration paths" or "backward compatibility"
- ✅ If backend exists, replace it. If it doesn't, create it.
- ❌ DO NOT spend time planning migration strategies
4. What IS Actually Complex?
ONLY these warrant user questions:
- Missing business logic (how should X work?)
- Unclear entity relationships (should this be FK or junction table?)
- Complex validation rules (what are the business rules?)
- Ambiguous UI behavior (how should this feature work?)
These are NOT complex (they're baseline):
- ❌ SQL Server → PostgreSQL (just do it)
- ❌ long → Guid conversion (just do it)
- ❌ Stored procedures → EF Core (just do it)
- ❌ Creating missing endpoints (just do it)
- ❌ Backend/frontend mismatch (make them match)
- ❌ Service layer exists (remove it)
Correct Assessment Examples:
- ✅ "Status-to-category relationship is undefined. Direct FK or junction table?" (Real complexity)
- ❌ "Uses stored procedures, need to migrate to EF Core" (Baseline - execute it)
- ❌ "IDs are long but need to be Guid" (Baseline - execute it)
Table of Contents
Frontend Migrations
1. Permission System Migration
Old Pattern: Menu-based string matching
// ❌ OLD - Direct string matching
if (session.user.menus.includes('regions/create')) {
// Show create button
}
// ❌ OLD - Environment variable admin check
if (session.user.adminRole.toString() === env('NEXT_PUBLIC_ADMIN_ROLE_ID')) {
options.push('reactivate');
}
New Pattern: Type-safe hierarchical permissions
// ✅ NEW - Import helpers and constants
import {hasPermission, isAdmin} from '../../lib/permissionHelpers';
import {PERMISSIONS} from '../../lib/permissions';
// ✅ NEW - Type-safe permission check
if (hasPermission(session, PERMISSIONS.SPATIAL.REGIONS.CREATE)) {
// Show create button
}
// ✅ NEW - Helper function for admin
if (isAdmin(session)) {
options.push('reactivate');
}
Permission Format Change:
- Old:
regions/create,regions/edit,assets/view - New:
spatial:regions:create,spatial:regions:update,catalog:items:read - Format:
{component}:{resource}:{action}
Migration Steps:
Add imports at top of file:
import {hasPermission, isAdmin, hasAnyPermission} from '../../lib/permissionHelpers'; import {PERMISSIONS} from '../../lib/permissions';Replace all
session.user.menus.includes(...)withhasPermission(session, PERMISSIONS...)Replace admin role checks with
isAdmin(session)Update
useEffecthooks that build action menus:React.useEffect(() => { let options = []; if (hasPermission(session, PERMISSIONS.SPATIAL.REGIONS.UPDATE)) { options.push('edit'); } if (hasPermission(session, PERMISSIONS.SPATIAL.REGIONS.DELETE)) { options.push('delete'); } if (isAdmin(session)) { options.push('reactivate'); } setEditOptions(options); }, [session]);
Helper Functions Available:
hasPermission(session, permission)- Check single permissionhasAnyPermission(session, permissions[])- Check if user has ANY of the permissionshasAllPermissions(session, permissions[])- Check if user has ALL permissionsisAdmin(session)- Check if user is adminisSuperAdmin(session)- Check if user is super admin
Backward Compatibility Note:
The helpers include fallback to old menu-based system during transition, so pages can be migrated incrementally.
2. API Service Layer Migration
Old Pattern: Singleton API client at module level
// ❌ OLD - location.service.ts
import {AnonymousAuthenticationProvider} from '@microsoft/kiota-abstractions';
import {FetchRequestAdapter} from '@microsoft/kiota-http-fetchlibrary';
import {createSpatialApiClient} from '@/lib/acsis-spatial-api-client';
// ❌ Singleton instance - no authentication!
const authProvider = new AnonymousAuthenticationProvider();
const adapter = new FetchRequestAdapter(authProvider);
const spatialApi = createSpatialApiClient(adapter);
const LocationService = {
SaveRegion: async (regionData: UpdateRegionRequest) => {
return await spatialApi.regions.post(regionData); // ❌ No Axios compatibility
}
}
New Pattern: Factory function with authentication per request
// ✅ NEW - location.service.ts
import { getSpatialApiClient, toAxiosResponse } from "./kiotaClients";
import { RegionDraft } from "@/lib/acsis-spatial-api-client/models";
const LocationService = {
SaveRegion: async (regionData: RegionDraft) => {
const spatialApi = getSpatialApiClient(); // ✅ Fresh client with auth token
const result = await spatialApi.regions.post(regionData);
return toAxiosResponse(result); // ✅ Axios-compatible response
}
}
The kiotaClients.ts Pattern:
// lib/kiotaClients.ts
import { createAuthorizedRequestAdapter } from "./authProvider";
import { createSpatialApiClient } from "@/lib/acsis-spatial-api-client";
import { createCatalogApiClient } from "@/lib/acsis-catalog-api-client";
// Factory functions for each component
export const getSpatialApiClient = () =>
createSpatialApiClient(createAuthorizedRequestAdapter({ serviceKey: "spatial" }));
export const getCatalogApiClient = () =>
createCatalogApiClient(createAuthorizedRequestAdapter({ serviceKey: "catalog" }));
// Axios compatibility wrapper
export const toAxiosResponse = <T>(data: T): any => ({
status: data === undefined || data === null ? 204 : 200,
data,
});
Migration Steps:
- Remove module-level API client singleton
- Import factory function:
import { getSpatialApiClient, toAxiosResponse } from "./kiotaClients"; - Update each service method:
- Call factory at start of method:
const spatialApi = getSpatialApiClient(); - Wrap result:
return toAxiosResponse(result);
- Call factory at start of method:
- Update type imports to use new DTO names (e.g.,
RegionDraftinstead ofUpdateRegionRequest)
Why This Matters:
- ✅ Authentication token included in every request
- ✅ Token refresh handled automatically
- ✅ No stale singleton with expired token
- ✅ Axios compatibility for gradual migration
3. Response Handling Migration
Old Pattern: Wrapper objects with isSuccess flag
// ❌ OLD
LocationService.SaveRegion(requestJson)
.then((response) => {
if (response.status === 200) {
if (response.data.isSuccess) { // ❌ Wrapper object check
// Success notification
setNotification({
message: intl.formatMessage({id: 'notification.success.saveRegion'}),
type: 'success'
});
} else {
// Error from wrapper
var error = response.data.error;
setNotification({
message: error,
type: 'error'
});
}
}
});
New Pattern: HTTP status codes
// ✅ NEW
LocationService.SaveRegion(requestJson)
.then((response) => {
if (response.status === 200 || response.status === 204) {
// Success - backend returns 200/204 with entity or no content
setNotification({
message: intl.formatMessage({id: 'notification.success.saveRegion'}),
type: 'success'
});
// Optionally use returned entity
if (response.data) {
const savedRegion = response.data;
// Update local state
}
} else if (response.status === 400) {
// Validation error - backend returns error string in body
var error = response.data || 'Unknown error';
setNotification({
message: error,
type: 'error'
});
}
})
.catch((error) => {
// Exception handling
const detailedMessage = intl.formatMessage({
id: 'notification.error.failedSaveRegionDetail',
defaultMessage: 'More Info: {error}'
}, {error: error?.message || error?.toString() || 'Unknown error'});
setNotification({
message: detailedMessage,
type: 'error'
});
});
Backend Response Patterns:
- Success: Returns
200 OKwith entity, or204 No Content - Validation Error: Returns
400 Bad Requestwith error string in body - Not Found: Returns
404 Not Foundwith error string - Server Error: Returns
500(should not happen - fix backend)
Migration Steps:
- Remove
if (response.data.isSuccess)checks - Use
if (response.status === 200 || response.status === 204)for success - Use
else if (response.status === 400)for validation errors - Add
.catch()block if missing for exception handling - Update error extraction:
var error = response.data || 'Unknown error';
Error Object Handling:
// ✅ Handle both string and object errors
var error = error?.message || error?.toString() || 'Unknown error';
4. Data Accessor Migration
Old Pattern: Custom accessor names
// ❌ OLD - Custom/abbreviated accessor names
const columns = React.useMemo(() => [
{
Header: <FormattedMessage id="pages.regions.index.label.regionName" />,
accessor: 'regionName' // ❌ Doesn't match backend
},
{
Header: <FormattedMessage id="pages.regions.index.label.regionDescription" />,
accessor: 'regionDescr' // ❌ Abbreviated
}
], []);
// ❌ OLD - Backend returned custom shape
const regions = response.data.map((region) => ({
regionId: region.id,
regionName: region.name,
regionDescr: region.description,
isActive: region.isActive,
rowKey: region.id,
rowLink: null
}));
New Pattern: Direct property mapping
// ✅ NEW - Accessors match backend exactly
const columns = React.useMemo(() => [
{
Header: <FormattedMessage id="pages.regions.index.label.regionName" />,
accessor: 'name' // ✅ Matches backend property
},
{
Header: <FormattedMessage id="pages.regions.index.label.regionDescription" />,
accessor: 'description' // ✅ Full property name
}
], []);
// ✅ NEW - Minimal mapping, backend already correct
const regions = response.data.map((region) => ({
...region, // Backend returns {id, name, description}
rowKey: region.id,
rowLink: null
}));
Migration Steps:
- Check backend API response shape (look at generated models in
lib/acsis-{component}-api-client/models/) - Update column
accessorproperties to match backend property names exactly - Remove abbreviations (e.g.,
Descr→description) - Simplify data mapping - spread backend object instead of manual mapping
- Only add UI-specific metadata (like
rowKey,rowLink)
Common Accessor Changes:
| Old Accessor | New Accessor |
|---|---|
regionName |
name |
regionDescr |
description |
locationName |
name |
categoryName |
name |
itemTypeName |
name |
General Rule: If backend returns name, use name. No prefixes.
5. Removed Fields Migration
Old Pattern: UI referenced fields that no longer exist
// ❌ OLD - isActive field removed from backend
const columns = [
{ accessor: 'name' },
{ accessor: 'description' },
{ accessor: 'isActive' } // ❌ No longer exists in API
];
// ❌ OLD - Soft delete with isActive toggle
const toggleActive = (id) => {
LocationService.UpdateRegion({id, isActive: !currentActive});
};
New Pattern: Hard deletes, removed fields
// ✅ NEW - isActive removed
const columns = [
{ accessor: 'name' },
{ accessor: 'description' }
// isActive removed entirely
];
// ✅ NEW - Hard delete instead
const deleteRegion = (id) => {
if (confirm('Are you sure? This cannot be undone.')) {
LocationService.DeleteRegion(id);
}
};
Commonly Removed Fields:
isActive- Soft deletes replaced with hard deletescreatedBy,createdDate- May be removed if not shown in UImodifiedBy,modifiedDate- May be removed if not shown in UI- Custom computed fields - Removed if not in API response
Migration Steps:
- Get list of properties from backend API model
- Compare to UI table columns/form fields
- Remove any UI fields not in API response
- If field was
isActive:- Remove toggle/reactivate functionality
- Add hard delete with confirmation
- Update permissions (DELETE instead of UPDATE)
Detection Strategy:
// Check backend model (in lib/acsis-*-api-client/models/):
export interface Region {
id?: Guid;
name?: string | null;
description?: string | null;
// No isActive!
}
// If UI references isActive anywhere, remove it
6. Keyboard Shortcuts in Menus
Pattern: Inline keyboard shortcuts using <kbd> elements with dual-state styling
Use Case: Dropdown menus (user menu, actions menu, etc.) where space is constrained and shortcuts should be subtle but discoverable.
Implementation:
// Import the shortcut hint provider
import {useShortcutHints} from '@/providers/ShortcutHintProvider';
// In component
const {isAltKeyHeld} = useShortcutHints();
// In Menu.Item render prop
<Menu.Item>
{({active, focus}) => (
<button className="group flex items-center justify-between w-full px-4 py-2">
<FormattedMessage id="action.label" defaultMessage="Action"/>
<kbd className={classNames(
"ml-auto font-sans text-xs transition-colors duration-150",
isAltKeyHeld
? "flex items-center justify-center h-4 w-5 px-1 rounded-md ring-1 font-semibold text-indigo-600 ring-indigo-600 bg-white dark:text-amber-400 dark:ring-amber-400 dark:bg-gray-900"
: active || focus
? "text-gray-400 dark:text-gray-500"
: "text-transparent"
)}>
P
</kbd>
</button>
)}
</Menu.Item>
Key Features:
<kbd>element: Semantic HTML for keyboard input- Dual-state styling:
- Transparent by default (hidden)
- Muted gray (
text-gray-400/500) on hover/focus - Bright with badge styling when Alt/Option key is held
- Smooth transitions:
transition-colors duration-150 - Accessible: Uses HeadlessUI's
activeandfocusstates
For Select Dropdowns:
<Menu.Item>
{({active, focus}) => (
<div className="group relative">
<LanguageDropDown ref={selectRef} />
<kbd className={classNames(
"absolute right-4 top-1/2 -translate-y-1/2 pointer-events-none font-sans text-xs",
// Same styling as above
)}>
L
</kbd>
</div>
)}
</Menu.Item>
Opening Select with Keyboard:
// Dispatch non-bubbling event to prevent menu from closing
const clickEvent = new MouseEvent('mousedown', {
bubbles: false, // ✅ Prevents HeadlessUI Menu from closing
cancelable: true,
view: window
});
languageSelectRef.current?.dispatchEvent(clickEvent);
Why This Pattern:
- ✅ More space-efficient than floating badges
- ✅ Follows industry standard (HeadlessUI, Radix, etc.)
- ✅ Discoverable without being distracting
- ✅ Semantic and accessible
- ✅ Smooth visual feedback
7. Smooth Panel Transitions
Pattern: CSS Grid-based height transitions for create/edit panels
Use Case: Panels that expand/collapse (e.g., "New Region" form, filters, advanced options)
Old Pattern (blocky, sequential):
// ❌ Panel waits for complete animation before table moves
<Transition show={visible} as={Fragment}>
<div className="bg-white rounded-lg">
{/* Panel content */}
</div>
</Transition>
New Pattern (smooth, simultaneous):
// ✅ Grid handles height collapse, Transition handles opacity
<div className={`grid transition-all duration-75 ease-in-out ${
createPanelVisible ? 'grid-rows-[1fr] mb-4' : 'grid-rows-[0fr]'
}`}>
<div className="overflow-hidden">
<Transition
show={createPanelVisible}
as={Fragment}
enter="transition-opacity duration-75 ease-out"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition-opacity duration-75 ease-in"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="bg-white dark:bg-gray-900 shadow sm:rounded-lg">
{/* Panel content */}
</div>
</Transition>
</div>
</div>
How It Works:
- Outer grid wrapper: Transitions
grid-rows-[1fr]→grid-rows-[0fr]1fr= "take up as much space as content needs"0fr= "collapse to zero height"- Smooth height transition without knowing exact pixel height
- Inner overflow wrapper:
overflow-hiddenclips content during collapse - Transition component: Handles opacity fade in/out
- Conditional margin:
mb-4only when visible, so spacing collapses too
Timing:
duration-75(75ms) for snappy, responsive feelduration-100(100ms) if slightly smoother transition needed- DO NOT go slower than 150ms - feels sluggish for power users
Why This Pattern:
- ✅ Table rises smoothly as panel collapses (simultaneous)
- ✅ No blocky "wait then jump" behavior
- ✅ Works without knowing content height
- ✅ Pure CSS, no JavaScript measurements
- ✅ Fast and responsive
The Magic: grid-template-rows: 1fr to 0fr is the secret to animating height without knowing the exact size!
8. Danger State Patterns
Pattern: Visual feedback for destructive actions
Use Case: Delete buttons, destructive operations that can't be undone
Implementation in Table Menus:
// ActionsCell.tsx - Delete button
<Menu.Item>
{({active}) => (
<button
onClick={(e) => {
e.preventDefault();
deleteSelectedRow?.(row.original.rowKey);
}}
className={classNames(
active
? "text-white bg-red-600 dark:bg-red-600" // ✅ Red on hover
: "text-gray-700 dark:text-gray-300",
"block text-left w-full px-4 py-2 text-sm rounded-md transition-colors duration-75"
)}
>
<FormattedMessage id="button.delete" defaultMessage="Delete"/>
</button>
)}
</Menu.Item>
Key Features:
- Red background (
bg-red-600) on hover/active state - Fast transition (
duration-75) for instant feedback - Consistent across light/dark mode
- Clear danger signal - user knows it's destructive
Why Red:
- ✅ Universal danger color
- ✅ Clear visual differentiation from edit/view actions
- ✅ Prevents accidental clicks
- ✅ Industry standard (GitHub, Google, Microsoft all use red for delete)
DO NOT:
- ❌ Use gray hover states for delete buttons
- ❌ Make delete look the same as other actions
- ❌ Use slow transitions (feels unresponsive)
Migration Steps:
- Find delete buttons in action menus
- Change hover state from
bg-gray-700tobg-red-600 - Add
transition-colors duration-75for smooth color change - Test in both light and dark modes
9. Morphing Create Button Pattern
Pattern: Single button that morphs between "Create" and "Close" states
Use Case: Primary action button that opens/closes a create panel
Old Pattern (separate buttons):
// ❌ OLD - Separate New and Cancel buttons
<div className="flex space-x-2">
{!createPanelVisible && (
<button onClick={() => setCreatePanelVisible(true)}>
<PlusIcon />
New Category
</button>
)}
{createPanelVisible && (
<button onClick={() => setCreatePanelVisible(false)}>
<XMarkIcon />
Cancel
</button>
)}
</div>
New Pattern (morphing button):
// ✅ NEW - Single morphing button with ShortcutHint
import { useKeyboardShortcuts } from '@/lib/keyboardShortcuts';
import { ShortcutHint } from '@/components/Common/ShortcutHint';
// Add keyboard shortcuts
useKeyboardShortcuts([
{
key: 'n',
description: 'Create new location category',
action: () => {
if (hasPermission(session, PERMISSIONS.SPATIAL.LOCATION_CATEGORIES.CREATE) && !createPanelVisible) {
setCreatePanelVisible(true);
}
}
},
{
key: 'Escape',
description: 'Close create panel',
allowInInput: true, // ✅ Works even when typing
action: () => {
if (createPanelVisible) {
setCreatePanelVisible(false);
}
}
}
]);
// Morphing button with shortcut hint
<ShortcutHint
keys={createPanelVisible ? "ESC" : "N"}
position="top-right"
>
<button
onClick={() => {
if (createPanelVisible) {
setCreatePanelVisible(false);
} else {
setCreatePanelVisible(true);
}
}}
className={`inline-flex items-center justify-center rounded-md px-3 py-2 text-sm font-semibold shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 transition-all duration-200 ease-in-out ${
createPanelVisible
? 'border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700'
: 'bg-indigo-600 dark:bg-indigo-500 text-white hover:bg-indigo-500 dark:hover:bg-indigo-400'
}`}
>
{createPanelVisible ? (
<>
<XMarkIcon className="-ml-1 mr-2 h-5 w-5" />
<FormattedMessage id="button.close" defaultMessage="Close" />
</>
) : (
<>
<PlusIcon className="-ml-1 mr-2 h-5 w-5" />
<FormattedMessage id="button.newCategory" defaultMessage="New Category"/>
</>
)}
</button>
</ShortcutHint>
Key Features:
- Single button - Less visual clutter, clearer action
- State-based styling:
- Closed: Indigo background (primary action)
- Open: Gray border (secondary/cancel action)
- Smooth transitions -
transition-all duration-200 - Icon changes - PlusIcon → XMarkIcon
- Text changes - "New [Entity]" → "Close"
- Keyboard shortcuts - "N" to open, "ESC" to close
- ShortcutHint - Shows current shortcut key
Why This Pattern:
- ✅ Saves space (one button slot instead of two)
- ✅ Clear visual feedback of current state
- ✅ Keyboard accessible
- ✅ Consistent with regions page pattern
- ✅ Smooth, professional feel
10. Local-First UX Pattern
Pattern: Optimistic UI updates with silent background sync
Use Case: All create, update, and delete operations
Philosophy: Make the UI feel instant and responsive. Only interrupt the user for errors they can actually do something about.
A. No Success Notifications
Old Pattern (interruptive):
// ❌ OLD - Shows success notification
LocationService.SaveCategory(data)
.then((response) => {
if (response.status === 200) {
setNotificationDetails({
type: "success",
mainMessage: "Location Category Created",
detailedMessage: "The category has been created successfully."
});
setShowNotification(true);
setTimeout(() => setShowNotification(false), 4000);
refreshCategories();
}
});
New Pattern (silent success):
// ✅ NEW - Silent success, only show errors
LocationService.SaveCategory(data)
.then((response) => {
if (response.status === 200 || response.status === 204) {
// Success - update state silently
closeCreatePanel();
// Silent background refresh
refreshCategories();
} else if (response.status === 400) {
// Error - show notification
setNotificationDetails({
type: "error",
mainMessage: "Failed to create category",
detailedMessage: response.data || "Validation failed"
});
setShowNotification(true);
}
})
.catch((error) => {
// Error - show notification
setNotificationDetails({
type: "error",
mainMessage: "Failed to create category",
detailedMessage: error?.message || "Unknown error"
});
setShowNotification(true);
});
B. Maintain Selection After Save
Old Pattern (resets state):
// ❌ OLD - Resets selection, user must re-click
function saveCategory() {
LocationService.SaveCategory(data)
.then((response) => {
if (response.status === 200) {
refreshCategories(); // ❌ Calls setSelectedCategory(null)
}
});
}
function refreshCategories() {
LocationService.GetCategories()
.then((response) => {
setAllCategories(response.data);
setSelectedCategory(null); // ❌ User loses selection
});
}
New Pattern (maintains selection):
// ✅ NEW - Keeps selection, updates in place
function saveCategory() {
LocationService.SaveCategory(data)
.then((response) => {
if (response.status === 200 || response.status === 204) {
// Update the category in the list directly
setAllCategories(prev =>
prev.map(cat =>
cat.id === selectedCategory.id
? { ...cat, name: selectedCategory.name, description: selectedCategory.description }
: cat
)
);
// Exit edit mode but KEEP the category selected
setEditMode(false);
// Silent background refresh to sync any server-side changes
refreshCategories();
}
});
}
function refreshCategories() {
// Silent background refresh - no loader, no selection reset
LocationService.GetCategories()
.then((response) => {
setAllCategories(response.data);
// ✅ Don't touch selectedCategory
})
.catch(() => {
// ✅ Silent failure - list will update on next visit
});
}
Key Principles:
- Only show errors - Success is expected, don't interrupt
- Update state directly - Don't reload/reset
- Maintain context - Keep selection after save
- Silent sync - Background refresh without UI changes
- Fail visibly - Errors get full notification treatment
Why This Pattern:
- ✅ Feels instant and responsive
- ✅ Reduces cognitive load (fewer popups)
- ✅ User stays in flow (doesn't lose selection)
- ✅ Modern UX standard (Gmail, Notion, Linear all do this)
- ✅ Errors stand out more (only notifications are problems)
Migration Steps:
- Remove ALL success notification code
- Keep error notifications
- Update state directly after successful operations
- Make refresh functions silent (no selection reset)
- Exit edit mode but preserve selection
Backend Migrations
1. Service Layer Removal
Old Pattern: Service layer abstraction
// ❌ OLD - RegionsApi.cs (thin controller)
private static async Task<Results<Ok, BadRequest<string>>> SaveRegionHandler(
[FromBody] UpdateRegionRequest reqBody,
[FromServices] LocationsService locations, // ❌ Abstraction layer
ClaimsPrincipal user
) {
var response = await locations.SaveRegion(reqBody, user.UserToken()); // ❌ Token passing
if(response.Success is false) { // ❌ Wrapper object
return TypedResults.BadRequest(response.Error);
}
return TypedResults.Ok(); // ❌ No entity returned
}
// ❌ OLD - LocationsService.cs (unnecessary layer)
public class LocationsService {
private readonly SpatialDb _db;
public async Task<OperationResult> SaveRegion(UpdateRegionRequest req, string token) {
// Business logic duplicated from controller
var region = await _db.Regions.FindAsync(req.Id);
region.Name = req.Name;
await _db.SaveChangesAsync();
return new OperationResult { Success = true }; // ❌ Wrapper
}
}
New Pattern: Direct minimal API handlers
// ✅ NEW - RegionsApi.cs (all logic in handler)
private static async Task<Results<Ok<Region>, BadRequest<string>, NotFound<string>>> SaveRegionHandler(
[FromBody] RegionDraft reqBody,
[FromServices] SpatialDb db, // ✅ Direct DbContext injection
[FromServices] PrismDb prismDb // ✅ For passport creation
) {
// ✅ Validation with FluentValidation
var validationResult = await new RegionDraftValidator().ValidateAsync(reqBody);
if(validationResult.IsValid is false) {
return TypedResults.BadRequest(string.Join("; ", validationResult.Errors.Select(e => e.ErrorMessage)));
}
// ✅ UPDATE existing (if ID provided)
if(reqBody.Id.HasValue && reqBody.Id.Value != Guid.Empty) {
var existingRegion = await db.Regions.FirstOrDefaultAsync(r => r.Id == reqBody.Id.Value);
if(existingRegion is null) {
return TypedResults.NotFound("The specified region ID does not exist.");
}
existingRegion.Name = reqBody.Name!;
existingRegion.Description = reqBody.Description;
await db.SaveChangesAsync();
return TypedResults.Ok(existingRegion); // ✅ Return entity
}
// ✅ CREATE new (if no ID)
var passport = await PassportHelper.CreatePassportAsync(prismDb, Region.PTID);
var newRegion = new Region {
Id = passport,
Name = reqBody.Name!,
Description = reqBody.Description,
PlatformTypeId = Region.PTID
};
db.Regions.Add(newRegion);
await db.SaveChangesAsync();
return TypedResults.Ok(newRegion); // ✅ Return created entity
}
Migration Steps:
- Remove service class: Delete
LocationsService.csor similar - Update API handler:
- Remove
[FromServices] LocationsServiceparameter - Add
[FromServices] {Component}Db dbparameter - Add
[FromServices] PrismDb prismDbif creating entities with passports
- Remove
- Move business logic to handler: Copy logic from service method into handler
- Remove token passing: DbContext is automatically tenant-scoped
- Add FluentValidation: Validate input DTOs inline
- Return entities: Use
TypedResults.Ok(entity)instead of wrappers - Update return type: Use
Results<Ok<Entity>, BadRequest<string>, NotFound<string>>
Benefits:
- ✅ No unnecessary abstraction
- ✅ All logic visible in one place
- ✅ Automatic tenant scoping (no manual token handling)
- ✅ Type-safe return types
- ✅ Fewer files to maintain
2. Draft DTO Pattern
Old Pattern: Separate create/update DTOs
// ❌ OLD - Multiple DTOs for same entity
public class CreateRegionRequest {
public string Name { get; set; }
public string Description { get; set; }
}
public class UpdateRegionRequest {
public Guid Id { get; set; }
public string Name { get; set; }
public string Description { get; set; }
public bool IsActive { get; set; } // ❌ Removed field
}
New Pattern: Single *Draft DTO
// ✅ NEW - Single DTO for create and update
/// <summary>
/// Draft model for creating or updating a region.
/// If Id is provided and non-empty, performs an update. Otherwise, creates new.
/// </summary>
public class RegionDraft {
/// <summary>
/// Optional ID for update scenario. Leave null or empty for create.
/// </summary>
public Guid? Id { get; set; }
/// <summary>
/// Region name (required)
/// </summary>
public string? Name { get; set; }
/// <summary>
/// Optional description
/// </summary>
public string? Description { get; set; }
// isActive removed - hard deletes instead
}
Validator Pattern:
// ✅ FluentValidation for Draft DTO
public class RegionDraftValidator : AbstractValidator<RegionDraft> {
public RegionDraftValidator() {
RuleFor(x => x.Name)
.NotEmpty()
.WithMessage("Region name is required")
.MaximumLength(100)
.WithMessage("Region name must not exceed 100 characters");
RuleFor(x => x.Description)
.MaximumLength(500)
.WithMessage("Description must not exceed 500 characters")
.When(x => !string.IsNullOrEmpty(x.Description));
}
}
Migration Steps:
- Create
*Draftclass in{Component}.Abstractions/Drafts/folder - Add optional
Idproperty for update scenario - Make all fields nullable (
string?,Guid?, etc.) - Create validator class inheriting
AbstractValidator<*Draft> - Update API handler to accept
*Draftparameter - Handle create vs update based on
Id.HasValue && Id.Value != Guid.Empty - Validate in handler:
await new RegionDraftValidator().ValidateAsync(reqBody) - Delete old DTOs after migration complete
Naming Convention:
- Input DTOs:
{Entity}Draft(e.g.,RegionDraft,LocationDraft) - Response: Plain entity name (e.g.,
Region,Location)
3. TypedResults Pattern
Old Pattern: Wrapper objects and generic Ok()
// ❌ OLD - Generic return type
private static async Task<Results<Ok, BadRequest<string>>> SaveRegionHandler(...) {
var result = await service.SaveRegion(req);
if (!result.Success) {
return TypedResults.BadRequest(result.Error);
}
return TypedResults.Ok(); // ❌ No entity returned, no type info
}
New Pattern: Typed results with explicit entity types
// ✅ NEW - Explicit return types
private static async Task<Results<Ok<Region>, BadRequest<string>, NotFound<string>>> SaveRegionHandler(
[FromBody] RegionDraft reqBody,
[FromServices] SpatialDb db,
[FromServices] PrismDb prismDb
) {
// Validation error
if (validationFailed) {
return TypedResults.BadRequest("Error message"); // ✅ BadRequest<string>
}
// Not found
if (entity is null) {
return TypedResults.NotFound("Entity not found"); // ✅ NotFound<string>
}
// Success - return entity
return TypedResults.Ok(entity); // ✅ Ok<Region> with entity
}
Common Return Type Patterns:
| Scenario | Return Type | Usage |
|---|---|---|
| Create/Update | Results<Ok<Entity>, BadRequest<string>, NotFound<string>> |
Returns created/updated entity |
| Get by ID | Results<Ok<Entity>, NotFound<string>> |
Returns entity or 404 |
| List | Results<Ok<List<Entity>>, BadRequest<string>> |
Returns list (empty list if none) |
| Delete | Results<NoContent, NotFound<string>, BadRequest<string>> |
204 on success, 404 if not found, 400 if dependencies |
| Action (no return) | Results<NoContent, BadRequest<string>> |
204 on success, 400 on error |
Migration Steps:
- Update return type to include all possible results
- Return entities on success:
TypedResults.Ok(entity) - Return error strings on failure:
TypedResults.BadRequest(errorMessage) - Use 404 for not found:
TypedResults.NotFound(message) - Use 204 for no content:
TypedResults.NoContent()(deletes, actions)
OpenAPI Benefits:
- ✅ Kiota generates correct return types in client
- ✅ Swagger documentation shows exact response shapes
- ✅ Type-safety from backend to frontend
4. Hard Delete Pattern
Old Pattern: Soft deletes with isActive flag
// ❌ OLD - Soft delete
private static async Task<Ok> DeleteRegionHandler(Guid id, LocationsService service) {
var region = await service.GetRegion(id);
region.IsActive = false; // ❌ Soft delete
await service.SaveChanges();
return TypedResults.Ok();
}
New Pattern: Hard deletes with dependency checks
// ✅ NEW - Hard delete with dependency validation
private static async Task<Results<NoContent, NotFound<string>, BadRequest<string>>> DeleteRegionHandler(
Guid id,
[FromServices] SpatialDb db
) {
var region = await db.Regions.FindAsync(id);
if (region is null) {
return TypedResults.NotFound($"Region with ID {id} not found.");
}
// ✅ Check for dependencies before deleting
var locationCount = await db.Locations.CountAsync(l => l.RegionId == id);
if (locationCount > 0) {
return TypedResults.BadRequest($"Cannot delete region because it has {locationCount} locations. Please remove or reassign locations first.");
}
db.Regions.Remove(region); // ✅ Hard delete
await db.SaveChangesAsync();
return TypedResults.NoContent(); // ✅ 204 No Content on success
}
Migration Steps:
- Remove
isActiveproperty from entity class - Update delete handler to use
db.{Entities}.Remove(entity) - Add dependency checks before deletion
- Return meaningful errors if dependencies exist
- Return 204 No Content on successful deletion
- Update UI to remove reactivate functionality
- Add confirmation dialog in UI for destructive action
Dependency Check Patterns:
// Check related entities
var dependentCount = await db.ChildEntities.CountAsync(c => c.ParentId == id);
if (dependentCount > 0) {
return TypedResults.BadRequest($"Cannot delete because {dependentCount} dependent records exist.");
}
// Alternative: Check multiple dependencies
var hasLocations = await db.Locations.AnyAsync(l => l.RegionId == id);
var hasAssets = await db.Assets.AnyAsync(a => a.RegionId == id);
if (hasLocations || hasAssets) {
return TypedResults.BadRequest("Cannot delete region with associated locations or assets.");
}
Common Pitfalls
1. Response Handling
Pitfall: Forgetting to remove isSuccess checks
// ❌ WRONG
if (response.data.isSuccess) { }
// ✅ CORRECT
if (response.status === 200 || response.status === 204) { }
2. Permission Constants
Pitfall: Hardcoding permission strings
// ❌ WRONG
if (hasPermission(session, 'spatial:regions:create')) { }
// ✅ CORRECT
if (hasPermission(session, PERMISSIONS.SPATIAL.REGIONS.CREATE)) { }
3. Accessor Names
Pitfall: Using old accessor names
// ❌ WRONG
accessor: 'regionName'
// ✅ CORRECT
accessor: 'name'
4. API Client Instance
Pitfall: Singleton without auth
// ❌ WRONG
const spatialApi = createSpatialApiClient(new FetchRequestAdapter(new AnonymousAuthenticationProvider()));
// ✅ CORRECT
const spatialApi = getSpatialApiClient();
5. Create vs Update
Pitfall: Separate endpoints
// ❌ OLD PATTERN
await spatialApi.regions.post(createData);
await spatialApi.regions.byId(id).put(updateData);
// ✅ NEW PATTERN
await spatialApi.regions.post({ id: undefined, ...data }); // Create
await spatialApi.regions.post({ id: guid, ...data }); // Update
6. Error Object Structure
Pitfall: Assuming error shape
// ❌ WRONG
var error = response.data.message;
// ✅ CORRECT
var error = response.data || 'Unknown error';
var error = error?.message || error?.toString() || 'Unknown error';
7. Token Authentication
Pitfall: Manual token passing in backend
// ❌ WRONG
await service.SaveRegion(req, user.UserToken());
// ✅ CORRECT
// DbContext is automatically tenant-scoped via DynaplexDbContext
await db.SaveChangesAsync();
8. Missing Catch Blocks
Pitfall: No exception handling
// ❌ WRONG
.then((response) => {
if (response.status === 200) { }
});
// ✅ CORRECT
.then((response) => {
if (response.status === 200) { }
})
.catch((error) => {
// Handle exception
});
Migration Checklist
Pre-Migration Analysis
Analyze Backend API
- Check OpenAPI spec or API code
- List all endpoints for this resource
- Document request/response models
- Note any removed fields (like
isActive) - Identify if service layer still exists
Analyze UI Page
- List all API calls made
- List all table columns/form fields
- Find permission checks
- Check for removed field references
Identify Conflicts
- Fields in UI not in API → Remove from UI
- Old DTOs in backend → Migrate to Draft pattern
- Service layer in backend → Remove it
- Old permission format → Update to hierarchical
Frontend Migration
Permission System
- Add imports:
hasPermission,isAdmin,PERMISSIONS - Replace all
session.user.menus.includes()calls - Replace admin role checks with
isAdmin(session) - Update permission constants if needed
- Add imports:
API Service Layer
- Remove singleton API client
- Import factory:
getSpatialApiClient()or equivalent - Update each service method to use factory
- Wrap results with
toAxiosResponse() - Update type imports (new DTO names)
Response Handling
- Remove
response.data.isSuccesschecks - Use
response.status === 200 || response.status === 204 - Use
response.status === 400for validation errors - Add
.catch()blocks if missing - Update error extraction
- Remove
Data Accessors
- Update column accessors to match backend exactly
- Remove abbreviations
- Simplify data mapping (spread backend object)
Removed Fields
- Remove UI references to fields not in API
- Remove
isActivetoggle if present - Update delete to hard delete with confirmation
- Remove reactivate functionality
UX Patterns (CRITICAL - Don't Skip!)
- Keyboard Shortcuts
- Add imports:
useKeyboardShortcuts,ShortcutHint - Add "N" shortcut for create (with permission check)
- Add "Escape" shortcut for close (with
allowInInput: true) - Wrap create button in
<ShortcutHint>component
- Add imports:
- Morphing Create Button
- Single button that toggles state (
createPanelVisible) - Shows "New [Entity]" + PlusIcon when closed (indigo bg)
- Shows "Close" + XMarkIcon when open (gray border)
- ShortcutHint shows "N" when closed, "ESC" when open
- Single button that toggles state (
- No Success Notifications (Local-First)
- Remove ALL success notifications (create, update, delete)
- Keep ONLY error notifications
- Silent background refresh after success
- Update state directly without reload/reset
- Maintain Selection After Save
- After edit/save, keep entity selected
- Update list state directly (don't reset selection)
- Exit edit mode but preserve selection
- User shouldn't need to re-select to verify changes
- Keyboard Shortcuts
Backend Migration
Service Layer Removal
- Move logic from service to API handler
- Remove service class file
- Inject DbContext directly in handler
- Remove manual token passing
- Update return types to
Results<...>
Draft DTOs
- Create
*Draftclass in Drafts folder - Add optional
Idproperty - Make fields nullable
- Create
*DraftValidatorwith FluentValidation - Update handler to use Draft DTO
- Handle create vs update based on ID
- Create
TypedResults
- Update return type signature
- Return entities on success
- Return error strings on failure
- Use 404 for not found
- Use 204 for no content
Hard Deletes
- Remove
isActivefrom entity - Update delete to hard delete
- Add dependency checks
- Return meaningful errors
- Update UI with confirmation dialog
- Remove
Post-Migration
Regenerate API Client
- Run Kiota to regenerate client from updated OpenAPI spec
- Verify new types match expectations
- Update any import paths if needed
Testing
- Test create flow
- Test update flow
- Test delete flow (with and without dependencies)
- Test permissions (show/hide buttons correctly)
- Test error handling (validation errors, not found, etc.)
- Test without permissions (buttons should be hidden)
Cleanup
- Delete old service class files
- Delete old DTO files (CreateRequest, UpdateRequest)
- Remove unused imports
- Update OpenAPI spec if needed
Summary: Key Transformations
| Aspect | Old Pattern | New Pattern |
|---|---|---|
| Permissions | session.user.menus.includes('regions/create') |
hasPermission(session, PERMISSIONS.SPATIAL.REGIONS.CREATE) |
| API Client | Module-level singleton | Per-request factory (getSpatialApiClient()) |
| Response | response.data.isSuccess |
response.status === 200 \|\| response.status === 204 |
| Backend Service | LocationsService.SaveRegion(req, token) |
Direct DbContext injection |
| DTOs | CreateRequest + UpdateRequest |
Single *Draft with optional ID |
| Accessors | regionName, regionDescr |
name, description |
| Deletes | Soft delete (isActive: false) |
Hard delete with dependency checks |
| Auth | AnonymousAuthenticationProvider |
createAuthorizedRequestAdapter({ serviceKey }) |
| Return Types | Generic Ok() |
Results<Ok<Entity>, BadRequest<string>, NotFound<string>> |
| Keyboard Shortcuts | Floating ShortcutHint badges | Inline <kbd> with dual-state (hover muted, Alt bright) |
| Panel Transitions | Transition with scale/translate |
CSS Grid grid-rows-[1fr] → [0fr] + opacity |
| Delete Buttons | Gray hover (bg-gray-700) |
Red danger state (bg-red-600) |
Regions Page Reference Implementation
The regions page (engines/assettrak-ui/src/acsis-assettrak-ui/pages/regions/index.js) serves as the reference implementation of all these patterns.
Key Commits:
c3a18530- API Client Modernizationb4b4304a- Permission System Modernization
Study this page when migrating others - it demonstrates all patterns working together.