npm package: @shifl-inc/ui
Description: Reusable Vue 3 UI components for Shifl.
Latest version: 0.9.7

# Shifl Grid (Vue 3)

JSON-driven data grid for Shifl UI, built with Vue 3 + TypeScript. Library-first output via Vite (library mode) with Tailwind + CSS variables for theming.

## Status

- ✅ Minimal grid (column visibility toggle, client-side sorting, global filter)
- ✅ API integration with server-side data fetching
- ✅ Pagination, search, filters, and sorting via API
- 🚧 Everything else scaffolded/stubbed for incremental delivery (editing, virtual scroll, tours, views)

## Requirements

- Node `>=20.11.0`
- Peer deps: `vue@^3.4.0`

## Install

```bash
npm install @shifl-inc/ui
```

## Usage

### Setup

```ts
// main.ts
import { createApp } from 'vue';
import App from './App.vue';
import { ShiflGridPlugin } from '@shifl-inc/ui';
import '@shifl-inc/ui/style.css';

const app = createApp(App);
app.use(ShiflGridPlugin);
app.mount('#app');
```

### Client-Side Mode (Static Data)

For static data that doesn't require API calls:

```vue
<script setup lang="ts">
import { ShiflGrid } from '@shifl-inc/ui';
import type { GridConfig } from '@shifl-inc/ui';

const config: GridConfig = {
  name: 'My Preferred Shipments',
  columns: [
    { key: 'shifl_ref', label: 'Shifl Ref #', sortable: true, freeze: true },
    { key: 'company_name', label: 'Customer', sortable: true }
  ],
  search: ['shifl_ref', 'company_name'],
  sort: { key: 'shifl_ref', order: 'asc' },
  rows: [
    { shifl_ref: 'SF-1001', company_name: 'GMA Inc' },
    { shifl_ref: 'SF-1002', company_name: 'Walads Autowash' }
  ],
  paginationMeta: {
    show: true,
    currentPage: 1,
    perPage: 20,
    total: 2
  }
};
</script>

<template>
  <ShiflGrid :config="config" />
</template>
```

### Server-Side Mode (API Integration)

For dynamic data fetched from an API:

```vue
<script setup lang="ts">
import { ShiflGrid } from '@shifl-inc/ui';
import type { GridConfig } from '@shifl-inc/ui';

const config: GridConfig = {
  name: 'My Preferred Shipments',
  apiConfig: {
    apiUrl: 'https://api.example.com/shipments',
    method: 'GET', // Optional: 'GET' | 'POST' | 'PUT' (default: 'GET')
    getAuthToken: () => localStorage.getItem('token'), // Optional: function to get auth token
    headers: {
      // Optional: additional headers
      'X-Custom-Header': 'value'
    },
    additionalParams: {
      // Optional: extra params to send with requests
      module: 'shipments',
      customerId: 123
    },
    debounceMs: 300, // Optional: debounce search queries (default: 300ms)
    responseTransformer: <T,>(response: unknown) => {
      // Optional: transform API response to expected format
      const res = response as {
        data?: T[];
        meta?: {
          current_page?: number;
          per_page?: number;
          total?: number;
        };
      };
      return {
        data: res.data || [],
        meta: res.meta
          ? {
              currentPage: res.meta.current_page,
              perPage: res.meta.per_page,
              total: res.meta.total,
              show: true
            }
          : undefined
      };
    }
  },
  columns: [
    { key: 'shifl_ref', label: 'Shifl Ref #', sortable: true, freeze: true },
    { key: 'company_name', label: 'Customer', sortable: true }
  ],
  search: ['shifl_ref', 'company_name'],
  paginationMeta: {
    show: true,
    currentPage: 1,
    perPage: 20,
    total: 0 // Will be updated from API response
  }
};
</script>

<template>
  <ShiflGrid :config="config" />
</template>
```

## Configuration Options

### GridConfig

