325 lines
11 KiB
Markdown
325 lines
11 KiB
Markdown
# @openharbor/vue-data
|
|
|
|
A Vue 3 Composition API library for simplified data management with CQRS backends, powered by a flexible builder pattern. This library helps you effortlessly connect your Vue components to backend services using a POST-centric interaction model, providing reactive data, loading states, and robust error handling.
|
|
|
|
## Features
|
|
|
|
* **Vue 3 Composition API:** Fully integrated with Vue's reactivity system.
|
|
* **CQRS-Oriented:** Designed for backends that use POST requests for both queries and commands.
|
|
* **Flexible Data Source Builders:** Configure data fetching and command execution with a fluent API.
|
|
* **Reactive State Management:** Automatically tracks `data`/`items`, `total`, `loading` status, and `error` states.
|
|
* **Pluggable Commands:** Easily define domain-specific commands (e.g., `AddProduct`, `RemoveProduct`, `UpdateProductDetails`, `PlaceOrder`).
|
|
* **Standardized Error Handling:** Consistent approach to backend error messages and validation errors.
|
|
|
|
---
|
|
|
|
## Installation
|
|
|
|
To use this library in your Vue 3 project, you need to install it via npm or Yarn. Remember that `vue` is a peer dependency, so ensure you have Vue 3 installed in your project.
|
|
|
|
```bash
|
|
# Using npm
|
|
npm install @openharbor/vue-data
|
|
|
|
# Using Yarn
|
|
yarn add @openharbor/vue-data
|
|
```
|
|
|
|
## Usage
|
|
|
|
The library provides three main composables: `useHttpDataSource`, `useSingleDataSource`, and `useListDataSource`, each tailored for different data interaction patterns.
|
|
|
|
### Core Concepts
|
|
|
|
* **Query Criteria (`IQueryCriteria`):** An object defining the parameters for your data requests (e.g., filters, pagination, sorting).
|
|
* **Models (`TModel`):** The shape of the data objects you are fetching or sending as commands.
|
|
* **Commands (`ICommand`):** The data payload sent to your backend for actions (e.g., `AddProductCommand`, `RemoveProductCommand`, `UpdateProductDetailsCommand`).
|
|
* **`keyResolver`:** A function `(model: TModel) => TModel[keyof TModel]` that extracts a unique identifier (like an `id`) from your model. This is crucial for `useSingleDataSource` and for certain command resolution scenarios.
|
|
* **`autoFetch`:** A boolean option to automatically trigger a `read` operation when the component mounts or when `criteria` changes.
|
|
|
|
### `useHttpDataSource`
|
|
|
|
This is the most generic data source composable. It's suitable for a wide range of querying and commanding needs where the structure isn't strictly a single item or a traditional list.
|
|
|
|
```typescript
|
|
// Example: src/views/MyGenericDataComponent.vue
|
|
<script setup lang="ts">
|
|
import { ref, watchEffect } from 'vue';
|
|
import { useHttpDataSource, type IQueryCriteria } from '@openharbor/vue-data'; // <-- Updated import path
|
|
|
|
interface Product {
|
|
id: string;
|
|
name: string;
|
|
price: number;
|
|
}
|
|
|
|
interface ProductQueryCriteria extends IQueryCriteria {
|
|
searchText?: string;
|
|
minPrice?: number;
|
|
}
|
|
|
|
const productDataSource = useHttpDataSource<ProductQueryCriteria, Product>({
|
|
queryUrl: '/api/products/query', // Your backend's POST endpoint for querying products
|
|
keyResolver: (product) => product.id,
|
|
commands: {
|
|
// Command to add a new product
|
|
'addProduct': {
|
|
url: '/api/products/add', // Your backend's POST endpoint for adding a product
|
|
},
|
|
// Command to update product details
|
|
'updateProductDetails': {
|
|
url: '/api/products/update', // Your backend's POST endpoint for updating
|
|
},
|
|
// Command to remove a product
|
|
'removeProduct': {
|
|
url: '/api/products/remove', // Your backend's POST endpoint for removing
|
|
}
|
|
},
|
|
defaultCriteria: {
|
|
searchText: '',
|
|
minPrice: 0
|
|
},
|
|
autoFetch: true // Fetch data on mount and when criteria changes
|
|
});
|
|
|
|
// Access reactive state
|
|
const { data, total, loading, error, criteria, read, executeCommand } = productDataSource;
|
|
|
|
// Modify criteria to trigger a re-fetch (if autoFetch is true)
|
|
criteria.value.searchText = 'new search term';
|
|
|
|
// Manually trigger a read (overrides current reactive criteria for this call)
|
|
// await read({ searchText: 'specific term' });
|
|
|
|
// Execute an 'addProduct' command
|
|
const addProduct = async () => {
|
|
try {
|
|
const newProduct = await executeCommand('addProduct', {
|
|
name: 'New Gadget',
|
|
price: 99.99
|
|
});
|
|
console.log('Product added:', newProduct);
|
|
// After adding, you might want to refresh the list manually
|
|
await read();
|
|
} catch (e) {
|
|
console.error('Error adding product:', e);
|
|
}
|
|
};
|
|
|
|
// Execute a 'removeProduct' command
|
|
const removeProductById = async (id: string) => {
|
|
try {
|
|
// Assuming your removeProduct command expects an object like { productId: '...' } as payload
|
|
await executeCommand('removeProduct', { productId: id });
|
|
console.log('Product removed:', id);
|
|
// After removing, autoFetch might re-trigger, or you can manually read()
|
|
} catch (e) {
|
|
console.error('Error removing product:', e);
|
|
}
|
|
};
|
|
|
|
watchEffect(() => {
|
|
if (error.value) {
|
|
console.error('Data source error:', error.value);
|
|
}
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<div>
|
|
<h1>Products</h1>
|
|
<input v-model="criteria.searchText" placeholder="Search products" />
|
|
<p v-if="loading">Loading products...</p>
|
|
<p v-if="error">Error: {{ error.message }}</p>
|
|
<ul v-if="data">
|
|
<li v-for="product in data" :key="product.id">
|
|
{{ product.name }} - ${{ product.price }}
|
|
<button @click="removeProductById(product.id)">Remove</button>
|
|
</li>
|
|
</ul>
|
|
<p v-if="total !== null">Total products: {{ total }}</p>
|
|
<button @click="addProduct">Add New Product</button>
|
|
</div>
|
|
</template>
|
|
```
|
|
|
|
### `useSingleDataSource`
|
|
|
|
Best for managing a single entity (e.g., editing a product details page, displaying a user profile). It requires a `keyResolver` and can easily set up common CQRS commands for a single entity.
|
|
|
|
```typescript
|
|
// Example: src/views/ProductDetailComponent.vue
|
|
<script setup lang="ts">
|
|
import { ref, watchEffect } from 'vue';
|
|
import { useSingleDataSource, type IQueryCriteria } from '@openharbor/vue-data'; // <-- Updated import path
|
|
|
|
interface Product {
|
|
id: string;
|
|
name: string;
|
|
description: string;
|
|
}
|
|
|
|
interface ProductDetailQueryCriteria extends IQueryCriteria {
|
|
productId: string;
|
|
}
|
|
|
|
const props = defineProps<{ id: string }>();
|
|
|
|
// Define reactive form data for editing
|
|
const editedProduct = ref<Product | null>(null);
|
|
|
|
const productDetailSource = useSingleDataSource<ProductDetailQueryCriteria, Product>({
|
|
queryUrl: '/api/products/detail', // Endpoint to fetch single product details
|
|
keyResolver: (product) => product.id,
|
|
defaultCriteria: {
|
|
productId: props.id // Initial query based on prop
|
|
},
|
|
// Automatically add common commands for a single entity
|
|
addStandardRestCommands: { // NOTE: This option name can be changed to 'addStandardCqrsCommands'
|
|
route: '/api/commands/products' // A single command endpoint for product-related commands
|
|
},
|
|
autoFetch: true // Fetch details on mount
|
|
});
|
|
|
|
const { data, loading, error, criteria, read, executeCommand } = productDetailSource;
|
|
|
|
// Watch for changes in the fetched data to populate the form
|
|
watchEffect(() => {
|
|
if (data.value) {
|
|
editedProduct.value = { ...data.value }; // Create a copy for editing
|
|
}
|
|
});
|
|
|
|
// Update criteria if props.id changes
|
|
watch(() => props.id, (newId) => {
|
|
if (newId) {
|
|
criteria.value.productId = newId;
|
|
}
|
|
}, { immediate: true });
|
|
|
|
const saveProductChanges = async () => {
|
|
if (!editedProduct.value) return;
|
|
try {
|
|
// Assuming 'update' command expects the full product object as payload
|
|
// Command type would likely be inferred by the backend from the payload, or a specific command object.
|
|
const updated = await executeCommand('update', editedProduct.value);
|
|
console.log('Product updated:', updated);
|
|
// After update, data.value will automatically be updated by the composable
|
|
} catch (e) {
|
|
console.error('Error saving product changes:', e);
|
|
}
|
|
};
|
|
|
|
const removeSelectedProduct = async () => {
|
|
if (!data.value) return;
|
|
if (!confirm(`Are you sure you want to remove ${data.value.name}?`)) return;
|
|
|
|
try {
|
|
// Assuming 'delete' (now 'removeProduct') command expects the product object or its ID in the payload
|
|
await executeCommand('delete', { productId: data.value.id }); // Using 'delete' as internal key for addStandardRestCommands
|
|
console.log('Product removed!');
|
|
// After remove, data.value will be set to null by the composable
|
|
// You might want to redirect the user after removal
|
|
} catch (e) {
|
|
console.error('Error removing product:', e);
|
|
}
|
|
};
|
|
</script>
|
|
|
|
<template>
|
|
<div>
|
|
<h1 v-if="loading">Loading Product Details...</h1>
|
|
<p v-if="error">Error: {{ error.message }}</p>
|
|
|
|
<div v-if="editedProduct">
|
|
<h2>Edit Product: {{ editedProduct.name }}</h2>
|
|
<label>Name: <input v-model="editedProduct.name" /></label><br />
|
|
<label>Description: <textarea v-model="editedProduct.description"></textarea></label><br />
|
|
<button @click="saveProductChanges">Save Changes</button>
|
|
<button @click="removeSelectedProduct">Remove Product</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
```
|
|
|
|
### `useListDataSource`
|
|
|
|
Ideal for displaying collections of data, such as tables, grids, or paginated lists. It functions as a semantic wrapper around `useHttpDataSource` for clarity.
|
|
|
|
```typescript
|
|
// Example: src/views/ProductListingComponent.vue
|
|
<script setup lang="ts">
|
|
import { ref, watchEffect } from 'vue';
|
|
import { useListDataSource, type IQueryCriteria } from '@openharbor/vue-data'; // <-- Updated import path
|
|
|
|
interface Product {
|
|
id: string;
|
|
name: string;
|
|
category: string;
|
|
}
|
|
|
|
interface ProductsListQueryCriteria extends IQueryCriteria {
|
|
pageNumber: number;
|
|
pageSize: number;
|
|
sortBy: string;
|
|
filterByCategory?: string;
|
|
}
|
|
|
|
const paginationCriteria = ref<ProductsListQueryCriteria>({
|
|
pageNumber: 1,
|
|
pageSize: 10,
|
|
sortBy: 'name'
|
|
});
|
|
|
|
const productsListSource = useListDataSource<ProductsListQueryCriteria, Product>({
|
|
queryUrl: '/api/products/list', // Your backend's POST endpoint for listing products
|
|
defaultCriteria: paginationCriteria.value,
|
|
autoFetch: true // Fetch when criteria changes
|
|
});
|
|
|
|
const { items, total, loading, error, criteria, read } = productsListSource;
|
|
|
|
const nextPage = () => {
|
|
if (total.value && criteria.value.pageNumber * criteria.value.pageSize < total.value) {
|
|
criteria.value.pageNumber++;
|
|
}
|
|
};
|
|
|
|
const prevPage = () => {
|
|
if (criteria.value.pageNumber > 1) {
|
|
criteria.value.pageNumber--;
|
|
}
|
|
};
|
|
|
|
// You can still add commands here if needed for list-level operations (e.g., bulk remove)
|
|
// productsListSource.executeCommand('bulkRemove', { productIds: ['id1', 'id2'] });
|
|
|
|
watchEffect(() => {
|
|
if (error.value) {
|
|
console.error('Product list error:', error.value);
|
|
}
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<div>
|
|
<h1>Product Catalog</h1>
|
|
<p v-if="loading">Loading products...</p>
|
|
<p v-if="error">Error: {{ error.message }}</p>
|
|
|
|
<div v-if="items">
|
|
<ul>
|
|
<li v-for="product in items" :key="product.id">
|
|
{{ product.name }} ({{ product.category }})
|
|
</li>
|
|
</ul>
|
|
<div>
|
|
<button @click="prevPage" :disabled="criteria.pageNumber === 1">Previous</button>
|
|
<span>Page {{ criteria.pageNumber }} of {{ total ? Math.ceil(total / criteria.pageSize) : 1 }}</span>
|
|
<button @click="nextPage" :disabled="total ? criteria.value.pageNumber * criteria.value.pageSize >= total : true">Next</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
```
|