diff --git a/README.md b/README.md index bff64e5..a0ecc72 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,10 @@ Components: - [``](src/lib/components/StructuredText) - [``](src/lib/components/Head) +Stores: + +- [`querySubscription`](src/lib/stores/querySubscription) + ## Installation ``` diff --git a/package-lock.json b/package-lock.json index 3452278..7731aa3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "3.0.3", "license": "MIT", "dependencies": { + "datocms-listen": "^0.1.15", "datocms-structured-text-utils": "^4.0.1", "svelte-intersection-observer": "^1.0.0" }, @@ -51,6 +52,20 @@ } } }, + "node_modules/@0no-co/graphql.web": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@0no-co/graphql.web/-/graphql.web-1.0.7.tgz", + "integrity": "sha512-E3Qku4mTzdrlwVWGPxklDnME5ANrEGetvYw4i2GCRlppWXXE4QD66j7pwb8HelZwS6LnqEChhrSOGCXpbiu6MQ==", + "license": "MIT", + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0" + }, + "peerDependenciesMeta": { + "graphql": { + "optional": true + } + } + }, "node_modules/@aashutoshrathi/word-wrap": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz", @@ -3098,6 +3113,15 @@ "integrity": "sha512-hBSVCvSmWC+QypYObzwGOd9wqdDpOt+0wl0KbU+R+uuZBS1jN8VsD1ss3irQDknRj5NvxiTF6oj/nDRnN/UQNw==", "dev": true }, + "node_modules/datocms-listen": { + "version": "0.1.15", + "resolved": "https://registry.npmjs.org/datocms-listen/-/datocms-listen-0.1.15.tgz", + "integrity": "sha512-0LcdKYW/ilWdyrRzQ+YbkAk4r2dge63vzHtfv19L8pfbfYurQgXP9/ck703QZL//YqBoc0OKBS4BP4WUQ6JDKA==", + "license": "MIT", + "dependencies": { + "@0no-co/graphql.web": "^1.0.1" + } + }, "node_modules/datocms-structured-text-generic-html-renderer": { "version": "2.1.12", "resolved": "https://registry.npmjs.org/datocms-structured-text-generic-html-renderer/-/datocms-structured-text-generic-html-renderer-2.1.12.tgz", diff --git a/package.json b/package.json index 7433cc4..fabe828 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ }, "type": "module", "dependencies": { + "datocms-listen": "^0.1.15", "datocms-structured-text-utils": "^4.0.1", "svelte-intersection-observer": "^1.0.0" }, diff --git a/src/lib/components/NakedImage/__tests__/NakedImage.svelte.test.ts b/src/lib/components/NakedImage/__tests__/NakedImage.svelte.test.ts index b7955fd..2b5d1b7 100644 --- a/src/lib/components/NakedImage/__tests__/NakedImage.svelte.test.ts +++ b/src/lib/components/NakedImage/__tests__/NakedImage.svelte.test.ts @@ -3,92 +3,92 @@ import { fireEvent, render, screen } from '@testing-library/svelte'; import { describe, expect, it, vi } from 'vitest'; import { - completeData, - minimalData, - minimalDataWithRelativeUrl, + completeData, + minimalData, + minimalDataWithRelativeUrl } from '../../Image/__tests__/__fixtures__/image'; import { NakedImage } from '../../..'; describe('NakedImage', () => { - describe('passing className and/or style', () => { - it('renders correctly', () => { - const onLoad = vi.fn(); + describe('passing className and/or style', () => { + it('renders correctly', () => { + const onLoad = vi.fn(); - const { container, component } = render(NakedImage, { - props: { - data: minimalData, - imgClass: 'img-class-name', - imgStyle: 'background: red; overflow: visible; padding: 0px 10px;', - pictureClass: 'picture-class-name', - pictureStyle: 'background: green;', - }, - }); + const { container, component } = render(NakedImage, { + props: { + data: minimalData, + imgClass: 'img-class-name', + imgStyle: 'background: red; overflow: visible; padding: 0px 10px;', + pictureClass: 'picture-class-name', + pictureStyle: 'background: green;' + } + }); - component.$on('load', onLoad); + component.$on('load', onLoad); - const picture = screen.getByTestId('picture'); - const img = screen.getByTestId('img'); + const picture = screen.getByTestId('picture'); + const img = screen.getByTestId('img'); - fireEvent.load(img); + fireEvent.load(img); - expect(picture).toBeInTheDocument(); - expect(onLoad).toHaveBeenCalled(); - expect(container).toMatchSnapshot(); - }); - }); + expect(picture).toBeInTheDocument(); + expect(onLoad).toHaveBeenCalled(); + expect(container).toMatchSnapshot(); + }); + }); - describe('full data', () => { - it('renders correctly', () => { - const { container } = render(NakedImage, { - props: { data: completeData }, - }); - expect(container).toMatchSnapshot(); - }); - }); + describe('full data', () => { + it('renders correctly', () => { + const { container } = render(NakedImage, { + props: { data: completeData } + }); + expect(container).toMatchSnapshot(); + }); + }); - describe('minimal data', () => { - it('renders correctly', () => { - const { container } = render(NakedImage, { - props: { data: minimalData }, - }); - expect(container).toMatchSnapshot(); - }); - }); + describe('minimal data', () => { + it('renders correctly', () => { + const { container } = render(NakedImage, { + props: { data: minimalData } + }); + expect(container).toMatchSnapshot(); + }); + }); - describe('minimalDataWithRelativeUrl', () => { - it('renders correctly', () => { - const { container } = render(NakedImage, { - props: { data: minimalDataWithRelativeUrl }, - }); - expect(container).toMatchSnapshot(); - }); - }); + describe('minimalDataWithRelativeUrl', () => { + it('renders correctly', () => { + const { container } = render(NakedImage, { + props: { data: minimalDataWithRelativeUrl } + }); + expect(container).toMatchSnapshot(); + }); + }); - describe('priority=true', () => { - it('renders correctly', () => { - const { container } = render(NakedImage, { - props: { data: minimalData, priority: true }, - }); - expect(container).toMatchSnapshot(); - }); - }); + describe('priority=true', () => { + it('renders correctly', () => { + const { container } = render(NakedImage, { + props: { data: minimalData, priority: true } + }); + expect(container).toMatchSnapshot(); + }); + }); - describe('usePlaceholder=false', () => { - it('renders correctly', () => { - const { container } = render(NakedImage, { - props: { data: minimalData, usePlaceholder: false }, - }); - expect(container).toMatchSnapshot(); - }); - }); + describe('usePlaceholder=false', () => { + it('renders correctly', () => { + const { container } = render(NakedImage, { + props: { data: minimalData, usePlaceholder: false } + }); + expect(container).toMatchSnapshot(); + }); + }); - describe('explicit sizes', () => { - it('renders correctly', () => { - const { container } = render(NakedImage, { - props: { data: minimalData, sizes: '(max-width: 600px) 200px, 50vw' }, - }); - expect(container).toMatchSnapshot(); - }); - }); + describe('explicit sizes', () => { + it('renders correctly', () => { + const { container } = render(NakedImage, { + props: { data: minimalData, sizes: '(max-width: 600px) 200px, 50vw' } + }); + expect(container).toMatchSnapshot(); + }); + }); }); diff --git a/src/lib/index.ts b/src/lib/index.ts index 6806d59..bef9e01 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -9,6 +9,8 @@ export { default as Image } from './components/Image/Image.svelte'; export { default as StructuredText } from './components/StructuredText/StructuredText.svelte'; export { default as VideoPlayer } from './components/VideoPlayer/VideoPlayer.svelte'; +export * from './stores/querySubscription'; + export type PredicateComponentTuple = [ (n: Node) => boolean, // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/src/lib/stores/querySubscription/README.md b/src/lib/stores/querySubscription/README.md new file mode 100644 index 0000000..6a9f463 --- /dev/null +++ b/src/lib/stores/querySubscription/README.md @@ -0,0 +1,107 @@ +# Live real-time updates + +`querySubscription` returns a Svelte store that you can use to implement client-side updates of the page as soon as the content changes. It uses DatoCMS's [Real-time Updates API](https://www.datocms.com/docs/real-time-updates-api/api-reference) to receive the updated query results in real-time, and is able to reconnect in case of network failures. + +Live updates are great both to get instant previews of your content while editing it inside DatoCMS, or to offer real-time updates of content to your visitors (ie. news site). + +## Table of Contents + + + + +- [Initialization options](#initialization-options) +- [Connection status](#connection-status) +- [Error object](#error-object) +- [Example](#example) + + + +``` + +## Reference + +Import `querySubscription` from `datocms-svelte` and use it inside your components like this: + +```js +import { querySubscription } from '@datocms/svelte'; + +const subscription = querySubscription(options: Options); +``` + +## Initialization options + +| prop | type | required | description | default | +| ------------------ | ------------------------------------------------------------------------------------------ | ------------------ | ------------------------------------------------------------------ | ------------------------------------ | +| enabled | boolean | :x: | Whether the subscription has to be performed or not | true | +| query | string \| [`TypedDocumentNode`](https://github.com/dotansimha/graphql-typed-document-node) | :white_check_mark: | The GraphQL query to subscribe | | +| token | string | :white_check_mark: | DatoCMS API token to use | | +| variables | Object | :x: | GraphQL variables for the query | | +| preview | boolean | :x: | If true, the Content Delivery API with draft content will be used | false | +| environment | string | :x: | The name of the DatoCMS environment where to perform the query | defaults to primary environment | +| initialData | Object | :x: | The initial data to use on the first render | | +| reconnectionPeriod | number | :x: | In case of network errors, the period (in ms) to wait to reconnect | 1000 | +| fetcher | a [fetch-like function](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) | :x: | The fetch function to use to perform the registration query | window.fetch | +| eventSourceClass | an [EventSource-like](https://developer.mozilla.org/en-US/docs/Web/API/EventSource) class | :x: | The EventSource class to use to open up the SSE connection | window.EventSource | +| baseUrl | string | :x: | The base URL to use to perform the query | `https://graphql-listen.datocms.com` | + +## Connection status + +The `status` property represents the state of the server-sent events connection. It can be one of the following: + +- `connecting`: the subscription channel is trying to connect +- `connected`: the channel is open, we're receiving live updates +- `closed`: the channel has been permanently closed due to a fatal error (ie. an invalid query) + +## Error object + +| prop | type | description | +| -------- | ------ | ------------------------------------------------------- | +| code | string | The code of the error (ie. `INVALID_QUERY`) | +| message | string | An human friendly message explaining the error | +| response | Object | The raw response returned by the endpoint, if available | + +## Example + +```svelte + + +

