Skip to content

DataContainer (@bpmlib/utils-data-container)

Vue 3 composable untuk mengelola paginated data dengan filtering, sorting, dan search capabilities

Versi: 0.1.1
Kategori: Framework Utils (Vue 3)

npm versionTypeScriptVue


TL;DR

Composable untuk manage state paginated data dengan support multiple pagination modes (length-aware, cursor-based, simple), advanced filtering dengan nested paths, sorting, dan search. Menyediakan separation between draft dan applied filters, auto-detection pagination mode dari API response, dan URL parameter generation untuk seamless integration dengan backend APIs.

Composables:

ts
import { useDataContainer } from '@bpmlib/utils-data-container';

Types:

ts
import type {
    DataContainerObject,
    PageConfig,
    SortConfig,
    SearchConfig,
    ErrorInfo,
    UrlParams,
    PaginationMode,
    QueryIntent,
    PaginationIntent,
    SortIntent,
    SearchIntent,
} from '@bpmlib/utils-data-container';

Installation & Setup

Requirements

Peer Dependencies

Library ini memerlukan peer dependencies berikut:

Wajib:

bash
npm install vue@^3.3.0
DependencyVersiStatusDeskripsi
vue^3.3.0RequiredVue 3 framework

Package Installation

bash
npm install @bpmlib/utils-data-container
bash
yarn add @bpmlib/utils-data-container
bash
pnpm add @bpmlib/utils-data-container
bash
bun install @bpmlib/utils-data-container

Import

Basic Import:

ts
import { useDataContainer } from '@bpmlib/utils-data-container';

With Types:

ts
import { useDataContainer } from '@bpmlib/utils-data-container';
import type {
    DataContainerObject,
    PageConfig,
    PaginationMode,
    QueryIntent
} from '@bpmlib/utils-data-container';

Quick Start

Basic Usage

Contoh paling sederhana untuk manage paginated data:

vue
<script setup lang="ts">
import { useDataContainer } from '@bpmlib/utils-data-container';

interface User {
  id: number;
  name: string;
  email: string;
}

const container = useDataContainer<User>();

async function fetchUsers() {
  container.loadStatus = 0;
  
  const response = await fetch('/api/users?' + container.getterUrlStringAttribute());
  const data = await response.json();
  
  container.mapFromResponse(data);
}

fetchUsers();
</script>

<template>
  <div>
    <div v-if="container.isLoading">Loading...</div>
    <div v-else-if="container.hasError">{{ container.error?.message }}</div>
    <div v-else>
      <div v-for="user in container.data" :key="user.id">
        {{ user.name }} - {{ user.email }}
      </div>
    </div>
  </div>
</template>

Key Points:

  • useDataContainer manage reactive state untuk data, filter, pagination, sort, search
  • loadStatus direct value (tidak perlu .value di script)
  • getterUrlStringAttribute() generate URL query string
  • mapFromResponse() auto-detect pagination mode

Comprehensive Example

Full-featured example dengan filtering, pagination, dan sorting:

vue
<script setup lang="ts">
import { useDataContainer } from '@bpmlib/utils-data-container';

interface Product {
  id: number;
  name: string;
  price: number;
  category: string;
}

interface ProductFilter {
  category?: string;
  minPrice?: number;
  maxPrice?: number;
}

const container = useDataContainer<Product, ProductFilter>({
  category: '',
  minPrice: 0,
  maxPrice: 0
});

async function fetchProducts() {
  container.loadStatus = 0;
  
  try {
    const response = await fetch('/api/products?' + container.getterUrlStringAttribute());
    const data = await response.json();
    container.mapFromResponse(data);
  } catch (error) {
    container.setError('Failed to fetch products', 'FETCH_ERROR');
  }
}

function handleFilterChange() {
  if (container.hasUnappliedFilterChanges()) {
    container.applyFilter();
    fetchProducts();
  }
}

function handleSort(field: string) {
  container.setSort(field, container.sort.sortDir === 'a' ? 'd' : 'a');
  fetchProducts();
}

function handlePageChange(page: number) {
  container.setPage({
    numeric: { ...container.page.numeric, current: page }
  });
  fetchProducts();
}

fetchProducts();
</script>

<template>
  <div>
    <div class="filters">
      <input v-model="container.filter.category" @change="handleFilterChange">
      <input v-model.number="container.filter.minPrice" @change="handleFilterChange">
      <input v-model.number="container.filter.maxPrice" @change="handleFilterChange">
      <button @click="container.resetFilterToInit">Reset</button>
    </div>

    <input v-model="container.search" @change="fetchProducts" placeholder="Search...">

    <div class="sort">
      <button @click="handleSort('name')">Sort by Name</button>
      <button @click="handleSort('price')">Sort by Price</button>
    </div>

    <div v-if="container.isLoading">Loading...</div>
    <div v-else-if="container.hasError">{{ container.error?.message }}</div>
    <div v-else-if="container.isEmpty">No products found</div>
    <div v-else>
      <div v-for="product in container.data" :key="product.id">
        {{ product.name }} - ${{ product.price }} ({{ product.category }})
      </div>
    </div>

    <div v-if="container.page.paginationMode === 'lengthaware'" class="pagination">
      <button 
        v-for="page in container.page.numeric.max" 
        :key="page"
        @click="handlePageChange(page)"
        :disabled="page === container.page.numeric.current"
      >
        {{ page }}
      </button>
    </div>
  </div>
</template>

Core Concepts

Library ini didesain dengan beberapa konsep fundamental yang mempengaruhi cara kamu menggunakannya.

