From 88765854f4884efc971c931e6d6ed7758c93cb7f Mon Sep 17 00:00:00 2001 From: Convly Date: Wed, 9 Apr 2025 15:18:24 +0200 Subject: [PATCH 1/2] enhancement(demo): integrate toast notifications in React Vite demo --- demo/react-vite/package.json | 1 + demo/react-vite/pnpm-lock.yaml | 26 ++++++++++++++++++++ demo/react-vite/src/app.tsx | 24 ++++++++++-------- demo/react-vite/src/hooks/useFiles.ts | 4 ++- demo/react-vite/src/pages/CollectionDemo.tsx | 4 ++- 5 files changed, 47 insertions(+), 12 deletions(-) diff --git a/demo/react-vite/package.json b/demo/react-vite/package.json index a82fdea..ab998e6 100644 --- a/demo/react-vite/package.json +++ b/demo/react-vite/package.json @@ -15,6 +15,7 @@ "framer-motion": "^12.6.3", "react": "^19.0.0", "react-dom": "^19.0.0", + "react-hot-toast": "^2.5.2", "react-router-dom": "^7.5.0", "tailwindcss": "^4.1.3" }, diff --git a/demo/react-vite/pnpm-lock.yaml b/demo/react-vite/pnpm-lock.yaml index ce9bef6..d857990 100644 --- a/demo/react-vite/pnpm-lock.yaml +++ b/demo/react-vite/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: react-dom: specifier: ^19.0.0 version: 19.1.0(react@19.1.0) + react-hot-toast: + specifier: ^2.5.2 + version: 2.5.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react-router-dom: specifier: ^7.5.0 version: 7.5.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -890,6 +893,11 @@ packages: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} engines: {node: '>=18'} + goober@2.1.16: + resolution: {integrity: sha512-erjk19y1U33+XAMe1VTvIONHYoSqE4iS7BYUZfHaqeohLmnC0FdxEh7rQU+6MZ4OajItzjZFSRtVANrQwNq6/g==} + peerDependencies: + csstype: ^3.0.10 + graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} @@ -1124,6 +1132,13 @@ packages: peerDependencies: react: ^19.1.0 + react-hot-toast@2.5.2: + resolution: {integrity: sha512-Tun3BbCxzmXXM7C+NI4qiv6lT0uwGh4oAfeJyNOjYUejTsm35mK9iCaYLGv8cBz9L5YxZLx/2ii7zsIwPtPUdw==} + engines: {node: '>=10'} + peerDependencies: + react: '>=16' + react-dom: '>=16' + react-refresh@0.14.2: resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==} engines: {node: '>=0.10.0'} @@ -2095,6 +2110,10 @@ snapshots: globals@14.0.0: {} + goober@2.1.16(csstype@3.1.3): + dependencies: + csstype: 3.1.3 + graceful-fs@4.2.11: {} graphemer@1.4.0: {} @@ -2277,6 +2296,13 @@ snapshots: react: 19.1.0 scheduler: 0.26.0 + react-hot-toast@2.5.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + dependencies: + csstype: 3.1.3 + goober: 2.1.16(csstype@3.1.3) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + react-refresh@0.14.2: {} react-router-dom@7.5.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0): diff --git a/demo/react-vite/src/app.tsx b/demo/react-vite/src/app.tsx index e001e02..4c1f43f 100644 --- a/demo/react-vite/src/app.tsx +++ b/demo/react-vite/src/app.tsx @@ -1,17 +1,21 @@ -import { BrowserRouter as Router, Route, Routes } from 'react-router-dom'; - -import { Home } from '@/pages/Home.tsx'; import { CollectionDemo } from '@/pages/CollectionDemo.tsx'; import { FilesDemo } from '@/pages/FilesDemo.tsx'; +import { Home } from '@/pages/Home.tsx'; +import { Toaster } from 'react-hot-toast'; +import { BrowserRouter as Router, Route, Routes } from 'react-router-dom'; + export default function App() { return ( - - - } /> - } /> - } /> - - + <> + + + } /> + } /> + } /> + + + + ); } diff --git a/demo/react-vite/src/hooks/useFiles.ts b/demo/react-vite/src/hooks/useFiles.ts index cc75bfe..51fa408 100644 --- a/demo/react-vite/src/hooks/useFiles.ts +++ b/demo/react-vite/src/hooks/useFiles.ts @@ -2,6 +2,7 @@ import { useStrapi } from '@/hooks/useStrapi.ts'; import type { File } from '@/types.ts'; import React from 'react'; +import toast from 'react-hot-toast'; export const useFiles = () => { const strapi = useStrapi(); @@ -11,8 +12,9 @@ export const useFiles = () => { try { const response = await strapi.files.find(); setFiles(response); + toast.success(`${response.length} files fetched successfully`); } catch (error) { - console.error('Error fetching files:', error); + toast.error(error instanceof Error ? error.message : `${error}`); } }; diff --git a/demo/react-vite/src/pages/CollectionDemo.tsx b/demo/react-vite/src/pages/CollectionDemo.tsx index fb43d11..f0845ce 100644 --- a/demo/react-vite/src/pages/CollectionDemo.tsx +++ b/demo/react-vite/src/pages/CollectionDemo.tsx @@ -7,6 +7,7 @@ import { Layout } from '@/layouts/Layout.tsx'; import type { Category, QueryParam } from '@/types.ts'; import { DEFAULT_COLLECTION_QUERIES } from '@/utils/constants.ts'; import React, { useEffect, useState } from 'react'; +import toast from 'react-hot-toast'; export const CollectionDemo: React.FC = () => { const categories = useCollection('categories'); @@ -31,8 +32,9 @@ export const CollectionDemo: React.FC = () => { try { const { data } = await categories.find(query); setDocuments(data as unknown as Category[]); + toast.success(`${data.length} categories fetched successfully`); } catch (error) { - console.error('Error fetching categories:', error); + toast.error(error instanceof Error ? error.message : `${error}`); } }; From 57a55941d5c8ad45960d097b2948c6ffd9948dcf Mon Sep 17 00:00:00 2001 From: Convly Date: Wed, 9 Apr 2025 16:30:21 +0200 Subject: [PATCH 2/2] feat(content-types): allow custom root paths for content-type managers --- README.md | 54 ++-- src/client.ts | 37 ++- src/content-types/abstract.ts | 65 +++++ src/content-types/collection/manager.ts | 46 ++-- src/content-types/single/manager.ts | 34 ++- tests/unit/client.test.ts | 4 +- .../collection/collection-manager.test.ts | 241 ++++++++++++------ .../collection/single-manager.test.ts | 154 +++++++---- 8 files changed, 451 insertions(+), 184 deletions(-) create mode 100644 src/content-types/abstract.ts diff --git a/README.md b/README.md index bfad287..3b4f382 100644 --- a/README.md +++ b/README.md @@ -40,8 +40,8 @@ - [API Token Authentication](#api-token-authentication) 3. [API Reference](#-api-reference) 4. [Resource Managers](#-resource-managers) - - [`.collection()`](#collectionresource) - - [`.single()`](#singleresource) + - [`.collection()`](#collectionresource-options) + - [`.single()`](#singleresource-options) - [`.files`](#files) 5. [Debug](#-debug) 6. [Demo Projects](#-demo-projects) @@ -123,18 +123,23 @@ const client = strapi({ The Strapi client library instance provides key properties and utility methods for content and API interaction: - **`baseURL`**: base URL of your Strapi backend. -- **`fetch`**: perform generic requests to the Strapi Content API using fetch-like syntax. -- **`.collection(resource: string)`**: get a manager instance for handling collection-type resources. -- **`.single(resource: string)`**: get a manager instance for handling single-type resources. +- **`fetch()`**: perform generic requests to the Strapi Content API using fetch-like syntax. +- **`collection()`**: get a manager instance for handling collection-type resources. +- **`single()`**: get a manager instance for handling single-type resources. +- **`files`**: access the files manager instance for handling common files operations. ## 📁 Resource Managers -### `.collection(resource)` +### `.collection(resource, [options])` The `.collection()` method provides a manager for working with collection-type resources, which can have multiple entries. -**Note**: the `resource` corresponds to the plural name of your collection type, as defined in the Strapi model. +#### Params + +- `resource`: `string` - plural name of your collection type, as defined in the Strapi model +- `[options]`: `object` - additional options to pass to the collection type manager + - `[path]`: `string` - optional root path override for the manager's queries #### Available Methods: @@ -168,11 +173,21 @@ const updatedArticle = await articles.update('article-document-id', { title: 'Up await articles.delete('article-id'); ``` -### `.single(resource)` +You can also customize the root path for requests by providing a value for the `path` option: + +```typescript +const articles = client.collection('articles', { path: '/my-custom-path' }); +``` + +### `.single(resource, [options])` The `.single()` method provides a manager for working with single-type resources, which have only one entry. -**Note**: the `resource` corresponds to the singular name of your collection type, as defined in the Strapi model. +#### Params + +- `resource`: `string` - singular name of your single type, as defined in the Strapi model +- `[options]`: `object` - additional options to pass to the single type manager + - `[path]`: `string` - optional root path override for the manager's queries #### Available Methods: @@ -201,9 +216,15 @@ const updatedHomepage = await homepage.update( await homepage.delete(); ``` -### .files +You can also customize the root path for requests by providing a value for the `path` option: -The `files` property provides access to the Strapi Media Library through the Upload plugin. It allows you to retrieve files metadata without directly interacting with the REST API. +```typescript +const homepage = client.single('homepage', { path: '/my-custom-path' }); +``` + +### `.files` + +The `files` property provides access to the Strapi Media Library through the Upload plugin. It allows you to retrieve files metadata without directly interacting with the REST API manually. #### Methods @@ -212,7 +233,9 @@ The `files` property provides access to the Strapi Media Library through the Upl - `update(fileId: number, fileInfo: FileUpdateData): Promise` - Updates metadata for an existing file - `delete(fileId: number): Promise` - Deletes a file by its ID -#### Example: Finding Files +#### Examples + +**Finding all files** ```typescript // Initialize the client @@ -235,7 +258,7 @@ const imageFiles = await client.files.find({ }); ``` -#### Example: Finding a Single File +**Finding a Single File** ```typescript // Initialize the client @@ -252,7 +275,7 @@ console.log(file.url); // The file URL console.log(file.mime); // The file MIME type ``` -#### Example: Updating File Metadata +**Updating File Metadata** ```typescript // Initialize the client @@ -272,7 +295,7 @@ console.log(updatedFile.name); // Updated file name console.log(updatedFile.alternativeText); // Updated alt text ``` -#### Example: Deleting a File +**Deleting a File** ```typescript // Initialize the client @@ -353,6 +376,7 @@ This repository includes demo projects located in the `/demo` directory to help - **`demo/node-typescript`**: a Node.js project using TypeScript. - **`demo/node-javascript`**: a Node.js project using JavaScript. - **`demo/next-server-components`**: a Next.js project using TypeScript and server components. +- **`demo/react-vite`**: a React project using Vite and TypeScript ### Using Demo Commands diff --git a/src/client.ts b/src/client.ts index 7989858..a91ed88 100644 --- a/src/client.ts +++ b/src/client.ts @@ -8,6 +8,7 @@ import { HttpClient } from './http'; import { AuthInterceptors, HttpInterceptors } from './interceptors'; import { StrapiConfigValidator } from './validators'; +import type { ContentTypeManagerOptions } from './content-types/abstract'; import type { HttpClientConfig } from './http'; const debug = createDebug('strapi:core'); @@ -303,10 +304,11 @@ export class StrapiClient { * * This instance provides methods for performing operations on the associated documents: create, read, update, delete. * - * @param resource - The plural name of the collection to interact with. - * This should match the collection name as defined in the Strapi app. + * @param resource - The plural name of the collection to interact with. + * This should match the collection name as defined in the Strapi app. + * @param [options] - Optional parameter to specify additional configuration such as custom API path. * - * @returns An instance of {@link CollectionTypeManager} targeting the given {@link resource} name. + * @returns An instance of {@link CollectionTypeManager} for the given {@link resource}. * * @example * ```typescript @@ -314,7 +316,7 @@ export class StrapiClient { * const config = { baseURL: 'http://localhost:1337/api' }; * const client = new Strapi(config); * - * // Retrieve a CollectionTypeManager for the 'articles' resource + * // Retrieve a CollectionTypeManager for the 'articles' collection * const articles = client.collection('articles'); * * // Example: find all articles @@ -331,13 +333,19 @@ export class StrapiClient { * * // Example: delete an article * await articles.delete('dde61ffb-00a6-4cc7-a61f-fb00a63cc740'); + * + * // Example with a custom API path + * const customArticles = client.collection('articles', { path: '/custom-articles-path' }); + * const customAllArticles = await customArticles.find(); * ``` * * @see CollectionTypeManager * @see StrapiClient */ - collection(resource: string) { - return new CollectionTypeManager(resource, this._httpClient); + collection(resource: string, options: ClientCollectionOptions = {}) { + const { path } = options; + + return new CollectionTypeManager({ resource, path }, this._httpClient); } /** @@ -347,8 +355,9 @@ export class StrapiClient { * * @param resource - The singular name of the single-type resource to interact with. * This should match the single-type name as defined in the Strapi app. + * @param [options] - Optional parameter to specify additional configuration such as custom API path. * - * @returns An instance of {@link SingleTypeManager} targeting the given {@link resource} name. + * @returns An instance of {@link SingleTypeManager} for the given {@link resource}. * * @example * ```typescript @@ -366,12 +375,22 @@ export class StrapiClient { * * // Example: delete the homepage content * await homepage.delete(); + * + * // Example with a custom API path + * const customHomepage = client.single('homepage', { path: '/custom-homepage-path' }); + * const customHomepageDocument = await customHomepage.find(); * ``` * * @see SingleTypeManager * @see StrapiClient */ - single(resource: string) { - return new SingleTypeManager(resource, this._httpClient); + single(resource: string, options: SingleCollectionOptions = {}) { + const { path } = options; + + return new SingleTypeManager({ resource, path }, this._httpClient); } } + +// Local Client Types +export type ClientCollectionOptions = Pick; +export type SingleCollectionOptions = Pick; diff --git a/src/content-types/abstract.ts b/src/content-types/abstract.ts new file mode 100644 index 0000000..ba606b9 --- /dev/null +++ b/src/content-types/abstract.ts @@ -0,0 +1,65 @@ +import { HttpClient } from '../http'; + +/** + * Options to configure a content-type manager. + */ +export interface ContentTypeManagerOptions { + /** + * The name of the resource this manager handles. + */ + resource: string; + + /** + * Optional path override for the resource. + * + * If not provided, the resource name is used to construct the path. + */ + path?: string; +} + +/** + * Abstract base class for managing content types. + */ +export abstract class AbstractContentTypeManager { + /** + * Configuration options for the content-type manager. + */ + protected readonly _options: ContentTypeManagerOptions; + + /** + * HTTP client instance for communicating with the Strapi app. + */ + protected readonly _httpClient: HttpClient; + + protected constructor(options: ContentTypeManagerOptions, httpClient: HttpClient) { + this._options = options; + this._httpClient = httpClient; + } + + /** + * Gets the resource name for this manager. + */ + protected get _resource() { + return this._options.resource; + } + + /** + * Gets the configured path for this manager. + * + * Returns `undefined` if no path is explicitly set in the options. + */ + protected get _path() { + return this._options.path; + } + + /** + * Gets the root path for the resource. + * + * If a custom path is configured, it returns that value. + * + * Otherwise, it defaults to `/`. + */ + protected get _rootPath() { + return this._path ?? `/${this._resource}`; + } +} diff --git a/src/content-types/collection/manager.ts b/src/content-types/collection/manager.ts index 9880f44..b33bc4a 100644 --- a/src/content-types/collection/manager.ts +++ b/src/content-types/collection/manager.ts @@ -2,8 +2,10 @@ import createDebug from 'debug'; import { HttpClient } from '../../http'; import { URLHelper } from '../../utilities'; +import { AbstractContentTypeManager } from '../abstract'; import type * as API from '../../types/content-api'; +import type { ContentTypeManagerOptions } from '../abstract'; const debug = createDebug('strapi:ct:collection'); @@ -17,14 +19,11 @@ const debug = createDebug('strapi:ct:collection'); * - All operations use the resource's plural name to construct the API endpoint. * - It also supports optional query parameters for filtering, sorting, pagination, etc. */ -export class CollectionTypeManager { - private readonly _pluralName: string; - private readonly _httpClient: HttpClient; - +export class CollectionTypeManager extends AbstractContentTypeManager { /** * Creates an instance of {@link CollectionTypeManager}`. * - * @param pluralName - The singular name of the single-type resource as defined in the Strapi app. + * @param options - Configuration options, including the plural name of the resource as defined in the Strapi app. * @param httpClient - An instance of {@link HttpClient} to handle HTTP communication. * * @example @@ -33,11 +32,10 @@ export class CollectionTypeManager { * const articlesManager = new CollectionTypeManager('articles', httpClient); * ``` */ - constructor(pluralName: string, httpClient: HttpClient) { - this._pluralName = pluralName; - this._httpClient = httpClient; + constructor(options: ContentTypeManagerOptions, httpClient: HttpClient) { + super(options, httpClient); - debug('initialized manager for %o', pluralName); + debug('initialized a new "collection" manager with %o', options); } /** @@ -62,9 +60,9 @@ export class CollectionTypeManager { * ``` */ async find(queryParams?: API.BaseQueryParams): Promise { - debug('finding documents for %o', this._pluralName); + debug('finding documents for %o', this._resource); - let url = `/${this._pluralName}`; + let url = this._rootPath; if (queryParams) { url = URLHelper.appendQueryParams(url, queryParams); @@ -73,7 +71,7 @@ export class CollectionTypeManager { const response = await this._httpClient.get(url); const json = await response.json(); - debug('found %o %o documents', Number(json?.data?.length), this._pluralName); + debug('found %o %o documents', Number(json?.data?.length), this._resource); return json; } @@ -104,9 +102,9 @@ export class CollectionTypeManager { documentID: string, queryParams?: API.BaseQueryParams ): Promise { - debug('finding a document for %o with id: %o', this._pluralName, documentID); + debug('finding a document for %o with id: %o', this._resource, documentID); - let url = `/${this._pluralName}/${documentID}`; + let url = `${this._rootPath}/${documentID}`; if (queryParams) { url = URLHelper.appendQueryParams(url, queryParams); @@ -114,7 +112,7 @@ export class CollectionTypeManager { const response = await this._httpClient.get(url); - debug('found the %o document with document id %o', this._pluralName, documentID); + debug('found the %o document with document id %o', this._resource, documentID); return response.json(); } @@ -141,9 +139,9 @@ export class CollectionTypeManager { data: Record, queryParams?: API.BaseQueryParams ): Promise { - debug('creating a document for %o', this._pluralName); + debug('creating a document for %o', this._resource); - let url = `/${this._pluralName}`; + let url = this._rootPath; if (queryParams) { url = URLHelper.appendQueryParams(url, queryParams); @@ -157,7 +155,7 @@ export class CollectionTypeManager { { headers: { 'Content-Type': 'application/json' } } ); - debug('created the %o document', this._pluralName); + debug('created the %o document', this._resource); return response.json(); } @@ -193,9 +191,9 @@ export class CollectionTypeManager { data: Record, queryParams?: API.BaseQueryParams ): Promise { - debug('updating a document for %o with id: %o', this._pluralName, documentID); + debug('updating a document for %o with id: %o', this._resource, documentID); - let url = `/${this._pluralName}/${documentID}`; + let url = `${this._rootPath}/${documentID}`; if (queryParams) { url = URLHelper.appendQueryParams(url, queryParams); @@ -209,7 +207,7 @@ export class CollectionTypeManager { { headers: { 'Content-Type': 'application/json' } } ); - debug('updated the %o document with id %o', this._pluralName, documentID); + debug('updated the %o document with id %o', this._resource, documentID); return response.json(); } @@ -238,9 +236,9 @@ export class CollectionTypeManager { * ``` */ async delete(documentID: string, queryParams?: API.BaseQueryParams): Promise { - debug('deleting a document for %o with id: %o', this._pluralName, documentID); + debug('deleting a document for %o with id: %o', this._resource, documentID); - let url = `/${this._pluralName}/${documentID}`; + let url = `${this._rootPath}/${documentID}`; if (queryParams) { url = URLHelper.appendQueryParams(url, queryParams); @@ -248,6 +246,6 @@ export class CollectionTypeManager { await this._httpClient.delete(url); - debug('deleted the %o document with id %o', this._pluralName, documentID); + debug('deleted the %o document with id %o', this._resource, documentID); } } diff --git a/src/content-types/single/manager.ts b/src/content-types/single/manager.ts index 355e788..4317918 100644 --- a/src/content-types/single/manager.ts +++ b/src/content-types/single/manager.ts @@ -2,8 +2,10 @@ import createDebug from 'debug'; import { HttpClient } from '../../http'; import { URLHelper } from '../../utilities'; +import { AbstractContentTypeManager } from '../abstract'; import type * as API from '../../types/content-api'; +import type { ContentTypeManagerOptions } from '../abstract'; const debug = createDebug('strapi:ct:single'); @@ -17,14 +19,11 @@ const debug = createDebug('strapi:ct:single'); * - All operations use the resource's singular name to construct the API endpoint. * - It also supports optional query parameters for filtering, sorting, pagination, etc. */ -export class SingleTypeManager { - private readonly _singularName: string; - private readonly _httpClient: HttpClient; - +export class SingleTypeManager extends AbstractContentTypeManager { /** * Creates an instance of {@link SingleTypeManager}. * - * @param singularName - The singular name of the single-type resource as defined in the Strapi app. + * @param options - Configuration options, including the singular name of the resource as defined in the Strapi app. * @param httpClient - An instance of {@link HttpClient} to handle HTTP communication. * * @example @@ -33,11 +32,10 @@ export class SingleTypeManager { * const homepageManager = new SingleTypeManager('homepage', httpClient); * ``` */ - constructor(singularName: string, httpClient: HttpClient) { - this._singularName = singularName; - this._httpClient = httpClient; + constructor(options: ContentTypeManagerOptions, httpClient: HttpClient) { + super(options, httpClient); - debug('initialized manager for %o', singularName); + debug('initialized a new "single" manager with %o', options); } /** @@ -62,9 +60,9 @@ export class SingleTypeManager { * ``` */ async find(queryParams?: API.BaseQueryParams): Promise { - debug('finding document for %o', this._singularName); + debug('finding document for %o', this._resource); - let path = `/${this._singularName}`; + let path = this._rootPath; if (queryParams) { path = URLHelper.appendQueryParams(path, queryParams); @@ -72,7 +70,7 @@ export class SingleTypeManager { const response = await this._httpClient.get(path); - debug('the %o document has been fetched', this._singularName); + debug('the %o document has been fetched', this._resource); return response.json(); } @@ -106,9 +104,9 @@ export class SingleTypeManager { data: Record, queryParams?: API.BaseQueryParams ): Promise { - debug('updating document for %o', this._singularName); + debug('updating document for %o', this._resource); - let url = `/${this._singularName}`; + let url = this._rootPath; if (queryParams) { url = URLHelper.appendQueryParams(url, queryParams); @@ -122,7 +120,7 @@ export class SingleTypeManager { { headers: { 'Content-Type': 'application/json' } } ); - debug('the %o document has been updated', this._singularName); + debug('the %o document has been updated', this._resource); return response.json(); } @@ -152,9 +150,9 @@ export class SingleTypeManager { * @see URLHelper.appendQueryParams */ async delete(queryParams?: API.BaseQueryParams): Promise { - debug('deleting document for %o', this._singularName); + debug('deleting document for %o', this._resource); - let url = `/${this._singularName}`; + let url = this._rootPath; if (queryParams) { url = URLHelper.appendQueryParams(url, queryParams); @@ -162,6 +160,6 @@ export class SingleTypeManager { await this._httpClient.delete(url); - debug('the %o document has been deleted', this._singularName); + debug('the %o document has been deleted', this._resource); } } diff --git a/tests/unit/client.test.ts b/tests/unit/client.test.ts index 4f3139b..92a2db8 100644 --- a/tests/unit/client.test.ts +++ b/tests/unit/client.test.ts @@ -192,7 +192,7 @@ describe('Strapi', () => { // Assert expect(collection).toBeInstanceOf(CollectionTypeManager); - expect(collection).toHaveProperty('_pluralName', resource); + expect(collection).toHaveProperty('_options', { resource }); }); }); @@ -217,7 +217,7 @@ describe('Strapi', () => { // Assert expect(single).toBeInstanceOf(SingleTypeManager); - expect(single).toHaveProperty('_singularName', resource); + expect(single).toHaveProperty('_options', { resource }); }); }); diff --git a/tests/unit/content-types/collection/collection-manager.test.ts b/tests/unit/content-types/collection/collection-manager.test.ts index 757a8c4..50ceb66 100644 --- a/tests/unit/content-types/collection/collection-manager.test.ts +++ b/tests/unit/content-types/collection/collection-manager.test.ts @@ -3,7 +3,6 @@ import { MockHttpClient } from '../../mocks'; describe('CollectionTypeManager CRUD Methods', () => { const mockHttpClient = new MockHttpClient({ baseURL: 'http://localhost:1337' }); - const collectionManager = new CollectionTypeManager('articles', mockHttpClient); beforeEach(() => { jest @@ -20,6 +19,8 @@ describe('CollectionTypeManager CRUD Methods', () => { }); it('should return an object with CRUD methods for a collection type', () => { + const collectionManager = new CollectionTypeManager({ resource: 'articles' }, mockHttpClient); + expect(collectionManager).toHaveProperty('find', expect.any(Function)); expect(collectionManager).toHaveProperty('findOne', expect.any(Function)); expect(collectionManager).toHaveProperty('create', expect.any(Function)); @@ -27,95 +28,189 @@ describe('CollectionTypeManager CRUD Methods', () => { expect(collectionManager).toHaveProperty('delete', expect.any(Function)); }); - it('should append complex query params correctly in find method', async () => { - // Arrange - const expected = - '/articles?locale=en&populate=author&fields%5B0%5D=title&fields%5B1%5D=description&filters%5Bpublished%5D=true&sort=createdAt%3Adesc&pagination%5Bpage%5D=1&pagination%5BpageSize%5D=10'; - const requestSpy = jest.spyOn(MockHttpClient.prototype, 'request'); + describe('Simple Config', () => { + let collectionManager: CollectionTypeManager; - jest - .spyOn(MockHttpClient.prototype, 'request') - .mockImplementationOnce(() => + beforeEach(() => { + collectionManager = new CollectionTypeManager({ resource: 'articles' }, mockHttpClient); + }); + + it('should properly set collection and path properties', () => { + expect(collectionManager).toHaveProperty('_resource', 'articles'); + expect(collectionManager).toHaveProperty('_path', undefined); + expect(collectionManager).toHaveProperty('_rootPath', '/articles'); + }); + + it('should append complex query params correctly in find method', async () => { + // Arrange + const expected = + '/articles?locale=en&populate=author&fields%5B0%5D=title&fields%5B1%5D=description&filters%5Bpublished%5D=true&sort=createdAt%3Adesc&pagination%5Bpage%5D=1&pagination%5BpageSize%5D=10'; + + jest.spyOn(MockHttpClient.prototype, 'request').mockImplementationOnce(() => Promise.resolve( - new Response(JSON.stringify({ data: [{ id: 1 }, { id: 2 }], meta: {} }), { status: 200 }) + new Response(JSON.stringify({ data: [{ id: 1 }, { id: 2 }], meta: {} }), { + status: 200, + }) ) ); - // Act - await collectionManager.find({ - locale: 'en', - populate: 'author', - fields: ['title', 'description'], - filters: { published: true }, - sort: 'createdAt:desc', - pagination: { page: 1, pageSize: 10 }, + // Act + await collectionManager.find({ + locale: 'en', + populate: 'author', + fields: ['title', 'description'], + filters: { published: true }, + sort: 'createdAt:desc', + pagination: { page: 1, pageSize: 10 }, + }); + + // Assert + expect(mockHttpClient.request).toHaveBeenCalledWith(expected, { method: 'GET' }); }); - // Assert - expect(requestSpy).toHaveBeenCalledWith(expected, { method: 'GET' }); - }); + it('should fetch a single document with complex query params in findOne method', async () => { + // Arrange + const expected = + '/articles/1?locale=en&populate=comments&fields%5B0%5D=title&fields%5B1%5D=content'; - it('should fetch a single document with complex query params in findOne method', async () => { - // Arrange - const expected = - '/articles/1?locale=en&populate=comments&fields%5B0%5D=title&fields%5B1%5D=content'; - const requestSpy = jest.spyOn(MockHttpClient.prototype, 'request'); - - // Act - await collectionManager.findOne('1', { - locale: 'en', - populate: 'comments', - fields: ['title', 'content'], + // Act + await collectionManager.findOne('1', { + locale: 'en', + populate: 'comments', + fields: ['title', 'content'], + }); + + // Assert + expect(mockHttpClient.request).toHaveBeenCalledWith(expected, { method: 'GET' }); }); - // Assert - expect(requestSpy).toHaveBeenCalledWith(expected, { method: 'GET' }); - }); + it('should create a new document with create method', async () => { + // Arrange + const payload = { title: 'New Article' }; + + // Act + await collectionManager.create(payload, { locale: 'en' }); + + // Assert + expect(mockHttpClient.request).toHaveBeenCalledWith('/articles?locale=en', { + method: 'POST', + body: JSON.stringify({ data: payload }), + headers: { + 'Content-Type': 'application/json', + }, + }); + }); - it('should create a new document with create method', async () => { - // Arrange - const payload = { title: 'New Article' }; - const requestSpy = jest.spyOn(MockHttpClient.prototype, 'request'); - - // Act - await collectionManager.create(payload, { locale: 'en' }); - - // Assert - expect(requestSpy).toHaveBeenCalledWith('/articles?locale=en', { - method: 'POST', - body: JSON.stringify({ data: payload }), - headers: { - 'Content-Type': 'application/json', - }, + it('should update an existing document with update method', async () => { + // Arrange + const payload = { title: 'Updated Title' }; + + // Act + await collectionManager.update('1', payload, { locale: 'en' }); + + // Assert + expect(mockHttpClient.request).toHaveBeenCalledWith('/articles/1?locale=en', { + method: 'PUT', + body: JSON.stringify({ data: payload }), + headers: { + 'Content-Type': 'application/json', + }, + }); }); - }); - it('should update an existing document with update method', async () => { - // Arrange - const payload = { title: 'Updated Title' }; - const requestSpy = jest.spyOn(MockHttpClient.prototype, 'request'); - - // Act - await collectionManager.update('1', payload, { locale: 'en' }); - - // Assert - expect(requestSpy).toHaveBeenCalledWith('/articles/1?locale=en', { - method: 'PUT', - body: JSON.stringify({ data: payload }), - headers: { - 'Content-Type': 'application/json', - }, + it('should delete a document with delete method', async () => { + // Act + await collectionManager.delete('1', { locale: 'en' }); + + // Assert + expect(mockHttpClient.request).toHaveBeenCalledWith('/articles/1?locale=en', { + method: 'DELETE', + }); }); }); - it('should delete a document with delete method', async () => { - // Arrange - const requestSpy = jest.spyOn(MockHttpClient.prototype, 'request'); + describe('With Custom Path', () => { + const customPath = '/custom-path'; + let collectionManager: CollectionTypeManager; + + beforeEach(() => { + collectionManager = new CollectionTypeManager( + { resource: 'articles', path: customPath }, + mockHttpClient + ); + }); + + it('should properly set collection and path properties', () => { + expect(collectionManager).toHaveProperty('_resource', 'articles'); + expect(collectionManager).toHaveProperty('_path', customPath); + expect(collectionManager).toHaveProperty('_rootPath', customPath); + }); + + it('should use the custom path when using .find', async () => { + // Act + await collectionManager.find(); + + // Assert + expect(mockHttpClient.request).toHaveBeenCalledWith(customPath, { method: 'GET' }); + }); + + it('should use the custom path when using .findOne', async () => { + // Arrange + const id = '42'; + + // Act + await collectionManager.findOne(id); + + // Assert + expect(mockHttpClient.request).toHaveBeenCalledWith(`${customPath}/${id}`, { method: 'GET' }); + }); + + it('should use the custom path when using .create', async () => { + // Arrange + const payload = { foo: 'bar' }; - // Act - await collectionManager.delete('1', { locale: 'en' }); + // Act + await collectionManager.create(payload); - // Assert - expect(requestSpy).toHaveBeenCalledWith('/articles/1?locale=en', { method: 'DELETE' }); + // Assert + expect(mockHttpClient.request).toHaveBeenCalledWith( + `${customPath}`, + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ data: payload }), + }) + ); + }); + + it('should use the custom path when using .update', async () => { + // Arrange + const payload = { foo: 'baz' }; + const id = '42'; + + // Act + await collectionManager.update(id, payload); + + // Assert + expect(mockHttpClient.request).toHaveBeenCalledWith( + `${customPath}/${id}`, + expect.objectContaining({ + method: 'PUT', + body: JSON.stringify({ data: payload }), + }) + ); + }); + + it('should use the custom path when using .delete', async () => { + // Arrange + const id = '42'; + + // Act + await collectionManager.delete(id); + + // Assert + expect(mockHttpClient.request).toHaveBeenCalledWith(`${customPath}/${id}`, { + method: 'DELETE', + }); + }); }); }); diff --git a/tests/unit/content-types/collection/single-manager.test.ts b/tests/unit/content-types/collection/single-manager.test.ts index 444639b..257a0dc 100644 --- a/tests/unit/content-types/collection/single-manager.test.ts +++ b/tests/unit/content-types/collection/single-manager.test.ts @@ -4,8 +4,7 @@ import { MockHttpClient } from '../../mocks'; describe('SingleTypeManager CRUD Methods', () => { const mockHttpClientFactory = (url: string) => new MockHttpClient({ baseURL: url }); const config = { baseURL: 'http://localhost:1337/api' }; - const httpClient = mockHttpClientFactory(config.baseURL); - const singleTypeManager = new SingleTypeManager('homepage', httpClient); + const mockHttpClient = mockHttpClientFactory(config.baseURL); beforeEach(() => { jest @@ -21,55 +20,124 @@ describe('SingleTypeManager CRUD Methods', () => { jest.restoreAllMocks(); }); - it('should return an object with CRUD methods for a single type', () => { - expect(singleTypeManager).toHaveProperty('find', expect.any(Function)); - expect(singleTypeManager).toHaveProperty('update', expect.any(Function)); - expect(singleTypeManager).toHaveProperty('delete', expect.any(Function)); - }); + describe('Simple Config', () => { + let singleTypeManager: SingleTypeManager; - it('should fetch a single document with complex query params in find method', async () => { - // Arrange - const expected = - '/homepage?locale=en&populate=sections&fields%5B0%5D=title&fields%5B1%5D=content'; - const requestSpy = jest.spyOn(MockHttpClient.prototype, 'request'); - - // Act - await singleTypeManager.find({ - locale: 'en', - populate: 'sections', - fields: ['title', 'content'], + beforeEach(() => { + singleTypeManager = new SingleTypeManager({ resource: 'homepage' }, mockHttpClient); }); - // Assert - expect(requestSpy).toHaveBeenCalledWith(expected, { method: 'GET' }); - }); + it('should properly set resource and path properties', () => { + expect(singleTypeManager).toHaveProperty('_resource', 'homepage'); + expect(singleTypeManager).toHaveProperty('_path', undefined); + expect(singleTypeManager).toHaveProperty('_rootPath', '/homepage'); + }); + + it('should return an object with CRUD methods for a single type', () => { + expect(singleTypeManager).toHaveProperty('find', expect.any(Function)); + expect(singleTypeManager).toHaveProperty('update', expect.any(Function)); + expect(singleTypeManager).toHaveProperty('delete', expect.any(Function)); + }); + + it('should fetch a single document with complex query params in find method', async () => { + // Arrange + const expected = + '/homepage?locale=en&populate=sections&fields%5B0%5D=title&fields%5B1%5D=content'; + + // Act + await singleTypeManager.find({ + locale: 'en', + populate: 'sections', + fields: ['title', 'content'], + }); + + // Assert + expect(mockHttpClient.request).toHaveBeenCalledWith(expected, { method: 'GET' }); + }); + + it('should update an existing document with update method', async () => { + // Arrange + const payload = { title: 'Updated Title' }; + + // Act + await singleTypeManager.update(payload, { locale: 'en' }); + + // Assert + expect(mockHttpClient.request).toHaveBeenCalledWith('/homepage?locale=en', { + method: 'PUT', + body: JSON.stringify({ data: payload }), + headers: { + 'Content-Type': 'application/json', + }, + }); + }); + + it('should delete a document with delete method', async () => { + // Act + await singleTypeManager.delete({ locale: 'en' }); - it('should update an existing document with update method', async () => { - // Arrange - const payload = { title: 'Updated Title' }; - const requestSpy = jest.spyOn(MockHttpClient.prototype, 'request'); - - // Act - await singleTypeManager.update(payload, { locale: 'en' }); - - // Assert - expect(requestSpy).toHaveBeenCalledWith('/homepage?locale=en', { - method: 'PUT', - body: JSON.stringify({ data: payload }), - headers: { - 'Content-Type': 'application/json', - }, + // Assert + expect(mockHttpClient.request).toHaveBeenCalledWith('/homepage?locale=en', { + method: 'DELETE', + }); }); }); - it('should delete a document with delete method', async () => { - // Arrange - const requestSpy = jest.spyOn(MockHttpClient.prototype, 'request'); + describe('Custom Path Config', () => { + let singleTypeManager: SingleTypeManager; - // Act - await singleTypeManager.delete({ locale: 'en' }); + beforeEach(() => { + singleTypeManager = new SingleTypeManager( + { + resource: 'homepage', + path: '/custom-homepage', + }, + mockHttpClient + ); + }); + + it('should properly set resource and custom path properties', () => { + expect(singleTypeManager).toHaveProperty('_resource', 'homepage'); + expect(singleTypeManager).toHaveProperty('_path', '/custom-homepage'); + expect(singleTypeManager).toHaveProperty('_rootPath', '/custom-homepage'); + }); + + it('should use custom path when fetching document', async () => { + // Arrange + const expected = '/custom-homepage?locale=en'; + + // Act + await singleTypeManager.find({ locale: 'en' }); + + // Assert + expect(mockHttpClient.request).toHaveBeenCalledWith(expected, { method: 'GET' }); + }); + + it('should use custom path when updating document', async () => { + // Arrange + const payload = { title: 'Updated Title' }; - // Assert - expect(requestSpy).toHaveBeenCalledWith('/homepage?locale=en', { method: 'DELETE' }); + // Act + await singleTypeManager.update(payload, { locale: 'en' }); + + // Assert + expect(mockHttpClient.request).toHaveBeenCalledWith('/custom-homepage?locale=en', { + method: 'PUT', + body: JSON.stringify({ data: payload }), + headers: { + 'Content-Type': 'application/json', + }, + }); + }); + + it('should use custom path when deleting document', async () => { + // Act + await singleTypeManager.delete({ locale: 'en' }); + + // Assert + expect(mockHttpClient.request).toHaveBeenCalledWith('/custom-homepage?locale=en', { + method: 'DELETE', + }); + }); }); });