Connection status: {statusMessage[status]}

+ +{#if error} +

Error: {error.code}

+

{error.message}

+ {#if error.response} +
{JSON.stringify(error.response, null, 2)}
+ {/if} +{/if} + +{#if data} + +{/if} +``` diff --git a/src/lib/stores/querySubscription/index.ts b/src/lib/stores/querySubscription/index.ts new file mode 100644 index 0000000..c13e343 --- /dev/null +++ b/src/lib/stores/querySubscription/index.ts @@ -0,0 +1,92 @@ +import { + subscribeToQuery, + type ChannelErrorData, + type ConnectionStatus, + type Options, + type UnsubscribeFn +} from 'datocms-listen'; +import { onMount } from 'svelte'; +import { writable } from 'svelte/store'; + +export type SubscribeToQueryOptions = Omit< + Options, + 'onStatusChange' | 'onUpdate' | 'onChannelError' +>; + +export type EnabledQuerySubscriptionOptions = { + /** Whether the subscription has to be performed or not */ + enabled?: true; + /** The initial data to use while the initial request is being performed */ + initialData?: QueryResult; +} & SubscribeToQueryOptions; + +export type DisabledQuerySubscriptionOptions = { + /** Whether the subscription has to be performed or not */ + enabled: false; + /** The initial data to use while the initial request is being performed */ + initialData?: QueryResult; +} & Partial>; + +export type QuerySubscriptionOptions = + | EnabledQuerySubscriptionOptions + | DisabledQuerySubscriptionOptions; + +export type Subscription = { + error: ChannelErrorData | null; + data: QueryResult | null; + status: ConnectionStatus | null; +}; + +export function querySubscription( + options: QuerySubscriptionOptions +) { + const { enabled, initialData, ...other } = options; + const subscribeToQueryOptions = other as EnabledQuerySubscriptionOptions< + QueryResult, + QueryVariables + >; + + const { subscribe, update } = writable>({ + error: null, + data: initialData || null, + status: enabled ? 'connecting' : 'closed' + }); + + onMount(() => { + if (enabled === false) { + update((old) => ({ ...old, status: 'closed' })); + return; + } + + let unsubscribe: UnsubscribeFn | null; + + async function subscribe() { + unsubscribe = await subscribeToQuery({ + ...subscribeToQueryOptions, + onStatusChange: (status) => { + update((old) => ({ ...old, status })); + }, + onUpdate: (updateData) => { + update((old) => ({ + ...old, + error: null, + data: updateData.response.data + })); + }, + onChannelError: (errorData) => { + update((old) => ({ ...old, error: errorData, data: null })); + } + }); + } + + subscribe(); + + return () => { + if (unsubscribe) { + unsubscribe(); + } + }; + }); + + return { subscribe }; +}