```typescript
interface GridConfig<T = Record<string, unknown>> {
  id?: string;
  name?: string;
  customerId?: number;
  module?: string;
  resourceId?: string | number | null;
  creatorId?: number;
  pageLocation?: string;
  view?: 'regular' | 'compact' | 'comfortable';

  // API Configuration (for server-side mode)
  apiConfig?: {
    apiUrl: string; // Required: API endpoint URL
    method?: 'GET' | 'POST' | 'PUT'; // Optional: HTTP method (default: 'GET')
    getAuthToken?: () => string | undefined; // Optional: function to get auth token
    headers?: Record<string, string>; // Optional: additional headers
    additionalParams?: Record<string, unknown> | ((context) => Record<string, unknown>); // Optional: extra params
    paramsBuilder?: (context) => URLSearchParams | Record<string, unknown>; // Optional: custom query params
    bodyBuilder?: (context) => unknown; // Optional: custom request body
    responseTransformer?: <T>(response: unknown) => ApiResponse<T>; // Optional: transform API response
    debounceMs?: number; // Optional: search debounce delay (default: 300ms)
  };

  columns: GridColumn[];
  search?: string[]; // Searchable column keys (for client-side filtering)
  searchMode?: 'client' | 'server'; // Auto-set to 'server' if apiConfig is provided
  filters?: GridFilter[];
  sort?: GridSort;
  paginationMeta?: PaginationMeta;
  rows?: T[]; // Static data (for client-side mode)
}
```

### Client-Side Mode Features

- **Sorting**: Client-side only; click column headers to toggle asc/desc
- **Global filter**: Simple case-insensitive substring search across listed `search` fields
- **Visibility**: Toggle columns via pills above the grid
- **Pagination**: Client-side pagination of static data

### Server-Side Mode Features

- **Sorting**: Server-side; click column headers to sort via API
- **Search**: Debounced search queries sent to API (default: 300ms delay)
- **Filters**: Filter values sent as query parameters or in request body
- **Pagination**: Handled by server; pagination metadata from API response
- **Authentication**: Optional bearer token via `getAuthToken` function
- **Custom params**: Use `additionalParams` to send extra parameters
- **Pagination state**: User's selected page is the source of truth; API's `currentPage` is informational only

## API Response Formats

### Default Response Format (Recommended)

The grid works out-of-the-box with this format:

```json
{
  "data": [
    { "id": 1, "name": "Item 1", "status": "active" },
    { "id": 2, "name": "Item 2", "status": "inactive" }
  ],
  "meta": {
    "currentPage": 1,
    "perPage": 20,
    "total": 100,
    "lastPage": 5,
    "from": 1,
    "to": 20,
    "show": true
  }
}
```

### Alternative Default Formats

The default transformer also supports these formats:

#### Format 1: Using `rows` instead of `data`

```json
{
  "rows": [
    { "id": 1, "name": "Item 1" },
    { "id": 2, "name": "Item 2" }
  ],
  "meta": {
    "currentPage": 1,
    "perPage": 20,
    "total": 100
  }
}
```

#### Format 2: Data only (no pagination)

```json
{
  "data": [
    { "id": 1, "name": "Item 1" },
    { "id": 2, "name": "Item 2" }
  ]
}
```

### Required Fields

The grid requires these fields in `meta` for proper pagination:

- **Required for pagination to work:**
  - `perPage` (or `per_page` in Laravel format) - number of items per page
  - `total` (total number of records) - used to calculate total pages

- **Optional but recommended:**
  - `currentPage` (or `current_page` in Laravel format) - **informational only**, does not override user's selected page
  - `lastPage` (or `last_page`) - for better UX
  - `from` - starting record number
  - `to` - ending record number
  - `show` - whether to show pagination (default: `true`)

### Important: Pagination State Behavior

**The grid uses the user's pagination state as the source of truth.** When a user clicks page 2, the grid will:

- Display page 2 as active in the UI
- Send `page=2` in the API request
- Show the correct active page even if the API response returns `currentPage: 1`

This behavior ensures the pagination UI remains correct even when using mock/dummy APIs that always return `currentPage: 1`. The API's `currentPage` value is only used for informational purposes and does not override the user's selection.

## Custom Response Transformers

If your API uses a different response format, use the `responseTransformer` option:

### Laravel-Style Pagination Format

```typescript
const config: GridConfig = {
  apiConfig: {
    apiUrl: 'https://api.example.com/data',
    getAuthToken: () => localStorage.getItem('token'),
    responseTransformer: <T>(response: unknown) => {
      const res = response as {
        data?: T[];
        meta?: {
          current_page?: number;
          per_page?: number;
          total?: number;
          last_page?: number;
          from?: number;
          to?: number;
        };
      };

      return {
        data: res.data || [],
        meta: res.meta
          ? {
              currentPage: res.meta.current_page,
              perPage: res.meta.per_page,
              total: res.meta.total,
              lastPage: res.meta.last_page,
              from: res.meta.from,
              to: res.meta.to,
              show: true
            }
          : undefined
      };
    }
  }
  // ... rest of config
};
```

