ChurchCRM uses Webpack to bundle frontend JavaScript/TypeScript and CSS. This skill covers entry points, API utilities, type safety, and best practices for building modern webpack modules.
Verified versions in this repo (package.json):
react19.2.4react-dom19.2.4typescript5.9.3webpack5.105.2webpack-cli6.0.1ts-loader9.5.4
Webpack bundles load BEFORE window.CRM is initialized.
- ❌ DON'T assign
window.CRM.rootin constructors or module scope - ✅ DO use
api-utils.tsfunctions which evaluate at runtime
// ❌ WRONG - window.CRM is undefined when module loads
const API_ROOT = window.CRM.root + '/api'; // undefined + string!
// ✅ CORRECT - Evaluates at runtime when CRM is ready
import { buildAPIUrl } from './api-utils';
const url = buildAPIUrl('person/123'); // Safe, evaluates laterimport { buildAPIUrl, buildAdminAPIUrl, fetchAPIJSON } from './api-utils';
// Public API endpoints
const personUrl = buildAPIUrl('person/123'); // → '/api/person/123'
const photoUrl = buildAPIUrl('person/123/photo'); // → '/api/person/123/photo'
// Admin API endpoints
const configUrl = buildAdminAPIUrl('system/config/key'); // → '/admin/api/system/config/key'
// Dynamic root path (works with subdirectories)
// If installed at /churchcrm/, buildAPIUrl('person/123') → '/churchcrm/api/person/123'interface Person {
id: number;
firstName: string;
lastName: string;
}
// ✅ Recommended - Type-safe JSON fetch
const person = await fetchAPIJSON<Person>('person/123');
console.log(person.firstName); // IDE autocomplete works!
// ✅ With error handling
try {
const data = await fetchAPIJSON<Person>('person/123');
console.log('Success:', data);
} catch (error) {
console.error('API error:', error);
}
// ✅ With fetch options
const response = await fetchAPI('person/123/photo', {
method: 'DELETE',
headers: { 'X-Custom': 'value' }
});| Function | Purpose | Returns |
|---|---|---|
getRootPath() |
Get window.CRM.root dynamically |
string (e.g., /churchcrm) |
buildAPIUrl(path) |
Build /api/ endpoint URL |
string |
buildAdminAPIUrl(path) |
Build /admin/api/ endpoint URL |
string |
fetchAPI(path, options) |
Generic fetch wrapper | Promise<Response> |
fetchAPIJSON<T>(path, options) |
Fetch and parse JSON | Promise<T> |
fetchAdminAPI(path, options) |
Admin API fetch variant | Promise<Response> |
fetchAdminAPIJSON<T>(path, options) |
Admin API JSON variant | Promise<T> |
// webpack/photo-uploader.js
document.addEventListener('DOMContentLoaded', function() {
const uploadButton = document.getElementById('upload-photo');
if (!uploadButton) return; // Element doesn't exist yet
uploadButton.addEventListener('click', async function() {
try {
const result = await fetch('/api/photo', { method: 'POST' });
console.log('Upload complete');
} catch (error) {
console.error('Upload failed:', error);
}
});
});// webpack/avatar-loader.ts
import { buildAPIUrl, fetchAPIJSON } from './api-utils';
interface AvatarInfo {
id: number;
url: string;
exists: boolean;
}
class AvatarLoader {
async load(personId: number): Promise<void> {
try {
const path = `person/${personId}/avatar`;
const avatar = await fetchAPIJSON<AvatarInfo>(path);
if (avatar.exists) {
this.displayAvatar(avatar);
}
} catch (error) {
console.error('Failed to load avatar:', error);
}
}
private displayAvatar(avatar: AvatarInfo): void {
const img = document.querySelector('img.avatar') as HTMLImageElement;
if (img) {
img.src = avatar.url;
}
}
}
// Initialize on DOM ready
document.addEventListener('DOMContentLoaded', () => {
const loader = new AvatarLoader();
const personId = parseInt((document.getElementById('person-id') as HTMLElement)?.dataset.id || '0');
if (personId > 0) {
loader.load(personId);
}
});// webpack/admin-dashboard-app.tsx
import React, { useState, useEffect } from 'react';
import { createRoot } from 'react-dom/client';
import { fetchAPIJSON } from './api-utils';
interface User {
id: number;
name: string;
email: string;
}
const AdminDashboard: React.FC = () => {
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const loadUsers = async () => {
try {
const data = await fetchAPIJSON<User[]>('users');
setUsers(data);
} catch (error) {
console.error('Failed to load users:', error);
} finally {
setLoading(false);
}
};
loadUsers();
}, []);
if (loading) return <div>Loading...</div>;
return (
<div>
<h1>Dashboard</h1>
<ul>
{users.map(user => (
<li key={user.id}>{user.name} ({user.email})</li>
))}
</ul>
</div>
);
};
// Mount app
const container = document.getElementById('admin-dashboard-app');
if (container) {
const root = createRoot(container);
root.render(<AdminDashboard />);
}Each feature should have associated CSS:
// webpack/my-feature.js
import './my-feature.css'; // Import at top
import './my-feature.scss'; // SCSS also supported
// TypeScript same pattern
import './my-feature.css'; // In webpack/my-feature.tsOutput Configuration (webpack.config.js):
entry: {
'skin/v2/my-feature': './webpack/my-feature.js', // → src/skin/v2/my-feature.js
'skin/v2/my-component-app': './webpack/my-component-app.tsx',
}// webpack/types/api-models.ts
export interface Person {
id: number;
firstName: string;
lastName: string;
familyId: number;
}
export interface Family {
id: number;
name: string;
address: string;
}
export interface ApiResponse<T> {
success: boolean;
data?: T;
message?: string;
errors?: Record<string, string>;
}// webpack/person-viewer.ts
import type { Person, ApiResponse } from './types/api-models';
import { fetchAPIJSON } from './api-utils';
async function viewPerson(id: number): Promise<void> {
const response = await fetchAPIJSON<ApiResponse<Person>>(`person/${id}`);
if (response.success && response.data) {
console.log(`${response.data.firstName} ${response.data.lastName}`);
}
}Always use async/await for API calls:
// ✅ CORRECT
const data = await fetchAPIJSON('person/123');
// ❌ WRONG - No synchronous API calls
const data = fetch('/api/person/123'); // Returns Promise immediatelyAlways wrap async operations:
// ✅ CORRECT
try {
const data = await fetchAPIJSON('person/123');
} catch (error) {
console.error('Failed:', error);
// Show error to user
}
// ❌ WRONG - Unhandled promise rejection
const data = await fetchAPIJSON('person/123'); // Crash if failsAvoid modifying window.CRM:
// ✅ CORRECT - Use api-utils
import { buildAPIUrl } from './api-utils';
const url = buildAPIUrl('person/123');
// ❌ WRONG - Assumes window.CRM exists
const url = window.CRM.root + '/api/person/123';Always verify elements exist:
// ✅ CORRECT
const button = document.getElementById('my-button');
if (button) {
button.addEventListener('click', handler);
}
// ❌ WRONG - Crashes if element doesn't exist
document.getElementById('my-button').addEventListener('click', handler);Use ES6 imports for better bundling:
// ✅ CORRECT - Enables tree shaking
import { buildAPIUrl } from './api-utils';
// ❌ LESS EFFICIENT - Full module imported
import * as utils from './api-utils';// ✅ Only load when needed
async function openModal() {
const { Modal } = await import('bootstrap');
new Modal(element).show();
}// ✅ Always use i18next.t()
window.CRM.notify(i18next.t('Settings saved'), { type: 'success' });
// ❌ WRONG - Untranslatable
window.CRM.notify('Settings saved', { type: 'success' });Separate concerns into different entry points:
// webpack.config.js
entry: {
'skin/v2/admin': './webpack/admin-dashboard.js', // Admin pages
'skin/v2/photo-uploader': './webpack/photo-uploader.js', // Photo upload
'skin/v2/kiosk': './webpack/kiosk/registration.tsx', // Kiosk app
}This project uses Biome (not ESLint) for TypeScript/React linting. ESLint suppression comments are silently ignored by Biome.
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => { ... }, []);// biome-ignore lint/correctness/useExhaustiveDependencies: <reason>
useEffect(() => { ... }, []);| Rule | When |
|---|---|
lint/correctness/useExhaustiveDependencies |
useEffect / useCallback with intentional empty or partial deps |
lint/suspicious/noExplicitAny |
Legitimate any in interop/legacy code |
lint/style/noNonNullAssertion |
When null is structurally impossible |
When initializing a third-party DOM library that must only run once:
// Keep a ref to the latest callback so the handler never goes stale
const onChangeRef = useRef(onChange);
useEffect(() => {
onChangeRef.current = onChange;
}, [onChange]);
// biome-ignore lint/correctness/useExhaustiveDependencies: initialized once on mount; re-running would create duplicate DOM nodes. name/placeholder are mount-time constants; onChange uses onChangeRef; value is synced by a separate effect.
useEffect(() => {
// ... initialize library ...
}, []);
// Sync controlled value changes without re-initializing
useEffect(() => {
if (instanceRef.current) {
instanceRef.current.root.innerHTML = value ?? "";
}
}, [value]);Why []? Libraries like Quill append DOM nodes into a container. Re-running the effect without destroying the old instance first creates duplicate toolbars/canvases. The guard if (instanceRef.current) return; only partially mitigates this.
- API Utilities: See webpack/api-utils.ts source
- Bootstrap Build: See
npm run build:frontenddocumentation - Admin API Calls: See admin-api-development.md skill