Lazy State Management & Mutable Filter Pattern

Filter state di-manage dalam dua layer: draft (filter) dan applied (_appliedFilter). System membedakan antara draft filter (user sedang input) dan applied filter (yang benar-benar digunakan untuk fetch).

How it works:

  • filter - Mutable reactive object yang bisa langsung di-bind ke form input
  • _appliedFilter - Readonly snapshot dari last applied filter
  • Perubahan pada filter tidak otomatis trigger fetch - kamu kontrol kapan apply
  • applyFilter() copy filter ke _appliedFilter yang digunakan untuk URL generation
  • hasUnappliedFilterChanges() check apakah ada pending changes
  • Pattern ini mencegah accidental fetch setiap user ketik di form

Example:

ts
// User input langsung update filter (tidak trigger fetch)
container.filter.status = 'active';
container.filter.role = 'admin';

// Check apakah ada perubahan yang belum di-apply
if (container.hasUnappliedFilterChanges()) {
  // Apply filter dan fetch
  container.applyFilter();
  fetchData();
}

// Reset ke applied state (discard draft)
container.resetFilterToApplied();

// Reset ke initial state
container.resetFilterToInit();

Use Case: Form dengan "Apply" button, mencegah accidental fetch, implement filter preview, atau dirty state tracking.

See: Filter Methods, Example 2


Pagination Mode Auto-Detection

Library auto-detect pagination mode berdasarkan response structure, tanpa perlu manual configuration.

How it works:

  • Check response fields untuk determine mode
  • max_page + total'lengthaware' (classic pagination)
  • next_cursor / previous_cursor'cursor' (token-based)
  • has_more'simple' (next/prev only)
  • No pagination fields → 'none'
  • Mode automatically set di page.paginationMode

Detection Logic:

ts
// Backend response examples

// Length-aware (classic)
{
  "content": [...],
  "max_page": 10,
  "current_page": 1,
  "total": 95
}
// → paginationMode: 'lengthaware'

// Cursor-based
{
  "content": [...],
  "next_cursor": "eyJpZCI6MTAwfQ==",
  "previous_cursor": null
}
// → paginationMode: 'cursor'

// Simple
{
  "content": [...],
  "has_more": true
}
// → paginationMode: 'simple'

// No pagination
{
  "content": [...]
}
// → paginationMode: 'none'

Use Case: Backend API consistency, support multiple APIs dengan format berbeda, dynamic UI rendering based on mode.

See: PaginationMode Type, Example 3

WORKS WELL WITH

Library ini dirancang untuk bekerja seamless dengan @bpmlib/vue-sapaginator - Vue 3 pagination component yang auto-detect mode dari DataContainer dan render UI sesuai pagination type.


URL Parameter Generation

Library generate URL query parameters dari current state untuk seamless backend integration.

How it works:

  • getterObjectAttribute() - Return plain object untuk axios/fetch params
  • getterUrlStringAttribute() - Return URL-encoded string untuk direct append
  • Nested filters di-encode sebagai filter[key][nested]=value
  • Empty values otomatis di-skip (tidak masuk URL)

Generated Format:

ts
// Container state
container.setFilter('status', 'active');
container.setFilter('nested.category', 'tech');
container.setSort('name', 'a');
container.search = 'laptop';
container.setPage({ paginationMode: 'lengthaware', numeric: { current: 2 }, perPage: 20 });

// Object output
container.getterObjectAttribute();
// {
//   page: 2,
//   perPage: 20,
//   sortBy: 'name',
//   sortDir: 'asc',
//   filter: { status: 'active', nested: { category: 'tech' } },
//   q: 'laptop'
// }

// String output
container.getterUrlStringAttribute();
// "page=2&perPage=20&sortBy=name&sortDir=asc&filter[status]=active&filter[nested][category]=tech&q=laptop"

See: URL Generation Methods, Example 1


Query Intent Initialization

NEW v0.1.1

Feature baru untuk initialize container state dari URL query parameters atau router state.

QueryIntent memungkinkan kamu initialize pagination, sort, dan search state saat composable dibuat, berguna untuk URL-driven state atau SSR scenarios.

How it works:

  • Pass QueryIntent sebagai second parameter ke useDataContainer()
  • Object ini define initial state untuk pagination, sort, dan search
  • Berguna untuk hydrate state dari URL query params (e.g., Vue Router, Nuxt route)
  • State langsung applied saat initialization, tidak perlu manual setup

Example:

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

const route = useRoute();

// Parse query params dari URL
const queryIntent: QueryIntent = {
  pagination: {
    mode: 'lengthaware',
    perPage: Number(route.query.perPage) || 20,
    page: Number(route.query.page) || 1
  },
  sort: {
    by: route.query.sortBy as string || 'name',
    dir: route.query.sortDir === 'desc' ? 'desc' : 'asc'
  },
  search: {
    param: 'q',
    value: route.query.q as string || ''
  }
};

// Initialize container dengan URL state
const container = useDataContainer<Product, ProductFilter>(
  { category: '', status: '' },
  queryIntent
);

// Container sudah initialized dengan state dari URL
// page.numeric.current = 1
// page.perPage = 20
// sort.sortBy = 'name'
// search = ''

Use Case: Deep linking, shareable URLs, SSR hydration, browser back/forward support, bookmarkable search results.

See: QueryIntent Types, Example 8


API Reference

Composables

useDataContainer

Composable untuk manage paginated data state dengan support filtering, sorting, search, dan multiple pagination modes.

Parameters

