Skip to content

Contributing to DxCloud UI

This document provides comprehensive coding standards, architectural patterns, and best practices for contributing to the DxCloud UI project. It serves as both a reference guide for new contributors and a source of truth for maintaining consistency across the codebase.

Document Version: 2.0 Last Updated: 2025-10-06

Table of Contents


Project Architecture

Monorepo Structure

DxCloud UI is a pnpm monorepo using Turborepo for build orchestration. The project follows a clean separation of concerns with apps and packages.

dxcloud.ui.vite/
├── apps/
│   ├── web/          # Main DxCloud application
│   ├── portal/       # Client portal application
│   └── demo/         # Demo application
├── packages/
│   ├── core/         # Core utilities, services, models, security
│   ├── vue/          # Shared Vue components, composables, stores
│   ├── ui/           # Framework-agnostic web components
│   ├── modules/      # Feature modules (notification, portal, server)
│   ├── assets/       # Shared static assets
│   ├── config-eslint/    # Shared ESLint configuration
│   ├── config-tailwind/  # Shared Tailwind configuration
│   └── config-typescript/ # Shared TypeScript configurations
└── scripts/          # Build and utility scripts

Key Principles:

  • Apps consume packages but don't expose anything
  • Packages are reusable across multiple apps
  • Use workspace:* protocol for internal dependencies
  • Each package has its own package.json and build configuration

Technology Stack

Frontend Framework:

  • Vue 3.5 (Composition API with <script setup>)
  • TypeScript 5.5 (strict mode)
  • Vite 6 (build tool)

State Management:

  • Pinia 3 (with HMR support)
  • Pinia Logger (dev only)

Routing:

  • Vue Router 4
  • Custom route guards (authGuard, clientGuard)

UI Libraries:

  • PrimeVue 4.0 (component library)
  • TailwindCSS 3.4
  • AG Grid 31 Enterprise (data grids)
  • Bootstrap SASS (legacy styles)
  • Font Awesome 4.7
  • Iconify

Forms & Validation:

  • Vuelidate 2

Authentication:

  • OIDC Client TS

HTTP Client:

  • Axios 1.11 (with interceptors)

Testing:

  • Vitest 3 (unit tests with jsdom)
  • Playwright (e2e tests)
  • Testing Library Vue

Development Tools:

  • ESLint 9 (linting)
  • Prettier 3.6 (formatting)
  • Husky (git hooks)
  • lint-staged (pre-commit hooks)

Package Dependencies

Dependency Flow:

apps/web → @repo/vue → @repo/core
         → @repo/modules
         → @repo/ui

Core Package (@repo/core):

  • Framework-agnostic utilities
  • API services
  • Data models
  • Security (OIDC configuration)
  • Constants
  • Configuration

Vue Package (@repo/vue):

  • Vue-specific components
  • Composables
  • Shared Pinia stores
  • Vue plugins (auth, loader, dates)
  • PrimeVue themes
  • Utilities (toast, confirm, loader, event bus)

Web App (apps/web):

  • Application-specific components
  • Views (organized by feature)
  • Pinia stores (50+ stores)
  • Router configuration
  • Layout components

Development Workflow

Environment Setup

Prerequisites:

  • Node.js >= 20.19.4
  • pnpm 10.14.0

Environment Files:

  • .env - Shared environment variables
  • .env.local - Local overrides (not committed)

Example .env.local:

bash
VITE_AUTH_SERVER=https://auth.example.com
VITE_AUTH_CLIENT=dxcloud-web
VITE_AUTH_SCOPE=openid profile email api
RESOURCE_SERVER_BASE=https://api.example.com

Common Commands

Development:

bash
pnpm dev                    # Start all apps in dev mode
pnpm build                  # Build all packages and apps
pnpm build:package          # Build packages only
pnpm preview                # Preview production builds

Testing:

bash
pnpm test                   # Run all tests
pnpm test:watch             # Run tests in watch mode
turbo test --filter=web     # Run tests for specific app

# E2E tests (in apps/web)
cd apps/web
pnpm test:e2e               # Run e2e tests
pnpm test:e2e:ui            # Run with Playwright UI
pnpm test:e2e:codegen       # Generate test code
pnpm test:e2e:report        # View test report

Linting & Formatting:

bash
pnpm lint                   # Lint all packages
pnpm lint:fix               # Fix lint issues
pnpm format                 # Format with Prettier
pnpm format:check           # Check formatting

Localization:

bash
pnpm locale                 # Add missing i18n keys
pnpm locale:fix             # Add and remove unused keys

API Client Generation:

bash
cd apps/web
pnpm build:api              # Generate TypeScript API client from specification.json

Docker:

bash
pnpm docker:dev             # Build and start containers
pnpm docker:build           # Build Docker images
pnpm docker:up              # Start containers
pnpm docker:down            # Stop containers

Cleanup:

bash
pnpm clean                  # Remove node_modules, dist, .turbo

Git Workflow

Main Branches:

  • develop - Main development branch (use for PRs)
  • main/master - Production branch

Branch Naming:

  • feature/DC-XXXX-description - New features
  • bugfix/DC-XXXX-description - Bug fixes
  • hotfix/DC-XXXX-description - Critical production fixes

Commit Messages:

  • Review recent commits with git log to match project conventions
  • Use clear, concise commit messages
  • Reference ticket numbers (e.g., DC-4761)

Example:

bash
git commit -m "DC-4761: Add journal entry validation"

Pre-commit Hooks

Pre-commit hooks run automatically on git commit:

Configured in .lintstagedrc:

json
{
  "*.{vue,js,jsx,cjs,mjs,ts,tsx,cts,mts}": ["eslint --fix", "prettier --write"]
}

What Runs:

  1. ESLint on staged files (with auto-fix)
  2. Prettier formatting on staged files

Bypass (not recommended):

bash
git commit --no-verify

Code Organization

Feature Structure

Organize features using a consistent, modular structure:

feature-name/
├── FeatureView.vue              # Main view component
├── FeatureView.d.ts             # Type definitions
├── components/                   # Feature-specific components
│   ├── feature-modal/
│   │   └── FeatureModal.vue
│   └── feature-import/
│       └── FeatureImportModal.vue
├── composables/                  # Reusable logic
│   ├── use-feature-grid-common.ts
│   ├── use-feature-grid-control.ts
│   ├── use-feature-grid-service.ts
│   └── use-feature-grid-modal.ts
└── containers/                   # Container components
    ├── FeatureGrid.vue
    └── FeatureLineGrid.vue

Real Examples:

  • apps/web/src/views/main/client/process/journal-entries/ - Journal Entries
  • apps/web/src/views/main/client/process/cashbook-entries/ - Cashbook Entries
  • apps/web/src/views/main/client/process/working-trial-balance/ - Working Trial Balance

File Naming Conventions

Vue Components (PascalCase):

  • FeatureView.vue - View components
  • FeatureModal.vue - Modal components
  • FeatureGrid.vue - Grid containers
  • DxCustomComponent.vue - Custom components

TypeScript Files (kebab-case):

  • use-feature-service.ts - Composables
  • feature.store.ts - Pinia stores
  • feature.service.ts - API services
  • feature.guard.ts - Route guards
  • feature.constants.ts - Constants

Type Definition Files:

  • FeatureView.d.ts - Component type definitions
  • interfaces.ts - Shared interfaces
  • types.ts - Type aliases and unions

Import Order

Maintain consistent import order:

typescript
// 1. External libraries
import * as _ from 'lodash-es';
import { computed, onMounted, ref, shallowRef } from 'vue';
import { storeToRefs } from 'pinia';
import { useI18n } from 'vue-i18n';

// 2. AG Grid
import { GridApi, GridOptions, ColDef } from '@ag-grid-community/core';
import { AgGridVue } from '@ag-grid-community/vue3';

// 3. Monorepo packages
import { useFeatureService } from '@repo/core/services';
import { DataType, FinancialYear } from '@repo/core/models';
import { EventConstants } from '@repo/core/constants';
import { useDxEventBus, useDxToast, useDxConfirm } from '@repo/vue/utils';

// 4. App imports
import { useFeatureStore, useRootStore } from '@/stores';
import { useFeatureComposable } from '@/composables';
import type { FeatureContext } from './FeatureView';

// 5. Local imports
import FeatureModal from './components/feature-modal/FeatureModal.vue';

TypeScript Best Practices

Type Safety

Rule: Avoid any type at all costs. Use proper interfaces or unknown for dynamic data.

Bad:

typescript
const eventBusListener = async (event: string, payload: any) => {
  // Type safety lost
};

const newEntry: any = { id: guid(), $newEntry: true };

Good:

typescript
interface SaveEventPayload {
  type: 'financialData' | 'trialBalanceEntries';
  data?: unknown;
}

const eventBusListener = async (event: string, payload: SaveEventPayload) => {
  if (payload.type === 'financialData') {
    // Type-safe access
  }
};

const newEntry: Partial<JournalEntry> & { $newEntry: boolean } = {
  id: guid(),
  $newEntry: true,
  adjustmentType: 1,
  lines: []
};

Error Typing

Always properly type error objects in catch blocks.

Bad:

typescript
try {
  await saveData(data);
} catch (error: any) {
  dxToast.error(error?.response?.data?.message);
}

Good:

typescript
interface ApiError {
  response?: {
    data?: {
      message?: string;
      errors?: Record<string, string[]>;
    };
  };
  message?: string;
}

try {
  await saveData(data);
} catch (err: unknown) {
  const error = err as ApiError;
  const errorMessage =
    error?.response?.data?.message ??
    error.message ??
    t('feature.could_not_save_data');
  dxToast.error(errorMessage);
}

Interface Design

Define clear interfaces for props, context, and return types.

typescript
// Component props
export interface FeatureModalProps {
  selectedPeriod: FinancialYear;
  currentData: DataType[];
  currentAccounts: FinancialData[];
}

// Modal result
interface FeatureModalResult {
  success: boolean;
  data?: DataType[];
  errors?: string[];
}

// Composable context
export interface FeatureContext {
  gridApi: ShallowRef<GridApi | null>;
  data: ShallowRef<DataType[]>;
  selectedItem: ShallowRef<DataType | undefined>;
  isReadOnly: ComputedRef<boolean>;
}

// Composable return
interface FeatureServiceReturn {
  loadData: () => Promise<void>;
  saveData: (showToast?: boolean) => Promise<boolean>;
  deleteData: (ids: string[]) => Promise<void>;
}

Vue 3 Patterns

Component Structure

Use <script setup> with TypeScript for all new components:

