diff --git a/.github/workflows/chromatic.yml b/.github/workflows/chromatic.yml index b0a723a98..41d99e8ae 100644 --- a/.github/workflows/chromatic.yml +++ b/.github/workflows/chromatic.yml @@ -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: diff --git a/src/composables/useFetch.js b/src/composables/useFetch.js index 00663a759..4ec8906b5 100644 --- a/src/composables/useFetch.js +++ b/src/composables/useFetch.js @@ -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} return.data - A ref object containing the response data from the fetch operation. + * @returns {Ref} return.validationError - A ref object containing validation error data, relevant when `expectValidationError` is true. + * @returns {Ref} 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 @@ -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; @@ -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; @@ -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; @@ -105,7 +120,6 @@ export function useFetch(url, options = {}) { return { data, - error, validationError, isLoading, fetch, diff --git a/src/composables/useFetch.mdx b/src/composables/useFetch.mdx new file mode 100644 index 000000000..445bc05fe --- /dev/null +++ b/src/composables/useFetch.mdx @@ -0,0 +1,110 @@ +import {Meta} from '@storybook/blocks'; + + + +# 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 +``` diff --git a/src/composables/useFetch.test.js b/src/composables/useFetch.test.js index bbacfe4ee..5f9333697 100644 --- a/src/composables/useFetch.test.js +++ b/src/composables/useFetch.test.js @@ -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}, }); @@ -105,7 +105,6 @@ describe('typical uses', () => { await fetchPromise; expect(isLoading.value).toBe(false); - expect(error.value).toBe(null); expect(data.value).toMatchInlineSnapshot(` { "items": [ @@ -127,7 +126,7 @@ 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, @@ -135,7 +134,6 @@ describe('typical uses', () => { await fetch(); - expect(error.value).toBe(null); expect(validationError.value).toBe(null); expect(data.value).toStrictEqual(body); @@ -143,7 +141,7 @@ describe('typical uses', () => { 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___'}, @@ -152,7 +150,6 @@ describe('typical uses', () => { await fetch(); - expect(error.value).toBe(null); expect(validationError.value).toStrictEqual({ title: ['has to be longer'], }); @@ -162,7 +159,7 @@ 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, @@ -170,7 +167,6 @@ describe('typical uses', () => { await fetch(); - expect(error.value).toBe(null); expect(validationError.value).toBe(null); expect(data.value).toMatchInlineSnapshot(` { diff --git a/src/composables/useFetchPaginated.js b/src/composables/useFetchPaginated.js index 99fa01504..a88e415f9 100644 --- a/src/composables/useFetchPaginated.js +++ b/src/composables/useFetchPaginated.js @@ -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} return.items - A computed ref containing the items for the current page. + * @returns {Ref} return.isLoading - A ref object indicating whether the fetch operation is currently in progress. + * @returns {ComputedRef} 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, @@ -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; diff --git a/src/composables/useFetchPaginated.mdx b/src/composables/useFetchPaginated.mdx new file mode 100644 index 000000000..6325d2c61 --- /dev/null +++ b/src/composables/useFetchPaginated.mdx @@ -0,0 +1,82 @@ +import {Meta} from '@storybook/blocks'; + + + +# 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, +} +*/ +``` diff --git a/src/composables/useFetchPaginated.test.js b/src/composables/useFetchPaginated.test.js index 46a397724..451cd9619 100644 --- a/src/composables/useFetchPaginated.test.js +++ b/src/composables/useFetchPaginated.test.js @@ -38,9 +38,7 @@ export const restHandlers = [ const count = parseInt(params.get('count')); const offset = parseInt(params.get('offset')); - console.log('params:', count, offset, offset + count); const itemsSubset = items.slice(offset, offset + count); - console.log('itesmsubset:', itemsSubset); return HttpResponse.json({ itemsMax: items.length, items: itemsSubset, @@ -75,6 +73,17 @@ describe('typical uses', () => { }); expect(isLoading.value).toBe(false); + expect(pagination.value).toMatchInlineSnapshot(` + { + "firstItemIndex": 0, + "itemCount": 0, + "lastItemIndex": 0, + "offset": 0, + "page": 1, + "pageCount": 0, + "pageSize": 5, + } + `); const fetchPromise = fetch(); await fetchPromise;