NameTypeDefaultDescription
initialFilterTFilter{}Initial filter values yang akan digunakan saat reset
queryIntentQueryIntentundefinedInitial state untuk pagination, sort, search dari URL/router Lihat selengkapnya
queryIntent

NEW v0.1.1

Parameter baru untuk initialize container state dari URL query params atau router state.

Optional object untuk set initial pagination, sort, dan search configuration. Berguna untuk URL-driven state atau deep linking.

Type: QueryIntent

Structure:

ts
interface QueryIntent {
  pagination?: PaginationIntent;
  sort?: SortIntent;
  search?: SearchIntent;
}

Contoh:

ts
const container = useDataContainer<User, UserFilter>(
  { status: '' },
  {
    pagination: { mode: 'lengthaware', page: 2, perPage: 20 },
    sort: { by: 'name', dir: 'asc' },
    search: { param: 'q', value: 'john' }
  }
);

// Container sudah initialized dengan state di atas
console.log(container.page.numeric.current); // 2
console.log(container.sort.sortBy); // 'name'
console.log(container.search); // 'john'

Use Case:

  • Initialize dari Vue Router query params
  • SSR hydration dengan URL state
  • Deep linking untuk shareable search results
  • Browser back/forward support

Generic Types:

  • TData - Type of individual data items in array
  • TFilter - Type of filter object structure
  • TAdditional - Type of additional data dari response (metadata, stats, dll)

Returns

Contains:

Reactive State
data
ts
data: Ref<TData[]>

Array of data items yang di-fetch dari API. Menggunakan shallowRef untuk performance.

Penting - Penggunaan .value:

  • Di <script>: Perlu .valuecontainer.data.value
  • Di <template>: Tidak perlu .value (auto-unwrap) → container.data

filter
ts
filter: Reactive<TFilter>

Draft filter state yang bisa langsung di-bind ke form inputs. Perubahan tidak otomatis trigger fetch.


page
ts
page: PageConfig

Reactive pagination configuration object. Contains numeric (page numbers), cursor (tokens), perPage, hasMore, totalData, dan paginationMode.

See: PageConfig Type, setPage()


sort
ts
sort: SortConfig

Reactive sort configuration object. Contains sortBy (field name) dan sortDir ('a' or 'd').

See: SortConfig Type, setSort()


loadStatus

BREAKING CHANGE v0.1.1

Changed dari Ref<-1 | 0 | 1> ke direct value dengan getter/setter. Tidak perlu .value lagi di <script>.

ts
loadStatus: -1 | 0 | 1

Loading status indicator: -1 (error), 0 (loading), 1 (loaded successfully).

Penggunaan:

ts
// v0.1.1 - Direct assignment (NO .value)
container.loadStatus = 0;
if (container.loadStatus === 1) { }

// v0.1.0 - Old way (deprecated, akan error)
// container.loadStatus.value = 0;  // ERROR: Cannot read 'value' of number

Di template: Tetap works tanpa .value (tidak ada breaking change di template).


error
ts
error: Ref<ErrorInfo | null>

Error information object dengan message dan code. Null jika tidak ada error.

See: ErrorInfo Type, setError(), clearError()


additionalData
ts
additionalData: Ref<TAdditional | null>

Additional data dari API response (metadata, statistics, dll). Menggunakan shallowRef untuk performance. Populated dari appended field di response.


ts
search: string

Search value dengan getter/setter. Tidak perlu .value untuk access. Value akan masuk URL params dengan parameter name dari searchParam (default: 'q').

See: setSearchParam(), getSearchParam()


Computed Properties
isLoading
ts
isLoading: ComputedRef<boolean>

Computed property, true ketika loadStatus === 0.


isLoaded
ts
isLoaded: ComputedRef<boolean>

Computed property, true ketika loadStatus === 1.


hasError
ts
hasError: ComputedRef<boolean>

Computed property, true ketika loadStatus === -1 atau error !== null.


isEmpty
ts
isEmpty: ComputedRef<boolean>

Computed property, true ketika data.length === 0.


Data Methods
appendData()
ts
appendData: (item: TData) => void

Append single item ke data array.

Use Case: Optimistic updates, manual data addition, real-time updates dari websocket.


deleteDataByIndex()
ts
deleteDataByIndex: (index: number) => boolean

Delete item dari data array by index. Returns true jika berhasil, false jika index invalid.

Use Case: Optimistic deletes, manual data removal.


dataLength()
ts
dataLength: () => number

Get current length of data array.

Use Case: Display counts, validation, conditional logic.


Filter Methods
setFilter()
ts
setFilter: (key: string, value: unknown) => void

Set single filter value dengan support nested paths menggunakan dot notation.

Parameters:

  • key - Filter key atau nested path (e.g., 'status', 'nested.category')
  • value - Filter value (any type)

Contoh:

ts
container.setFilter('status', 'active');
container.setFilter('nested.category', 'tech');
container.setFilter('pricing.min', 100);

// Result:
// filter = {
//   status: 'active',
//   nested: { category: 'tech' },
//   pricing: { min: 100 }
// }

applyFilter()
ts
applyFilter: () => void

Copy current filter state ke _appliedFilter. Applied filter digunakan untuk URL generation dan backend request.

Use Case: "Apply" button pattern, commit filter changes sebelum fetch.


resetFilterToInit()
ts
resetFilterToInit: () => void

Reset filter ke initialFilter values (passed saat create container).

Use Case: "Reset All" button.


resetFilterToApplied()
ts
resetFilterToApplied: () => void

Discard draft changes, reset filter ke last applied state (_appliedFilter).

Use Case: "Cancel" button, revert uncommitted changes.