vue
<script setup lang="ts">
import * as _ from 'lodash-es';
import { computed, onMounted, ref, shallowRef } from 'vue';
import { storeToRefs } from 'pinia';
import { useI18n } from 'vue-i18n';

// locale
const { t } = useI18n<[MessageSchema]>();

// store
const featureStore = useFeatureStore();
const { data, selectedItem } = storeToRefs(featureStore);

// state
const gridApi = shallowRef<GridApi | null>(null);
const items = shallowRef<DataType[]>([]);
const isLoading = ref(false);

// computed
const hasData = computed(() => items.value.length > 0);

// methods
const loadData = async () => {
  // Implementation
};

// hooks
onMounted(async () => {
  await loadData();
});
</script>

<template>
  <div class="feature-view">
    <!-- Template content -->
  </div>
</template>

<style scoped lang="scss">
.feature-view {
  // Styles
}
</style>

Composition API

Lifecycle Hooks:

typescript
import { onMounted, onBeforeUnmount, onBeforeRouteLeave } from 'vue-router';

// hooks
onMounted(async () => {
  await loadData();
});

onBeforeUnmount(() => {
  // Cleanup timers, subscriptions
  timeoutIds.forEach(id => clearTimeout(id));
});

onBeforeRouteLeave((to, from, next) => {
  if (hasUnsavedChanges()) {
    // Confirm navigation
  } else {
    next();
  }
});

Watchers:

typescript
import { watch, watchEffect } from 'vue';

// watches
watch(selectedItem, (newValue, oldValue) => {
  console.log('Item changed:', newValue);
});

watch([selectedItem, isReadOnly], ([item, readonly]) => {
  // React to changes
});

watch(selectedItem, (newValue) => {
  // Runs immediately
}, { immediate: true });

watchEffect(() => {
  console.log('Items:', items.value.length);
});

Performance Optimization

Use shallowRef for large data structures and AG Grid APIs:

Good:

typescript
// AG Grid APIs (don't need deep reactivity)
const entryGridApi = shallowRef<GridApi<JournalEntry> | null>(null);
const lineGridApi = shallowRef<GridApi<JournalEntryLine> | null>(null);

// Large arrays (deep reactivity not needed)
const entries = shallowRef<JournalEntry[]>([]);
const trialBalanceEntries = shallowRef<TrialBalanceEntry[]>([]);

Bad:

typescript
// Don't use ref for AG Grid APIs or large datasets
const gridApi = ref<GridApi | null>(null); // Unnecessary overhead
const largeDataset = ref<DataType[]>([]); // Performance impact

When to use ref vs shallowRef:

Use CaseTypeReason
AG Grid APIshallowRefNo reactivity needed on API object
Large arrays (>100 items)shallowRefAvoid deep reactivity overhead
Simple values (string, number, boolean)refMinimal overhead
Nested objects requiring reactivityrefNeed deep reactivity
Form datarefNeed deep reactivity for validation

Async Components

Load modals and heavy components lazily:

typescript
import { defineAsyncComponent } from 'vue';

const FeatureModal = defineAsyncComponent(
  () => import('./components/feature-modal/FeatureModal.vue')
);

const FeatureImportModal = defineAsyncComponent(
  () => import('./components/feature-import/FeatureImportModal.vue')
);

Benefits:

  • Reduces initial bundle size
  • Improves time to interactive
  • Loads components on-demand

State Management

Pinia Store Pattern

Follow this structure for all stores:

typescript
import * as _ from 'lodash-es';
import { acceptHMRUpdate, defineStore } from 'pinia';
import { computed, ref } from 'vue';
import { DataType, ServiceOptions } from '@repo/core/models';
import { useDataService } from '@repo/core/services';

export const useFeatureStore = defineStore('feature', () => {
  // services
  const dataService = useDataService();

  // state
  const items = ref<DataType[]>([]);
  const selectedId = ref<string>();

  // getters
  const selectedItem = computed(() =>
    items.value.find(t => t.id === selectedId.value)
  );

  const hasItems = computed(() => items.value.length > 0);

  // actions
  const fetchItems = async (parameters?: string) => {
    const result = await dataService.get(parameters);
    items.value = result;
    return result;
  };

  const saveItems = async (data: DataType[], options?: ServiceOptions) => {
    const result = await dataService.post(data, options);

    // Update store state
    result.forEach(item => {
      const existing = items.value.find(i => i.id === item.id);
      if (existing) {
        _.assign(existing, item);
      } else {
        items.value.push(item);
      }
    });

    return result;
  };

  const deleteItems = async (ids: string[]) => {
    await dataService.deleteItems(ids);
    items.value = items.value.filter(item => !ids.includes(item.id));
  };

  const resetStore = () => {
    items.value = [];
    selectedId.value = undefined;
  };

  return {
    // state
    items,
    selectedId,

    // getters
    selectedItem,
    hasItems,

    // actions
    fetchItems,
    saveItems,
    deleteItems,
    resetStore
  };
});

// HMR
if (import.meta.hot) {
  import.meta.hot.accept(acceptHMRUpdate(useFeatureStore, import.meta.hot));
}

Key Principles:

  • Use Setup Stores (function syntax) over Options Stores
  • Group related state, getters, and actions
  • Always include HMR support
  • Include a resetStore action for cleanup
  • Update store state after mutations for reactivity

Store Usage

Always use storeToRefs to maintain reactivity:

Good:

typescript
import { storeToRefs } from 'pinia';

const featureStore = useFeatureStore();
const { items, selectedItem } = storeToRefs(featureStore);

// Call actions directly from store
await featureStore.fetchItems();

Bad:

typescript
// Loses reactivity
const { items, selectedItem } = featureStore;

Multiple Stores:

typescript
const clientStore = useClientStore();
const financialStore = useFinancialStore();
const rootStore = useRootStore();

const { currentClient } = storeToRefs(clientStore);
const { selectedPeriod } = storeToRefs(financialStore);
const { practiceId, clientId } = storeToRefs(rootStore);

HMR Support

Hot Module Replacement (HMR) allows updating stores without full page reload:

typescript
if (import.meta.hot) {
  import.meta.hot.accept(acceptHMRUpdate(useFeatureStore, import.meta.hot));
}

Always include this at the end of every store file.


Composables Pattern

Context-Based Composables

Break down complex views into focused composables using a context pattern:

Define Context Interface (FeatureView.d.ts):

typescript
import { ComputedRef, Ref } from 'vue';
import { GridApi } from '@ag-grid-community/core';
import { JournalEntry, JournalEntryLine, TrialBalanceEntry } from '@repo/core/models';

export interface JournalGridContext {
  entryGridApi: Ref<GridApi<JournalEntry> | null>;
  lineGridApi: Ref<GridApi<JournalEntryLine> | null>;
  trialBalanceEntries: Ref<TrialBalanceEntry[]>;
  entries: Ref<JournalEntry[] | undefined>;
  lines: Ref<JournalEntryLine[] | undefined>;
  selectedJournalEntry: Ref<JournalEntry | undefined>;
  isReadOnly: ComputedRef<boolean>;
}

Create Composables:

use-feature-grid-common.ts (Common logic):

typescript
import { JournalGridContext } from '../JournalEntriesView';
import { useDxPrime } from '@repo/vue/utils';

export const useJournalGridCommon = (context: JournalGridContext) => {
  const { entryGridApi, lineGridApi, selectedJournalEntry, isReadOnly } = context;
  const dxPrime = useDxPrime();

  const initNewEntry = () => {
    const newEntry: any = { id: dxPrime.guid(), $newEntry: true, adjustmentType: 1, lines: [] };
    entryGridApi.value?.setGridOption('pinnedTopRowData', [newEntry]);
    return newEntry;
  };

  const initNewEntryLine = () => {
    if (isReadOnly.value) return;
    if (!selectedJournalEntry.value?.id) return;

    const newEntryLine: any = {
      id: dxPrime.guid(),
      $newEntry: true,
      date: selectedJournalEntry.value?.lines?.[0]?.date,
      amount: 0,
      accountId: null,
      journalEntryId: selectedJournalEntry.value?.id
    };
    lineGridApi.value?.setGridOption('pinnedTopRowData', [newEntryLine]);

    return newEntryLine;
  };

  return {
    initNewEntry,
    initNewEntryLine
  };
};

use-feature-grid-service.ts (Data operations):

typescript
export const useJournalGridService = (context: JournalGridContext) => {
  const { entries, entryGridApi } = context;

  const loadData = async () => {
    const data = await journalStore.fetchEntries();
    entries.value = data;
  };

  const saveData = async () => {
    // Save logic
  };

  return {
    loadData,
    saveData
  };
};

use-feature-grid-control.ts (Grid configuration):

typescript
export const useJournalGridControl = (context: JournalGridContext) => {
  const { isReadOnly } = context;

  const columnDefs: ColDef[] = [
    {
      field: 'date',
      headerName: t('journal.date'),
      editable: !isReadOnly.value
    }
  ];

  const gridOptions: GridOptions = {
    rowSelection: 'single',
    suppressCellFocus: false
  };

  return {
    columnDefs,
    gridOptions
  };
};

Use in Main Component:

typescript
// JournalEntriesView.vue
const context: JournalGridContext = {
  entryGridApi,
  lineGridApi,
  selectedJournalEntry,
  trialBalanceEntries,
  entries,
  lines,
  isReadOnly
};

const { initNewEntry, initNewEntryLine } = useJournalGridCommon(context);
const { columnDefs, gridOptions } = useJournalGridControl(context);
const { loadData, saveData } = useJournalGridService(context);

Reusable Logic

Extract reusable logic into standalone composables:

typescript
// dx-grid-row-manager.ts
export const useDxGridRowManager = (gridApi: Ref<GridApi | null | undefined>) => {
  const newEntry = ref<any>();

  const addNewRow = () => {
    const newRow = gridApi.value?.getPinnedTopRow(0);
    if (newRow && newEntry.value && _.get(newEntry.value, '$dirty')) {
      const rowData: any[] = [];
      gridApi.value?.forEachLeafNode((rowNode) => {
        rowData.push(rowNode.data);
      });
      rowData.unshift(newEntry.value);
      gridApi.value?.setGridOption('rowData', rowData);
      setNewRow(newRow);
    }
  };

  const setNewRow = (newRow?: IRowNode) => {
    newEntry.value = newRow ?? newEntry.value;
    if (newEntry.value) {
      _.assign(newEntry.value, { $self: newEntry.value });
      gridApi.value?.setGridOption('pinnedTopRowData', [newEntry.value]);
    } else {
      gridApi.value?.setGridOption('pinnedTopRowData', []);
    }
  };

  return {
    newEntry,
    addNewRow,
    setNewRow
  };
};

Composable Organization

