Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
279 changes: 279 additions & 0 deletions docs/store/crud-store.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,279 @@
---
sidebar_position: 2
---

# CRUD Store

A factory for creating Pinia stores with standard CRUD operations. Unlike the [Object Store](./object-store.md) which is **multi-type** (keyed by register/schema slugs), a CRUD store manages a **single entity type** with a flat list and a single active item.

Use this for any entity that has its own API endpoint and doesn't go through the register/schema system (e.g. sources, agents, applications, configurations, endpoints).

## createCrudStore

Factory function that creates a Pinia store with list/item state, pagination, filters, and async CRUD actions.

```js
import { createCrudStore } from '@conduction/nextcloud-vue'

export const useSourceStore = createCrudStore(name, config)
```

### Parameters

| Parameter | Type | Description | Default |
|-----------|------|-------------|---------|
| `name` | String | Pinia store ID (e.g. `'source'`, `'agent'`) | **required** |
| `config.endpoint` | String | API resource path segment (e.g. `'sources'`) | **required** |
| `config.baseUrl` | String | API base URL (before endpoint) | `'/apps/openregister/api'` |
| `config.entity` | Function\|null | Entity class constructor for wrapping items | `null` |
| `config.cleanFields` | String[] | Fields to strip before POST/PUT | `['id','uuid','created','updated']` |
| `config.features` | Object | Feature flags (see below) | `{}` |
| `config.features.loading` | Boolean | Add `loading`/`error` state and getters | `false` |
| `config.features.viewMode` | Boolean | Add `viewMode` state, getter, and setter action | `false` |
| `config.parseListResponse` | Function | Custom response parser for `refreshList` (see below) | `(json) => json.results` |
| `config.extend` | Object | Extra `{ state, getters, actions }` merged into the store | `{}` |

### Return Value

Returns a Pinia `defineStore` composable with the following API.

#### State

| Property | Type | Description |
|----------|------|-------------|
| `item` | Object\|null | The currently active/selected item |
| `list` | Array | The full list of items |
| `filters` | Object | Active filter criteria |
| `pagination` | Object | `{ page, limit }` |
| `loading` | Boolean | Whether a request is in progress (requires `features.loading`) |
| `error` | String\|null | Last error message (requires `features.loading`) |
| `viewMode` | String | Current view mode, e.g. `'cards'` (requires `features.viewMode`) |
| `_options` | Object | Internal config: `{ endpoint, cleanFields, baseApiUrl }` (available to extend actions) |

#### Getters

| Getter | Condition | Description |
|--------|-----------|-------------|
| `isLoading` | `features.loading` | Alias for `state.loading` |
| `getError` | `features.loading` | Alias for `state.error` |
| `getViewMode` | `features.viewMode` | Alias for `state.viewMode` |

#### Actions

| Action | Signature | Description |
|--------|-----------|-------------|
| `setItem` | `(data)` | Set the active item. Wraps in Entity class if configured. Pass `null` to clear. |
| `setList` | `(data)` | Set the item list. Maps each item through Entity class if configured. |
| `setPagination` | `(page, limit?)` | Set pagination parameters. Default limit: 20. |
| `setFilters` | `(filters)` | Merge filter criteria into current filters. |
| `setViewMode` | `(mode)` | Set view mode (requires `features.viewMode`). |
| `refreshList` | `(search?, soft?)` | GET the list from the API. Optional search query. If `soft=true`, skips loading state toggle. |
| `getOne` | `(id)` | GET a single item by ID. Sets it as the active item. |
| `deleteOne` | `(item)` | DELETE an item (must have `.id`). Refreshes the list and clears the active item. |
| `save` | `(item)` | POST (no `.id`) or PUT (with `.id`). Cleans via `cleanForSave`, sets the active item, refreshes the list. |
| `cleanForSave` | `(item)` | Strip `cleanFields` from item. Override in `extend.actions` for custom cleaning. |

## Configuration Details

### `config.entity`

When provided, `setItem` and `setList` wrap raw API data in this class via `new Entity(data)`. When `null`, raw data is used as-is.

```js
import { Source } from '../../entities/index.js'

export const useSourceStore = createCrudStore('source', {
endpoint: 'sources',
entity: Source,
})
```