hasUnappliedFilterChanges()
ts
hasUnappliedFilterChanges: () => boolean

Check apakah filter berbeda dengan _appliedFilter. Returns true jika ada pending changes.

Use Case: Enable/disable "Apply" button, dirty state indicator, prevent navigation dengan unsaved changes.


Search Methods
setSearchParam()
ts
setSearchParam: (param: string) => void

Set parameter name untuk search di URL query string.

Default: 'q'

Contoh:

ts
container.setSearchParam('search');
container.search = 'laptop';

// URL: ?search=laptop

getSearchParam()
ts
getSearchParam: () => string

Get current search parameter name.


Pagination Methods
setPage()
ts
setPage: (config: Partial<PageConfig> | null) => void

Update pagination configuration. Accept partial config, hanya update field yang di-pass.

Parameters:

  • config - Partial PageConfig object atau null untuk reset

Contoh:

ts
// Update current page
container.setPage({
  numeric: { ...container.page.numeric, current: 2 }
});

// Update per page
container.setPage({
  perPage: 50
});

// Switch to cursor mode
container.setPage({
  paginationMode: 'cursor',
  cursor: { next: 'token123', prev: null }
});

// Reset pagination
container.setPage(null);

Sort Methods
setSort()
ts
setSort: (sortBy: string, sortDir?: 'a' | 'd') => void

Set sort field dan direction.

Parameters:

  • sortBy - Field name untuk sort
  • sortDir - Optional direction: 'a' (ascending) or 'd' (descending). Default: 'a'

Contoh:

ts
container.setSort('name');           // Sort by name ascending
container.setSort('price', 'd');     // Sort by price descending
container.setSort('', 'a');          // Clear sort

Error Methods
setError()
ts
setError: (message: string, code: number | string) => void

Set error state manually.

Parameters:

  • message - Human-readable error message
  • code - Error code (HTTP status atau custom identifier)

Contoh:

ts
container.setError('Failed to fetch data', 500);
container.setError('Validation failed', 'VALIDATION_ERROR');

Note: Automatically sets loadStatus to -1.


clearError()
ts
clearError: () => void

Clear error state. Set error ke null, tidak mengubah loadStatus.


URL Generation Methods
getterObjectAttribute()
ts
getterObjectAttribute: () => UrlParams

Generate plain object dari applied state untuk use dengan axios/fetch params.

Returns: UrlParams object

Contoh:

ts
const params = container.getterObjectAttribute();
// {
//   page: 2,
//   perPage: 20,
//   sortBy: 'name',
//   sortDir: 'asc',
//   filter: { status: 'active' },
//   q: 'search term'
// }

// Use dengan axios
axios.get('/api/users', { params });

// Use dengan fetch
const url = new URL('/api/users', window.location.origin);
Object.entries(params).forEach(([key, value]) => {
  url.searchParams.append(key, String(value));
});

getterUrlStringAttribute()
ts
getterUrlStringAttribute: () => string

Generate URL-encoded query string dari applied state untuk direct append.

Returns: URL-encoded string

Contoh:

ts
const queryString = container.getterUrlStringAttribute();
// "page=2&perPage=20&sortBy=name&sortDir=asc&filter[status]=active&q=search"

// Use dengan fetch
fetch('/api/users?' + queryString);

// Use dengan axios
axios.get('/api/users?' + queryString);

Utility Methods
reset()
ts
reset: () => void

Complete state reset. Reset semua state ke initial values:

  • data[]
  • filterinitialFilter
  • page → default pagination
  • sort → no sort
  • search''
  • loadStatus0
  • errornull
  • additionalDatanull

mapFromResponse()
ts
mapFromResponse: (
  response: {
    success?: boolean;
    message?: string;
    code?: string | number;
    content?: TData[] | TData | unknown;
    appended?: TAdditional;
    max_page?: number | null;
    current_page?: number | null;
    total?: number | null;
    next_cursor?: string | null;
    previous_cursor?: string | null;
    per_page?: number | null;
    has_more?: boolean | null;
  },
  mapper?: ((item: unknown) => TData) | null,
) => void

Map API response ke container state. Auto-detect pagination mode, update data, handle errors, dan populate metadata.

Parameters:

  • response - API response object dengan standard structure
  • mapper - Optional transformer function untuk convert backend items ke frontend model

Response Structure:

ts
{
  success: boolean,          // Success flag (optional)
  message: string,           // Error message jika failed (optional)
  code: string | number,     // Error code (optional)
  content: TData[] | TData,  // Data array atau single item
  appended: TAdditional,     // Additional metadata (optional)
  
  // Pagination fields (optional, auto-detected)
  max_page: number,          // Total pages (lengthaware)
  current_page: number,      // Current page (lengthaware)
  total: number,             // Total items count (lengthaware)
  next_cursor: string,       // Next cursor token (cursor)
  previous_cursor: string,   // Previous cursor token (cursor)
  per_page: number,          // Items per page
  has_more: boolean,         // Has more pages (simple)
}

Contoh:

ts
// Basic usage
const response = await fetch('/api/users').then(r => r.json());
container.mapFromResponse(response);

// With mapper function
container.mapFromResponse(response, (item: any) => ({
  id: item.user_id,
  name: item.full_name,
  email: item.email_address,
  createdAt: new Date(item.created_at)
}));

// Auto-detects pagination mode
// Response dengan max_page → paginationMode: 'lengthaware'
// Response dengan next_cursor → paginationMode: 'cursor'
// Response dengan has_more → paginationMode: 'simple'
// Response tanpa pagination → paginationMode: 'none'