When to create a composable:

  • Logic used in multiple components
  • Complex business logic that deserves isolation
  • Stateful logic that needs to be tested
  • Integration with third-party libraries

When NOT to create a composable:

  • Simple one-off logic
  • Component-specific UI logic
  • Logic tightly coupled to template

Routing and Navigation

Route Configuration

Routes are defined in /apps/web/src/router/index.ts:

typescript
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router';
import { authGuard } from '@repo/vue/plugins';
import { clientGuard } from '@/shared/guards/client.guard';
import AppLayout from '@/layout/AppLayout.vue';

const routes: RouteRecordRaw[] = [
  {
    path: '/',
    component: AppLayout,
    children: [
      {
        path: '/',
        component: () => import('@/layout/AppMainLayout.vue'),
        beforeEnter: authGuard,
        children: [
          {
            path: '',
            name: 'main',
            redirect: '/clients'
          },
          {
            path: 'clients',
            name: 'clients',
            component: () => import('@/views/main/client/client-listing/ClientListingView.vue')
          },
          {
            path: '',
            beforeEnter: clientGuard,
            component: () => import('@/layout/AppClientLayout.vue'),
            children: [
              {
                path: 'process',
                name: 'process',
                component: () => import('@/views/main/client/process/ClientProcessView.vue')
              },
              {
                path: 'financial-data/journal-entries',
                name: 'journalEntries',
                meta: {
                  menuItemId: 'fff6f585-869b-4a47-a5fc-bc8c5df47956',
                  menuType: 1,
                  component: 'JournalEntriesView'
                },
                component: () => import('@/views/main/client/process/journal-entries/JournalEntriesView.vue')
              }
            ]
          }
        ]
      }
    ]
  }
];

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  scrollBehavior: () => ({ top: 0 }),
  routes
});

export default router;

Key Principles:

  • Use lazy loading (() => import(...)) for all route components
  • Nested routes reflect layout hierarchy
  • Always specify name for programmatic navigation

Route Guards

Auth Guard (authGuard):

typescript
import { authGuard } from '@repo/vue/plugins';

// Apply to route
{
  path: '/clients',
  beforeEnter: authGuard,
  component: () => import('@/views/ClientListingView.vue')
}

Client Guard (clientGuard):

Located at /apps/web/src/shared/guards/client.guard.ts:

typescript
import { NavigationGuard } from 'vue-router';
import { storeToRefs } from 'pinia';

export const clientGuard: NavigationGuard = async (to, from, next) => {
  const rootStore = useRootStore();
  const clientStore = useClientStore();
  const { practiceId, clientId } = storeToRefs(rootStore);
  const { currentClient } = storeToRefs(clientStore);

  if (practiceId.value && clientId.value) {
    // Load base data if needed
    if (!currentClient.value?.id) {
      await Promise.all([
        clientStore.fetchCurrentClient(),
        menuStore.fetchMenuItems(),
        ribbonStore.loadRibbonItems(),
        financialStore.fetchFinancialYears()
      ]);
    }
    next();
  } else {
    console.warn('No valid client file selected');
    next({ name: 'clients', replace: true });
  }
};

Route Meta

Route meta properties provide additional configuration:

typescript
{
  path: 'journal-entries',
  name: 'journalEntries',
  meta: {
    menuItemId: 'fff6f585-869b-4a47-a5fc-bc8c5df47956', // Menu item ID
    menuType: 1,                                        // Menu type
    component: 'JournalEntriesView',                    // Component name
    allowMultiple: false                                 // Allow multiple tabs
  },
  component: () => import('@/views/main/client/process/journal-entries/JournalEntriesView.vue')
}

Accessing Route Meta:

typescript
import { useRoute } from 'vue-router';

const route = useRoute();
const menuItemId = route.meta.menuItemId;

API Integration

Service Layer Pattern

All services should follow this structure:

typescript
import { api } from '@repo/core/config';
import { DataType, FinancialYear } from '@repo/core/models';

export const useDataService = () => {
  const get = async (parameters?: string): Promise<DataType[]> => {
    parameters = parameters || '';
    try {
      const response = await api.get('endpoint' + parameters);
      return response.data;
    } catch (err) {
      console.error(err);
      throw err;
    }
  };

  const post = async (data: DataType[], options?: any): Promise<DataType[]> => {
    if (!options) options = {};
    options.autoSave = options.autoSave ?? true;

    try {
      const response = await api.post('endpoint', data, { headers: options });
      return response.data;
    } catch (err) {
      console.error(err);
      throw err;
    }
  };

  const deleteItems = async (keys: string[]) => {
    try {
      const response = await api.delete('endpoint/keys', {
        data: '"' + keys + '"'
      });
      return response.data;
    } catch (err) {
      console.error(err);
      throw err;
    }
  };

  const exportExcel = async (financialYearId: string, data?: string[]) => {
    try {
      const response = await api.post(
        `endpoint/exportExcel?financialYearId=${financialYearId}`,
        data,
        { responseType: 'blob' }
      );
      return response.data;
    } catch (err) {
      console.error(err);
      throw err;
    }
  };

  return {
    get,
    post,
    deleteItems,
    exportExcel
  };
};

Key Principles:

  • Services are composables (use function syntax)
  • Always wrap in try-catch
  • Log errors before re-throwing
  • Use api instance from @repo/core/config
  • Support options parameter for headers/config

OData Queries

Use OData conventions for filtering, expanding, and ordering:

typescript
// Fetch with filter and expand
const data = await dataService.get(
  `?$filter=financialYearId eq ${financialYearId}&$expand=lines`
);

// Order by
const data = await dataService.get('?$orderby=name asc');

// Complex queries
const data = await dataService.get(
  `/${id}?$expand=AccountMapping,lines($orderby=order)`
);

// Multiple filters
const data = await dataService.get(
  '?$filter=deleted eq false&$filter=archived eq false&$expand=*'
);

Error Handling

Standard error handling pattern:

typescript
const saveData = async (showToast = true) => {
  return new Promise<boolean>((resolve) => {
    // Set saving flag
    isTabSaving.value = true;

    // Stop editing
    gridApi.value?.stopEditing();

    setTimeout(async () => {
      // Start loader
      startLoader();

      try {
        const dataToSave = getUnsavedData();

        if (dataToSave.length === 0) {
          isTabSaving.value = false;
          stopLoader();
          resolve(true);
          return;
        }

        // Save data
        await dataStore.saveData(dataToSave);

        // Clear dirty flags
        dataToSave.forEach(item => {
          _.assign(item, { $dirty: false, $saving: false });
        });

        // Update grid
        gridApi.value?.redrawRows();

        // Show success message
        if (showToast) {
          dxToast.success(t('feature.data_saved'));
        }

        isTabSaving.value = false;
        stopLoader();
        resolve(true);
      } catch (err: unknown) {
        const error = err as ApiError;

        isTabSaving.value = false;
        stopLoader();
        resolve(false);

        // Show error
        const errorMessage =
          error?.response?.data?.message ??
          t('feature.could_not_save_data');
        dxToast.error(errorMessage);
      }
    }, 500);
  });
};

API Configuration

API is configured in packages/core/src/config/app.axios.ts:

typescript
import axios, { CreateAxiosDefaults } from 'axios';
import { ResourceConfig } from '@repo/core/resources';
import { setRequestInterceptor, setResponseInterceptor } from '@repo/core/security';

const config: CreateAxiosDefaults = {
  baseURL: ResourceConfig.serverBase,
  headers: { 'Content-Type': 'application/json' }
};

export const api = axios.create(config);

setRequestInterceptor(api);
setResponseInterceptor(api);

Interceptors:

  • Request Interceptor: Adds auth token to headers
  • Response Interceptor: Handles 401 errors, refreshes tokens

AG Grid Integration

Grid Setup Best Practices

The project uses AG Grid Enterprise with specific patterns optimized for performance and consistency.

Base Grid Configuration

Always extend useDxGridBase() for consistent grid behavior:

typescript
<script setup lang="ts">
import * as _ from 'lodash-es';
import { ref, shallowRef } from 'vue';
import { GridApi, GridOptions, ColDef, GridReadyEvent } from '@ag-grid-community/core';
import { AgGridVue } from '@ag-grid-community/vue3';
import { useDxGridBase } from '@/utils';

// data
const gridApi = shallowRef<GridApi<DataType> | null>(null);
const rowData = shallowRef<DataType[]>([]);
const defaultColDef = ref<ColDef>();
const columnTypes = ref<{ [key: string]: ColTypeDef }>();
const columnDefs = ref<ColDef<DataType>[]>();
const gridOptions = ref<GridOptions<DataType>>();

// grids - use base configuration
const { baseGridOptions, baseColumnTypes, baseDefaultColDef } = useDxGridBase();

// events
const onGridReady = (event: GridReadyEvent<DataType>) => {
  gridApi.value = event.api;
};

// methods
const initGrid = () => {
  // merge base options with custom options
  gridOptions.value = {
    ...baseGridOptions,
    rowSelection: 'single',
    onGridReady,
    onCellValueChanged: onCellValueChange,
    onSelectionChanged: onSelectionChanged
  };

  // merge base column types
  columnTypes.value = {
    ...baseColumnTypes,
    // add custom column types if needed
  };

  // merge base default column def
  defaultColDef.value = {
    ...baseDefaultColDef,
    // override if needed
  };

  // define columns
  columnDefs.value = [
    {
      field: 'name',
      headerName: t('feature.name'),
      editable: true,
      cellEditor: 'agTextCellEditor'
    },
    {
      field: 'amount',
      headerName: t('feature.amount'),
      editable: true,
      type: 'currencyColumn' // use baseColumnTypes
    }
  ];
};

// hooks
onBeforeMount(() => {
  initGrid();
});
</script>

<template>
  <AgGridVue
    class="ag-theme-dx-grid"
    style="width: 100%; height: 100%"
    :row-data="rowData"
    :grid-options="gridOptions"
    :column-defs="columnDefs"
    :default-col-def="defaultColDef"
    :column-types="columnTypes"
  />
</template>

Key Points:

  • ✅ Always use shallowRef for gridApi and large data arrays
  • ✅ Extend baseGridOptions, baseColumnTypes, and baseDefaultColDef
  • ✅ Use ref for grid configuration objects
  • ✅ Initialize grid in onBeforeMount() hook

Example: apps/web/src/views/main/client/process/cashbook-entries/containers/CashbookEntryGrid.vue

Pinned Rows for New Entries

Use pinned top rows for new entry rows:

typescript
const initNewRow = () => {
  const newRow: Partial<DataType> & { $newEntry: boolean } = {
    id: guid(),
    $newEntry: true,
    date: selectedPeriod.value?.end
  };
  gridApi.value?.setGridOption('pinnedTopRowData', [newRow]);
  return newRow;
};