### `config.cleanFields`

Array of field names stripped from the item before POST/PUT. Default: `['id', 'uuid', 'created', 'updated']`.

Override for entities with extra read-only fields:

```js
export const useApplicationStore = createCrudStore('application', {
endpoint: 'applications',
entity: Application,
cleanFields: ['id', 'uuid', 'created', 'updated', 'usage', 'owner'],
})
```

### `config.parseListResponse`

Called inside `refreshList` after the API responds. Receives the parsed JSON body with the **store instance as `this`**, so custom parsers can perform side effects (e.g. updating extra state).

Must return an array of items to pass to `setList`.

**Default:** `(json) => json.results`

**Custom example** (organisation store extracts user stats from the same response):

```js
export const useOrganisationStore = createCrudStore('organisation', {
endpoint: 'organisations',
entity: Organisation,
parseListResponse(json) {
this.setUserStats(json) // side effect: update extra state
return json.results || [] // return the list array
},
extend: {
state: () => ({
userStats: { total: 0, active: null, list: [] },
}),
actions: {
setUserStats(stats) {
this.userStats = { /* ... */ }
},
},
},
})
```

### `config.extend`

Merge extra state, getters, and actions into the store. Actions with the same name as base actions **override** them.

| Property | Type | Description |
|----------|------|-------------|
| `extend.state` | Function | State factory returning extra state properties |
| `extend.getters` | Object | Extra getters (or overrides of base getters) |
| `extend.actions` | Object | Extra actions (or overrides of base actions) |

Inside extend actions, `this` is the full store instance. Use `this._options.baseApiUrl` to build API URLs and `this._options.cleanFields` to reference the configured clean fields.

## Examples

### Minimal (pure CRUD)

```js
import { createCrudStore } from '@conduction/nextcloud-vue'
import { Source } from '../../entities/index.js'

export const useSourceStore = createCrudStore('source', {
endpoint: 'sources',
entity: Source,
})
```

**Result:** 8 lines instead of ~140. Provides `setItem`, `setList`, `refreshList`, `getOne`, `deleteOne`, `save`, `cleanForSave`, `setPagination`, `setFilters`.

### With features and a domain action

```js
import { createCrudStore } from '@conduction/nextcloud-vue'
import { Agent } from '../../entities/index.js'

export const useAgentStore = createCrudStore('agent', {
endpoint: 'agents',
entity: Agent,
features: { loading: true, viewMode: true },
parseListResponse(json) {
return Array.isArray(json) ? json : (json.results || [])
},
extend: {
actions: {
async getStats() {
const response = await fetch(this._options.baseApiUrl + '/stats')
if (!response.ok) throw new Error('HTTP ' + response.status)
return response.json()
},
},
},
})
```

### Overriding base actions

Override `cleanForSave` for custom field handling while reusing `cleanFields`:

```js
export const useApplicationStore = createCrudStore('application', {
endpoint: 'applications',
entity: Application,
cleanFields: ['id', 'uuid', 'created', 'updated', 'usage', 'owner'],
features: { loading: true, viewMode: true },
extend: {
actions: {
cleanForSave(item) {
const cleaned = { ...item }
for (const field of this._options.cleanFields) {
delete cleaned[field]
}
// Custom: coerce boolean
if (cleaned.active !== undefined) {
cleaned.active = cleaned.active === '' ? true : Boolean(cleaned.active)
}
return cleaned
},
},
},
})
```

### With extra state and custom parseListResponse

```js
export const useOrganisationStore = createCrudStore('organisation', {
endpoint: 'organisations',
entity: Organisation,
features: { viewMode: true },
parseListResponse(json) {
this.setUserStats(json)
return json.results || []
},
extend: {
state: () => ({
activeOrganisation: null,
userStats: { total: 0, active: null, list: [] },
}),
getters: {
activeOrganisationGetter: (state) => state.activeOrganisation,
},
actions: {
setUserStats(stats) { /* ... */ },
async joinOrganisation(uuid) { /* ... */ },
async leaveOrganisation(uuid) { /* ... */ },
},
},
})
```

## CRUD Store vs Object Store