See: Pagination Mode Auto-Detection, Example 5


Types

DataContainerObject

TIP

Type definition untuk return value dari useDataContainer(). Useful untuk typing component props atau function parameters.

ts
interface DataContainerObject<
  TData = unknown,
  TFilter extends Record<string, unknown> = Record<string, unknown>,
  TAdditional = unknown,
>

See Reactive State, Computed Properties, dan Methods sections untuk full structure.


PageConfig

ts
interface PageConfig {
  numeric: {
    current: number | null;
    max: number | null;
  };
  cursor: {
    next: string | null;
    prev: string | null;
  };
  perPage: number | null;
  hasMore: boolean | null;
  totalData: number | null;
  paginationMode: PaginationMode;
  isFrontEndPreferCursor: boolean;
}

Pagination configuration object yang contains state untuk semua pagination modes.

Properties:

  • numeric.current - Current page number (lengthaware/simple)
  • numeric.max - Total pages (lengthaware only)
  • cursor.next - Next page cursor token (cursor mode)
  • cursor.prev - Previous page cursor token (cursor mode)
  • perPage - Items per page
  • hasMore - Has more pages flag (simple mode)
  • totalData - Total items count (lengthaware only)
  • paginationMode - Current detected mode
  • isFrontEndPreferCursor - Frontend preference untuk cursor mode (set via QueryIntent)

SortConfig

ts
interface SortConfig {
  sortDir: 'a' | 'd';
  sortBy: string;
}

Sort configuration object.

Properties:

  • sortDir - Sort direction: 'a' (ascending) or 'd' (descending)
  • sortBy - Field name to sort by (empty string = no sort)

SearchConfig

ts
interface SearchConfig {
  searchParam: string;
  searchValue: string;
}

Search configuration object (internal).

Properties:

  • searchParam - Parameter name untuk URL (default: 'q')
  • searchValue - Current search value

Note: Access via container.search getter/setter dan getSearchParam() method.


ErrorInfo

ts
interface ErrorInfo {
  message: string;
  code: number | string;
}

Error information object dengan human-readable message dan error code (numeric status atau string identifier).


UrlParams

ts
interface UrlParams {
  perPage?: number;
  page?: number;
  cursor?: string;
  useCursor?: number;
  sortBy?: string;
  sortDir?: string;
  filter?: Record<string, string | string[]>;
  [key: string]: unknown;
}

URL parameters object structure. Contains pagination, sort, filter, dan dynamic search params.

See: getterObjectAttribute()


PaginationMode

ts
type PaginationMode = 'lengthaware' | 'cursor' | 'simple' | 'none';

Pagination mode types.

Values:

  • 'lengthaware' - Classic pagination dengan total pages (has max_page + total)
  • 'cursor' - Cursor-based dengan tokens (has next_cursor/previous_cursor)
  • 'simple' - Simple dengan "has more" indicator (has has_more)
  • 'none' - No pagination

See: Pagination Mode Auto-Detection


QueryIntent

NEW v0.1.1

Type baru untuk initialize container state dari URL query params.

ts
interface QueryIntent {
  pagination?: PaginationIntent;
  sort?: SortIntent;
  search?: SearchIntent;
}

Optional configuration untuk initialize pagination, sort, dan search state saat container creation.

Properties:

See: Query Intent Initialization, Example 8


PaginationIntent

NEW v0.1.1

Part of QueryIntent untuk initialize pagination state.

ts
interface PaginationIntent {
  mode: 'none' | 'lengthaware' | 'simple' | 'cursor';
  perPage?: number;
  page?: number;
  preferCursor?: boolean;
}

Initial pagination configuration.

Properties:

  • mode - Pagination mode to use
  • perPage - Items per page (optional)
  • page - Starting page number (optional, for lengthaware/simple)
  • preferCursor - Prefer cursor mode jika backend supports both (optional)

Contoh:

ts
const intent: PaginationIntent = {
  mode: 'lengthaware',
  perPage: 20,
  page: 2
};

SortIntent

NEW v0.1.1

Part of QueryIntent untuk initialize sort state.

ts
interface SortIntent {
  by: string;
  dir?: 'asc' | 'desc';
}

Initial sort configuration.

Properties:

  • by - Field name to sort by
  • dir - Sort direction (optional, default: 'asc')

Contoh:

ts
const intent: SortIntent = {
  by: 'name',
  dir: 'desc'
};

SearchIntent

NEW v0.1.1

Part of QueryIntent untuk initialize search state.

ts
interface SearchIntent {
  param?: string;
  value?: string;
}

Initial search configuration.

Properties:

  • param - Search parameter name untuk URL (optional, default: 'q')
  • value - Initial search value (optional, default: '')

Contoh:

ts
const intent: SearchIntent = {
  param: 'q',
  value: 'laptop'
};

Examples

Contains:

1. Basic Data Fetching with Pagination

Complete example untuk fetch paginated data dengan length-aware pagination.

vue
<script setup lang="ts">
import { useDataContainer } from '@bpmlib/utils-data-container';

interface Product {
  id: number;
  name: string;
  price: number;
}

const container = useDataContainer<Product>();

async function fetchProducts() {
  container.loadStatus = 0;
  
  try {
    const response = await fetch('/api/products?' + container.getterUrlStringAttribute());
    const data = await response.json();
    container.mapFromResponse(data);
  } catch (error) {
    container.setError('Failed to fetch products', 'FETCH_ERROR');
  }
}

function goToPage(page: number) {
  container.setPage({
    numeric: { ...container.page.numeric, current: page }
  });
  fetchProducts();
}