const addNewRow = () => {
  const pinnedRow = gridApi.value?.getPinnedTopRow(0);
  if (pinnedRow?.data && pinnedRow.data.$dirty) {
    const newData = pinnedRow.data;

    // Add to main data
    items.value.unshift(newData);
    _.assign(newData, { $dirty: true, $newEntry: false });

    // Clear pinned row and refresh
    gridApi.value?.setGridOption('pinnedTopRowData', []);
    gridApi.value?.setGridOption('rowData', items.value);

    // Initialize new pinned row
    initNewRow();
  }
};

Use pinned bottom rows for totals:

typescript
const setGridFooter = () => {
  if (!items.value || items.value.length === 0) {
    gridApi.value?.setGridOption('pinnedBottomRowData', [
      { $editable: false, name: '0 Items', amount: 0 }
    ]);
    return;
  }

  const count = items.value.length;
  const total = _.sum(items.value.map(item => item.amount ?? 0));

  gridApi.value?.setGridOption('pinnedBottomRowData', [
    { $editable: false, name: `${count} Items`, amount: total }
  ]);
};

Column Definitions

Common Column Patterns:

typescript
// Text column
{
  field: 'name',
  headerName: t('feature.name'),
  editable: !isReadOnly.value,
  cellEditor: 'agTextCellEditor'
}

// Number column
{
  field: 'amount',
  headerName: t('feature.amount'),
  editable: !isReadOnly.value,
  cellEditor: 'agNumberCellEditor',
  valueFormatter: (params) => formatCurrency(params.value),
  type: 'numericColumn'
}

// Date column
{
  field: 'date',
  headerName: t('feature.date'),
  editable: !isReadOnly.value,
  cellEditor: 'DxGridDateEditor',
  cellRenderer: 'DxGridDateRenderer'
}

// Select column
{
  field: 'accountId',
  headerName: t('feature.account'),
  editable: !isReadOnly.value,
  cellEditor: 'DxGridSelectEditor',
  cellEditorParams: {
    options: accounts.value,
    optionLabel: 'name',
    optionValue: 'id'
  }
}

// Checkbox column
{
  field: 'isActive',
  headerName: t('feature.active'),
  editable: !isReadOnly.value,
  cellEditor: 'DxGridCheckboxEditor',
  cellRenderer: 'DxGridCheckboxRenderer'
}

// Action column
{
  headerName: '',
  cellRenderer: 'ActionRenderer',
  cellRendererParams: {
    onDelete: (data: DataType) => deleteRow(data)
  },
  width: 80,
  pinned: 'right'
}

Cell Editing

Handle cell edits:

typescript
const onCellValueChange = (event: CellValueChangedEvent) => {
  const data = event.data;

  // mark as dirty
  _.assign(data, { $dirty: true });

  // trigger validation
  validateRow(data);

  // update footer
  setGridFooter();

  // refresh row
  gridApi.value?.redrawRows({ rowNodes: [event.node] });
};

Transaction API for Updates

Always use the Transaction API (applyTransaction or applyTransactionAsync) for better performance instead of replacing all row data:

Bad (Re-renders entire grid):

typescript
// avoid this - replaces all data
gridApi.value?.setGridOption('rowData', items.value);

Good (Updates only changed rows):

typescript
// add new rows
gridApi.value?.applyTransaction({
  add: [newItem]
});

// update existing rows
gridApi.value?.applyTransaction({
  update: [updatedItem]
});

// delete rows
gridApi.value?.applyTransaction({
  remove: [deletedItem]
});

// async transaction for bulk updates
gridApi.value?.applyTransactionAsync(
  { add: newItems, update: updatedItems },
  (result) => {
    console.log('Transaction complete', result);
  }
);

Example: apps/web/src/views/main/client/process/journal-entries/composables/use-journal-grid-control.ts

Row Identification with getRowId

Always provide getRowId in baseGridOptions for efficient row updates:

typescript
const gridOptions: GridOptions = {
  ...baseGridOptions,
  getRowId: (params: GetRowIdParams) => {
    return params.data.id; // use unique identifier
  }
};

Why: AG Grid uses this to track rows across updates, enabling efficient transaction API usage.

Location: Already configured in useDxGridBase() at apps/web/src/utils/dx-grid-base.ts:211

Column Types for Consistency

Use predefined column types from baseColumnTypes:

typescript
const columnDefs: ColDef[] = [
  {
    field: 'amount',
    headerName: t('feature.amount'),
    type: 'currencyColumn', // predefined in baseColumnTypes
    editable: true
  },
  {
    field: 'quantity',
    headerName: t('feature.quantity'),
    type: 'numberColumn', // predefined in baseColumnTypes
    editable: true
  },
  {
    field: 'status',
    headerName: t('feature.status'),
    type: 'enumColumn', // predefined in baseColumnTypes
    editable: true
  }
];

Available Column Types (from useDxGridBase()):

  • currencyColumn - Right-aligned currency with decimal formatting
  • numberColumn - Right-aligned number with decimal formatting
  • enumColumn - Lookup-based columns with formatter and filter

Custom Cell Renderers

Create cell renderers using DOM elements, not HTML strings (security):

Bad (XSS risk):

typescript
const cellRenderer = (params: ICellRendererParams) => {
  return `<div>${params.value}</div>`; // unsafe
};

Good (Safe DOM creation):

typescript
const cellRenderer = (params: ICellRendererParams) => {
  if (!params.data) return '';

  const div = document.createElement('div');
  div.textContent = params.value;

  // add classes
  div.classList.add('custom-cell');

  // add conditional styling
  if (params.data.$dirty) {
    div.classList.add('tw-font-bold');
  }

  return div;
};

Example: apps/web/src/views/main/client/process/cashbook-entries/containers/CashbookEntryGrid.vue:139

Row Class Rules for Visual State

Use rowClassRules in baseGridOptions for conditional row styling:

typescript
const gridOptions: GridOptions = {
  ...baseGridOptions,
  rowClassRules: {
    modified: (params) => params.data?.$dirty,      // show modified rows
    invalid: (params) => params.data?.hasError,     // show errors
    'has-errors': (params) => params.data?.$errors && !_.isEmpty(params.data?.$errors)
  }
};

Already configured in useDxGridBase() - rows automatically styled based on state.

Value Getters, Setters, and Formatters

For complex column logic, use value getters, setters, and formatters:

typescript
// value getter - compute display value
const accountValueGetter = (params: ValueGetterParams<DataType>) => {
  const account = _.find(accounts.value, (a) =>
    _.toLower(params.data?.accountId) === _.toLower(a.id)
  );
  return account?.id ?? '';
};

// value setter - handle updates
const accountValueSetter = (params: NewValueParams<DataType>) => {
  if (params.newValue) {
    const account = _.find(accounts.value, (a) => a.id === params.newValue);
    if (account?.id) {
      params.data.accountId = account.id;
      _.assign(params.data, { $account: account, $dirty: true });
      return true;
    }
  }
  return false;
};

// value formatter - display formatting
const accountValueFormatter = (params: ICellRendererParams<DataType>) => {
  const account = _.find(accounts.value, (a) =>
    _.toLower(params.data?.accountId) === _.toLower(a.id)
  );
  return account?.$accountName ?? '';
};

// use in column definition
const columnDefs: ColDef[] = [
  {
    field: 'accountId',
    headerName: t('feature.account'),
    valueGetter: accountValueGetter,
    valueSetter: accountValueSetter,
    valueFormatter: accountValueFormatter,
    editable: true
  }
];

Example: apps/web/src/views/main/client/process/journal-entries/composables/use-journal-grid-control.ts:150-196

Grid State Synchronization

Sync grid data with Vue reactive state after transactions:

typescript
import { useDxGridData } from '@/utils';

// utils
const { getGridData } = useDxGridData(gridApi);

// after transaction
const onApplyTransaction = (event: RowNodeTransaction) => {
  // sync grid data with reactive state
  items.value = getGridData();

  // update related state
  updateFooter();
  updateSelection();
};

Example: apps/web/src/views/main/client/process/journal-entries/composables/use-journal-grid-control.ts:77-102

Editable Callback Pattern

Use editable callbacks for dynamic editability:

typescript
const columnEditable = (params: EditableCallbackParams) => {
  // check locked state
  if (_.get(params.data, '$locked')) return false;

  // check if row has ID
  if (!params.data?.id) return false;

  // check user permissions
  if (!hasEditPermission.value) return false;

  return true;
};

const columnDefs: ColDef[] = [
  {
    field: 'amount',
    headerName: t('feature.amount'),
    editable: columnEditable // dynamic editability
  }
];

Example: apps/web/src/views/main/client/process/journal-entries/composables/use-journal-grid-control.ts:146

Stop Editing Before Operations

Always stop editing before performing operations:

typescript
const saveData = async () => {
  // stop editing first
  gridApi.value?.stopEditing();

  setTimeout(async () => {
    // perform save operation
    const dataToSave = getUnsavedData();
    await dataStore.saveData(dataToSave);
  }, 500); // allow time for value change events
};

Why: Ensures all pending edits are committed before reading data.

Grid Performance Tips

  1. Use shallowRef for grid API and data:

    typescript
    const gridApi = shallowRef<GridApi | null>(null);
    const rowData = shallowRef<DataType[]>([]);
  2. Use asyncTransactionWaitMillis (already in baseGridOptions):

    typescript
    asyncTransactionWaitMillis: 50 // batch updates
  3. Disable animations for large datasets (already in baseGridOptions):

    typescript
    animateRows: false
  4. Use getRowId for efficient updates (already in baseGridOptions)

  5. Avoid setGridOption('rowData', ...) for updates - use transactions instead


Authentication and Authorization

OIDC Configuration

Authentication is configured in packages/core/src/security/auth.config.ts:

typescript
import { UserManagerSettings, WebStorageStateStore } from 'oidc-client-ts';

export const AuthConfig: UserManagerSettings = {
  userStore: new WebStorageStateStore({ store: window.sessionStorage }),
  authority: import.meta.env.VITE_AUTH_SERVER,
  client_id: import.meta.env.VITE_AUTH_CLIENT,
  redirect_uri: `${window.location.origin}/callback`,
  post_logout_redirect_uri: window.location.origin,

  response_type: 'code',
  scope: import.meta.env.VITE_AUTH_SCOPE,

  loadUserInfo: true,

  silent_redirect_uri: `${window.location.origin}/silent-renew.html`,
  automaticSilentRenew: true,

  monitorAnonymousSession: true,
  revokeTokensOnSignout: true,

  filterProtocolClaims: false
};

