All checks were successful
Publish to npm / publish (release) Successful in 11s
|
||
---|---|---|
.gitea/workflows | ||
.vscode | ||
src | ||
.gitignore | ||
index.html | ||
package.json | ||
README.md | ||
tsconfig.json | ||
vite.config.ts | ||
yarn.lock |
@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, anderror
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.
# 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 anid
) from your model. This is crucial foruseSingleDataSource
and for certain command resolution scenarios.autoFetch
: A boolean option to automatically trigger aread
operation when the component mounts or whencriteria
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.
// 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.
// 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.
// 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>