fetchProducts();
</script>

<template>
  <div>
    <div v-if="container.isLoading">Loading...</div>
    <div v-else-if="container.hasError" class="error">
      {{ container.error?.message }}
      <button @click="fetchProducts">Retry</button>
    </div>
    <div v-else-if="container.isEmpty">No products found</div>
    <div v-else>
      <div v-for="product in container.data" :key="product.id">
        <h3>{{ product.name }}</h3>
        <p>${{ product.price }}</p>
      </div>
      
      <div v-if="container.page.paginationMode === 'lengthaware'" class="pagination">
        <button 
          v-for="page in container.page.numeric.max" 
          :key="page"
          @click="goToPage(page)"
          :disabled="page === container.page.numeric.current"
        >
          {{ page }}
        </button>
      </div>
    </div>
  </div>
</template>

Key Takeaways:

  • getterUrlStringAttribute() builds query string dengan pagination params
  • mapFromResponse() auto-detect pagination mode
  • Use isLoading, hasError, isEmpty untuk conditional rendering

2. Advanced Filtering with Nested Paths

Demonstrates filtering dengan nested objects dan apply/reset functionality.

vue
<script setup lang="ts">
import { useDataContainer } from '@bpmlib/utils-data-container';

interface Product {
  id: number;
  name: string;
}

interface ProductFilter {
  status?: string;
  pricing?: {
    min?: number;
    max?: number;
  };
}

const container = useDataContainer<Product, ProductFilter>({
  status: '',
  pricing: { min: 0, max: 0 }
});

async function fetchProducts() {
  container.loadStatus = 0;
  const response = await fetch('/api/products?' + container.getterUrlStringAttribute());
  const data = await response.json();
  container.mapFromResponse(data);
}

function handleApply() {
  if (container.hasUnappliedFilterChanges()) {
    container.applyFilter();
    fetchProducts();
  }
}

fetchProducts();
</script>

<template>
  <div>
    <div class="filters">
      <input v-model="container.filter.status" placeholder="Status">
      <input v-model.number="container.filter.pricing.min" placeholder="Min">
      <input v-model.number="container.filter.pricing.max" placeholder="Max">
      
      <button @click="handleApply" :disabled="!container.hasUnappliedFilterChanges()">
        Apply
      </button>
      <button @click="container.resetFilterToApplied">Cancel</button>
      <button @click="container.resetFilterToInit">Reset All</button>
      
      <p v-if="container.hasUnappliedFilterChanges()" class="warning">
        Unapplied changes
      </p>
    </div>
    
    <div v-if="container.isLoading">Loading...</div>
    <div v-else>
      <div v-for="product in container.data" :key="product.id">
        {{ product.name }}
      </div>
    </div>
  </div>
</template>

Key Takeaways:

  • Nested filter paths: container.filter.pricing.min
  • hasUnappliedFilterChanges() enable/disable Apply button
  • applyFilter() commit changes sebelum fetch
  • resetFilterToApplied() cancel (revert ke last applied)
  • resetFilterToInit() reset ke initial values

3. Multiple Pagination Modes

Handle different pagination modes dari same component.

vue
<script setup lang="ts">
import { useDataContainer } from '@bpmlib/utils-data-container';
import { computed } from 'vue';

interface Item {
  id: number;
  title: string;
}

const container = useDataContainer<Item>();

async function fetchItems() {
  container.loadStatus = 0;
  
  const response = await fetch('/api/items?' + container.getterUrlStringAttribute());
  const data = await response.json();
  
  container.mapFromResponse(data);
}

function nextPage() {
  const mode = container.page.paginationMode;
  
  if (mode === 'lengthaware' || mode === 'simple') {
    const current = container.page.numeric.current ?? 1;
    container.setPage({
      numeric: { ...container.page.numeric, current: current + 1 }
    });
  } else if (mode === 'cursor') {
    container.setPage({
      cursor: { ...container.page.cursor, prev: container.page.cursor.next }
    });
  }
  
  fetchItems();
}

function prevPage() {
  const mode = container.page.paginationMode;
  
  if (mode === 'lengthaware' || mode === 'simple') {
    const current = container.page.numeric.current ?? 1;
    if (current > 1) {
      container.setPage({
        numeric: { ...container.page.numeric, current: current - 1 }
      });
      fetchItems();
    }
  } else if (mode === 'cursor' && container.page.cursor.prev) {
    container.setPage({
      cursor: { next: container.page.cursor.prev, prev: null }
    });
    fetchItems();
  }
}

const canGoNext = computed(() => {
  const mode = container.page.paginationMode;
  if (mode === 'lengthaware') {
    return (container.page.numeric.current ?? 0) < (container.page.numeric.max ?? 0);
  } else if (mode === 'simple') {
    return container.page.hasMore === true;
  } else if (mode === 'cursor') {
    return container.page.cursor.next !== null;
  }
  return false;
});

const canGoPrev = computed(() => {
  const mode = container.page.paginationMode;
  if (mode === 'lengthaware' || mode === 'simple') {
    return (container.page.numeric.current ?? 1) > 1;
  } else if (mode === 'cursor') {
    return container.page.cursor.prev !== null;
  }
  return false;
});

fetchItems();
</script>