Initialize in main.ts:

typescript
import { createAuth } from '@repo/vue/plugins';
import { AuthConfig } from '@repo/core/security';

const app = createApp(App);
app.use(createAuth(AuthConfig));

Auth Guards

Protect routes:

typescript
import { authGuard } from '@repo/vue/plugins';

{
  path: '/clients',
  beforeEnter: authGuard,
  component: () => import('@/views/ClientListingView.vue')
}

Access auth state:

typescript
import { useAppStore } from '@repo/vue/stores';
import { storeToRefs } from 'pinia';

const appStore = useAppStore();
const { user, isAuthenticated } = storeToRefs(appStore);

// Check claims
if (user.value?.profile?.role === 'admin') {
  // Admin-only logic
}

Token Management

Tokens are automatically managed:

  • Access Token: Added to API requests via interceptor
  • Refresh Token: Automatically refreshed on expiry
  • Silent Renew: Uses hidden iframe to refresh tokens

Event System

Event Bus Pattern

Use the event bus for cross-component communication:

typescript
import { useDxEventBus } from '@repo/vue/utils';
import { EventConstants } from '@repo/core/constants';

// utils
const dxEventBus = useDxEventBus();

// listeners
const eventBusListener = async (event: string, payload: EventPayload) => {
  switch (event) {
    case EventConstants.closeClient:
      await onCloseClient();
      break;
    case EventConstants.save:
      await onSave(payload);
      break;
  }
};
dxEventBus.on(eventBusListener);

// emit events
dxEventBus.emit(EventConstants.selectedYearChanged, selectedPeriod.value);

Event Constants

All events are defined in packages/core/src/constants/event.constants.ts:

typescript
export const EventConstants = {
  save: 'save',
  reload: 'reload',
  closeClient: 'closeClient',
  selectedYearChanged: 'selectedYearChanged',
  // ... more events
};

Always use constants instead of string literals.

Cleanup

Event bus listeners in <script setup> are automatically cleaned up on component unmount. No manual cleanup needed.


Form Handling

Vuelidate Integration

typescript
import { useVuelidate } from '@vuelidate/core';
import { required, email, minLength } from '@vuelidate/validators';

const formData = ref({
  name: '',
  email: '',
  password: ''
});

const rules = {
  name: { required },
  email: { required, email },
  password: { required, minLength: minLength(8) }
};

const v$ = useVuelidate(rules, formData);

const submitForm = async () => {
  const isValid = await v$.value.$validate();
  if (!isValid) {
    dxToast.error(t('form.validation_failed'));
    return;
  }

  // Submit form
  await saveData(formData.value);
};

Form Validation

Display validation errors:

vue
<template>
  <div class="field">
    <label for="name">{{ t('form.name') }}</label>
    <InputText
      id="name"
      v-model="formData.name"
      :class="{ 'p-invalid': v$.name.$error }"
      @blur="v$.name.$touch()"
    />
    <small v-if="v$.name.$error" class="p-error">
      {{ v$.name.$errors[0].$message }}
    </small>
  </div>
</template>

Form Submission

typescript
const isSubmitting = ref(false);

const submitForm = async () => {
  // Validate
  const isValid = await v$.value.$validate();
  if (!isValid) return;

  // Submit
  isSubmitting.value = true;
  try {
    await saveData(formData.value);
    dxToast.success(t('form.saved_successfully'));
  } catch (err: unknown) {
    const error = err as ApiError;
    dxToast.error(error?.response?.data?.message ?? t('form.save_failed'));
  } finally {
    isSubmitting.value = false;
  }
};

UI Components

PrimeVue Usage

PrimeVue components are manually imported:

typescript
import Button from 'primevue/button';
import InputText from 'primevue/inputtext';
import Dropdown from 'primevue/dropdown';
import Dialog from 'primevue/dialog';
import DataTable from 'primevue/datatable';
import Column from 'primevue/column';

Common Components:

  • Button - Buttons
  • InputText - Text inputs
  • Dropdown - Select dropdowns
  • Dialog - Modals
  • DataTable - Data tables
  • Calendar - Date pickers
  • Toast - Toast notifications
  • ConfirmDialog - Confirmation dialogs

Custom Components

Custom components are in packages/vue/src/components:

typescript
import { DxLoader, DxModal, DxSelect, DxDatePicker } from '@repo/vue/components';

Available Components:

  • DxLoader - Loading spinner/bar
  • DxModal - Custom modal
  • DxSelect - Enhanced select dropdown
  • DxDatePicker - Date picker
  • DxAutoComplete - Autocomplete input
  • DxCurrency - Currency input
  • DxEditor - Rich text editor
  • DxPdfViewer - PDF viewer

Component Library

AG Grid Cell Renderers/Editors:

  • DxGridCheckboxEditor / DxGridCheckboxRenderer
  • DxGridDateEditor / DxGridDateRenderer
  • DxGridSelectEditor / DxGridSelectRenderer

User Feedback

Toast Notifications

typescript
import { useDxToast } from '@repo/vue/utils';

// utils
const dxToast = useDxToast();

// success
dxToast.success(t('message.saved_successfully'));

// error
dxToast.error(t('message.save_failed'));

// warning
dxToast.warn(t('message.unsaved_changes'));

// info
dxToast.info(t('message.loading'));

Confirmation Dialogs

typescript
import { useDxConfirm } from '@repo/vue/utils';

// utils
const dxConfirm = useDxConfirm();

// confirmation
const confirmed = await dxConfirm.confirmation(
  t('message.confirm_delete'),
  t('message.confirm_delete_description'),
  t('message.yes'),
  t('message.no')
);

if (confirmed) {
  await deleteData();
}

// error dialog
await dxConfirm.error(
  t('message.error'),
  t('message.operation_failed'),
  t('message.ok')
);

// warning dialog
await dxConfirm.warning(
  t('message.warning'),
  t('message.unsaved_changes'),
  t('message.ok')
);

Loading States

typescript
import { useDxLoader } from '@repo/vue/utils';

// utils
const { startLoader, stopLoader } = useDxLoader();

// methods
const loadData = async () => {
  startLoader();
  try {
    await fetchData();
  } finally {
    stopLoader();
  }
};

Progress Indicators

typescript
import { useDxProgress } from '@repo/vue/utils';

// utils
const dxProgress = useDxProgress();

// methods
const uploadFile = async (file: File) => {
  dxProgress.start();

  try {
    // Simulate progress
    for (let i = 0; i <= 100; i += 10) {
      dxProgress.set(i);
      await new Promise(resolve => setTimeout(resolve, 100));
    }

    dxProgress.finish();
  } catch (err) {
    dxProgress.clear();
  }
};

Error Handling

Standard Error Pattern

typescript
// methods
const performAction = async () => {
  // validate
  if (hasUnsavedChanges()) {
    dxToast.error(t('feature.save_changes_first'));
    return;
  }

  // check selection
  const selectedRows = gridApi.value?.getSelectedRows() ?? [];
  if (selectedRows.length === 0) {
    await dxConfirm.error(
      t('message.error'),
      t('feature.no_items_selected'),
      t('message.ok')
    );
    return;
  }

  // perform action
  startLoader();
  try {
    await dataStore.performAction(selectedRows);
    dxToast.success(t('feature.action_completed'));
  } catch (err: unknown) {
    const error = err as ApiError;
    dxToast.error(
      error?.response?.data?.message ?? t('feature.action_failed')
    );
  } finally {
    stopLoader();
  }
};

Validation Before Operations

Always validate state before performing operations:

typescript
// methods
const deleteData = async () => {
  // check for unsaved changes
  if (hasUnsavedChanges()) {
    dxToast.error(t('feature.save_changes_first'));
    return;
  }

  // check for selection
  const selectedRows = gridApi.value?.getSelectedRows() ?? [];
  if (selectedRows.length === 0) {
    await dxConfirm.error(
      t('message.error'),
      t('feature.no_items_selected'),
      t('message.ok')
    );
    return;
  }

  // confirm deletion
  const confirmed = await dxConfirm.confirmation(
    t('message.confirm_delete'),
    t('feature.confirm_delete_description', { count: selectedRows.length }),
    t('message.yes'),
    t('message.no')
  );

  if (!confirmed) return;

  // delete
  try {
    await dataStore.deleteItems(selectedRows.map(r => r.id));
    dxToast.success(t('feature.deleted_successfully'));
  } catch (err: unknown) {
    const error = err as ApiError;
    dxToast.error(
      error?.response?.data?.message ?? t('feature.delete_failed')
    );
  }
};

User-Friendly Messages

Always provide context in error messages:

Bad:

typescript
dxToast.error('Error');

Good:

typescript
dxToast.error(t('feature.could_not_save_data'));
// OR
dxToast.error(
  error?.response?.data?.message ?? t('feature.could_not_save_data')
);

Memory Management

Timer Cleanup

Always clean up timers and intervals:

Bad:

typescript
setTimeout(async () => {
  await loadData();
}, 500);

Good:

typescript
import { onBeforeUnmount, ref } from 'vue';

// data
const timeoutIds = new Set<NodeJS.Timeout>();

// methods
const safeTimeout = (callback: () => void, delay: number) => {
  const id = setTimeout(() => {
    timeoutIds.delete(id);
    callback();
  }, delay);
  timeoutIds.add(id);
  return id;
};

// hooks
onBeforeUnmount(() => {
  timeoutIds.forEach(id => clearTimeout(id));
  timeoutIds.clear();
});

// usage
safeTimeout(async () => {
  await loadData();
}, 500);

Route Leave Guards

Clean up ongoing operations when navigating away:

typescript
import { onBeforeRouteLeave } from 'vue-router';

// state
const isTabSaving = ref(false);

// hooks
onBeforeRouteLeave((to, from, next) => {
  let attempts = 0;
  const maxAttempts = 10;

  const interval = setInterval(() => {
    attempts++;
    if (!isTabSaving.value) {
      clearInterval(interval);
      next();
    } else if (attempts >= maxAttempts) {
      clearInterval(interval);
      next(false);
    }
  }, 500);

  // cleanup function if navigation is cancelled
  return () => {
    clearInterval(interval);
  };
});

What Auto-Cleans

Automatically cleaned up in Vue 3 <script setup>:

  • watch and watchEffect
  • computed properties
  • Reactive state (ref, reactive, shallowRef)
  • Event bus listeners (via VueUse)
  • VueUse composables

Manual Cleanup Required

Requires manual cleanup:

  • setTimeout and setInterval
  • DOM event listeners (addEventListener)
  • Third-party library subscriptions
  • WebSocket connections
  • Resource handles (PDF.js documents, file readers)

