Skip to content

Commit e0d8c8f

Browse files
committed
feat: Add basics of vue
1 parent f2c5cf0 commit e0d8c8f

File tree

13 files changed

+583
-12
lines changed

13 files changed

+583
-12
lines changed

eslint.config.mjs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,4 +47,14 @@ export default [
4747
'no-console': 'off',
4848
},
4949
},
50+
// Disable React-specific rules for Vue package
51+
{
52+
files: ['packages/vue/**/*.?(m|c)ts?(x)', 'packages/vue/**/*.?(m|c)js?(x)'],
53+
rules: {
54+
'react-hooks/rules-of-hooks': 'off',
55+
'react-hooks/exhaustive-deps': 'off',
56+
'react/react-in-jsx-scope': 'off',
57+
'react/jsx-uses-react': 'off',
58+
},
59+
},
5060
];

packages/vue/README.md

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ npm install --save @data-client/vue @data-client/rest @data-client/test
4040

4141
For more details, see [the Installation docs page](https://dataclient.io/docs/getting-started/installation).
4242

43-
## Planned Usage
43+
## Usage (alpha)
4444

4545
### Simple [TypeScript definition](https://dataclient.io/rest/api/Entity)
4646

@@ -82,6 +82,23 @@ const ArticleResource = resource({
8282
});
8383
```
8484

85+
### Provide the Data Client
86+
87+
Call `provideDataClient()` once in your root component's setup. This creates the controller, store, and managers, and provides them via Vue's provide/inject.
88+
89+
```ts
90+
// App.vue (script setup)
91+
import { provideDataClient } from '@data-client/vue';
92+
93+
provideDataClient({
94+
// optional overrides
95+
// managers: getDefaultManagers(),
96+
// initialState,
97+
// Controller,
98+
// gcPolicy,
99+
});
100+
```
101+
85102
### One line [data binding](https://dataclient.io/docs/getting-started/data-dependency)
86103

87104
```vue
@@ -95,7 +112,6 @@ const ArticleResource = resource({
95112
</template>
96113
97114
<script setup lang="ts">
98-
// Planned API - not yet implemented
99115
const props = defineProps<{ id: string }>();
100116
const article = useSuspense(ArticleResource.get, { id: props.id });
101117
</script>
@@ -113,7 +129,6 @@ const article = useSuspense(ArticleResource.get, { id: props.id });
113129
</template>
114130
115131
<script setup lang="ts">
116-
// Planned API - not yet implemented
117132
const props = defineProps<{ id: string; article: Article }>();
118133
const ctrl = useController();
119134
@@ -136,7 +151,6 @@ const handleDeleteArticle = () =>
136151
</template>
137152
138153
<script setup lang="ts">
139-
// Planned API - not yet implemented
140154
const props = defineProps<{ symbol: string }>();
141155
const price = useLive(PriceResource.get, { symbol: props.symbol });
142156
</script>
@@ -145,7 +159,6 @@ const price = useLive(PriceResource.get, { symbol: props.symbol });
145159
### [Type-safe Imperative Actions](https://dataclient.io/docs/api/Controller)
146160

147161
```typescript
148-
// Planned API - not yet implemented
149162
const ctrl = useController();
150163
await ctrl.fetch(ArticleResource.update, { id }, articleData);
151164
await ctrl.fetchIfStale(ArticleResource.get, { id });
@@ -164,7 +177,6 @@ const queryTotalVotes = new schema.Query(
164177
posts => posts.reduce((total, post) => total + post.votes, 0),
165178
);
166179

167-
// Planned API - not yet implemented
168180
const totalVotes = useQuery(queryTotalVotes);
169181
const totalVotesForUser = useQuery(queryTotalVotes, { userId });
170182
```
@@ -174,7 +186,6 @@ const groupTodoByUser = new schema.Query(
174186
TodoResource.getList.schema,
175187
todos => Object.groupBy(todos, todo => todo.userId),
176188
);
177-
// Planned API - not yet implemented
178189
const todosByUser = useQuery(groupTodoByUser);
179190
```
180191

@@ -300,5 +311,5 @@ For the small price of 9kb gziped. &nbsp;&nbsp; [🏁Get started now](https://da
300311
- `ctrl.resolve`
301312
- `ctrl.subscribe`
302313
- `ctrl.unsubscribe`
303-
- Components: `<DataProvider/>`, `<AsyncBoundary/>`, `<ErrorBoundary/>`
314+
- Components: `<AsyncBoundary/>`, `<ErrorBoundary/>`
304315
- Middleware: `LogoutManager`, `NetworkManager`, `SubscriptionManager`, `PollingSubscription`, `DevToolsManager`
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import { mount } from '@vue/test-utils';
2+
import nock from 'nock';
3+
import { defineComponent, h, nextTick, Suspense } from 'vue';
4+
5+
// Reuse the same endpoints/fixtures used by the React tests
6+
import { CoolerArticleResource } from '../../../../__tests__/new';
7+
// Minimal shared fixture (copied from React test fixtures)
8+
const payload = {
9+
id: 5,
10+
title: 'hi ho',
11+
content: 'whatever',
12+
tags: ['a', 'best', 'react'],
13+
};
14+
import useSuspense from '../consumers/useSuspense';
15+
import { provideDataClient } from '../providers/provideDataClient';
16+
17+
describe('vue useSuspense()', () => {
18+
async function flushUntil(
19+
wrapper: any,
20+
predicate: () => boolean,
21+
tries = 100,
22+
) {
23+
for (let i = 0; i < tries; i++) {
24+
if (predicate()) return;
25+
await Promise.resolve();
26+
await nextTick();
27+
await new Promise(resolve => setTimeout(resolve, 0));
28+
}
29+
}
30+
31+
beforeAll(() => {
32+
nock(/.*/)
33+
.persist()
34+
.defaultReplyHeaders({
35+
'Access-Control-Allow-Origin': '*',
36+
'Access-Control-Allow-Headers': 'Access-Token',
37+
'Content-Type': 'application/json',
38+
})
39+
.options(/.*/)
40+
.reply(200)
41+
.get(`/article-cooler/${payload.id}`)
42+
.reply(200, payload)
43+
.put(`/article-cooler/${payload.id}`)
44+
.reply(200, (uri, requestBody: any) => ({
45+
...payload,
46+
...requestBody,
47+
}));
48+
});
49+
50+
afterAll(() => {
51+
nock.cleanAll();
52+
});
53+
54+
const ArticleComp = defineComponent({
55+
name: 'ArticleComp',
56+
async setup() {
57+
const article = await useSuspense(CoolerArticleResource.get, {
58+
id: payload.id,
59+
});
60+
return () =>
61+
h('div', [
62+
h('h3', (article as any).value.title),
63+
h('p', (article as any).value.content),
64+
]);
65+
},
66+
});
67+
68+
const ProvideWrapper = defineComponent({
69+
name: 'ProvideWrapper',
70+
setup(_props, { slots, expose }) {
71+
const { controller } = provideDataClient();
72+
expose({ controller });
73+
return () =>
74+
h(
75+
Suspense,
76+
{},
77+
{
78+
default: () => (slots.default ? slots.default() : h(ArticleComp)),
79+
fallback: () => h('div', { class: 'fallback' }, 'Loading'),
80+
},
81+
);
82+
},
83+
});
84+
85+
it('suspends on empty store, then renders after fetch resolves', async () => {
86+
const wrapper = mount(ProvideWrapper, {
87+
slots: { default: () => h(ArticleComp) },
88+
});
89+
90+
// Initially should render fallback while Suspense is pending
91+
expect(wrapper.find('.fallback').exists()).toBe(true);
92+
93+
// Flush pending promises/ticks until content renders
94+
await flushUntil(wrapper, () => wrapper.find('h3').exists());
95+
96+
const title = wrapper.find('h3');
97+
const content = wrapper.find('p');
98+
expect(title.exists()).toBe(true);
99+
expect(content.exists()).toBe(true);
100+
expect(title.text()).toBe(payload.title);
101+
expect(content.text()).toBe(payload.content);
102+
});
103+
104+
it('re-renders when controller.setResponse() updates data', async () => {
105+
const wrapper = mount(ProvideWrapper, {
106+
slots: { default: () => h(ArticleComp) },
107+
});
108+
// Wait for initial render
109+
await flushUntil(wrapper, () => wrapper.find('h3').exists());
110+
111+
// Verify initial values
112+
expect(wrapper.find('h3').text()).toBe(payload.title);
113+
expect(wrapper.find('p').text()).toBe(payload.content);
114+
115+
// Update the store using controller.setResponse
116+
const exposed: any = wrapper.vm as any;
117+
const newTitle = payload.title + ' updated';
118+
const newContent = (payload as any).content + ' v2';
119+
exposed.controller.setResponse(
120+
CoolerArticleResource.get,
121+
{ id: payload.id },
122+
{ ...payload, title: newTitle, content: newContent },
123+
);
124+
125+
await flushUntil(wrapper, () => wrapper.find('h3').text() === newTitle);
126+
127+
expect(wrapper.find('h3').text()).toBe(newTitle);
128+
expect(wrapper.find('p').text()).toBe(newContent);
129+
});
130+
131+
it('re-renders when controller.fetch() mutates data', async () => {
132+
const wrapper = mount(ProvideWrapper, {
133+
slots: { default: () => h(ArticleComp) },
134+
});
135+
// Wait for initial render
136+
await flushUntil(wrapper, () => wrapper.find('h3').exists());
137+
138+
// Verify initial values
139+
expect(wrapper.find('h3').text()).toBe(payload.title);
140+
expect(wrapper.find('p').text()).toBe(payload.content);
141+
142+
// Mutate the data using controller.fetch with update endpoint
143+
const exposed: any = wrapper.vm as any;
144+
const updatedTitle = payload.title + ' mutated';
145+
const updatedContent = payload.content + ' mutated';
146+
147+
await exposed.controller.fetch(
148+
CoolerArticleResource.update,
149+
{ id: payload.id },
150+
{ title: updatedTitle, content: updatedContent },
151+
);
152+
153+
// Wait for re-render with new data
154+
await flushUntil(wrapper, () => wrapper.find('h3').text() === updatedTitle);
155+
156+
expect(wrapper.find('h3').text()).toBe(updatedTitle);
157+
expect(wrapper.find('p').text()).toBe(updatedContent);
158+
});
159+
});
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { Controller as DataController } from '@data-client/core';
2+
import type { State, Manager, GCInterface } from '@data-client/core';
3+
import { defineComponent } from 'vue';
4+
5+
import { provideDataClient } from '../providers/provideDataClient.js';
6+
7+
export interface ProviderProps {
8+
managers?: Manager[];
9+
initialState?: State<unknown>;
10+
Controller?: typeof DataController;
11+
gcPolicy?: GCInterface;
12+
}
13+
14+
export default defineComponent<ProviderProps>({
15+
name: 'DataProvider',
16+
props: ['managers', 'initialState', 'Controller', 'gcPolicy'] as any,
17+
setup(props, { slots }) {
18+
provideDataClient({
19+
Controller: props.Controller,
20+
gcPolicy: props.gcPolicy,
21+
initialState: props.initialState,
22+
managers: props.managers,
23+
});
24+
return () => (slots.default ? slots.default() : null);
25+
},
26+
});
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export { default as useSuspense } from './useSuspense.js';
2+
export {
3+
injectController as useController,
4+
injectState as useStateRef,
5+
} from '../context.js';
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import type { State } from '@data-client/core';
2+
import type { ShallowRef } from 'vue';
3+
4+
import { injectController, injectState } from '../context.js';
5+
6+
export function useController() {
7+
return injectController();
8+
}
9+
10+
export function useStateRef(): ShallowRef<State<unknown>> {
11+
return injectState();
12+
}

0 commit comments

Comments
 (0)