| | CRUD Store | Object Store |
|---|---|---|
| **Use case** | Single entity type with its own API endpoint | Objects within OpenRegister's register/schema system |
| **State shape** | Flat: `item`, `list` | Per-type: `collections[type]`, `objects[type][id]` |
| **API pattern** | `/api/{endpoint}` | `/api/objects/{register}/{schema}` |
| **Entity wrapping** | Optional via `config.entity` | Not used (raw objects) |
| **Plugin system** | No (use `extend` instead) | Yes (files, audit trails, relations, etc.) |
| **Caching** | None (list is refreshed on each mutation) | Per-type object cache |
| **Factory** | `createCrudStore(name, config)` | `createObjectStore(storeId, options)` |

## Usage in Components

```js
// In store.js (singleton initialization)
import { useSourceStore } from './modules/source.js'
const sourceStore = useSourceStore(pinia)
export { sourceStore }

// In a component
import { sourceStore } from '../../store/store.js'

// Read state
sourceStore.list // Array of Source entities
sourceStore.item // Currently active Source or null
sourceStore.loading // Boolean (if features.loading enabled)

// Actions
await sourceStore.refreshList('search term')
await sourceStore.getOne(123)
await sourceStore.save({ title: 'New Source', type: 'internal' })
await sourceStore.deleteOne(sourceStore.item)
sourceStore.setItem(null) // clear selection
```
2 changes: 1 addition & 1 deletion eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ const compat = new FlatCompat({
})