Internationalization

Translation Keys

All user-facing text must use i18n:

Good:

typescript
import { useI18n } from 'vue-i18n';

// locale
const { t } = useI18n<[MessageSchema, DateTimeSchema, NumberSchema]>();

// simple translation
const title = t('feature.title');

// with parameters
const message = t('feature.items_saved', { count: 5 });

// conditional text
const text = count === 1
  ? t('feature.item')
  : t('feature.items');

Bad:

typescript
const title = 'Feature Title'; // Hardcoded text
const message = `Saved ${count} items`; // Not translatable

Translation File Organization

Translation files are located in apps/web/src/locales/:

Example (en-ZA.json):

json
{
  "feature": {
    "title": "Feature Title",
    "header": "Feature Header",
    "save": "Save",
    "cancel": "Cancel",
    "item": "Item",
    "items": "Items",
    "items_saved": "{count} {itemText} saved successfully",
    "could_not_save": "Could not save data",
    "confirm_delete": "Are you sure you want to delete {count} {itemText}?"
  }
}

Locale Management

Add missing keys:

bash
pnpm locale

Add and remove unused keys:

bash
pnpm locale:fix

Testing

Unit Testing

Write unit tests for:

  • Business logic in composables
  • Utility functions
  • Calculations and validations
  • Store actions

Example (account-category.store.spec.ts):

typescript
import { useAccountCategoryStore } from '@/stores';
import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia';

describe('accountCategoryStore', () => {
  beforeEach(() => {
    setActivePinia(createTestingPinia({ stubActions: true }));
  });

  it('fetchAccountCategories', async () => {
    const store = useAccountCategoryStore();
    expect(store.accountCategories).toEqual([]);
    await store.fetchAccountCategories();
    expect(store.accountCategories).toEqual([]);
  });

  it('fetchAccountCategoryByKey', async () => {
    const store = useAccountCategoryStore();
    expect(store.accountCategory).toBeUndefined();
    await store.fetchAccountCategoryByKey(10);
    expect(store.accountCategory).toBeUndefined();
  });
});

Run tests:

bash
pnpm test
pnpm test:watch
pnpm test:coverage

Integration Testing

Test component interactions:

typescript
import { mount } from '@vue/test-utils';
import { describe, it, expect } from 'vitest';
import FeatureComponent from './FeatureComponent.vue';

describe('FeatureComponent', () => {
  it('renders correctly', () => {
    const wrapper = mount(FeatureComponent, {
      props: {
        title: 'Test Title'
      }
    });
    expect(wrapper.text()).toContain('Test Title');
  });

  it('emits event on button click', async () => {
    const wrapper = mount(FeatureComponent);
    await wrapper.find('button').trigger('click');
    expect(wrapper.emitted()).toHaveProperty('submit');
  });
});

E2E Testing

Use Playwright for end-to-end tests:

Configuration (playwright.config.ts):

typescript
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: 'html',
  use: {
    baseURL: 'http://127.0.0.1:8080',
    trace: 'on-first-retry',
    storageState: 'playwright/.auth/user.json'
  },
  projects: [
    { name: 'setup', testMatch: /.*\.setup\.ts/ },
    {
      name: 'chromium',
      use: {
        ...devices['Desktop Chrome'],
        storageState: 'playwright/.auth/user.json'
      },
      dependencies: ['setup']
    }
  ]
});

Example Test:

typescript
import { test, expect } from '@playwright/test';

test.describe('Feature Workflow', () => {
  test('should create and save new item', async ({ page }) => {
    await page.goto('/feature');

    // Wait for grid to load
    await page.waitForSelector('.ag-theme-dx-grid');

    // Add new item
    await page.click('[data-testid="add-item"]');
    await page.fill('[data-testid="item-name"]', 'Test Item');
    await page.fill('[data-testid="item-amount"]', '100');

    // Save
    await page.click('[data-testid="save"]');

    // Verify success message
    await expect(page.locator('.p-toast-message-success')).toBeVisible();
  });
});

Run E2E tests:

bash
cd apps/web
pnpm test:e2e
pnpm test:e2e:ui
pnpm test:e2e:report

Test Organization

apps/web/
├── tests/                    # Unit tests
│   ├── stores/
│   └── components/
└── e2e/                      # E2E tests
    ├── auth.setup.ts
    └── feature.spec.ts

Build and Deployment

Build Configuration

Vite Configuration (vite.config.ts):

typescript
export default defineConfig({
  plugins: [
    vue(),
    VueDevTools({ launchEditor: 'webstorm' }),
    VueI18nPlugin({
      include: [
        resolve(__dirname, './src/locales/**'),
        resolve(__dirname, '../../packages/vue/src/locales/**')
      ]
    }),
    removeConsole(),
    visualizer({
      open: true,
      gzipSize: true,
      filename: 'dist/stats.html',
      brotliSize: true
    })
  ],
  server: {
    proxy: {
      '/api': {
        target: process.env.RESOURCE_SERVER_BASE,
        changeOrigin: true,
        secure: false,
        rewrite: (path) => _.replace(path, /^\/api/, '')
      }
    }
  },
  build: {
    sourcemap: true,
    chunkSizeWarningLimit: 5000,
    rollupOptions: {
      output: {
        manualChunks: (id) => {
          if (_.includes(id, 'node_modules')) {
            return 'vendor';
          }
        }
      }
    }
  }
});

Environment Variables

Required Environment Variables:

  • VITE_AUTH_SERVER - OIDC authority server
  • VITE_AUTH_CLIENT - OIDC client ID
  • VITE_AUTH_SCOPE - OIDC scopes
  • RESOURCE_SERVER_BASE - API base URL

Access in code:

typescript
const authServer = import.meta.env.VITE_AUTH_SERVER;
const isDev = import.meta.env.DEV;
const isProd = import.meta.env.PROD;

Docker Support

Build and run:

bash
pnpm docker:dev

Individual commands:

bash
pnpm docker:build
pnpm docker:up
pnpm docker:down

Performance Best Practices

Bundle Optimization

Code Splitting:

  • Use lazy loading for routes
  • Use defineAsyncComponent for modals
  • Split vendor chunks

Bundle Analysis:

bash
pnpm build
# Opens dist/stats.html with bundle visualization

Lazy Loading

Routes:

typescript
{
  path: '/feature',
  component: () => import('@/views/FeatureView.vue')
}

Components:

typescript
const FeatureModal = defineAsyncComponent(
  () => import('./FeatureModal.vue')
);

Reactivity Optimization

Use shallowRef for large data:

typescript
// Good for large arrays
const items = shallowRef<DataType[]>([]);

// Good for AG Grid API
const gridApi = shallowRef<GridApi | null>(null);

Use computed for derived state:

typescript
const filteredItems = computed(() => {
  return items.value.filter(item => item.isActive);
});

Grid Performance

Use getRowId for efficient updates:

typescript
const gridOptions: GridOptions = {
  getRowId: (params) => params.data.id
};

Use transaction API:

typescript
// Good
gridApi.value?.applyTransaction({
  add: [newItem],
  update: [updatedItem],
  remove: [deletedItem]
});

// Avoid
gridApi.value?.setGridOption('rowData', items.value);

Security Guidelines

Authentication

  • Always use OIDC for authentication
  • Store tokens in session storage
  • Use automatic silent renew
  • Revoke tokens on logout

Authorization

  • Check user roles/claims in route guards
  • Hide UI elements based on permissions
  • Validate permissions on backend

Data Protection

  • Never store sensitive data in local storage
  • Always use HTTPS in production
  • Sanitize user input
  • Use CSP headers

Accessibility

Keyboard Navigation

  • Ensure all interactive elements are keyboard accessible
  • Use proper tab order
  • Implement keyboard shortcuts for common actions

Screen Reader Support

  • Use semantic HTML
  • Add ARIA labels where needed
  • Ensure dynamic content updates are announced

ARIA Labels

vue
<button
  aria-label="Delete item"
  @click="deleteItem"
>
  <Icon name="trash" />
</button>

Code Style

ESLint Configuration

ESLint is configured in eslint.config.js:

javascript
import eslintConfig from '@repo/eslint-config';

export default eslintConfig;

Run ESLint:

bash
pnpm lint
pnpm lint:fix

Prettier Configuration

Prettier is configured in .prettierrc:

json
{
  "semi": true,
  "tabWidth": 2,
  "singleQuote": true,
  "printWidth": 120,
  "endOfLine": "auto",
  "trailingComma": "none"
}

Run Prettier:

bash
pnpm format
pnpm format:check

Code Formatting

General Rules:

  • Use single quotes for strings
  • Use semicolons
  • 2-space indentation
  • Max line length: 120 characters
  • No trailing commas

Vue Style:

  • Use <script setup lang="ts">
  • Order: script, template, style
  • Use scoped styles

TypeScript Style:

  • Prefer interfaces over types
  • Use explicit return types for functions
  • Avoid any type

Utility Functions Pattern

The project uses a consistent pattern for utility functions via composables that provide reusable functionality. All utilities follow the useDx*() naming convention and are organized by responsibility.

Comment Section for Utils

Always group utility function imports under a // utils comment:

typescript
import { useDxToast, useDxConfirm, useDxModal } from '@repo/vue/utils';

// utils
const dxToast = useDxToast();
const dxConfirm = useDxConfirm();
const dxModal = useDxModal();

Available Utilities

From @repo/vue/utils (Shared across all apps):

  • useDxToast() - Toast notifications
  • useDxConfirm() - Confirmation dialogs
  • useDxModal() - Modal management
  • useDxLoader() - Loading indicators (internal use)
  • useDxProgress() - Global progress bar and spinner
  • useDxEventBus() - Event bus for cross-component communication
  • useDxPrime() - PrimeVue utilities and formatters
  • useDxFileSaver() - File download and save
  • useDxCustomModal() - Custom modal utilities
  • useDxMessage() - PostMessage communication (iFrame)

From @/utils (Web app specific):

  • useDxState() - Application state management and navigation
  • useDxGridBase() - AG Grid base configuration
  • useDxGridData() - AG Grid data synchronization
  • useDxGridFormat() - AG Grid formatting utilities
  • useDxClientProcess() - Client process utilities
  • useDxRibbon() - Ribbon bar utilities
  • useDxSpreadsheet() - Spreadsheet utilities
  • useDxFiresheet() - Firesheet utilities

Toast Notifications (useDxToast)

Display temporary messages to users:

typescript
import { useDxToast } from '@repo/vue/utils';

// utils
const dxToast = useDxToast();

// success (3 second duration)
dxToast.success(t('feature.data_saved'));
dxToast.success(t('feature.data_saved'), t('message.success'));

