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
- Development Workflow
- Code Organization
- TypeScript Best Practices
- Vue 3 Patterns
- State Management
- Composables Pattern
- Routing and Navigation
- API Integration
- AG Grid Integration
- Authentication and Authorization
- Event System
- Form Handling
- UI Components
- User Feedback
- Error Handling
- Memory Management
- Internationalization
- Testing
- Build and Deployment
- Performance Best Practices
- Security Guidelines
- Accessibility
- Code Style
- Common Patterns
- Anti-Patterns
- Troubleshooting
- Code Review Checklist
- Resources
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 scriptsKey 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.jsonand 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/uiCore 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:
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.comCommon Commands
Development:
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 buildsTesting:
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 reportLinting & Formatting:
pnpm lint # Lint all packages
pnpm lint:fix # Fix lint issues
pnpm format # Format with Prettier
pnpm format:check # Check formattingLocalization:
pnpm locale # Add missing i18n keys
pnpm locale:fix # Add and remove unused keysAPI Client Generation:
cd apps/web
pnpm build:api # Generate TypeScript API client from specification.jsonDocker:
pnpm docker:dev # Build and start containers
pnpm docker:build # Build Docker images
pnpm docker:up # Start containers
pnpm docker:down # Stop containersCleanup:
pnpm clean # Remove node_modules, dist, .turboGit Workflow
Main Branches:
develop- Main development branch (use for PRs)main/master- Production branch
Branch Naming:
feature/DC-XXXX-description- New featuresbugfix/DC-XXXX-description- Bug fixeshotfix/DC-XXXX-description- Critical production fixes
Commit Messages:
- Review recent commits with
git logto match project conventions - Use clear, concise commit messages
- Reference ticket numbers (e.g.,
DC-4761)
Example:
git commit -m "DC-4761: Add journal entry validation"Pre-commit Hooks
Pre-commit hooks run automatically on git commit:
Configured in .lintstagedrc:
{
"*.{vue,js,jsx,cjs,mjs,ts,tsx,cts,mts}": ["eslint --fix", "prettier --write"]
}What Runs:
- ESLint on staged files (with auto-fix)
- Prettier formatting on staged files
Bypass (not recommended):
git commit --no-verifyCode 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.vueReal Examples:
apps/web/src/views/main/client/process/journal-entries/- Journal Entriesapps/web/src/views/main/client/process/cashbook-entries/- Cashbook Entriesapps/web/src/views/main/client/process/working-trial-balance/- Working Trial Balance
File Naming Conventions
Vue Components (PascalCase):
FeatureView.vue- View componentsFeatureModal.vue- Modal componentsFeatureGrid.vue- Grid containersDxCustomComponent.vue- Custom components
TypeScript Files (kebab-case):
use-feature-service.ts- Composablesfeature.store.ts- Pinia storesfeature.service.ts- API servicesfeature.guard.ts- Route guardsfeature.constants.ts- Constants
Type Definition Files:
FeatureView.d.ts- Component type definitionsinterfaces.ts- Shared interfacestypes.ts- Type aliases and unions
Import Order
Maintain consistent import order:
// 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:
const eventBusListener = async (event: string, payload: any) => {
// Type safety lost
};
const newEntry: any = { id: guid(), $newEntry: true };✅ Good:
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:
try {
await saveData(data);
} catch (error: any) {
dxToast.error(error?.response?.data?.message);
}✅ Good:
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.
// 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:
<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:
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:
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:
// 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:
// Don't use ref for AG Grid APIs or large datasets
const gridApi = ref<GridApi | null>(null); // Unnecessary overhead
const largeDataset = ref<DataType[]>([]); // Performance impactWhen to use ref vs shallowRef:
| Use Case | Type | Reason |
|---|---|---|
| AG Grid API | shallowRef | No reactivity needed on API object |
| Large arrays (>100 items) | shallowRef | Avoid deep reactivity overhead |
| Simple values (string, number, boolean) | ref | Minimal overhead |
| Nested objects requiring reactivity | ref | Need deep reactivity |
| Form data | ref | Need deep reactivity for validation |
Async Components
Load modals and heavy components lazily:
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:
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
resetStoreaction for cleanup - Update store state after mutations for reactivity
Store Usage
Always use storeToRefs to maintain reactivity:
✅ Good:
import { storeToRefs } from 'pinia';
const featureStore = useFeatureStore();
const { items, selectedItem } = storeToRefs(featureStore);
// Call actions directly from store
await featureStore.fetchItems();❌ Bad:
// Loses reactivity
const { items, selectedItem } = featureStore;Multiple Stores:
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:
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):
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):
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):
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):
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:
// 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:
// 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:
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
namefor programmatic navigation
Route Guards
Auth Guard (authGuard):
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:
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:
{
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:
import { useRoute } from 'vue-router';
const route = useRoute();
const menuItemId = route.meta.menuItemId;API Integration
Service Layer Pattern
All services should follow this structure:
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
apiinstance from@repo/core/config - Support options parameter for headers/config
OData Queries
Use OData conventions for filtering, expanding, and ordering:
// 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:
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:
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:
<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
shallowRefforgridApiand large data arrays - ✅ Extend
baseGridOptions,baseColumnTypes, andbaseDefaultColDef - ✅ Use
reffor 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:
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();
}
};Grid Footer Calculations
Use pinned bottom rows for totals:
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:
// 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:
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):
// avoid this - replaces all data
gridApi.value?.setGridOption('rowData', items.value);✅ Good (Updates only changed rows):
// 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:
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:
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 formattingnumberColumn- Right-aligned number with decimal formattingenumColumn- Lookup-based columns with formatter and filter
Custom Cell Renderers
Create cell renderers using DOM elements, not HTML strings (security):
❌ Bad (XSS risk):
const cellRenderer = (params: ICellRendererParams) => {
return `<div>${params.value}</div>`; // unsafe
};✅ Good (Safe DOM creation):
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:
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:
// 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:
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:
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:
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
Use
shallowReffor grid API and data:typescriptconst gridApi = shallowRef<GridApi | null>(null); const rowData = shallowRef<DataType[]>([]);Use
asyncTransactionWaitMillis(already inbaseGridOptions):typescriptasyncTransactionWaitMillis: 50 // batch updatesDisable animations for large datasets (already in
baseGridOptions):typescriptanimateRows: falseUse
getRowIdfor efficient updates (already inbaseGridOptions)Avoid
setGridOption('rowData', ...)for updates - use transactions instead
Authentication and Authorization
OIDC Configuration
Authentication is configured in packages/core/src/security/auth.config.ts:
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:
import { createAuth } from '@repo/vue/plugins';
import { AuthConfig } from '@repo/core/security';
const app = createApp(App);
app.use(createAuth(AuthConfig));Auth Guards
Protect routes:
import { authGuard } from '@repo/vue/plugins';
{
path: '/clients',
beforeEnter: authGuard,
component: () => import('@/views/ClientListingView.vue')
}Access auth state:
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:
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:
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
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:
<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
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:
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- ButtonsInputText- Text inputsDropdown- Select dropdownsDialog- ModalsDataTable- Data tablesCalendar- Date pickersToast- Toast notificationsConfirmDialog- Confirmation dialogs
Custom Components
Custom components are in packages/vue/src/components:
import { DxLoader, DxModal, DxSelect, DxDatePicker } from '@repo/vue/components';Available Components:
DxLoader- Loading spinner/barDxModal- Custom modalDxSelect- Enhanced select dropdownDxDatePicker- Date pickerDxAutoComplete- Autocomplete inputDxCurrency- Currency inputDxEditor- Rich text editorDxPdfViewer- PDF viewer
Component Library
AG Grid Cell Renderers/Editors:
DxGridCheckboxEditor/DxGridCheckboxRendererDxGridDateEditor/DxGridDateRendererDxGridSelectEditor/DxGridSelectRenderer
User Feedback
Toast Notifications
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
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
import { useDxLoader } from '@repo/vue/utils';
// utils
const { startLoader, stopLoader } = useDxLoader();
// methods
const loadData = async () => {
startLoader();
try {
await fetchData();
} finally {
stopLoader();
}
};Progress Indicators
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
// 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:
// 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:
dxToast.error('Error');✅ Good:
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:
setTimeout(async () => {
await loadData();
}, 500);✅ Good:
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:
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>:
watchandwatchEffectcomputedproperties- Reactive state (
ref,reactive,shallowRef) - Event bus listeners (via VueUse)
- VueUse composables
Manual Cleanup Required
❌ Requires manual cleanup:
setTimeoutandsetInterval- 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:
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:
const title = 'Feature Title'; // Hardcoded text
const message = `Saved ${count} items`; // Not translatableTranslation File Organization
Translation files are located in apps/web/src/locales/:
Example (en-ZA.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:
pnpm localeAdd and remove unused keys:
pnpm locale:fixTesting
Unit Testing
Write unit tests for:
- Business logic in composables
- Utility functions
- Calculations and validations
- Store actions
Example (account-category.store.spec.ts):
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:
pnpm test
pnpm test:watch
pnpm test:coverageIntegration Testing
Test component interactions:
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):
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:
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:
cd apps/web
pnpm test:e2e
pnpm test:e2e:ui
pnpm test:e2e:reportTest Organization
apps/web/
├── tests/ # Unit tests
│ ├── stores/
│ └── components/
└── e2e/ # E2E tests
├── auth.setup.ts
└── feature.spec.tsBuild and Deployment
Build Configuration
Vite Configuration (vite.config.ts):
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 serverVITE_AUTH_CLIENT- OIDC client IDVITE_AUTH_SCOPE- OIDC scopesRESOURCE_SERVER_BASE- API base URL
Access in code:
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:
pnpm docker:devIndividual commands:
pnpm docker:build
pnpm docker:up
pnpm docker:downPerformance Best Practices
Bundle Optimization
Code Splitting:
- Use lazy loading for routes
- Use
defineAsyncComponentfor modals - Split vendor chunks
Bundle Analysis:
pnpm build
# Opens dist/stats.html with bundle visualizationLazy Loading
Routes:
{
path: '/feature',
component: () => import('@/views/FeatureView.vue')
}Components:
const FeatureModal = defineAsyncComponent(
() => import('./FeatureModal.vue')
);Reactivity Optimization
Use shallowRef for large data:
// Good for large arrays
const items = shallowRef<DataType[]>([]);
// Good for AG Grid API
const gridApi = shallowRef<GridApi | null>(null);Use computed for derived state:
const filteredItems = computed(() => {
return items.value.filter(item => item.isActive);
});Grid Performance
Use getRowId for efficient updates:
const gridOptions: GridOptions = {
getRowId: (params) => params.data.id
};Use transaction API:
// 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
<button
aria-label="Delete item"
@click="deleteItem"
>
<Icon name="trash" />
</button>Code Style
ESLint Configuration
ESLint is configured in eslint.config.js:
import eslintConfig from '@repo/eslint-config';
export default eslintConfig;Run ESLint:
pnpm lint
pnpm lint:fixPrettier Configuration
Prettier is configured in .prettierrc:
{
"semi": true,
"tabWidth": 2,
"singleQuote": true,
"printWidth": 120,
"endOfLine": "auto",
"trailingComma": "none"
}Run Prettier:
pnpm format
pnpm format:checkCode 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
anytype
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:
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 notificationsuseDxConfirm()- Confirmation dialogsuseDxModal()- Modal managementuseDxLoader()- Loading indicators (internal use)useDxProgress()- Global progress bar and spinneruseDxEventBus()- Event bus for cross-component communicationuseDxPrime()- PrimeVue utilities and formattersuseDxFileSaver()- File download and saveuseDxCustomModal()- Custom modal utilitiesuseDxMessage()- PostMessage communication (iFrame)
From @/utils (Web app specific):
useDxState()- Application state management and navigationuseDxGridBase()- AG Grid base configurationuseDxGridData()- AG Grid data synchronizationuseDxGridFormat()- AG Grid formatting utilitiesuseDxClientProcess()- Client process utilitiesuseDxRibbon()- Ribbon bar utilitiesuseDxSpreadsheet()- Spreadsheet utilitiesuseDxFiresheet()- Firesheet utilities
Toast Notifications (useDxToast)
Display temporary messages to users:
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:
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 confirmationserror()- Error messageswarning()- Warning messagesinformation()- Informational messagescustom()- Custom dialog with options
Parameters:
header- Dialog titlemessage- Dialog message (supports HTML)btnOk- OK button textbtnCancel- Cancel button text (optional)size- Dialog size:'sm','md','lg','xl'(optional)helpRoute- Help route for help button (optional)
Modal Management (useDxModal)
Programmatically open and manage modals:
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 onepush()- Stacks modal on top of existing modalsprompt()- Shows modal and waits for resultpop()- Closes the top modalclose()- Closes all modals
See Modal Patterns section for more details.
Global Progress (useDxProgress)
Global loading bar and spinner for async operations:
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()withstopLoader()in try-finally
Event Bus (useDxEventBus)
Cross-component communication without prop drilling:
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
EventConstantsfor 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 operationreload- Reload datacloseClient- Close current clientselectedYearChanged- Financial year changedtabOff- Tab deactivatedaccountLinkComplete- Account linking completedreloadClientMenu- Reload client menu
Prime Utilities (useDxPrime)
Formatting, validation, and data utilities:
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 separatorformatCurrency(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 v4isDirty(obj)- Check if object has$dirtyflagclearDirty(obj)- Remove all$dirtyflagsdateFormatterXlToJs(serial)- Excel serial to JS DatedateFormatterJsToXl(date)- JS Date to Excel serialformatErrors(errors)- Format Vuelidate errorsgenerateCSV(data, filename)- Generate and download CSVparseResponseError(data, response, field, top)- Parse API errors
File Operations (useDxFileSaver)
Download and save files:
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 filedownloadFile(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):
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 routeopenClient(clientId)- Open clientcloseClient()- Close current clientreload()- Reload client dataaddTab(data)- Add new tabremoveTab(tabId)- Remove tabloadTab(page, tabId)- Load specific tablogin()/logout()- Authenticationsave(payload)- Trigger save eventreloadClientMenu()- Reload client menusendMessage(data)- Send message to parent (iFrame mode)
Grid Data Utilities (useDxGridData)
Sync AG Grid with Vue reactive state:
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 gridsetGridData(reload?, resize?, hideFooter?)- Update grid and footersetGridFooter()- Calculate and display totalssaveGridData(saveFunc, options?)- Debounced save (1 second)
Key Features:
- Automatically tracks
$dirty,$newEntry,$savingflags - 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:
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 configurationbaseColumnTypes- Currency, number, enum column typesbaseDefaultColDef- Default column definition
See AG Grid Integration for complete details.
Best Practices
1. Always use // utils comment section:
// 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:
// utils
const dxToast = useDxToast();
const dxConfirm = useDxConfirm();
const dxModal = useDxModal();
const dxEventBus = useDxEventBus();
const dxPrime = useDxPrime();3. Destructure when using multiple methods:
// utils
const { startLoader, stopLoader } = useDxProgress();
const { formatNumber, formatCurrency, guid } = useDxPrime();
const { getGridData, setGridData } = useDxGridData(gridApi);4. Always use i18n with user-facing utilities:
// ✅ 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:
// ✅ 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:
// ✅ Good
dxEventBus.emit(EventConstants.save);
dxEventBus.on((event) => {
if (event === EventConstants.save) {}
});
// ❌ Bad
dxEventBus.emit('save'); // Magic stringCommon Patterns
Modal 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.
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, feedbackAppClientMenu.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.
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 balanceWorkingTrialBalanceModal.vue- Account mapping from trial balanceFileManagerModal.vue- Select file, create folder (nested modals)
When to Use Each
| Method | Use Case | Behavior |
|---|---|---|
open() | Top-level modals from main UI | Closes all existing modals first |
push() | Nested modals, wizards, multi-step flows | Stacks on top of existing modals |
Other Modal Methods
// 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:
// 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:
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:
// 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:
Use
anytypetypescriptconst data: any = fetchData(); // BadUse
Object.assign(use lodash_.assigninstead)typescriptObject.assign(item, { $dirty: true }); // BadMutate props directly
typescriptprops.item.name = 'New Name'; // BadUse
reffor large datatypescriptconst items = ref<DataType[]>([]); // Bad for >100 itemsForget to clean up timers
typescriptsetTimeout(() => {}, 1000); // Bad - no cleanupHardcode strings
typescriptconst message = 'Item saved'; // Bad - not translatableCatch errors without handling
typescripttry { await saveData(); } catch (err) { // Bad - no error handling }Use direct DOM manipulation
typescriptdocument.getElementById('item').innerHTML = 'Text'; // BadStore sensitive data in local storage
typescriptlocalStorage.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
anytypes (use proper interfaces orunknown) - [ ] All function parameters are typed
- [ ] All function returns are typed
- [ ] Props interfaces defined for all components
- [ ] Error objects properly typed
Memory Management
- [ ] All
setTimeoutandsetIntervalcleaned up - [ ] Route guards with intervals have cleanup functions
- [ ] No memory leaks in long-running operations
- [ ] Third-party resources properly disposed
Vue Best Practices
- [ ] Use
shallowReffor AG Grid APIs and large datasets - [ ] Use
storeToRefsfor 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:
- Vue 3 Documentation
- Pinia Documentation
- AG Grid Vue Documentation
- TypeScript Handbook
- Vue i18n Documentation
- Vitest Documentation
- Playwright Documentation
- PrimeVue Documentation
- Vite 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!