module.exports = defineConfig([{
ignores: ['dist/**', 'node_modules/**', 'src/types/**/*.d.ts'],
ignores: ['dist/**', 'node_modules/**', 'src/**/*.d.ts'],
}, {
extends: compat.extends('@nextcloud'),

Expand Down
8 changes: 2 additions & 6 deletions src/components/CnIndexSidebar/CnIndexSidebar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,7 @@
</template>
</NcButton>
</template>
<p class="cn-index-sidebar__filter-description">
{{ filter.description }}
</p>
<p class="cn-index-sidebar__filter-description">{{ filter.description }}</p>

Check warning on line 60 in src/components/CnIndexSidebar/CnIndexSidebar.vue

View workflow job for this annotation

GitHub Actions / Frontend Quality

Expected 1 line break before closing tag (`</p>`), but no line breaks found

Check warning on line 60 in src/components/CnIndexSidebar/CnIndexSidebar.vue

View workflow job for this annotation

GitHub Actions / Frontend Quality

Expected 1 line break after opening tag (`<p>`), but no line breaks found
</NcPopover>
</div>
<NcSelect
Expand Down Expand Up @@ -90,9 +88,7 @@
<div class="cn-index-sidebar__tab-content">
<div class="cn-sidebar-columns">
<h3>{{ columnsHeading }}</h3>
<p class="cn-sidebar-columns__description">
{{ columnsDescription }}
</p>
<p class="cn-sidebar-columns__description">{{ columnsDescription }}</p>

Check warning on line 91 in src/components/CnIndexSidebar/CnIndexSidebar.vue

View workflow job for this annotation

GitHub Actions / Frontend Quality

Expected 1 line break before closing tag (`</p>`), but no line breaks found

Check warning on line 91 in src/components/CnIndexSidebar/CnIndexSidebar.vue

View workflow job for this annotation

GitHub Actions / Frontend Quality

Expected 1 line break after opening tag (`<p>`), but no line breaks found

<template v-if="allColumns.length > 0 || allGroups.length > 0">
<!-- Schema properties group (collapsible) -->
Expand Down
1 change: 1 addition & 0 deletions src/components/CnInfoWidget/CnInfoWidget.vue
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@
* Format a field value for display based on its schema type.
*
* @param {*} value - The raw value.
* @param {object} schemaProp - The JSON Schema property definition.

Check warning on line 159 in src/components/CnInfoWidget/CnInfoWidget.vue

View workflow job for this annotation

GitHub Actions / Frontend Quality

@param "schemaProp" does not match an existing function parameter
* @return {string} Formatted display value.
*/
formatFieldValue(value) {
Expand Down
4 changes: 1 addition & 3 deletions src/components/CnItemCard/CnItemCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,7 @@
<component :is="icon" v-if="icon" :size="iconSize" />
</slot>
<div class="cn-item-card__title-content">
<h3 class="cn-item-card__title">
{{ title }}
</h3>
<h3 class="cn-item-card__title">{{ title }}</h3>

Check warning on line 9 in src/components/CnItemCard/CnItemCard.vue

View workflow job for this annotation

GitHub Actions / Frontend Quality

Expected 1 line break before closing tag (`</h3>`), but no line breaks found

Check warning on line 9 in src/components/CnItemCard/CnItemCard.vue

View workflow job for this annotation

GitHub Actions / Frontend Quality

Expected 1 line break after opening tag (`<h3>`), but no line breaks found
<span v-if="subtitle" class="cn-item-card__subtitle">{{ subtitle }}</span>
</div>
</div>
Expand Down
4 changes: 1 addition & 3 deletions src/components/CnMassImportDialog/CnMassImportDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,7 @@
<tbody>
<template v-for="(sheet, key) in result.summary">
<tr :key="key">
<td class="cn-mass-import__sheet-name">
{{ key }}
</td>
<td class="cn-mass-import__sheet-name">{{ key }}</td>

Check warning on line 35 in src/components/CnMassImportDialog/CnMassImportDialog.vue

View workflow job for this annotation

GitHub Actions / Frontend Quality

Expected 1 line break before closing tag (`</td>`), but no line breaks found

Check warning on line 35 in src/components/CnMassImportDialog/CnMassImportDialog.vue

View workflow job for this annotation

GitHub Actions / Frontend Quality

Expected 1 line break after opening tag (`<td>`), but no line breaks found
<td class="cn-mass-import__stat cn-mass-import__stat--found">
{{ sheet.found || 0 }}
</td>
Expand Down
4 changes: 1 addition & 3 deletions src/components/CnNotesCard/CnNotesCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,7 @@
</strong>
<span class="cn-notes-card__time">{{ formatDate(note.creationDateTime || note.created) }}</span>
</div>
<p class="cn-notes-card__body">
{{ note.message || note.content }}
</p>
<p class="cn-notes-card__body">{{ note.message || note.content }}</p>

Check warning on line 57 in src/components/CnNotesCard/CnNotesCard.vue

View workflow job for this annotation

GitHub Actions / Frontend Quality

Expected 1 line break after opening tag (`<p>`), but no line breaks found
<NcButton
v-if="canDeleteNote(note)"
type="tertiary-no-background"
Expand Down
4 changes: 1 addition & 3 deletions src/components/CnObjectCard/CnObjectCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,7 @@
class="cn-object-card__image">

<div class="cn-object-card__title-area">
<h3 class="cn-object-card__title">
{{ title }}
</h3>
<h3 class="cn-object-card__title">{{ title }}</h3>
<p v-if="description" class="cn-object-card__description">
{{ truncatedDescription }}
</p>
Expand Down
8 changes: 2 additions & 6 deletions src/components/CnPageHeader/CnPageHeader.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,8 @@
</slot>
</div>
<div class="cn-page-header__text">
<h1 class="cn-page-header__title">
{{ title }}
</h1>
<p v-if="description" class="cn-page-header__description">
{{ description }}
</p>
<h1 class="cn-page-header__title">{{ title }}</h1>
<p v-if="description" class="cn-page-header__description">{{ description }}</p>
</div>
<slot name="extra" />
</div>
Expand Down
2 changes: 1 addition & 1 deletion src/components/CnRowActions/CnRowActions.vue
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export default {
props: {
/**
* Action definitions.
* @type {Array<{label: string, icon?: object, handler: Function, disabled?: boolean | Function, destructive?: boolean}>}
* @type {Array<{label: string, icon?: Component, handler: Function, disabled?: boolean | Function, destructive?: boolean}>}
*/
actions: {
type: Array,
Expand Down
1 change: 1 addition & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ export {

// Store
export { useObjectStore, createObjectStore } from './store/index.js'
export { createCrudStore } from './store/index.js'
export { createSubResourcePlugin, emptyPaginated } from './store/index.js'

// Store plugins
Expand Down
Loading
Loading