<template>
  <div>
    <!-- Data -->
    <div v-if="container.isLoading">Loading...</div>
    <div v-else>
      <div v-for="item in container.data" :key="item.id">
        {{ item.title }}
      </div>
    </div>
    
    <!-- Universal Pagination Controls -->
    <div class="pagination">
      <button @click="prevPage" :disabled="!canGoPrev">Previous</button>
      
      <!-- Show current page for lengthaware/simple -->
      <span v-if="container.page.paginationMode === 'lengthaware'">
        Page {{ container.page.numeric.current }} of {{ container.page.numeric.max }}
      </span>
      <span v-else-if="container.page.paginationMode === 'simple'">
        Page {{ container.page.numeric.current }}
      </span>
      <span v-else-if="container.page.paginationMode === 'cursor'">
        Cursor pagination
      </span>
      
      <button @click="nextPage" :disabled="!canGoNext">Next</button>
    </div>
    
    <!-- Mode Indicator -->
    <p>Mode: {{ container.page.paginationMode }}</p>
  </div>
</template>

Key Takeaways:

  • Single component handles all pagination modes
  • mapFromResponse() automatically detects mode dari backend response
  • Mode-specific logic for next/prev navigation
  • Computed properties untuk enable/disable buttons based on mode
  • Works dengan any API tanpa configuration changes

4. Search with Custom Parameter

Customize search parameter name untuk match backend API.

vue
<script setup lang="ts">
import { useDataContainer } from '@bpmlib/utils-data-container';
import { watch } from 'vue';

interface Article {
  id: number;
  title: string;
}

const container = useDataContainer<Article>();

container.setSearchParam('query');

async function fetchArticles() {
  container.loadStatus = 0;
  const response = await fetch('/api/articles?' + container.getterUrlStringAttribute());
  const data = await response.json();
  container.mapFromResponse(data);
}

let searchTimeout: number;
watch(() => container.search, () => {
  clearTimeout(searchTimeout);
  searchTimeout = window.setTimeout(fetchArticles, 300);
});

fetchArticles();
</script>

<template>
  <div>
    <input v-model="container.search" placeholder="Search...">
    
    <div v-if="container.isLoading">Searching...</div>
    <div v-else-if="container.isEmpty">
      {{ container.search ? 'No results' : 'Start typing' }}
    </div>
    <div v-else>
      <div v-for="article in container.data" :key="article.id">
        <h3>{{ article.title }}</h3>
      </div>
    </div>
  </div>
</template>

Key Takeaways:

  • setSearchParam() customize parameter name (default: 'q')
  • container.search direct access tanpa .value
  • Watch search untuk auto-fetch dengan debounce
  • Generated URL: ?query=search+term

5. Response Mapping with Custom Transformer

Transform backend data format ke frontend model.

vue
<script setup lang="ts">
import { useDataContainer } from '@bpmlib/utils-data-container';

interface User {
  id: number;
  fullName: string;
  email: string;
  createdAt: Date;
}

interface BackendUser {
  user_id: number;
  first_name: string;
  last_name: string;
  email_address: string;
  created_at: string;
}

const container = useDataContainer<User>();

async function fetchUsers() {
  container.loadStatus = 0;
  const response = await fetch('/api/users');
  const data = await response.json();
  
  container.mapFromResponse(data, (item: BackendUser) => ({
    id: item.user_id,
    fullName: `${item.first_name} ${item.last_name}`,
    email: item.email_address,
    createdAt: new Date(item.created_at)
  }));
}

fetchUsers();
</script>

<template>
  <div v-if="container.isLoaded">
    <div v-for="user in container.data" :key="user.id">
      <h3>{{ user.fullName }}</h3>
      <p>{{ user.email }}</p>
      <small>{{ user.createdAt.toLocaleDateString() }}</small>
    </div>
  </div>
</template>

Key Takeaways:

  • Mapper function transform backend format ke frontend model
  • Useful untuk rename fields, compute values, convert types
  • additionalData via appended field untuk metadata/stats

6. Complete CRUD Operations

Data manipulation methods untuk optimistic updates.

vue
<script setup lang="ts">
import { useDataContainer } from '@bpmlib/utils-data-container';
import { ref } from 'vue';

interface Todo {
  id: number;
  title: string;
  completed: boolean;
}

const container = useDataContainer<Todo>();
const newTitle = ref('');

async function fetchTodos() {
  container.loadStatus = 0;
  const response = await fetch('/api/todos');
  const data = await response.json();
  container.mapFromResponse(data);
}

async function createTodo() {
  if (!newTitle.value.trim()) return;
  
  const response = await fetch('/api/todos', {
    method: 'POST',
    body: JSON.stringify({ title: newTitle.value })
  });
  const data = await response.json();
  
  container.appendData({ id: data.id, title: newTitle.value, completed: false });
  newTitle.value = '';
}

async function toggleTodo(index: number) {
  const todo = container.data.value[index];
  await fetch(`/api/todos/${todo.id}`, {
    method: 'PATCH',
    body: JSON.stringify({ completed: !todo.completed })
  });
  container.data.value[index].completed = !todo.completed;
}

async function deleteTodo(index: number) {
  const todo = container.data.value[index];
  await fetch(`/api/todos/${todo.id}`, { method: 'DELETE' });
  container.deleteDataByIndex(index);
}

fetchTodos();
</script>

<template>
  <div>
    <div class="create-form">
      <input v-model="newTitle" @keyup.enter="createTodo">
      <button @click="createTodo">Add</button>
    </div>
    
    <div v-if="container.isLoaded">
      <p>Total: {{ container.dataLength() }}</p>
      <div v-for="(todo, index) in container.data" :key="todo.id">
        <input type="checkbox" :checked="todo.completed" @change="toggleTodo(index)">
        <span :class="{ completed: todo.completed }">{{ todo.title }}</span>
        <button @click="deleteTodo(index)">Delete</button>
      </div>
    </div>
  </div>
