Skip to content
Merged

Back #36

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
116 changes: 85 additions & 31 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,40 +2,66 @@

> Factory-driven state management for Nuxt powered by [Harlem](https://harlemjs.com/)

![Version](https://img.shields.io/badge/version-5.3.0-42b883)
![License](https://img.shields.io/badge/license-MIT-blue)

Define your data **shape** once with Zod — get typed **models**, computed **views**, and async **actions** with a single `createStore` call.

- **Schema-first** — Define your data shape once, get TypeScript types and validation automatically
- **Reactive state** — Single items and collections with built-in mutations
- **Computed views** — Derived read-only state that updates when models change
- **API integration** — Declarative HTTP actions that fetch and commit data in one step
- **Status tracking** — Every action exposes loading, error, and status reactively
- **Concurrency control** — Block, skip, cancel, or allow parallel calls per action
- **Vue composables** — Reactive helpers for actions, models, and views in components
- **SSR ready** — Server-side rendering with automatic state hydration
Built on top of [Harlem](https://harlemjs.com/), a powerful and extensible state management library for Vue 3.

## Install
---

```bash
npm install @diphyx/harlemify
```
## The Problem

Every Nuxt app has the same boilerplate for every API resource:

```typescript
// nuxt.config.ts
export default defineNuxtConfig({
modules: ["@diphyx/harlemify"],
});
// Without Harlemify — this gets written for EVERY resource

// 1. Define types manually
interface User {
id: number;
name: string;
email: string;
}

// 2. Define state
const users = ref<User[]>([]);
const currentUser = ref<User | null>(null);
const loading = ref(false);
const error = ref<Error | null>(null);

// 3. Write fetch logic
async function fetchUsers() {
loading.value = true;
error.value = null;
try {
users.value = await $fetch("/api/users");
} catch (e) {
error.value = e as Error;
} finally {
loading.value = false;
}
}

// 4. Repeat for create, update, delete...
// 5. Repeat for every resource in your app...
```

## Usage
## The Solution

With Harlemify, define a data shape once and get everything else for free:

```typescript
const userShape = shape((factory) => ({
id: factory.number().meta({
identifier: true,
}),
name: factory.string(),
email: factory.email(),
}));
const userShape = shape((factory) => {
return {
id: factory.number().meta({
identifier: true,
}),
name: factory.string(),
email: factory.email(),
};
});

export const userStore = createStore({
name: "users",
Expand All @@ -57,27 +83,49 @@ export const userStore = createStore({
{
url: "/users",
},
{ model: "list", mode: ModelManyMode.SET },
),
get: api.get(
{
url(view) {
return `/users/${view.user.value?.id}`;
},
},
{ model: "current", mode: ModelOneMode.SET },
),
create: api.post(
{
url: "/users",
},
{ model: "list", mode: ModelManyMode.ADD },
),
delete: api.delete(
{
model: "list",
mode: ModelManyMode.SET,
url(view) {
return `/users/${view.user.value?.id}`;
},
},
{ model: "list", mode: ModelManyMode.REMOVE },
),
};
},
});
```

Use it in any component with built-in composables:

```vue
<script setup>
const { execute, loading } = useStoreAction(userStore, "list");
const { data } = useStoreView(userStore, "users");
const { execute, loading, error } = useStoreAction(userStore, "list");
const { data: users } = useStoreView(userStore, "users");

await execute();
</script>

<template>
<ul v-if="!loading">
<li v-for="user in data.value" :key="user.id">{{ user.name }}</li>
<p v-if="error">{{ error.message }}</p>
<ul v-else-if="!loading">
<li v-for="user in users" :key="user.id">{{ user.name }}</li>
</ul>
</template>
```
Expand All @@ -94,8 +142,14 @@ await execute();

## Documentation

Full docs with guides, API reference, and examples:

[https://diphyx.github.io/harlemify/](https://diphyx.github.io/harlemify/)

## Contributing

Contributions are welcome! Please open an issue first to discuss what you'd like to change.

## License

MIT
[MIT](LICENSE)
12 changes: 2 additions & 10 deletions docs/composables/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,21 +21,13 @@ const { set, add, remove } = useStoreModel(store, "list");

## [useStoreView](use-store-view.md)

Reactive view data with proxy access and change tracking.
Reactive view data as a `ComputedRef` with change tracking.

```typescript
const { data, track } = useStoreView(store, "user");

data.value; // User
data.name; // Proxy access without .value
```

Pass `proxy: false` to get a standard `ComputedRef` instead of the proxy:

```typescript
const { data } = useStoreView(store, "user", { proxy: false });

data.value; // User — standard ComputedRef
data.value.name; // string
```

## [useStoreCompose](use-store-compose.md)
Expand Down
65 changes: 7 additions & 58 deletions docs/composables/use-store-view.md
Original file line number Diff line number Diff line change
@@ -1,33 +1,18 @@
# useStoreView

Returns reactive view data with proxy access and a `track` method for watching changes.
Returns reactive view data as a `ComputedRef` with a `track` method for watching changes.

## Basic Usage

```typescript
const { data, track } = useStoreView(userStore, "user");

data.value; // User — standard ref access
data.name; // string — proxy access without .value
data.email; // string — proxy access without .value
data.value; // User — standard ComputedRef access
data.value.name; // string
data.value.email; // string
```

## Data Proxy

The `data` object is a proxy that supports both `.value` for the full ref value and direct property access:

```typescript
const { data } = useStoreView(userStore, "user");

// Standard access
data.value; // User

// Proxy access — reads from the current .value
data.name; // equivalent to data.value.name
data.email; // equivalent to data.value.email
```

This is useful in templates where you want to avoid repeated `.value` checks:
In templates, Vue auto-unwraps the `ComputedRef`:

```vue
<template>
Expand All @@ -36,25 +21,6 @@ This is useful in templates where you want to avoid repeated `.value` checks:
</template>
```

## Without Proxy

Pass `proxy: false` to get a standard Vue `ComputedRef` instead:

```typescript
const { data } = useStoreView(userStore, "user", { proxy: false });

data.value; // User — standard ComputedRef
data.value.name; // access via .value in script
```

In templates, Vue auto-unwraps the `ComputedRef`, so `.value` is not needed:

```vue
<template>
<p>{{ data.name }}</p>
</template>
```

## Track

Watch for view changes with an optional stop handle:
Expand Down Expand Up @@ -108,32 +74,15 @@ onMounted(() => {
<h2>{{ userData.name }}</h2>
<p>{{ userData.email }}</p>
<ul>
<li v-for="user in usersData.value" :key="user.id">{{ user.name }}</li>
<li v-for="user in usersData" :key="user.id">{{ user.name }}</li>
</ul>
</template>
```

## Options

| Option | Type | Default | Description |
| ------- | --------- | ------- | ----------------------------------------------------------------------- |
| `proxy` | `boolean` | `true` | When `true`, returns a proxy. When `false`, returns a raw `ComputedRef` |

## Return Type

### With Proxy (default)

```typescript
type UseStoreViewProxy<T> = {
data: { value: T } & { [K in keyof T]: T[K] };
track: (handler: (value: T) => void, options?: UseStoreViewTrackOptions) => WatchStopHandle;
};
```

### Without Proxy

```typescript
type UseStoreViewComputed<T> = {
type UseStoreView<T> = {
data: ComputedRef<T>;
track: (handler: (value: T) => void, options?: UseStoreViewTrackOptions) => WatchStopHandle;
};
Expand Down
21 changes: 5 additions & 16 deletions e2e/composables.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,35 +160,35 @@ test.describe("composables page", () => {

// useStoreView

test("view non-destructured: data proxy shows shape defaults when no selection", async ({ page }) => {
test("view data shows shape defaults when no selection", async ({ page }) => {
await expect(page.getByTestId("view-data-value")).toContainText('"id":0');
await expect(page.getByTestId("view-data-title")).toHaveText("");
await expect(page.getByTestId("view-data-done")).toHaveText("false");
});

test("view non-destructured: data proxy reflects selection", async ({ page }) => {
test("view data reflects selection", async ({ page }) => {
await page.getByTestId("todo-1").getByTestId("select-todo").click();
await expect(page.getByTestId("view-data-title")).toHaveText("Buy groceries");
await expect(page.getByTestId("view-data-done")).toHaveText("false");
});

test("view non-destructured: proxy access matches .value access", async ({ page }) => {
test("view data title and done match .value", async ({ page }) => {
await page.getByTestId("todo-1").getByTestId("select-todo").click();
const json = await page.getByTestId("view-data-value").textContent();
const parsed = JSON.parse(json!);
await expect(page.getByTestId("view-data-title")).toHaveText(parsed.title);
await expect(page.getByTestId("view-data-done")).toHaveText(String(parsed.done));
});

test("view non-destructured: clear selection button uses proxy", async ({ page }) => {
test("view clear selection resets data", async ({ page }) => {
await page.getByTestId("todo-1").getByTestId("select-todo").click();
await expect(page.getByTestId("clear-selection")).toBeVisible();
await page.getByTestId("clear-selection").click();
await expect(page.getByTestId("view-data-title")).toHaveText("");
await expect(page.getByTestId("clear-selection")).not.toBeVisible();
});

test("view non-destructured: selected highlight uses proxy", async ({ page }) => {
test("view selected highlight", async ({ page }) => {
await page.getByTestId("todo-2").getByTestId("select-todo").click();
await expect(page.getByTestId("todo-2")).toHaveClass(/selected/);
await expect(page.getByTestId("todo-1")).not.toHaveClass(/selected/);
Expand All @@ -197,17 +197,6 @@ test.describe("composables page", () => {
await expect(page.getByTestId("todo-2")).not.toHaveClass(/selected/);
});

test("view without proxy: shows shape defaults when no selection", async ({ page }) => {
await expect(page.getByTestId("view-computed-value")).toContainText('"title":""');
await expect(page.getByTestId("view-computed-title")).toHaveText("");
});

test("view without proxy: reflects selection via .value", async ({ page }) => {
await page.getByTestId("todo-1").getByTestId("select-todo").click();
await expect(page.getByTestId("view-computed-title")).toHaveText("Buy groceries");
await expect(page.getByTestId("view-computed-value")).toContainText("Buy groceries");
});

test("view destructured: pending data shows incomplete todos", async ({ page }) => {
await expect(page.getByTestId("view-pending")).toContainText("Buy groceries");
await expect(page.getByTestId("view-pending")).toContainText("Deploy app");
Expand Down
Loading