**Laravel Response Example:**

```json
{
  "data": [{ "id": 1, "name": "Item 1" }],
  "meta": {
    "current_page": 1,
    "per_page": 20,
    "total": 100,
    "last_page": 5,
    "from": 1,
    "to": 20
  }
}
```

### REST API Standard Format

```typescript
const config: GridConfig = {
  apiConfig: {
    apiUrl: 'https://api.example.com/data',
    responseTransformer: <T>(response: unknown) => {
      const res = response as {
        results?: T[];
        items?: T[];
        count?: number;
        page?: number;
        pageSize?: number;
      };

      return {
        data: res.results || res.items || [],
        meta: {
          currentPage: res.page || 1,
          perPage: res.pageSize || 20,
          total: res.count || 0,
          show: true
        }
      };
    }
  }
  // ... rest of config
};
```

**REST Response Example:**

```json
{
  "results": [{ "id": 1, "name": "Item 1" }],
  "count": 100,
  "page": 1,
  "pageSize": 20
}
```

### GraphQL-Style Format

```typescript
const config: GridConfig = {
  apiConfig: {
    apiUrl: 'https://api.example.com/graphql',
    responseTransformer: <T>(response: unknown) => {
      const res = response as {
        data?: {
          items?: T[];
          pagination?: {
            page: number;
            pageSize: number;
            totalCount: number;
          };
        };
      };

      return {
        data: res.data?.items || [],
        meta: res.data?.pagination
          ? {
              currentPage: res.data.pagination.page,
              perPage: res.data.pagination.pageSize,
              total: res.data.pagination.totalCount,
              show: true
            }
          : undefined
      };
    }
  }
  // ... rest of config
};
```

**GraphQL Response Example:**

```json
{
  "data": {
    "items": [{ "id": 1, "name": "Item 1" }],
    "pagination": {
      "page": 1,
      "pageSize": 20,
      "totalCount": 100
    }
  }
}
```

## Request Parameters Sent by Grid

The grid automatically sends these parameters:

### GET Request (Query Parameters)

```
GET /api/data?search=query&page=1&per_page=20&sort=name&order=asc&filters[status]=active&filters[type][]=type1&filters[type][]=type2
```

### POST/PUT Request (Body)

```json
{
  "search": "query",
  "page": 1,
  "per_page": 20,
  "sort": "name",
  "order": "asc",
  "filters": {
    "status": "active",
    "type": ["type1", "type2"]
  },
  "module": "shipments",
  "customerId": 123
}
```

## Error Handling

The grid handles errors gracefully. Your API can return standard HTTP error responses:

```json
{
  "error": "Unauthorized",
  "message": "Invalid authentication token"
}
```

Or with status codes:

- `400` - Bad Request
- `401` - Unauthorized
- `403` - Forbidden
- `404` - Not Found
- `500` - Internal Server Error

The grid will display the error and stop loading.

## Scripts

- `npm run dev` — Vite dev server
- `npm run build` — Library build + d.ts via `vite-plugin-dts`
- `npm run lint` — ESLint (Vue + TS) flat config
- `npm run test` — Vitest (jsdom)
- `npm run storybook` — Storybook dev
- `npm run storybook:build` — Storybook static build

## Project structure (high level)

- `src/components/grid` — `ShiflGrid.vue` (minimal implementation)
- `src/composables` — Core behaviors (sort/filter/columns implemented, others stubbed/TODO)
- `src/types` — Grid + theme contracts
- `src/theme` — CSS tokens
- `src/styles` — Tailwind entry + component styles
- `src/utils` — Helpers (config normalization)
- `src/plugins` — Vue install plugin
- `stories/` — Storybook stories
- `.storybook/` — Storybook configuration

## API Integration

The grid supports server-side data fetching with automatic API calls for search, filters, sorting, and pagination. When `apiConfig` is provided, the grid automatically:

- Fetches data from your API endpoint
- Sends search, filter, sort, and pagination parameters
- Handles loading states and errors
- Updates the grid when parameters change
- Supports bearer token authentication
- Debounces search queries to reduce API calls
- **Uses user's pagination state as source of truth** - the active page displayed is based on user selection, not the API's `currentPage` response

See [API Response Formats](./API_RESPONSE_FORMATS.md) for detailed information about:

- Expected API response formats
- Custom response transformers
- Request parameters sent by the grid
- Error handling
- Pagination behavior and requirements

## Notes

- Virtual scroll, editing, tours, saved/recommended views, telemetry are intentionally stubbed for later milestones.