</template>

Key Takeaways:

  • appendData() untuk optimistic create
  • deleteDataByIndex() untuk optimistic delete
  • Direct mutation data.value[index] untuk updates
  • dataLength() untuk display count

7. Error Handling Patterns

Error handling strategies dan recovery patterns.

vue
<script setup lang="ts">
import { useDataContainer } from '@bpmlib/utils-data-container';
import { ref } from 'vue';

interface Product {
  id: number;
  name: string;
}

const container = useDataContainer<Product>();
const retryCount = ref(0);
const maxRetries = 3;

async function fetchProducts(silent = false) {
  if (!silent) container.loadStatus = 0;
  
  try {
    const response = await fetch('/api/products?' + container.getterUrlStringAttribute());
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    
    const data = await response.json();
    if (!data.success) throw new Error(data.message);
    
    container.mapFromResponse(data);
    retryCount.value = 0;
  } catch (error) {
    const message = error instanceof Error ? error.message : 'Network error';
    container.setError(message, 'FETCH_ERROR');
    
    if (retryCount.value < maxRetries) {
      retryCount.value++;
      const delay = Math.pow(2, retryCount.value) * 1000;
      setTimeout(() => fetchProducts(true), delay);
    }
  }
}

function handleRetry() {
  retryCount.value = 0;
  container.clearError();
  fetchProducts();
}

fetchProducts();
</script>

<template>
  <div>
    <div v-if="container.isLoading && !container.hasError">Loading...</div>
    
    <div v-else-if="container.hasError" class="error">
      <h3>Error: {{ container.error?.message }}</h3>
      <p v-if="retryCount < maxRetries">Retrying ({{ retryCount }}/{{ maxRetries }})</p>
      <p v-else>Max retries reached</p>
      <button @click="handleRetry">Retry</button>
      <button @click="container.reset(); fetchProducts()">Reset</button>
    </div>
    
    <div v-else-if="container.isLoaded">
      <div v-for="product in container.data" :key="product.id">
        {{ product.name }}
      </div>
    </div>
  </div>
</template>

Key Takeaways:

  • setError() untuk manual error state
  • clearError() untuk dismiss errors
  • Exponential backoff retry pattern
  • reset() untuk complete state cleanup

8. URL-Driven State with QueryIntent

NEW v0.1.1

Initialize container dari URL query parameters untuk deep linking.

vue
<script setup lang="ts">
import { useDataContainer } from '@bpmlib/utils-data-container';
import { useRoute, useRouter } from 'vue-router';
import type { QueryIntent } from '@bpmlib/utils-data-container';

interface Product {
  id: number;
  name: string;
  price: number;
}

interface ProductFilter {
  category?: string;
}

const route = useRoute();
const router = useRouter();

const queryIntent: QueryIntent = {
  pagination: {
    mode: 'lengthaware',
    perPage: Number(route.query.perPage) || 20,
    page: Number(route.query.page) || 1
  },
  sort: {
    by: (route.query.sortBy as string) || 'name',
    dir: route.query.sortDir === 'desc' ? 'desc' : 'asc'
  },
  search: {
    value: (route.query.q as string) || ''
  }
};

const container = useDataContainer<Product, ProductFilter>(
  { category: (route.query.category as string) || '' },
  queryIntent
);

container.applyFilter();

async function fetchProducts() {
  container.loadStatus = 0;
  const response = await fetch('/api/products?' + container.getterUrlStringAttribute());
  const data = await response.json();
  container.mapFromResponse(data);
}

function syncToUrl() {
  const query: Record<string, string> = {};
  
  if (container.page.numeric.current) query.page = String(container.page.numeric.current);
  if (container.page.perPage) query.perPage = String(container.page.perPage);
  if (container.sort.sortBy) {
    query.sortBy = container.sort.sortBy;
    query.sortDir = container.sort.sortDir === 'a' ? 'asc' : 'desc';
  }
  if (container.search) query.q = container.search;
  if (container._appliedFilter.category) query.category = container._appliedFilter.category;
  
  router.push({ query });
}

function handleApply() {
  container.applyFilter();
  syncToUrl();
  fetchProducts();
}

function handleSort(field: string) {
  container.setSort(field, container.sort.sortDir === 'a' ? 'd' : 'a');
  syncToUrl();
  fetchProducts();
}

fetchProducts();
</script>

<template>
  <div>
    <h2>URL-Driven State</h2>
    
    <div class="filters">
      <input v-model="container.filter.category">
      <button @click="handleApply" :disabled="!container.hasUnappliedFilterChanges()">
        Apply
      </button>
    </div>
    
    <input v-model="container.search" @change="syncToUrl(); fetchProducts()">
    
    <div class="sort">
      <button @click="handleSort('name')">Name</button>
      <button @click="handleSort('price')">Price</button>
      <span>({{ container.sort.sortBy }} {{ container.sort.sortDir === 'a' ? '↑' : '↓' }})</span>
    </div>
    
    <div v-if="container.isLoading">Loading...</div>
    <div v-else>
      <div v-for="product in container.data" :key="product.id">
        {{ product.name }} - ${{ product.price }}
      </div>
    </div>
  </div>
</template>

Key Takeaways:

  • QueryIntent initialize dari URL saat creation
  • Two-way sync: URL → Container (init), Container → URL (changes)
  • Users dapat share/bookmark URLs dengan full state
  • Browser back/forward works naturally
  • Useful untuk: shareable search, SSR hydration, bookmarkable filters