@docyrus/app-utils
v0.16.0
Published
Utility functions for frontend applications using Docyrus as backend
Keywords
Readme
@docyrus/app-utils
Framework-agnostic utility functions for frontend applications using Docyrus as backend. Provides date/number formatting, template compilation (Handlebars + JSONata), and a comprehensive set of expression helpers — all configured from tenant preferences.
Features
- Tenant Preferences: Fetch tenant configuration from the Docyrus API
- Date Formatting: PHP-style date formatting with timezone support (
Y-m-d,H:i:s, etc.) - Number Formatting: Locale-aware number formatting with custom separators and precision
- Number to Words: Convert numbers to words in Turkish and English with currency labels
- Duration Formatting: Format seconds as
HH:MM, decimal hours, or human-readable words - App Config: Get, upsert, and delete a per-app JSON configuration (1:1 per app)
- User App Config: Get, upsert, and delete a per-user JSON configuration scoped to an app (1:1 per user per app)
- Data Views: CRUD for saved data source view configurations (columns, filters, sort, color rules)
- Data Forms: CRUD for dynamic form designs attached to a data source (layout JSON, title, icon, color, default flag, soft-archive)
- Data Source Metadata: Read data source metadata across apps, by app, by ID, or by slug with optional expansions like
fields - Tenant Brands: CRUD for tenant brand records (colors, typography, spacing, logos, voice & messaging, slide / chart styling), plus a website-scrape RPC that fills the brand from a configured URL
- User Identity: Persist decoded OIDC ID-token payloads from Microsoft (Graph) and Google after a frontend OAuth2 flow — writes both
tenant_user.identity_*and the matchingtenant_connection_userrow - TanStack Query Integration (optional, via
@docyrus/app-utils/query): PrebuiltqueryOptionsfactories and a query-key registry for data source metadata, with reactive caching and per-resource invalidation helpers - Envelope Unwrapping:
envelopeUnwrapInterceptor(aRestApiClientinterceptor) andunwrapEnvelope(a per-call helper) for handling Docyrus'{ success, data }API envelope — both shape-strict so domain payloads with their owndatacolumn stay safe - Template Engine: Handlebars compilation with async support and inline JSONata formulas
- Formula Evaluation: JSONata expression evaluation with 70+ built-in helper functions
- JSONata Helpers: Date arithmetic (date-fns), string manipulation, number formatting, equality checks, and more
- TypeScript: Full type definitions included
- Framework-Agnostic: No Vue, React, or Angular dependency — works everywhere
Installation
npm install @docyrus/app-utilspnpm add @docyrus/app-utilsPeer Dependencies
| Package | Version | Required |
|---------|---------|----------|
| @docyrus/api-client | >= 0.1.0 | Yes (for getTenantPreferences) |
| handlebars | >= 4.7.0 | Yes (for template engine) |
| jsonata | >= 2.0.0 | Yes (for formula evaluation) |
| date-fns | >= 3.0.0 | Yes (for JSONata date helpers) |
| @tanstack/query-core | >= 5.17.0 | Optional (only for @docyrus/app-utils/query) |
Quick Start
import {
getTenantPreferences,
createDateUtils,
createNumberUtils,
createTemplateEngine,
createDataSourceClient
} from '@docyrus/app-utils';
// 1. Fetch tenant preferences
const preferences = await getTenantPreferences(apiClient);
// 2. Create utility instances
const dateUtils = createDateUtils({
preferences,
userTimezone: currentUser.timeZone?.id // e.g. 'Europe/Istanbul'
});
const numberUtils = createNumberUtils({ preferences });
const dataSources = createDataSourceClient(apiClient);
// 3. Create template engine (optional)
const engine = createTemplateEngine({
dateUtils,
numberUtils,
user: currentUser,
extraJsonataBindings: { hasRole }
});
// 4. Use utilities
dateUtils.formatDate('2024-01-15'); // '15.01.2024' (tenant format)
numberUtils.formatNumber(1234.5); // '1.234,50' (tenant locale)
await engine.compileFormula('$sum(items.price)', data);
// 5. Read app and data source metadata
const apps = await dataSources.listApps();
const sources = await dataSources.list({ expand: 'fields' });
const contacts = await dataSources.getBySlug('crm', 'contacts', {
expand: 'fields'
});
// 6. Use metadata
const appSlugs = apps.map(app => app.slug);
const fieldSlugs = contacts.fields?.map(field => field.slug) ?? [];
const requiredFields = contacts.fields?.filter(field => field.required) ?? [];Envelope handling
Docyrus REST endpoints wrap their payload in a { success, data } envelope (optionally with meta for pagination, or error / message for controller-level errors). @docyrus/app-utils provides two ways to handle that — both are tolerant of pre-stripped envelopes (i.e. idempotent), and both refuse to falsely unwrap domain payloads that carry their own data field (AppConfig, UserAppConfig, records with a data column).
envelopeUnwrapInterceptor — global, conservative
A RestApiClient response interceptor. After it runs, client.get/post/put/delete returns the inner payload directly (so generated TanStack DB collections, hand-written calls, and app-utils clients all see bare values). Required when using @docyrus/tanstack-db-generator collections — the generator emits methods that don't read .data themselves.
import { RestApiClient } from '@docyrus/api-client';
import { envelopeUnwrapInterceptor } from '@docyrus/app-utils';
const client = new RestApiClient({ baseURL: 'https://api.example.com' });
client.addInterceptor(envelopeUnwrapInterceptor);
// Now plain envelopes unwrap automatically:
const views = await client.get<DataView[]>('/v1/apps/crm/data-sources/contacts/views');
// ^ DataView[] (from { success, data: [...] })It's deliberately shape-strict and lossless — it strips only when the envelope's only top-level keys are data and optionally success. Envelopes that carry sidecar information are left intact so callers can read them:
| Response shape | Behaviour | Why |
|---|---|---|
| { data } | unwrap → data | Bare envelope. |
| { success, data } | unwrap → data | Most endpoints. |
| { data, meta } / { success, data, meta } | left alone | Paginated lists — meta.count/meta.total needed by data grids. |
| { success: false, data, error, message } | left alone | Controller-level error envelope — error info preserved. |
| { id, data, status, tenant_app_id, … } | left alone | Domain record (e.g. AppConfig) carrying its own data column. |
For paginated endpoints, read both the array and the page total off the envelope:
const { data: rows, meta } = await client.get<{
data: Record[];
meta?: { count?: number; total?: number };
}>('/v1/apps/crm/data-sources/contacts/items');unwrapEnvelope<T>(value) — per-call, aggressive
A small helper for ad-hoc unwrap at known sites. It's wider than the interceptor — it also strips envelopes carrying meta / error / message because the caller has explicitly typed the return as T and isn't surfacing sidecar fields. This is exactly what app-utils' built-in clients (createDataSourceClient, createDataViewClient, createDataFormClient, createAppConfigClient, createUserAppConfigClient) use internally, so they keep working whether or not you've registered the interceptor globally.
import { unwrapEnvelope } from '@docyrus/app-utils';
const records = unwrapEnvelope<Record[]>(
await client.get('/v1/apps/crm/data-sources/contacts/items')
);
// → Record[] (meta intentionally discarded)Domain payloads with a data column are still safe — the closed-set check ({ data, success, meta, error, message }) rejects records that carry off-set keys like id / status.
App Config & Data Views
createAppConfigClient(client, appId)
Manages the single JSON configuration object for an app (1:1 relationship — no config IDs).
import { createAppConfigClient } from '@docyrus/app-utils';
const config = createAppConfigClient(apiClient, appId);config.get()
Returns the app's configuration. Throws 404 if none exists.
const appConfig = await config.get();
// { id: '...', data: { theme: 'dark', ... }, status: 1, tenant_app_id: '...', ... }config.upsert(body)
Creates the config if it doesn't exist, or updates it if it does.
const updated = await config.upsert({
data: { theme: 'dark', sidebar: { collapsed: false } },
status: 1
});config.remove()
Hard deletes the config (irreversible).
await config.remove();createUserAppConfigClient(client, appId)
Manages the per-user JSON configuration object for an app (1:1 per user per app). Useful for storing user preferences, UI state, or personalisation that should persist per user independently of the shared app config.
import { createUserAppConfigClient } from '@docyrus/app-utils';
const userConfig = createUserAppConfigClient(apiClient, appId);userConfig.get()
Returns the current user's configuration for the app. Throws 404 if none exists.
const config = await userConfig.get();
// { id: '...', data: { theme: 'dark', ... }, status: 1, tenant_app_id: '...', user_id: '...', ... }userConfig.upsert(body)
Creates the config if it doesn't exist, or updates it if it does.
const updated = await userConfig.upsert({
data: { theme: 'dark', sidebarCollapsed: true },
status: 1
});userConfig.remove()
Hard deletes the user config (irreversible).
await userConfig.remove();createDataViewClient(client, appSlug, dataSourceSlug, options?)
Manages saved view configurations for a specific data source (1:many). Views define columns, filters, sorting, color rules, and quick-filter shortcuts.
import { createDataViewClient } from '@docyrus/app-utils';
const dataViews = createDataViewClient(apiClient, 'my-app', 'my-data-source');Pass an InventoryClient to read list / get from the cached data source's embedded views array, and patch the cache in place after every mutation:
const dataViews = createDataViewClient(apiClient, 'crm', 'invoices', { inventory });
await dataViews.list(); // reads from inventory cache (no network)
await dataViews.create({ name: 'Overdue' }); // POST, then inventory.upsertDataView(result)
await dataViews.update(id, { is_default: true }); // PUT, then inventory.upsertDataView(result)
await dataViews.update(id, { archived: true }); // PUT, then inventory.removeDataView(id)
await dataViews.remove(id); // DELETE, then inventory.removeDataView(id)dataViews.list(params?)
Returns all non-archived views. Optionally pass appId to filter views configured for a specific app (different from the data source's owning app).
const views = await dataViews.list();
const forSpecificApp = await dataViews.list({ appId: 'other-app-uuid' });dataViews.get(viewId)
Returns a single non-archived view by ID.
const view = await dataViews.get('view-uuid');dataViews.create(body)
Creates a new data view. name is required. The data source is determined by the dataSourceSlug passed to the client factory.
const view = await dataViews.create({
name: 'High Priority',
columns: { visible: ['title', 'priority', 'assignee'] },
filters: { priority: 'high' },
sort: { field: 'due_date', direction: 'asc' },
color: '#E74C3C',
icon: 'alert-triangle',
is_default: false,
sort_order: 2
});dataViews.update(viewId, body)
Partially updates a view. Use archived: true to soft-delete.
await dataViews.update('view-uuid', { name: 'Renamed View' });
await dataViews.update('view-uuid', { archived: true }); // soft-deletedataViews.remove(viewId)
Hard deletes a view (irreversible).
await dataViews.remove('view-uuid');createDataFormClient(client, appSlug, dataSourceSlug, options?)
Manages dynamic form designs attached to a data source (1:many). Forms describe the editable layout used to view and edit records of a data source — title, icon, color, the layout JSON itself (sections / fields / tabs), default selection, and soft-archive state.
import { createDataFormClient } from '@docyrus/app-utils';
const dataForms = createDataFormClient(apiClient, 'crm', 'contacts');Pass an InventoryClient to read list / get from the cached data source's embedded forms array, and patch the cache in place after every mutation:
const dataForms = createDataFormClient(apiClient, 'crm', 'contacts', { inventory });
await dataForms.list(); // reads from inventory cache (no network)
await dataForms.create({ name: 'Compact' }); // POST, then inventory.upsertDataForm(result)
await dataForms.update(id, { title: 'Customer' }); // PUT, then inventory.upsertDataForm(result)
await dataForms.update(id, { archived: true }); // PUT, then inventory.removeDataForm(id)
await dataForms.remove(id); // DELETE, then inventory.removeDataForm(id)Note: embedded forms carry the layout/display fields needed for rendering, but not description, status, or audit timestamps. If you need those, create a non-inventory-backed createDataFormClient and call get(id) against the standalone endpoint.
dataForms.list()
Returns all non-archived forms for the data source, ordered by created_on ascending.
const forms = await dataForms.list();dataForms.get(formId)
Returns a single non-archived form by ID. Throws 404 if the form does not exist or is archived.
const form = await dataForms.get('form-uuid');dataForms.create(body)
Creates a new form. name is required; everything else is optional. The data source is determined by the dataSourceSlug passed to the client factory — tenant_data_source_id, tenant scope, and the created_by audit fields are populated server-side.
const form = await dataForms.create({
name: 'Compact contact form',
title: 'Contact',
icon: 'user',
color: '#4F46E5',
layout: {
sections: [
{ id: 'main', fields: ['name', 'email', 'phone'] }
]
},
is_default: false,
status: 1
});dataForms.update(formId, body)
Partially updates a form. Only the fields present in the body are written; omitted fields are preserved. layout is replaced wholesale when provided. Use archived: true to soft-archive (the row is then excluded from list/get).
await dataForms.update('form-uuid', {
title: 'Customer Contact',
icon: 'address-book',
layout: { sections: [/* updated sections */] }
});
await dataForms.update('form-uuid', { archived: true }); // soft-deletedataForms.remove(formId)
Hard deletes a form (irreversible). Use update(formId, { archived: true }) instead if you want a recoverable soft-delete.
await dataForms.remove('form-uuid');createTenantBrandClient(client)
Manages tenant brand records — the visual identity, voice / messaging, and slide / chart styling configuration used to render brand-aware UI, presentation templates, and AI-generated content. Records are tenant-scoped automatically; cross-tenant access is impossible.
import { createTenantBrandClient } from '@docyrus/app-utils';
const tenantBrands = createTenantBrandClient(apiClient);tenantBrands.list()
Returns all non-archived brands for the active tenant, with the default brand first and then by creation date ascending.
const brands = await tenantBrands.list();tenantBrands.get(brandId)
Returns a single brand by ID. Throws 404 if the brand does not exist or belongs to another tenant.
const brand = await tenantBrands.get('brand-uuid');tenantBrands.create(body)
Creates a new brand. Only name is required; everything else is optional and defaults to null (or the column default). tenant_id and the created_by / last_modified_by audit fields are populated server-side.
const brand = await tenantBrands.create({
name: 'Acme Corp',
description: 'Primary marketing brand',
website_url: 'https://acme.com',
color_primary: '#FF6600',
color_secondary: '#1E1E1E',
font_family_primary: 'Inter',
is_default: true
});Note: the API does not enforce a single default brand per tenant — the caller is responsible for clearing
is_defaulton the previous default when promoting a new one.
tenantBrands.update(brandId, body)
Partially updates a brand. Only the fields present in the body are written; omitted fields are preserved. Setting a field to null clears it. Use archived: true to soft-archive (archived rows are excluded from list()).
await tenantBrands.update('brand-uuid', {
color_primary: '#0044CC',
logo_url: 'https://cdn.example.com/logos/acme-v2.svg',
is_default: false
});
await tenantBrands.update('brand-uuid', { archived: true }); // soft-archivetenantBrands.remove(brandId)
Hard deletes the brand (irreversible). Use update(brandId, { archived: true }) for a recoverable soft delete.
await tenantBrands.remove('brand-uuid');tenantBrands.fetchFromWebsite(brandId)
Scrapes the brand's configured website_url with Firecrawl and merges the extracted branding (colors, typography, spacing, components, images, voice / personality, fonts, icons, animations, layout) into the brand record. Only fields detected by the scraper are overwritten — existing values for missing fields are preserved.
This is a synchronous RPC — the request blocks until Firecrawl returns, so expect several seconds of latency. The response is intentionally a flat top-level object (not the standard { data } envelope) so callers can preview the raw scraper output alongside the persisted summary.
const { brand, scrapedBranding } = await tenantBrands.fetchFromWebsite('brand-uuid');Throws 400 if the brand has no website_url configured, 404 if the brand doesn't exist, or 500 if Firecrawl is unconfigured or returns no branding data.
createUserIdentityClient(client)
Persists the decoded OIDC ID-token payload from Microsoft (Graph) or Google for the authenticated user. Designed to be called from the frontend right after a successful OAuth2 flow (e.g. msal-browser, google-identity-services, nimbus-auth-js) — pass the verified ID-token claims and the backend writes the payload into tenant_user.identity_microsoft / identity_google and upserts the matching tenant_connection_user row so the provider connection is recorded alongside the identity claims.
These endpoints are hidden from Swagger, which is exactly why this helper exists — typed wrappers make them safer to call than hand-rolled fetch. They require the User.ReadWrite (or Users.ReadWrite.All) scope.
import { createUserIdentityClient } from '@docyrus/app-utils';
const userIdentity = createUserIdentityClient(apiClient);userIdentity.saveMicrosoft(payload)
Persists a Microsoft / Entra ID-token payload. sub is required; all other well-known claims (oid, tid, email, name, preferred_username) are typed but optional, and any additional claims (aud, iss, iat, exp, …) pass through verbatim.
await userIdentity.saveMicrosoft({
sub: '6zIRb2daYqUXVlg-xu4dA3nLsJekGkVhZxIg2GLpn2I',
oid: 'f0ea18f8-cf7d-4695-8d9e-d9674127b343',
tid: 'a2b0309e-37c1-486d-bdbd-4d91b7d25cd5',
email: '[email protected]',
name: 'Anıl Beyazoğlu',
preferred_username: '[email protected]',
iss: 'https://login.microsoftonline.com/.../v2.0',
aud: '0380d712-0e97-431f-b343-76604e1cfcd1',
iat: 1754578886,
exp: 1754582786
});userIdentity.saveGoogle(payload)
Persists a Google ID-token payload. sub is required; email and name are typed but optional, and any additional claims pass through verbatim.
await userIdentity.saveGoogle({
sub: '110169484474386276334',
email: '[email protected]',
name: 'Anıl Beyazoğlu'
});Behaviour notes
- Both calls return
void— the server responds with{ success: true }and nothing useful to surface. - Pass the decoded ID-token payload only, not the raw OAuth2 access / refresh tokens. The endpoints intentionally do not touch the
access_token/refresh_tokencolumns on the connection row. - The connection record uses the well-known provider slug (
microsoft/google), so consumers like the chat-platform webhook can match users viaidentity_microsoft->>'tid','oid','sub','preferred_username','email'andidentity_google->>'sub','email'. - Throws
404if the active product has no provider-auth row for the slug, or422ifsubis missing.
createDataSourceClient(client, options?)
Reads Docyrus app and data source metadata across apps or for a specific app. This is useful when you need schema-aware UI behavior such as loading available apps, reading field definitions, or resolving a data source by slug.
import { createDataSourceClient } from '@docyrus/app-utils';
const dataSources = createDataSourceClient(apiClient);Backing the client with an inventory cache
Pass an InventoryClient (see createInventoryClient) to read every list/get method through the inventory cache instead of hitting the network on every call:
import { createInventoryClient, createDataSourceClient } from '@docyrus/app-utils';
const inventory = createInventoryClient(apiClient);
const dataSources = createDataSourceClient(apiClient, { inventory });
// First call populates the inventory cache (one network request).
// Subsequent calls — including from other clients sharing the same inventory — are cache hits.
await dataSources.listApps();
await dataSources.listByAppSlug('crm');
await dataSources.getBySlug('crm', 'contacts');Cache resolution rules:
- If
params.expandis omitted, the cached entry (fetched with the inventory'sdataSourcesExpand, default'views,forms,fields') is used — every returned data source already carries its embeddedviews,forms, andfields(withenums). - If
params.expandis provided, the request bypasses the cache and hits the API directly — the cached entry may not include the requested expansions. getById/getBySlugfall back to the API when the lookup misses the cache (e.g. an entry that was created after the cache was populated).
Expand options
The metadata endpoints support comma-separated expansions via expand.
Available values:
childrenfieldsformsacltemplatesclient-configurationmisc
Example:
const sources = await dataSources.list({
expand: 'fields,forms'
});dataSources.listApps()
Lists apps available to the current tenant.
const apps = await dataSources.listApps();
// [{ id: 'uuid', name: 'CRM', slug: 'crm', logo_url: '...', status: 'active' }]dataSources.list(params?)
Lists all data sources the current tenant can access across all apps.
const all = await dataSources.list();
const allWithFields = await dataSources.list({ expand: 'fields' });dataSources.listByAppSlug(appSlug, params?)
Lists all data sources for a specific app slug.
const crmSources = await dataSources.listByAppSlug('crm');
const crmWithFields = await dataSources.listByAppSlug('crm', {
expand: 'fields,acl'
});dataSources.listByAppId(appId, params?)
Lists all data sources for a specific app ID.
const appSources = await dataSources.listByAppId('app-uuid');
const appSourcesWithFields = await dataSources.listByAppId('app-uuid', {
expand: 'fields'
});dataSources.getById(dataSourceId, params?)
Returns a single data source metadata object by data source ID.
const contacts = await dataSources.getById('data-source-uuid');
const contactsWithFields = await dataSources.getById('data-source-uuid', {
expand: 'fields'
});dataSources.getBySlug(appSlug, dataSourceSlug, params?)
Returns a single data source metadata object by app slug and data source slug.
const contacts = await dataSources.getBySlug('crm', 'contacts');
const contactsWithFields = await dataSources.getBySlug('crm', 'contacts', {
expand: 'fields,forms'
});Response shape
Basic responses include fields such as:
{
id: 'uuid',
name: 'Contacts',
slug: 'contacts',
appSlug: 'crm'
}When expand=fields is used, the response can include fields metadata as well.
createInventoryClient(client, options?)
A tenant-wide inventory of installed apps, data sources, and the saved views/forms attached to them — with no app or data source scope required. Useful for global pickers, sync caches, admin dashboards, or any UI that needs to enumerate everything the tenant can see in a single call.
import { createInventoryClient } from '@docyrus/app-utils';
const inventory = createInventoryClient(apiClient);One fetch, everything cached
listDataSources is the single source of truth. It calls GET /v1/apps/data-sources?expand=views,forms,fields once and caches every data source together with its embedded views, forms (with actions), and fields (with enum options). listDataViews and listDataForms are derived projections over that cache — they trigger no extra HTTP calls.
| Option | Default | Description |
|---------------------|-------------------------|--------------------------------------------------------------------------------------------------------|
| ttlMs | 300_000 (5 min) | Cache time-to-live. Pass Infinity to keep entries until refresh() / invalidate*(). |
| dataSourcesExpand | 'views,forms,fields' | Comma-separated expand. Override to include extras (e.g. 'views,forms,fields,acl') — keep views, forms, fields so derived views / form clients still work. |
The embedded form payload (camelCased, reduced) is normalized on cache write to the canonical DataForm shape — isDefault → is_default, missing tenant_data_source_id is backfilled from the parent data source, archived defaults to false. Embedded extras (ownership, ownerProductId, customized) are preserved as optional fields on DataForm.
const inventory = createInventoryClient(apiClient, {
ttlMs: 10 * 60 * 1000,
dataSourcesExpand: 'views,forms,fields,acl'
});
// Cache management
await inventory.refresh(); // drop both caches and re-fetch apps + data sources
inventory.invalidateApps(); // selectively drop apps
inventory.invalidateDataSources(); // selectively drop data sources (and the derived views/forms)
// Direct cache patches (used by data-view / data-form clients after mutations)
inventory.upsertDataView(view); // insert or replace by id, routed via view.tenant_data_source_id
inventory.removeDataView(viewId);
inventory.upsertDataForm(form);
inventory.removeDataForm(formId);Filters passed to listDataViews({ appId, dataSourceId }) / listDataForms({ dataSourceId }) are applied client-side over the cached data sources — they never refetch.
inventory.listApps()
Lists every app the current tenant can access. Backed by GET /v1/apps. Cached separately from data sources.
const apps = await inventory.listApps();
// [{ id: 'uuid', name: 'CRM', slug: 'crm', logo_url: '...', status: 'active' }]inventory.listDataSources()
Lists every data source the current tenant can access across all apps, with views, forms, and fields (with enums) embedded inline. Triggers exactly one network call per cache window.
const sources = await inventory.listDataSources();
// sources[0].views — DataView[]
// sources[0].forms — DataForm[] (normalized from the embedded camelCased shape)
// sources[0].fields[i].enums — DataSourceEnumOption[] for select-type fieldsinventory.listDataViews(params?)
Flattens every cached data source's views array into a single list. No network call. params.appId matches against tenant_app_id; params.dataSourceId matches against the data source's id.
const allViews = await inventory.listDataViews();
const crmViews = await inventory.listDataViews({
appId: 'fbeebd52-98ff-11ed-93e6-37fc9e45fc08'
});
const viewsForSpecificDataSources = await inventory.listDataViews({
dataSourceId: ['019c48d0-506a-7ad1-9a4d-20acbc82f300', '010e9ce2-ca44-11ed-bda6-6b0c6905cf5e']
});Both filters accept either a single id or an array.
inventory.listDataForms(params?)
Same flattening for forms.
const allForms = await inventory.listDataForms();
const formsForApp = await inventory.listDataForms({ appId: ['fbeebd52-...'] });createDataSourceQueries(client) — TanStack Query integration
Returns prebuilt queryOptions-style factories and a query-key registry, so you get caching, deduplication, and reactive auto-invalidation for free. Works with @tanstack/react-query, @tanstack/vue-query, @tanstack/solid-query, etc. — only @tanstack/query-core is required at runtime.
Imported from a separate entrypoint to keep the optional @tanstack/query-core peer dep out of the main bundle:
import { createDataSourceQueries } from '@docyrus/app-utils/query';
const queries = createDataSourceQueries(apiClient);Usage with React
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { createDataSourceQueries } from '@docyrus/app-utils/query';
const queries = createDataSourceQueries(apiClient);
function ContactsTable() {
const { data: contacts } = useQuery(
queries.getBySlugOptions('crm', 'contacts', { expand: 'fields,forms' })
);
const queryClient = useQueryClient();
const handleSchemaChange = () => queries.invalidateBySlug(queryClient, 'crm', 'contacts');
// ...
}Available *Options factories
Each factory mirrors the corresponding createDataSourceClient method and produces a { queryKey, queryFn } object you pass straight to useQuery (or useSuspenseQuery, prefetchQuery, etc.):
| Factory | Wraps |
|---------|-------|
| listAppsOptions() | dataSources.listApps() |
| listOptions(params?) | dataSources.list(params) |
| listByAppSlugOptions(appSlug, params?) | dataSources.listByAppSlug(...) |
| listByAppIdOptions(appId, params?) | dataSources.listByAppId(...) |
| getByIdOptions(dataSourceId, params?) | dataSources.getById(...) |
| getBySlugOptions(appSlug, dataSourceSlug, params?) | dataSources.getBySlug(...) |
Per-request options (staleTime, enabled, select, placeholderData, …) are passed at the call site:
useQuery({
...queries.listOptions({ expand: 'fields' }),
staleTime: 5 * 60 * 1000,
enabled: isAuthenticated
});Invalidation helpers
All take a QueryClient so consumers can call them anywhere (event handlers, mutation onSuccess, etc.).
| Helper | Invalidates |
|--------|-------------|
| invalidateAll(qc) | Every cached data-source query (apps + lists + details) |
| invalidateApps(qc) | Cached app list |
| invalidateLists(qc) | All list* variants (across apps and params) |
| invalidateDetails(qc) | All getById / getBySlug results |
| invalidateById(qc, dataSourceId) | Every cached detail for a single data source ID (any expand) |
| invalidateBySlug(qc, appSlug, dataSourceSlug) | Every cached detail for a single app+slug pair (any expand) |
| invalidateForAppSlug(qc, appSlug) | All list queries scoped to an app slug |
| invalidateForAppId(qc, appId) | All list queries scoped to an app ID |
import { useMutation, useQueryClient } from '@tanstack/react-query';
const queryClient = useQueryClient();
const updateSchema = useMutation({
mutationFn: (changes) => api.patchSchema(changes),
onSuccess: () => queries.invalidateBySlug(queryClient, 'crm', 'contacts')
});dataSourceKeys registry
The same query keys used internally are exported as dataSourceKeys (and as queries.keys). Use them when you need to read or set cache entries directly:
import { dataSourceKeys } from '@docyrus/app-utils/query';
queryClient.setQueryData(
dataSourceKeys.bySlug('crm', 'contacts', { expand: 'fields' }),
optimisticData
);The key shape is stable: every key starts with ['docyrus', 'data-sources', ...], so queryClient.invalidateQueries({ queryKey: dataSourceKeys.all() }) clears everything this module owns.
API Reference
getTenantPreferences(client)
Fetches tenant preferences from the Docyrus API.
import { getTenantPreferences } from '@docyrus/app-utils';
const preferences = await getTenantPreferences(apiClient);
// { date_format: 'd.m.Y', decimal_separator: ',', thousand_separator: '.', ... }Parameters:
client— A configuredRestApiClientfrom@docyrus/api-client
Returns: Promise<TenantPreferences>
createDateUtils(config)
Creates date formatting utilities configured from tenant preferences.
import { createDateUtils } from '@docyrus/app-utils';
const dateUtils = createDateUtils({
preferences,
userTimezone: 'Europe/Istanbul' // defaults to 'UTC'
});dateUtils.formatDate(date, options?)
Formats a date using the tenant's date_format (default Y-m-d).
dateUtils.formatDate('2024-06-15'); // uses tenant format
dateUtils.formatDate(new Date(), { format: 'd/m/Y' }); // custom format
dateUtils.formatDate('2024-06-15', { timezone: 'US/Eastern' }); // custom timezonedateUtils.formatDateTime(date, options?)
Formats a datetime using the tenant's date_time_format (default Y-m-d H:i:s). Handles timezone-aware parsing — appends Z to strings without a timezone suffix.
dateUtils.formatDateTime('2024-06-15T14:30:00'); // parses as UTC
dateUtils.formatDateTime('2024-06-15T14:30:00+03:00'); // respects offsetdateUtils.formatDateLong(date, options?)
Formats a date using the tenant's long_date_format (default Y-m-d H:i:s).
dateUtils.toUserTimezone(date)
Converts a date to the user's timezone without formatting.
const localDate = dateUtils.toUserTimezone('2024-06-15T12:00:00Z');Format strings use PHP date format syntax (via php-date-formatter):
| Token | Output | Example |
|-------|--------|---------|
| Y | 4-digit year | 2024 |
| m | Month (01-12) | 06 |
| d | Day (01-31) | 15 |
| H | Hours 24h (00-23) | 14 |
| i | Minutes (00-59) | 30 |
| s | Seconds (00-59) | 00 |
| D | Short day name | Sat |
| l | Full day name | Saturday |
| F | Full month name | June |
createNumberUtils(config)
Creates number formatting utilities configured from tenant preferences.
import { createNumberUtils } from '@docyrus/app-utils';
const numberUtils = createNumberUtils({ preferences });numberUtils.formatNumber(value, options?)
Formats a number using the tenant's locale, separators, and precision.
numberUtils.formatNumber(1234567.89);
// With tenant settings: thousand_separator='.', decimal_separator=',', decimal_precision=2
// → '1.234.567,89'
// Override per call:
numberUtils.formatNumber(1234.5, {
decimalPrecision: 3,
decimalSeparator: '.',
thousandSeparator: ','
});
// → '1,234.500'Formatting logic:
- If both
thousandSeparatoranddecimalSeparatorare set → manual formatting with regex - Otherwise →
toLocaleString()with the tenant'slocale - Pass
thousandSeparator: ''to disable grouping
formatNumberToWords(value, options?)
Converts a number to words with currency labels. Standalone function (no config needed).
import { formatNumberToWords } from '@docyrus/app-utils';
formatNumberToWords(1234.56);
// → 'BİNİKİYÜZOTUZDÖRT TÜRK LİRASI ELLİALTI KURUŞ'
formatNumberToWords(1234.56, { lang: 'EN', currency: 'USD' });
// → 'ONE THOUSAND TWO HUNDRED THIRTY FOUR DOLAR FIFTY SIX CENT'
formatNumberToWords(1234.56, { lang: 'TR', currency: 'EUR', useSpaces: true });
// → 'BİN İKİ YÜZ OTUZ DÖRT EURO ELLİ ALTI CENT'Options:
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| lang | 'TR' \| 'EN' | 'TR' | Word language |
| currency | 'TRY' \| 'EUR' \| 'USD' \| 'GBP' \| 'JPY' | 'TRY' | Currency labels |
| useSpaces | boolean | false | Add spaces between word groups |
Duration Formatters
Standalone functions for formatting durations (in seconds).
import {
formatDurationAsTime,
formatDurationAsHours,
formatDurationAsWords
} from '@docyrus/app-utils';
formatDurationAsTime(5461); // → '01:31'
formatDurationAsHours(5461); // → '1.52'
formatDurationAsHours(5461, 1); // → '1.5'
formatDurationAsWords(5461); // → '1 hr 31 mins'
formatDurationAsWords(45); // → '0 mins'createTemplateEngine(config?)
Creates a template engine combining Handlebars (with async support) and JSONata formula evaluation.
import { createTemplateEngine } from '@docyrus/app-utils';
const engine = createTemplateEngine({
dateUtils, // from createDateUtils()
numberUtils, // from createNumberUtils()
user: currentUser, // injected as metadata.user in formulas
extraJsonataBindings: { hasRole } // additional JSONata bindings
});Each createTemplateEngine call creates an isolated Handlebars instance, so multiple engines (e.g., different tenants) won't conflict.
engine.compileTpl(templateString)
Preprocesses and compiles a Handlebars template. Returns an async function.
const tpl = engine.compileTpl('Hello {{name}}, total: {{formatNumber amount}}');
const html = await tpl({ name: 'John', amount: 1234.5 });
// → 'Hello John, total: 1.234,50'Template preprocessing:
{{formula $expr}}→ wraps expression in quotes for the formula helper{{#if $expr}}→ converts to{{#if (formula '$expr')}}for JSONata evaluation{{<content}}→ strips HTML tags from inline content
engine.compileFormula(expression, data?)
Evaluates a JSONata expression with all helpers bound. Injects metadata.user from config.
const total = await engine.compileFormula('$sum(items.price)', { items });
const greeting = await engine.compileFormula('"Hello " & name', { name: 'World' });engine.jsonataHelpers
Direct access to the full bindings object (all built-in helpers + extraJsonataBindings).
Handlebars Helpers
The following helpers are registered automatically when using compileTpl:
Formatting Helpers
| Helper | Usage | Description |
|--------|-------|-------------|
| formatDate | {{formatDate dateField format="d/m/Y"}} | Format date (uses tenant date_format by default) |
| formatDateTime | {{formatDateTime dateField}} | Format datetime (uses tenant date_time_format) |
| formatNumber | {{formatNumber amount decimalPrecision=2}} | Format number with tenant settings |
| formatNumberToWords | {{formatNumberToWords amount lang="TR" currency="TRY"}} | Number to words |
| formatDurationAsTime | {{formatDurationAsTime seconds}} | Duration as HH:MM |
| formatDurationAsWords | {{formatDurationAsWords seconds}} | Duration as X hrs Y mins |
| formatDurationAsHours | {{formatDurationAsHours seconds decimalPrecision=1}} | Duration as decimal hours |
Logic & Data Helpers
| Helper | Usage | Description |
|--------|-------|-------------|
| formula | {{formula "$sum(items.price)"}} or {{#formula}}$expr{{/formula}} | Evaluate JSONata expression |
| path | {{#path "$.items[0]"}}...{{/path}} | JSONPath query — sets context to result |
| repeat | {{#repeat 5}}...{{/repeat}} | Repeat block N times |
| sum | {{sum items "price"}} or {{sum 1 2 3}} | Sum array field or values |
| json | {{json data}} | Pretty-print JSON |
JSONata Helpers
All helpers are available inside compileFormula and {{formula}} blocks. They are also exported as jsonataHelpers for direct use.
import { jsonataHelpers } from '@docyrus/app-utils';Date Functions (date-fns, null-safe wrapped)
All date functions accept strings or Date objects. Returns null for null/empty input.
Formatting:
formatDate, formatDistance, formatDistanceStrict, formatDistanceToNow, formatDistanceToNowStrict, formatDuration, formatISO, formatRelative
Arithmetic:
addYears, addMonths, addWeeks, addDays, addHours, addMinutes, addSeconds, addMilliseconds, subYears, subMonths, subWeeks, subDays, subHours, subMinutes, subSeconds, subMilliseconds
Comparison:
isAfter, isBefore, isDatesEqual, isPast
Difference:
differenceInYears, differenceInMonths, differenceInWeeks, differenceInDays, differenceInHours, differenceInMinutes, differenceInSeconds, differenceInBusinessDays, differenceInBusinessDaysCustom
Period boundaries:
startOfDay, startOfMonth, startOfWeek, startOfToday, startOfQuarter, endOfDay, endOfMonth, endOfWeek, endOfToday, endOfQuarter
differenceInBusinessDaysCustom(startDate, endDate, options?)
Calculates business days with configurable work hours and lunch breaks.
$differenceInBusinessDaysCustom(startDate, endDate, {
"workStartHour": 9,
"workEndHour": 18,
"lunchBreakHours": 1,
"lunchStartHour": 12.5,
"lunchEndHour": 13.5,
"includePartialDays": true
})Number / Format Functions
| Function | Signature | Description |
|----------|-----------|-------------|
| formatDecimal | (number, precision?, decSep?, thousandSep?) | Format decimal with separators |
| formatMoney | (number, precision?, decSep?, thousandSep?, currency?, position?) | Format as currency |
| percentage | (value, total, decimals) | Calculate percentage |
| truncate | (text, limit) | Truncate with ... |
| join | (array, separator) | Join array to string |
| formatDurationInSeconds | (seconds) | Human-readable duration via date-fns |
String Functions
| Function | Signature | Description |
|----------|-----------|-------------|
| sha256 | (message) | SHA-256 hash (async) |
| ascii | (str) | Strip accents and non-ASCII characters |
| slug | (str, separator?) | URL-friendly slug |
| extractNameFromEmail | (email, fallback?) | Extract name from email (e.g. john.doe@... → John Doe) |
| filterEmpty | (array) | Remove null/undefined/empty from array |
| padLeft | (value, length, char?) | Left-pad string |
| padRight | (value, length, char?) | Right-pad string |
| ifNull | (value, alternative) | Null coalescing |
| objectToString | (object) | Convert object to key:value string |
| startsWith | (haystack, needle) | String starts with |
| endsWith | (haystack, needle) | String ends with |
| includes | (haystack, needle) | String includes (null-safe) |
Equality / DB Helpers
| Function | Signature | Description |
|----------|-----------|-------------|
| isEqual | (a, b) | Deep equality — compares by id, Date, JSON, or strict |
| isEqualOrContained | (needle, haystack) | Check if value is equal to or contained in array |
| isNotEqualOrContained | (needle, haystack) | Negation of isEqualOrContained |
| getDbValue | (value) | Extract id from object or first item of array |
TypeScript
All types are exported:
import type {
TenantPreferences,
DateUtils,
DateUtilsConfig,
FormatOptions,
NumberUtils,
NumberUtilsConfig,
NumberFormatOptions,
NumberToWordsOptions,
NumberWordLang,
CurrencyCode,
TemplateEngine,
TemplateEngineConfig,
AppConfig,
UpsertAppConfigBody,
AppConfigClient,
UserAppConfig,
UpsertUserAppConfigBody,
UserAppConfigClient,
DataView,
CreateDataViewBody,
UpdateDataViewBody,
ListDataViewsParams,
DataViewClient,
DataForm,
CreateDataFormBody,
UpdateDataFormBody,
DataFormClient,
DataSourceExpand,
DataSourceField,
DataSource,
DataSourceApp,
DataSourceListParams,
DataSourceClient,
TenantBrand,
TenantBrandColorScheme,
TenantBrandActiveVoicePreference,
TenantBrandDirectness,
TenantBrandEmojiPolicy,
TenantBrandFormalityLevel,
TenantBrandMetricEmphasis,
TenantBrandSlideDensity,
TenantBrandIllustrationStyle,
CreateTenantBrandBody,
UpdateTenantBrandBody,
FetchTenantBrandFromWebsiteResponse,
TenantBrandClient,
MicrosoftIdentityPayload,
GoogleIdentityPayload,
UserIdentityClient,
AppUtilsConfig
} from '@docyrus/app-utils';License
MIT
