Go to file
2025-06-23 17:53:42 -04:00
.gitea/workflows fix tag determination 2025-06-23 17:53:42 -04:00
.vscode initial commit 2025-06-23 17:25:57 -04:00
src added more description in package.json and expose stuff in the indexes files since it was building actually nothing 2025-06-23 17:34:39 -04:00
.gitignore initial commit 2025-06-23 17:25:57 -04:00
index.html initial commit 2025-06-23 17:25:57 -04:00
package.json added more description in package.json and expose stuff in the indexes files since it was building actually nothing 2025-06-23 17:34:39 -04:00
README.md initial commit 2025-06-23 17:25:57 -04:00
tsconfig.json initial commit 2025-06-23 17:25:57 -04:00
vite.config.ts initial commit 2025-06-23 17:25:57 -04:00
yarn.lock initial commit 2025-06-23 17:25:57 -04:00

@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.

# 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.

// 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>