Skip to content

Commit

Permalink
pkp/pkp-lib#9527 Document useFetch&useFetchPaginated. Refine GA to ru…
Browse files Browse the repository at this point in the history
…n only on PR and master
  • Loading branch information
jardakotesovec committed Jan 8, 2024
1 parent d55b056 commit 716c15a
Show file tree
Hide file tree
Showing 7 changed files with 251 additions and 19 deletions.
5 changes: 4 additions & 1 deletion .github/workflows/chromatic.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,11 @@ name: 'Chromatic'

# Event for the workflow
on:
push:
pull_request:
types: [opened, synchronize, reopened]
push:
branches:
- main

# List of jobs
jobs:
Expand Down
26 changes: 20 additions & 6 deletions src/composables/useFetch.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,26 @@ function getCSRFToken() {

return FALLBACK_TOKEN;
}

/**
*
* Composable for handling API requests
* Listed options should be sufficient for PKP use cases. For additional options check [ofetch](https://github.com/unjs/ofetch) docs
* @function useFetch
* @param {string} url - The URL to which the HTTP request is to be sent.
* @param {Object} [options={}] - Optional configuration options for the request.
* @param {boolean} [options.expectValidationError=false] - Set to `true` to handle validation errors separately. When set, validation errors are stored in `validationError` rather than `error`.
* @param {Object} [options.query] - An object representing query parameters to be included in the request.
* @param {Object} [options.body] - The request payload, typically used with 'POST', 'PUT', or 'DELETE' requests.
* @param {Object} [options.headers] - Additional HTTP headers to be sent with the request.
* @param {string} [options.method] - The HTTP method to be used for the request (e.g., 'GET', 'POST', etc.).
*
* @returns {Object} An object containing several reactive properties and a method for performing the fetch operation:
* @returns {Ref<Object|null>} return.data - A ref object containing the response data from the fetch operation.
* @returns {Ref<Object|null>} return.validationError - A ref object containing validation error data, relevant when `expectValidationError` is true.
* @returns {Ref<boolean>} return.isLoading - A ref object indicating whether the fetch operation is currently in progress.
* @returns {Function} return.fetch - The function to call to initiate the fetch operation. This function is async and handles the actual fetching logic.
*
*/
export function useFetch(url, options = {}) {
/**
* Workaround for testing https://github.com/unjs/ofetch/issues/295
Expand All @@ -37,7 +56,6 @@ export function useFetch(url, options = {}) {
const dialogStore = useDialogStore();
const isLoading = ref(false);
const data = ref(null);
const error = ref(null);
const validationError = ref(null);

let lastRequestController = null;
Expand Down Expand Up @@ -76,7 +94,6 @@ export function useFetch(url, options = {}) {
try {
const result = await ofetchInstance(unref(url), opts);
data.value = result;
error.value = null;
validationError.value = null;
} catch (e) {
data.value = null;
Expand All @@ -90,13 +107,11 @@ export function useFetch(url, options = {}) {

if (expectValidationError && e.status >= 400 && e.status < 500) {
validationError.value = e.data;
error.value = null;
data.value = null;
return;
}

dialogStore.openDialogNetworkError(e);
error.value = e;
} finally {
lastRequestController = null;
isLoading.value = false;
Expand All @@ -105,7 +120,6 @@ export function useFetch(url, options = {}) {

return {
data,
error,
validationError,
isLoading,
fetch,
Expand Down
110 changes: 110 additions & 0 deletions src/composables/useFetch.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import {Meta} from '@storybook/blocks';

<Meta title="Composables/useFetch" />

# useFetch

`useFetch` is designed for interactions with our API.

## Technical decisions

The promise-based [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) serves as a modern replacement for [XMLHttpRequest](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest). It is already sufficiently supported across various browser versions, making it a reliable choice.

There are several libraries available to enhance the Fetch API and add useful features. We have chosen to use the [ofetch](https://github.com/unjs/ofetch) package as underlying library, a fundamental component of the well-known Vue.js framework [Nuxt.js](https://nuxt.com). We believe this provides good chance of long-term support.

## Features

This is not ultimate list of features, but highliging ones that are relevant to our usage.

- **Auto Retry** implemented in [ofetch](https://github.com/unjs/ofetch#%EF%B8%8F-auto-retry) and helps with unstable internet connection. Currently it retries only for GET requests.
- **Encoding Query params** implemented in [ofetch](https://github.com/unjs/ofetch#%EF%B8%8F-adding-query-search-params) to make easy to pass query params that are automatically correctly encoded
- **CSRF token** implemented in **useFetch**. to automatically add CSRF token in header for POST/PUT/DELETE requests.
- **Last request wins** implemented in **useFetch**. Typical use case is if user is changing filters/search criteria on slower connection to make sure that the last request data are applied. Internally it aborts previous request.
- **Automatic X-Http-Method-Override** implemented in **useFetch**. For better [compatibility](https://github.com/pkp/pkp-lib/issues/5981) with some servers its using **X-Http-Method-Override** header for **PUT/DELETE** requests, while keeping method as **POST**.
- **Error Handling** is implemented in useFetch. By default, any response that is not a **200** status is considered an unexpected error, and a dialog is displayed to inform the user. If you anticipate a validation error of user input (**4xx** status codes) that requires handling, this must be explicitly enabled. For more details, refer to the [POST 4xx](?path=/docs/composables-usefetch--docs#post-4xx-with-validation-error) in the Examples section.

## API

For detailed list of options please check `useFetch.js`. For common use cases following example should provide enough guidance.

## Examples

Note that currently API urls are generated on PHP side and passed to the pages on initial load. In future we intend to bring such utilities to client side as well.

All examples can be found also in `useFetch.test.js`. There might be use cases that we have not covered yet, feel free to open github [issue](https://github.com/pkp/pkp-lib/issues) to bring it to our attention.

### GET 200

```javascript
const url = ref(submissionsGetApiUrl);
const queryParams = ref({param1: 4, param2: 5});
// url, query and body can be normal objects or Refs
const {data, isLoading, fetch} = useFetch(url, {
query: queryParams,
});

console.log(isLoading.value); // false
console.log(data.value); // null

await fetch();
console.log(data.value); // { ... } data returned from server
console.log(isLoading.value); // false
```

### POST 200

```javascript
const body = {title: 'abc'};
const url = ref(submissionPostApiUrl);

// url, query and body can be normal objects or Refs
const {data, validationError, fetch} = useFetch(url, {
method: 'POST',
body,
// important if we expect endpoint to validate user input
// if endpoint returns 4xx, response is saved to validationError
expectValidationError: true,
});

await fetch();

console.log(validationError.value); // null
console.log(data.value); // {...} data returned from server
```

### POST 4xx with validation error

```javascript
const body = {title: 'abc'};
const url = ref(submissionPostApiUrl);

// url, query and body can be normal objects or Refs
const {data, validationError, fetch} = useFetch(url, {
method: 'POST',
body,
// important if we expect endpoint to validate user input
// if endpoint returns 4xx, response is saved to validationError
expectValidationError: true,
});

await fetch();

console.log(validationError.value); // {title: ['Unsupported characters']}
console.log(data.value); // null
```

### PUT 200

```javascript
const url = ref(submissionPutApiUrl);
const {data, validationError, fetch} = useFetch(url, {
method: 'PUT',
body: {title: 'abc'},
expectValidationError: true,
});

await fetch();

console.log(validationError.value); // null
console.log(data.value); // {...} data returned from server
```
12 changes: 4 additions & 8 deletions src/composables/useFetch.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ beforeEach(() => {
describe('typical uses', () => {
test('GET 200 request', async () => {
const url = ref('http://mock/get/status200');
const {data, error, isLoading, fetch} = useFetch(url, {
const {data, isLoading, fetch} = useFetch(url, {
query: {param1: 4, param2: 5},
});

Expand All @@ -105,7 +105,6 @@ describe('typical uses', () => {
await fetchPromise;

expect(isLoading.value).toBe(false);
expect(error.value).toBe(null);
expect(data.value).toMatchInlineSnapshot(`
{
"items": [
Expand All @@ -127,23 +126,22 @@ describe('typical uses', () => {
test('POST 200 request', async () => {
const body = {title: 'abc'};
const url = ref('http://mock/post/status200');
const {data, error, validationError, fetch} = useFetch(url, {
const {data, validationError, fetch} = useFetch(url, {
method: 'POST',
body,
expectValidationError: true,
});

await fetch();

expect(error.value).toBe(null);
expect(validationError.value).toBe(null);

expect(data.value).toStrictEqual(body);
});

test('POST 400 validation error request', async () => {
const url = ref('http://mock/post/status400');
const {data, error, validationError, fetch} = useFetch(url, {
const {data, validationError, fetch} = useFetch(url, {
method: 'POST',
body: {title: 'abc'},
headers: {'X-Csrf-Token': '___TOKEN___'},
Expand All @@ -152,7 +150,6 @@ describe('typical uses', () => {

await fetch();

expect(error.value).toBe(null);
expect(validationError.value).toStrictEqual({
title: ['has to be longer'],
});
Expand All @@ -162,15 +159,14 @@ describe('typical uses', () => {

test('PUT 200 request', async () => {
const url = ref('http://mock/put/status200');
const {data, error, validationError, fetch} = useFetch(url, {
const {data, validationError, fetch} = useFetch(url, {
method: 'PUT',
body: {title: 'abc'},
expectValidationError: true,
});

await fetch();

expect(error.value).toBe(null);
expect(validationError.value).toBe(null);
expect(data.value).toMatchInlineSnapshot(`
{
Expand Down
22 changes: 20 additions & 2 deletions src/composables/useFetchPaginated.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,24 @@ import {ref, computed} from 'vue';

import {useFetch} from './useFetch';

/**
* Composable for handling paginated API requests.
* Same options as for useFetch, only additional pagination related options are listed
*
* @exports
* @function useFetchPaginated
* @param {string} url - The URL to which the HTTP request is to be sent. This should be the endpoint for the paginated data.
* @param {Object} options - Configuration options, check useFetch.js for more options.
* @param {number} options.page - The current page number. This is normalized internally to a reactive ref.
* @param {number} options.pageSize - The number of items per page. This is also normalized to a reactive ref.
*
* @returns {Object} An object containing several reactive properties and a method for performing the fetch operation:
* @returns {ComputedRef<Array>} return.items - A computed ref containing the items for the current page.
* @returns {Ref<boolean>} return.isLoading - A ref object indicating whether the fetch operation is currently in progress.
* @returns {ComputedRef<Object>} return.pagination - A computed ref providing pagination details like page number, page size, total items, etc.
* @returns {Function} return.fetch - The function to initiate the fetch operation. This function is async and incorporates the pagination logic.
*
*/
export function useFetchPaginated(url, options) {
const {
page: _page,
Expand All @@ -28,8 +46,8 @@ export function useFetchPaginated(url, options) {
query: useFetchQuery,
});

const items = computed(() => data.value?.items);
const itemCount = computed(() => data.value?.itemsMax);
const items = computed(() => data.value?.items || []);
const itemCount = computed(() => data.value?.itemsMax || 0);

const pagination = computed(() => {
const firstItemIndex = itemCount.value ? offset.value + 1 : 0;
Expand Down
82 changes: 82 additions & 0 deletions src/composables/useFetchPaginated.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import {Meta} from '@storybook/blocks';

<Meta title="Composables/useFetchPaginated" />

# useFetchPaginated

`useFetchPaginated` is based on `useFetch` and provides additional features for pagination.

## API

For detailed list of options please check `useFetchPaginated.js`. For common use cases following example should provide enough guidance.

## Examples

All examples can be found also in `useFetchPaginated.test.js`.

### GET 200

```javascript
const url = ref(submissionsGetApiUrl);
const page = ref(1);
const pageSize = ref(5);
const {items, pagination, isLoading, fetch} = useFetchPaginated(url, {
query: {param1: 4, param2: 5},
page,
pageSize,
});

console.log(isLoading.value); // false
console.log(items.value); // []
console.log(pagination.value);
/* {
"firstItemIndex": 0,
"itemCount": 0,
"lastItemIndex": 0,
"offset": 0,
"page": 1,
"pageCount": 0,
"pageSize": 5,
}
*/

await fetch();

console.log(items.value); // [{...},{...},{...},{...},{...}]
console.log(pagination.value);
/*
{
"firstItemIndex": 1,
"itemCount": 11,
"lastItemIndex": 5,
"offset": 0,
"page": 1,
"pageCount": 3,
"pageSize": 5,
}
*/

// based on user interaction, changing to second page and changing pageSize to 3
page.value = 2;
pageSize.value = 3;

// intentionally separate execution of fetch and waiting for result to illustrate isLoading=true

const fetchPromise = fetch();
console.log(isLoading.value); // true
await fetchPromise();

console.log(items.value); // [{...}, {...}, {...}]
console.log(pagination.value);
/*
{
"firstItemIndex": 4,
"itemCount": 11,
"lastItemIndex": 6,
"offset": 3,
"page": 2,
"pageCount": 4,
"pageSize": 3,
}
*/
```
Loading

0 comments on commit 716c15a

Please sign in to comment.