// error (6 second duration)
dxToast.error(t('feature.save_failed'));
dxToast.error(t('feature.save_failed'), t('message.error'));

// warning (6 second duration)
dxToast.warn(t('feature.unsaved_changes'));

// info (3 second duration)
dxToast.info(t('feature.loading_data'));

// custom duration
dxToast.success(t('feature.completed'), t('message.success'), 5000);

Key Points:

  • Always use i18n for messages
  • Success/info: 3 seconds (short-lived feedback)
  • Warning/error: 6 seconds (give users time to read)
  • Errors should include context about what failed

Confirmation Dialogs (useDxConfirm)

Show modal confirmation dialogs:

typescript
import { useDxConfirm } from '@repo/vue/utils';

// utils
const dxConfirm = useDxConfirm();

// confirmation dialog
const confirmed = await dxConfirm.confirmation(
  t('message.confirm_delete'),
  t('feature.delete_confirmation_message'),
  t('message.yes'),
  t('message.no')
);

if (confirmed) {
  await deleteData();
}

// error dialog
await dxConfirm.error(
  t('message.error'),
  t('feature.operation_failed'),
  t('message.ok')
);

// warning dialog
await dxConfirm.warning(
  t('message.warning'),
  t('feature.unsaved_changes_warning'),
  t('message.continue'),
  t('message.cancel')
);

// information dialog
await dxConfirm.information(
  t('message.info'),
  t('feature.important_information'),
  t('message.ok')
);

// custom dialog with size and help route
await dxConfirm.custom(
  t('feature.custom_title'),
  t('feature.custom_message'),
  t('message.ok'),
  t('message.cancel'),
  { type: 'confirm' },
  'lg',
  '/help/custom-feature'
);

// close all modals
await dxConfirm.closeAllModals();

Dialog Types:

  • confirmation() - Yes/No confirmations
  • error() - Error messages
  • warning() - Warning messages
  • information() - Informational messages
  • custom() - Custom dialog with options

Parameters:

  • header - Dialog title
  • message - Dialog message (supports HTML)
  • btnOk - OK button text
  • btnCancel - Cancel button text (optional)
  • size - Dialog size: 'sm', 'md', 'lg', 'xl' (optional)
  • helpRoute - Help route for help button (optional)

Programmatically open and manage modals:

typescript
import { useDxModal } from '@repo/vue/utils';
import FeatureModal from './FeatureModal.vue';

// utils
const dxModal = useDxModal();

// open modal (closes all existing modals)
const modal = await dxModal.open(FeatureModal, {
  title: t('feature.modal_title'),
  data: selectedItem.value
});

// listen for modal events
modal.on('confirm', async (result) => {
  await saveData(result);
});

// push modal (stacks on top of existing modals)
const nestedModal = await dxModal.push(DetailModal, {
  itemId: selectedItem.value.id
});

// prompt modal (waits for result)
const result = await dxModal.prompt(ConfirmModal, {
  message: t('feature.confirm_message')
});

// close top modal
await dxModal.pop();

// close all modals
await dxModal.close();

Methods:

  • open() - Closes all existing modals, then opens new one
  • push() - Stacks modal on top of existing modals
  • prompt() - Shows modal and waits for result
  • pop() - Closes the top modal
  • close() - Closes all modals

See Modal Patterns section for more details.

Global Progress (useDxProgress)

Global loading bar and spinner for async operations:

typescript
import { useDxProgress } from '@repo/vue/utils';

// utils
const { startLoader, stopLoader, clearLoader } = useDxProgress();

// methods
const loadData = async () => {
  startLoader(); // shows both bar and spinner
  try {
    await fetchData();
  } finally {
    stopLoader(); // hides both bar and spinner
  }
};

// autosave mode (no spinner, only bar)
const saveData = async () => {
  startLoader(true); // autoSave = true
  try {
    await saveToServer();
  } finally {
    stopLoader();
  }
};

// force clear (emergency cleanup)
clearLoader();

Key Points:

  • Global state shared across entire application
  • Progress bar throttled to 100ms (appears quickly)
  • Spinner throttled to 900ms (appears only for longer operations)
  • Automatically tracks multiple concurrent requests
  • startLoader(true) - Auto-save mode (no spinner)
  • Always pair startLoader() with stopLoader() in try-finally

Event Bus (useDxEventBus)

Cross-component communication without prop drilling:

typescript
import { useDxEventBus } from '@repo/vue/utils';
import { EventConstants } from '@repo/core/constants';

// utils
const dxEventBus = useDxEventBus();

// emit events
dxEventBus.emit(EventConstants.save, { type: 'data', items: [] });
dxEventBus.emit(EventConstants.reload);
dxEventBus.emit(EventConstants.selectedYearChanged, selectedPeriod.value);

// listen for events
const eventBusListener = async (event: string, payload: any) => {
  switch (event) {
    case EventConstants.save:
      await onSave(payload);
      break;
    case EventConstants.reload:
      await onReload();
      break;
    case EventConstants.closeClient:
      await onCloseClient();
      break;
  }
};

dxEventBus.on(eventBusListener);

// listen once
dxEventBus.once((event, payload) => {
  console.log('One-time event:', event);
});

// manual cleanup (usually not needed in <script setup>)
dxEventBus.off(eventBusListener);

// reset all listeners
dxEventBus.reset();

Key Points:

  • Always use EventConstants for event names (never string literals)
  • Listeners in <script setup> auto-cleanup on unmount
  • Use for cross-cutting concerns (save, reload, close)
  • Avoid overuse - prefer props/emits for parent-child communication

Common Events (from EventConstants):

  • save - Trigger save operation
  • reload - Reload data
  • closeClient - Close current client
  • selectedYearChanged - Financial year changed
  • tabOff - Tab deactivated
  • accountLinkComplete - Account linking completed
  • reloadClientMenu - Reload client menu

Prime Utilities (useDxPrime)

Formatting, validation, and data utilities:

typescript
import { useDxPrime } from '@repo/vue/utils';

// utils
const dxPrime = useDxPrime();

// format numbers
const formatted = dxPrime.formatNumber(1234.56); // "1,234.56"

// format currency
const currency = dxPrime.formatCurrency(1234.56); // "R 1,234.56"
const usd = dxPrime.formatCurrency(1234.56, '$'); // "$ 1,234.56"

// format dates
const date = dxPrime.formatDate(new Date()); // "01/01/2024"
const custom = dxPrime.formatDate(new Date(), 'YYYY-MM-DD'); // "2024-01-01"

// format time
const time = dxPrime.formatTime(new Date()); // "14:30"

// generate GUID
const id = dxPrime.guid(); // "a1b2c3d4-e5f6-7890-abcd-ef1234567890"

// check if object has dirty flag
if (dxPrime.isDirty(dataItem)) {
  // Item has unsaved changes
}

// clear dirty flags
dxPrime.clearDirty(dataItem);

// excel date conversion
const jsDate = dxPrime.dateFormatterXlToJs(44927); // Convert Excel serial to JS Date
const xlDate = dxPrime.dateFormatterJsToXl(new Date()); // Convert JS Date to Excel serial

// format vuelidate errors
const errorMessage = dxPrime.formatErrors(v$.errors);

// generate CSV download
dxPrime.generateCSV(dataArray, 'export.csv');

// parse API response errors
const errors = dxPrime.parseResponseError(data, response, 'name', 5);

Available Methods:

  • formatNumber(input) - Format number with thousands separator
  • formatCurrency(input, symbol?) - Format currency (default: R)
  • formatDate(input, format?) - Format date (default: DD/MM/YYYY)
  • formatTime(input, format?) - Format time (default: HH:mm)
  • guid() - Generate UUID v4
  • isDirty(obj) - Check if object has $dirty flag
  • clearDirty(obj) - Remove all $dirty flags
  • dateFormatterXlToJs(serial) - Excel serial to JS Date
  • dateFormatterJsToXl(date) - JS Date to Excel serial
  • formatErrors(errors) - Format Vuelidate errors
  • generateCSV(data, filename) - Generate and download CSV
  • parseResponseError(data, response, field, top) - Parse API errors

File Operations (useDxFileSaver)

Download and save files:

typescript
import { useDxFileSaver } from '@repo/vue/utils';

// utils
const dxFileSaver = useDxFileSaver();

// save blob as file
const exportData = async () => {
  const blob = await dataService.exportExcel(financialYearId);
  dxFileSaver.saveAs(blob, 'export.xlsx');
};

// download file from axios response
const downloadReport = async () => {
  const response = await api.get('/reports/download', {
    responseType: 'blob'
  });
  dxFileSaver.downloadFile(response, 'report.pdf', 'application/pdf');
};

// save with options
dxFileSaver.saveAs(blob, 'data.json', {
  autoBom: true
});

Methods:

  • saveAs(data, filename?, options?) - Save blob or string as file
  • downloadFile(response, fileName, type) - Download from Axios response

Common MIME Types:

  • Excel: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
  • PDF: 'application/pdf'
  • CSV: 'text/csv'
  • JSON: 'application/json'
  • ZIP: 'application/zip'

Application State (useDxState)

Application-level state management and navigation (web app specific):

typescript
import { useDxState } from '@/utils';

// utils
const dxState = useDxState();

// navigation
await dxState.go({ name: 'clients' });
await dxState.go({ name: 'journalEntries', query: { tabId: '123' } });

// client operations
await dxState.openClient(clientId);
await dxState.closeClient();
await dxState.reload();

// tab operations
await dxState.addTab({
  id: guid(),
  name: 'Journal Entries',
  $name: 'journalEntries'
});
await dxState.removeTab(tabId);
await dxState.loadTab('journalEntries', tabId);

// authentication
await dxState.login();
await dxState.logout();

// events
dxState.save({ type: 'data', items: [] });
await dxState.reloadClientMenu();

// iframe communication
if (dxState.isIFrame.value) {
  dxState.sendMessage({
    event: 'customEvent',
    data: payload
  });
}

Key Methods:

  • go(to) - Navigate to route
  • openClient(clientId) - Open client
  • closeClient() - Close current client
  • reload() - Reload client data
  • addTab(data) - Add new tab
  • removeTab(tabId) - Remove tab
  • loadTab(page, tabId) - Load specific tab
  • login() / logout() - Authentication
  • save(payload) - Trigger save event
  • reloadClientMenu() - Reload client menu
  • sendMessage(data) - Send message to parent (iFrame mode)

Grid Data Utilities (useDxGridData)

Sync AG Grid with Vue reactive state:

typescript
import { useDxGridData } from '@/utils';

// state
const gridApi = shallowRef<GridApi | null>(null);

