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: longGuid, 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

  1. Frontend Migrations
  2. Backend Migrations
  3. Common Pitfalls
  4. Migration Checklist

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:

  1. Add imports at top of file:

    import {hasPermission, isAdmin, hasAnyPermission} from '../../lib/permissionHelpers';
    import {PERMISSIONS} from '../../lib/permissions';
    
  2. Replace all session.user.menus.includes(...) with hasPermission(session, PERMISSIONS...)

  3. Replace admin role checks with isAdmin(session)

  4. Update useEffect hooks 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 permission
  • hasAnyPermission(session, permissions[]) - Check if user has ANY of the permissions
  • hasAllPermissions(session, permissions[]) - Check if user has ALL permissions
  • isAdmin(session) - Check if user is admin
  • isSuperAdmin(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:

  1. Remove module-level API client singleton
  2. Import factory function: import { getSpatialApiClient, toAxiosResponse } from "./kiotaClients";
  3. Update each service method:
    • Call factory at start of method: const spatialApi = getSpatialApiClient();
    • Wrap result: return toAxiosResponse(result);
  4. Update type imports to use new DTO names (e.g., RegionDraft instead of UpdateRegionRequest)

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 OK with entity, or 204 No Content
  • Validation Error: Returns 400 Bad Request with error string in body
  • Not Found: Returns 404 Not Found with error string
  • Server Error: Returns 500 (should not happen - fix backend)

Migration Steps:

  1. Remove if (response.data.isSuccess) checks
  2. Use if (response.status === 200 || response.status === 204) for success
  3. Use else if (response.status === 400) for validation errors
  4. Add .catch() block if missing for exception handling
  5. 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:

  1. Check backend API response shape (look at generated models in lib/acsis-{component}-api-client/models/)
  2. Update column accessor properties to match backend property names exactly
  3. Remove abbreviations (e.g., Descrdescription)
  4. Simplify data mapping - spread backend object instead of manual mapping
  5. 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 deletes
  • createdBy, createdDate - May be removed if not shown in UI
  • modifiedBy, modifiedDate - May be removed if not shown in UI
  • Custom computed fields - Removed if not in API response

Migration Steps:

  1. Get list of properties from backend API model
  2. Compare to UI table columns/form fields
  3. Remove any UI fields not in API response
  4. 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 active and focus states

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:

  1. 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
  2. Inner overflow wrapper: overflow-hidden clips content during collapse
  3. Transition component: Handles opacity fade in/out
  4. Conditional margin: mb-4 only when visible, so spacing collapses too

Timing:

  • duration-75 (75ms) for snappy, responsive feel
  • duration-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:

  1. Find delete buttons in action menus
  2. Change hover state from bg-gray-700 to bg-red-600
  3. Add transition-colors duration-75 for smooth color change
  4. 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:

  1. Only show errors - Success is expected, don't interrupt
  2. Update state directly - Don't reload/reset
  3. Maintain context - Keep selection after save
  4. Silent sync - Background refresh without UI changes
  5. 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:

  1. Remove ALL success notification code
  2. Keep error notifications
  3. Update state directly after successful operations
  4. Make refresh functions silent (no selection reset)
  5. 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:

  1. Remove service class: Delete LocationsService.cs or similar
  2. Update API handler:
    • Remove [FromServices] LocationsService parameter
    • Add [FromServices] {Component}Db db parameter
    • Add [FromServices] PrismDb prismDb if creating entities with passports
  3. Move business logic to handler: Copy logic from service method into handler
  4. Remove token passing: DbContext is automatically tenant-scoped
  5. Add FluentValidation: Validate input DTOs inline
  6. Return entities: Use TypedResults.Ok(entity) instead of wrappers
  7. 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:

  1. Create *Draft class in {Component}.Abstractions/Drafts/ folder
  2. Add optional Id property for update scenario
  3. Make all fields nullable (string?, Guid?, etc.)
  4. Create validator class inheriting AbstractValidator<*Draft>
  5. Update API handler to accept *Draft parameter
  6. Handle create vs update based on Id.HasValue && Id.Value != Guid.Empty
  7. Validate in handler: await new RegionDraftValidator().ValidateAsync(reqBody)
  8. 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:

  1. Update return type to include all possible results
  2. Return entities on success: TypedResults.Ok(entity)
  3. Return error strings on failure: TypedResults.BadRequest(errorMessage)
  4. Use 404 for not found: TypedResults.NotFound(message)
  5. 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:

  1. Remove isActive property from entity class
  2. Update delete handler to use db.{Entities}.Remove(entity)
  3. Add dependency checks before deletion
  4. Return meaningful errors if dependencies exist
  5. Return 204 No Content on successful deletion
  6. Update UI to remove reactivate functionality
  7. 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
  • 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.isSuccess checks
    • Use response.status === 200 || response.status === 204
    • Use response.status === 400 for validation errors
    • Add .catch() blocks if missing
    • Update error extraction
  • 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 isActive toggle 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
    • 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
    • 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

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 *Draft class in Drafts folder
    • Add optional Id property
    • Make fields nullable
    • Create *DraftValidator with FluentValidation
    • Update handler to use Draft DTO
    • Handle create vs update based on ID
  • 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 isActive from entity
    • Update delete to hard delete
    • Add dependency checks
    • Return meaningful errors
    • Update UI with confirmation dialog

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 Modernization
  • b4b4304a - Permission System Modernization

Study this page when migrating others - it demonstrates all patterns working together.