// utils
const { getGridData, setGridData, setGridFooter, saveGridData } = useDxGridData(gridApi);

// get current grid data
const data = getGridData();

// set grid data and refresh
setGridData(true, true, false); // reloadData, resizeColumns, hideFooter

// update footer with totals
setGridFooter();

// save grid data (debounced)
await saveGridData(async (data, options) => {
  return await dataStore.saveItems(data, options);
});

Methods:

  • getGridData() - Get all rows from grid
  • setGridData(reload?, resize?, hideFooter?) - Update grid and footer
  • setGridFooter() - Calculate and display totals
  • saveGridData(saveFunc, options?) - Debounced save (1 second)

Key Features:

  • Automatically tracks $dirty, $newEntry, $saving flags
  • Shows toast notification for saved entries
  • Handles validation errors per row
  • Emits events after save (accountLinkComplete, selectedYearChanged)
  • Restores cell focus after save

Grid Base Configuration (useDxGridBase)

Standard AG Grid configuration:

typescript
import { useDxGridBase } from '@/utils';

// grids
const { baseGridOptions, baseColumnTypes, baseDefaultColDef } = useDxGridBase();

// merge with custom options
gridOptions.value = {
  ...baseGridOptions,
  rowSelection: 'single',
  onGridReady,
  onCellValueChanged
};

columnTypes.value = {
  ...baseColumnTypes
};

defaultColDef.value = {
  ...baseDefaultColDef
};

Provides:

  • baseGridOptions - Standard grid configuration
  • baseColumnTypes - Currency, number, enum column types
  • baseDefaultColDef - Default column definition

See AG Grid Integration for complete details.

Best Practices

1. Always use // utils comment section:

typescript
// locale
const { t } = useI18n();

// store
const featureStore = useFeatureStore();

// state
const data = ref([]);

// utils
const dxToast = useDxToast();
const dxConfirm = useDxConfirm();
const dxModal = useDxModal();

// methods
const loadData = async () => {};

2. Group related utilities:

typescript
// utils
const dxToast = useDxToast();
const dxConfirm = useDxConfirm();
const dxModal = useDxModal();
const dxEventBus = useDxEventBus();
const dxPrime = useDxPrime();

3. Destructure when using multiple methods:

typescript
// utils
const { startLoader, stopLoader } = useDxProgress();
const { formatNumber, formatCurrency, guid } = useDxPrime();
const { getGridData, setGridData } = useDxGridData(gridApi);

4. Always use i18n with user-facing utilities:

typescript
// ✅ Good
dxToast.success(t('feature.data_saved'));
await dxConfirm.confirmation(
  t('message.confirm_delete'),
  t('feature.delete_confirmation')
);

// ❌ Bad
dxToast.success('Data saved');
await dxConfirm.confirmation('Delete?', 'Are you sure?');

5. Pair start/stop operations in try-finally:

typescript
// ✅ Good
const loadData = async () => {
  startLoader();
  try {
    await fetchData();
  } finally {
    stopLoader();
  }
};

// ❌ Bad
const loadData = async () => {
  startLoader();
  await fetchData();
  stopLoader(); // Won't run if fetchData throws
};

6. Use EventConstants for event bus:

typescript
// ✅ Good
dxEventBus.emit(EventConstants.save);
dxEventBus.on((event) => {
  if (event === EventConstants.save) {}
});

// ❌ Bad
dxEventBus.emit('save'); // Magic string

Common Patterns

The project uses jenesius-vue-modal via the useDxModal() wrapper. There are two primary methods for showing modals:

dxModal.open() - Replace Existing Modals

Use open() when you want to close all existing modals and show a new one. This is ideal for top-level modals triggered from the main UI.

typescript
import { useDxModal } from '@repo/vue/utils';
import UserProfileModal from './UserProfileModal.vue';

// utils
const dxModal = useDxModal();

// methods
const showUserProfile = async () => {
  // Closes any open modals and shows this one
  const modal = await dxModal.open(UserProfileModal, {
    userId: currentUser.value.id
  });

  modal.on('confirm', async (result) => {
    await saveProfile(result);
  });
};

Examples in codebase:

  • AppTopMenu.vue - User profile, practice settings, feedback
  • AppClientMenu.vue - File manager, roll forward, tax export

dxModal.push() - Stack Modals

Use push() when you want to stack modals on top of each other. This is ideal for multi-step workflows, wizards, or opening a modal from within another modal.

typescript
import { useDxModal } from '@repo/vue/utils';
import FeatureModal from './FeatureModal.vue';
import DetailModal from './DetailModal.vue';

// utils
const dxModal = useDxModal();

// methods
const showFeatureModal = async () => {
  // Shows modal without closing existing modals
  const modal = await dxModal.push(FeatureModal, {
    title: t('feature.modal_title'),
    data: selectedItem.value
  });

  modal.on('confirm', async (result: FeatureModalResult) => {
    if (result.success) {
      await saveData(result.data);
    }
  });
};

// Open a modal from within another modal
const showDetailFromModal = async () => {
  // Stacks on top of the existing modal
  const detailModal = await dxModal.push(DetailModal, {
    itemId: selectedItem.value.id
  });
};

Examples in codebase:

  • JournalEntriesView.vue - Import modals, shortcut keys, working trial balance
  • WorkingTrialBalanceModal.vue - Account mapping from trial balance
  • FileManagerModal.vue - Select file, create folder (nested modals)

When to Use Each

MethodUse CaseBehavior
open()Top-level modals from main UICloses all existing modals first
push()Nested modals, wizards, multi-step flowsStacks on top of existing modals

Other Modal Methods

typescript
// Close the top modal
await dxModal.pop();

// Close all modals
await dxModal.close();

// Prompt-style modal (waits for result)
const result = await dxModal.prompt(ConfirmModal, { message: 'Are you sure?' });

Grid Patterns

Standard grid setup:

typescript
// state
const gridApi = shallowRef<GridApi<DataType> | null>(null);
const rowData = shallowRef<DataType[]>([]);

// grid options
const gridOptions: GridOptions = {
  rowSelection: 'multiple',
  suppressCellFocus: false,
  getRowId: (params) => params.data.id,
  onGridReady: (event) => {
    gridApi.value = event.api;
  }
};

File Operations

Download file:

typescript
import { useDxFileSaver } from '@repo/vue/utils';

// utils
const dxFileSaver = useDxFileSaver();

// methods
const downloadFile = async () => {
  const blob = await dataService.exportExcel(financialYearId);
  dxFileSaver.saveAs(blob, 'export.xlsx');
};

Upload file:

typescript
// methods
const uploadFile = async (file: File) => {
  const formData = new FormData();
  formData.append('file', file);

  try {
    await dataService.upload(formData);
    dxToast.success(t('file.uploaded_successfully'));
  } catch (err) {
    dxToast.error(t('file.upload_failed'));
  }
};

Anti-Patterns

Don't:

  1. Use any type

    typescript
    const data: any = fetchData(); // Bad
  2. Use Object.assign (use lodash _.assign instead)

    typescript
    Object.assign(item, { $dirty: true }); // Bad
  3. Mutate props directly

    typescript
    props.item.name = 'New Name'; // Bad
  4. Use ref for large data

    typescript
    const items = ref<DataType[]>([]); // Bad for >100 items
  5. Forget to clean up timers

    typescript
    setTimeout(() => {}, 1000); // Bad - no cleanup
  6. Hardcode strings

    typescript
    const message = 'Item saved'; // Bad - not translatable
  7. Catch errors without handling

    typescript
    try {
      await saveData();
    } catch (err) {
      // Bad - no error handling
    }
  8. Use direct DOM manipulation

    typescript
    document.getElementById('item').innerHTML = 'Text'; // Bad
  9. Store sensitive data in local storage

    typescript
    localStorage.setItem('token', token); // Bad

Troubleshooting

Common Issues

Issue: Type errors in store Solution: Use storeToRefs for reactive state

Issue: AG Grid not updating Solution: Use transaction API or setGridOption('rowData', ...)

Issue: Memory leaks Solution: Clean up timers in onBeforeUnmount

Issue: Authentication errors Solution: Check OIDC configuration in .env.local

Issue: Bundle size too large Solution: Use lazy loading and code splitting

Issue: i18n keys missing Solution: Run pnpm locale to add missing keys


Code Review Checklist

Before submitting a PR, ensure:

Type Safety

  • [ ] No any types (use proper interfaces or unknown)
  • [ ] All function parameters are typed
  • [ ] All function returns are typed
  • [ ] Props interfaces defined for all components
  • [ ] Error objects properly typed

Memory Management

  • [ ] All setTimeout and setInterval cleaned up
  • [ ] Route guards with intervals have cleanup functions
  • [ ] No memory leaks in long-running operations
  • [ ] Third-party resources properly disposed

Vue Best Practices

  • [ ] Use shallowRef for AG Grid APIs and large datasets
  • [ ] Use storeToRefs for Pinia store state
  • [ ] Async components for modals
  • [ ] Proper lifecycle hook usage
  • [ ] No side effects in computed properties

Code Organization

  • [ ] Complex logic extracted to composables
  • [ ] Composables use context pattern
  • [ ] Feature files properly structured
  • [ ] No files over 800 lines (refactor if needed)

Error Handling

  • [ ] All API calls wrapped in try-catch
  • [ ] User-friendly error messages
  • [ ] Validation before destructive operations
  • [ ] Proper error logging

Internationalization

  • [ ] All user-facing text uses t() function
  • [ ] Translation keys added to locale files
  • [ ] No hardcoded strings

Performance

  • [ ] ShallowRef used for large data
  • [ ] Expensive operations memoized or debounced
  • [ ] No unnecessary re-renders
  • [ ] Grid updates use transaction API

Testing

  • [ ] Unit tests for business logic
  • [ ] E2E tests for critical workflows
  • [ ] Edge cases covered
  • [ ] Test coverage > 80% for new code

Documentation

  • [ ] Complex logic commented
  • [ ] Public APIs documented
  • [ ] README updated if needed

Accessibility

  • [ ] Keyboard navigation works
  • [ ] Screen reader friendly
  • [ ] Proper ARIA labels
  • [ ] Focus management

Resources

Documentation:

Code Examples:

  • Journal Entries: apps/web/src/views/main/client/process/journal-entries/
  • Cashbook Entries: apps/web/src/views/main/client/process/cashbook-entries/
  • Working Trial Balance: apps/web/src/views/main/client/process/working-trial-balance/

Remember: Consistency is key. Follow existing patterns in the codebase. When in doubt, refer to well-implemented features like Journal Entries and Cashbook Entries as examples.

Happy Contributing!

Released under the MIT License.