diff --git a/apps/admin-x-framework/package.json b/apps/admin-x-framework/package.json index e9527bebb8..c37078f8ff 100644 --- a/apps/admin-x-framework/package.json +++ b/apps/admin-x-framework/package.json @@ -7,38 +7,39 @@ "private": true, "exports": { ".": { - "import": "./es/index.js", - "types": "./types/index.d.ts" + "import": "./es/index.js", + "types": "./types/index.d.ts" }, "./errors": { - "import": "./es/errors.js", - "types": "./types/errors.d.ts" + "import": "./es/errors.js", + "types": "./types/errors.d.ts" }, "./helpers": { - "import": "./es/helpers.js", - "types": "./types/helpers.d.ts" + "import": "./es/helpers.js", + "types": "./types/helpers.d.ts" }, "./hooks": { - "import": "./es/hooks.js", - "types": "./types/hooks.d.ts" + "import": "./es/hooks.js", + "types": "./types/hooks.d.ts" }, "./routing": { - "import": "./es/routing.js", - "types": "./types/routing.d.ts" + "import": "./es/routing.js", + "types": "./types/routing.d.ts" }, "./api/*": { - "import": "./es/api/*.js", - "types": "./types/api/*.d.ts" + "import": "./es/api/*.js", + "types": "./types/api/*.d.ts" } }, "sideEffects": false, "scripts": { "build": "vite build && tsc -p tsconfig.declaration.json", "prepare": "yarn build", - "test": "yarn test:types", + "test": "yarn test:types && yarn test:unit", "test:types": "tsc --noEmit", + "test:unit": "vitest run --coverage", "lint:code": "eslint --ext .js,.ts,.cjs,.tsx src/ --cache", - "lint": "yarn lint:code && (yarn lint:test || echo \"TODO ADD TESTS TO LINT\")", + "lint": "yarn lint:code && yarn lint:test", "lint:test": "eslint -c test/.eslintrc.cjs --ext .js,.ts,.cjs,.tsx test/ --cache" }, "files": [ @@ -46,15 +47,17 @@ "types" ], "devDependencies": { + "@testing-library/react": "14.1.0", + "@types/mocha": "10.0.1", "@vitejs/plugin-react": "4.2.0", "c8": "8.0.1", "eslint-plugin-react-hooks": "4.6.0", "eslint-plugin-react-refresh": "0.4.3", "mocha": "10.2.0", + "react": "18.2.0", + "react-dom": "18.2.0", "sinon": "17.0.0", "ts-node": "10.9.1", - "react": "^18.2.0", - "react-dom": "^18.2.0", "typescript": "5.2.2", "vite": "4.5.0" }, @@ -74,6 +77,12 @@ "build", {"projects": ["@tryghost/admin-x-design-system"], "target": "build"} ] + }, + "test:unit": { + "dependsOn": [ + "test:unit", + {"projects": ["@tryghost/admin-x-design-system"], "target": "build"} + ] } } } diff --git a/apps/admin-x-framework/src/api/offers.ts b/apps/admin-x-framework/src/api/offers.ts index 71cc331801..8f8fdb6a33 100644 --- a/apps/admin-x-framework/src/api/offers.ts +++ b/apps/admin-x-framework/src/api/offers.ts @@ -49,7 +49,7 @@ export const useBrowseOffers = createQuery({ export const useBrowseOffersById = createQueryWithId({ dataType, - path: `/offers/` + path: id => `/offers/${id}/` }); export const useEditOffer = createMutation({ diff --git a/apps/admin-x-framework/src/hooks.ts b/apps/admin-x-framework/src/hooks.ts index 91a9b37b49..76d347ef49 100644 --- a/apps/admin-x-framework/src/hooks.ts +++ b/apps/admin-x-framework/src/hooks.ts @@ -1,4 +1,6 @@ export {default as useFilterableApi} from './hooks/useFilterableApi'; +export {default as useForm} from './hooks/useForm'; +export type {Dirtyable, ErrorMessages, FormHook, OkProps, SaveHandler, SaveState} from './hooks/useForm'; export {default as useHandleError} from './hooks/useHandleError'; export {usePermission} from './hooks/usePermissions'; diff --git a/apps/admin-x-settings/src/hooks/useForm.ts b/apps/admin-x-framework/src/hooks/useForm.ts similarity index 100% rename from apps/admin-x-settings/src/hooks/useForm.ts rename to apps/admin-x-framework/src/hooks/useForm.ts diff --git a/apps/admin-x-framework/src/providers/FrameworkProvider.tsx b/apps/admin-x-framework/src/providers/FrameworkProvider.tsx index 3a04d1d817..11c57b5b64 100644 --- a/apps/admin-x-framework/src/providers/FrameworkProvider.tsx +++ b/apps/admin-x-framework/src/providers/FrameworkProvider.tsx @@ -8,7 +8,7 @@ export interface FrameworkProviderProps { basePath: string; ghostVersion: string; externalNavigate: RoutingProviderProps['externalNavigate']; - modals: RoutingProviderProps['modals']; + modals?: RoutingProviderProps['modals']; unsplashConfig: { Authorization: string; 'Accept-Version': string; diff --git a/apps/admin-x-framework/src/utils/api/fetchApi.ts b/apps/admin-x-framework/src/utils/api/fetchApi.ts index ce5196ea22..0967185340 100644 --- a/apps/admin-x-framework/src/utils/api/fetchApi.ts +++ b/apps/admin-x-framework/src/utils/api/fetchApi.ts @@ -19,7 +19,7 @@ export const useFetchApi = () => { const {ghostVersion, sentryDSN} = useFramework(); // eslint-disable-next-line @typescript-eslint/no-explicit-any - return async (endpoint: string | URL, options: RequestOptions = {}): Promise => { + return async (endpoint: string | URL, {headers = {}, retry, ...options}: RequestOptions = {}): Promise => { // By default, we set the Content-Type header to application/json const defaultHeaders: Record = { 'app-pragma': 'no-cache', @@ -28,7 +28,6 @@ export const useFetchApi = () => { if (typeof options.body === 'string') { defaultHeaders['content-type'] = 'application/json'; } - const headers = options?.headers || {}; const controller = new AbortController(); const {timeout} = options; @@ -41,7 +40,7 @@ export const useFetchApi = () => { // 1. Server Unreachable error from the browser (code 0 or TypeError), typically from short internet blips // 2. Maintenance error from Ghost, upgrade in progress so API is temporarily unavailable let attempts = 0; - const shouldRetry = options.retry === true || options.retry === undefined; + const shouldRetry = retry !== false; let retryingMs = 0; const startTime = Date.now(); const maxRetryingMs = 15_000; diff --git a/apps/admin-x-framework/src/utils/api/hooks.ts b/apps/admin-x-framework/src/utils/api/hooks.ts index 3b217880e9..b15f90888e 100644 --- a/apps/admin-x-framework/src/utils/api/hooks.ts +++ b/apps/admin-x-framework/src/utils/api/hooks.ts @@ -17,15 +17,6 @@ export interface Meta { } } -const parameterizedPath = (path: string, params: string | string[]) => { - const paramList = Array.isArray(params) ? params : [params]; - return paramList.reduce(function (updatedPath, param) { - updatedPath = updatedPath + param + '/'; - updatedPath.replace(/:[a-z0-9]+/, encodeURIComponent(param)); - return updatedPath; - }, path); -}; - interface QueryOptions { dataType: string path: string @@ -147,8 +138,8 @@ export const createInfiniteQuery = (options: InfiniteQueryOptions< }; }; -export const createQueryWithId = (options: QueryOptions) => (id: string, {searchParams, ...query}: QueryHookOptions = {}) => { - const queryHook = createQuery({...options, path: parameterizedPath(options.path, id)}); +export const createQueryWithId = (options: Omit, 'path'> & {path: (id: string) => string}) => (id: string, {searchParams, ...query}: QueryHookOptions = {}) => { + const queryHook = createQuery({...options, path: options.path(id)}); return queryHook({searchParams: searchParams || options.defaultSearchParams, ...query}); }; @@ -165,7 +156,7 @@ const mutate = ({fetchApi, path, payload, searchParams, o path: string; payload?: Payload; searchParams?: Record; - options: MutationOptions + options: Omit, 'path'> }) => { const {defaultSearchParams, body, ...requestOptions} = options; const url = apiUrl(path, searchParams || defaultSearchParams); @@ -184,33 +175,33 @@ const mutate = ({fetchApi, path, payload, searchParams, o }); }; -export const createMutation = (options: MutationOptions) => () => { +export const createMutation = ({path, searchParams, defaultSearchParams, updateQueries, invalidateQueries, ...mutateOptions}: MutationOptions) => () => { const fetchApi = useFetchApi(); const queryClient = useQueryClient(); const {onUpdate, onInvalidate, onDelete} = useFramework(); const afterMutate = useCallback((newData: ResponseData, payload: Payload) => { - if (options.invalidateQueries) { - queryClient.invalidateQueries([options.invalidateQueries.dataType]); - onInvalidate(options.invalidateQueries.dataType); + if (invalidateQueries) { + queryClient.invalidateQueries([invalidateQueries.dataType]); + onInvalidate(invalidateQueries.dataType); } - if (options.updateQueries) { - queryClient.setQueriesData([options.updateQueries.dataType], (data: unknown) => options.updateQueries!.update(newData, data, payload)); - if (options.updateQueries.emberUpdateType === 'createOrUpdate') { - onUpdate(options.updateQueries.dataType, newData); - } else if (options.updateQueries.emberUpdateType === 'delete') { + if (updateQueries) { + queryClient.setQueriesData([updateQueries.dataType], (data: unknown) => updateQueries!.update(newData, data, payload)); + if (updateQueries.emberUpdateType === 'createOrUpdate') { + onUpdate(updateQueries.dataType, newData); + } else if (updateQueries.emberUpdateType === 'delete') { if (typeof payload !== 'string') { throw new Error('Expected delete mutation to have a string (ID) payload. Either change the payload or update the createMutation hook'); } - onDelete(options.updateQueries.dataType, payload); + onDelete(updateQueries.dataType, payload); } } }, [onInvalidate, onUpdate, onDelete, queryClient]); return useMutation({ - mutationFn: payload => mutate({fetchApi, path: options.path(payload), payload, searchParams: options.searchParams?.(payload) || options.defaultSearchParams, options}), + mutationFn: payload => mutate({fetchApi, path: path(payload), payload, searchParams: searchParams?.(payload) || defaultSearchParams, options: mutateOptions}), onSuccess: afterMutate }); }; diff --git a/apps/admin-x-framework/test/.eslintrc.cjs b/apps/admin-x-framework/test/.eslintrc.cjs index 6fe6dc1504..42f8e77355 100644 --- a/apps/admin-x-framework/test/.eslintrc.cjs +++ b/apps/admin-x-framework/test/.eslintrc.cjs @@ -1,7 +1,6 @@ module.exports = { - parser: '@typescript-eslint/parser', plugins: ['ghost'], extends: [ - 'plugin:ghost/test' + 'plugin:ghost/ts-test' ] }; diff --git a/apps/admin-x-framework/test/hello.test.ts b/apps/admin-x-framework/test/hello.test.ts deleted file mode 100644 index e66b88fad4..0000000000 --- a/apps/admin-x-framework/test/hello.test.ts +++ /dev/null @@ -1,8 +0,0 @@ -import assert from 'assert/strict'; - -describe('Hello world', function () { - it('Runs a test', function () { - // TODO: Write me! - assert.ok(require('../')); - }); -}); diff --git a/apps/admin-x-framework/test/unit/hooks/useForm.test.ts b/apps/admin-x-framework/test/unit/hooks/useForm.test.ts new file mode 100644 index 0000000000..8c4548b74f --- /dev/null +++ b/apps/admin-x-framework/test/unit/hooks/useForm.test.ts @@ -0,0 +1,75 @@ +import {act, renderHook} from '@testing-library/react'; +import * as assert from 'assert/strict'; +import useForm from '../../../src/hooks/useForm'; + +describe('useForm', function () { + describe('formState', function () { + it('returns the initial form state', function () { + const {result} = renderHook(() => useForm({ + initialState: {a: 1}, + onSave: () => {} + })); + + assert.deepEqual(result.current.formState, {a: 1}); + }); + }); + + describe('updateForm', function () { + it('updates the form state', function () { + const {result} = renderHook(() => useForm({ + initialState: {a: 1}, + onSave: () => {} + })); + + act(() => result.current.updateForm(state => ({...state, b: 2}))); + + assert.deepEqual(result.current.formState, {a: 1, b: 2}); + }); + + it('sets the saveState to unsaved', function () { + const {result} = renderHook(() => useForm({ + initialState: {a: 1}, + onSave: () => {} + })); + + act(() => result.current.updateForm(state => ({...state, a: 2}))); + + assert.deepEqual(result.current.saveState, 'unsaved'); + }); + }); + + describe('handleSave', function () { + it('does nothing when the state has not changed', async function () { + let onSaveCalled = false; + + const {result} = renderHook(() => useForm({ + initialState: {a: 1}, + onSave: () => { + onSaveCalled = true; + } + })); + + assert.equal(await act(() => result.current.handleSave()), true); + + assert.equal(result.current.saveState, ''); + assert.equal(onSaveCalled, false); + }); + + it('calls the onSave callback when the state has changed', async function () { + let onSaveCalled = false; + + const {result} = renderHook(() => useForm({ + initialState: {a: 1}, + onSave: () => { + onSaveCalled = true; + } + })); + + act(() => result.current.updateForm(state => ({...state, a: 2}))); + assert.equal(await act(() => result.current.handleSave()), true); + + assert.equal(result.current.saveState, 'saved'); + assert.equal(onSaveCalled, true); + }); + }); +}); diff --git a/apps/admin-x-framework/test/unit/utils/api/fetchApi.test.tsx b/apps/admin-x-framework/test/unit/utils/api/fetchApi.test.tsx new file mode 100644 index 0000000000..2fda8f9557 --- /dev/null +++ b/apps/admin-x-framework/test/unit/utils/api/fetchApi.test.tsx @@ -0,0 +1,58 @@ +import {renderHook} from '@testing-library/react'; +import React, {ReactNode} from 'react'; +import FrameworkProvider from '../../../../src/providers/FrameworkProvider'; +import {useFetchApi} from '../../../../src/utils/api/fetchApi'; +import {withMockFetch} from '../../../utils/mockFetch'; + +const wrapper: React.FC<{ children: ReactNode }> = ({children}) => ( + {}} + ghostVersion='5.x' + sentryDSN='' + unsplashConfig={{ + Authorization: '', + 'Accept-Version': '', + 'Content-Type': '', + 'App-Pragma': '', + 'X-Unsplash-Cache': true + }} + onDelete={() => {}} + onInvalidate={() => {}} + onUpdate={() => {}} + > + {children} + +); + +describe('useFetchApi', function () { + it('makes an API request', async function () { + await withMockFetch({ + json: {test: 1} + }, async (mock) => { + const {result} = renderHook(() => useFetchApi(), {wrapper}); + + const data = await result.current<{test: number}>('http://localhost:3000/ghost/api/admin/test/', { + method: 'POST', + body: 'test', + retry: false + }); + + expect(data).toEqual({test: 1}); + + expect(mock.calls.length).toBe(1); + expect(mock.calls[0]).toEqual(['http://localhost:3000/ghost/api/admin/test/', { + body: 'test', + credentials: 'include', + headers: { + 'app-pragma': 'no-cache', + 'x-ghost-version': '5.x', + 'content-type': 'application/json' + }, + method: 'POST', + mode: 'cors', + signal: expect.any(AbortSignal) + }]); + }); + }); +}); diff --git a/apps/admin-x-framework/test/unit/utils/api/hooks.test.tsx b/apps/admin-x-framework/test/unit/utils/api/hooks.test.tsx new file mode 100644 index 0000000000..005f48165f --- /dev/null +++ b/apps/admin-x-framework/test/unit/utils/api/hooks.test.tsx @@ -0,0 +1,459 @@ +import {InfiniteData, QueryClient, QueryClientProvider} from '@tanstack/react-query'; +import {act, renderHook, waitFor} from '@testing-library/react'; +import React, {ReactNode} from 'react'; +import FrameworkProvider from '../../../../src/providers/FrameworkProvider'; +import {createInfiniteQuery, createMutation, createPaginatedQuery, createQuery, createQueryWithId} from '../../../../src/utils/api/hooks'; +import {withMockFetch} from '../../../utils/mockFetch'; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false + } + } +}); + +const wrapper: React.FC<{ children: ReactNode }> = ({children}) => ( + {}} + ghostVersion='5.x' + sentryDSN='' + unsplashConfig={{ + Authorization: '', + 'Accept-Version': '', + 'Content-Type': '', + 'App-Pragma': '', + 'X-Unsplash-Cache': true + }} + onDelete={() => {}} + onInvalidate={() => {}} + onUpdate={() => {}} + > + {/* Being nested, this overrides the default QueryClientProvider from the framework */} + + {children} + + +); + +describe('API hooks', function () { + describe('createQuery', function () { + afterEach(function () { + queryClient.clear(); + }); + + it('makes an API request', async function () { + await withMockFetch({ + json: {test: 1} + }, async (mock) => { + const useTestQuery = createQuery({ + dataType: 'test', + path: '/test/' + }); + + const {result} = renderHook(() => useTestQuery(), {wrapper}); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.data).toEqual({test: 1}); + + expect(mock.calls.length).toBe(1); + expect(mock.calls[0]).toEqual(['http://localhost:3000/ghost/api/admin/test/', { + credentials: 'include', + headers: { + 'app-pragma': 'no-cache', + 'x-ghost-version': '5.x' + }, + method: 'GET', + mode: 'cors', + signal: expect.any(AbortSignal) + }]); + }); + }); + + it('sends default query params', async function () { + await withMockFetch({}, async (mock) => { + const useTestQuery = createQuery({ + dataType: 'test', + path: '/test/', + defaultSearchParams: {a: '?'} + }); + + const {result} = renderHook(() => useTestQuery(), {wrapper}); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(mock.calls.length).toBe(1); + expect(mock.calls[0][0]).toEqual('http://localhost:3000/ghost/api/admin/test/?a=%3F'); + }); + }); + + it('can override default query params', async function () { + await withMockFetch({}, async (mock) => { + const useTestQuery = createQuery({ + dataType: 'test', + path: '/test/', + defaultSearchParams: {a: '?'} + }); + + const {result} = renderHook(() => useTestQuery({searchParams: {b: '1'}}), {wrapper}); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(mock.calls.length).toBe(1); + expect(mock.calls[0][0]).toEqual('http://localhost:3000/ghost/api/admin/test/?b=1'); + }); + }); + + it('can transform return data', async function () { + await withMockFetch({json: {test: 1}}, async () => { + const useTestQuery = createQuery({ + dataType: 'test', + path: '/test/', + returnData: data => (data as {test: number}).test + 1 + }); + + const {result} = renderHook(() => useTestQuery(), {wrapper}); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.data).toEqual(2); + }); + }); + }); + + describe('createPaginatedQuery', function () { + afterEach(function () { + queryClient.clear(); + }); + + it('makes a paginated API request', async function () { + await withMockFetch({ + json: {test: 1} + }, async (mock) => { + const useTestQuery = createPaginatedQuery({ + dataType: 'test', + path: '/test/' + }); + + const {result} = renderHook(() => useTestQuery(), {wrapper}); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.data).toEqual({test: 1}); + + expect(mock.calls.length).toBe(1); + expect(mock.calls[0]).toEqual(['http://localhost:3000/ghost/api/admin/test/?page=1', { + credentials: 'include', + headers: { + 'app-pragma': 'no-cache', + 'x-ghost-version': '5.x' + }, + method: 'GET', + mode: 'cors', + signal: expect.any(AbortSignal) + }]); + }); + }); + + it('sends default query params', async function () { + await withMockFetch({}, async (mock) => { + const useTestQuery = createPaginatedQuery({ + dataType: 'test', + path: '/test/', + defaultSearchParams: {a: '?'} + }); + + const {result} = renderHook(() => useTestQuery(), {wrapper}); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(mock.calls.length).toBe(1); + expect(mock.calls[0][0]).toEqual('http://localhost:3000/ghost/api/admin/test/?a=%3F&page=1'); + }); + }); + + it('can override default query params', async function () { + await withMockFetch({}, async (mock) => { + const useTestQuery = createPaginatedQuery({ + dataType: 'test', + path: '/test/', + defaultSearchParams: {a: '?'} + }); + + const {result} = renderHook(() => useTestQuery({searchParams: {b: '1'}}), {wrapper}); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(mock.calls.length).toBe(1); + expect(mock.calls[0][0]).toEqual('http://localhost:3000/ghost/api/admin/test/?b=1&page=1'); + }); + }); + + it('can transform return data', async function () { + await withMockFetch({json: {test: 1}}, async () => { + const useTestQuery = createPaginatedQuery({ + dataType: 'test', + path: '/test/', + returnData: data => ({test: (data as {test: number}).test + 1, meta: undefined}) + }); + + const {result} = renderHook(() => useTestQuery(), {wrapper}); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.data).toEqual({test: 2}); + }); + }); + + it('exposes pagination metadata', async function () { + await withMockFetch({json: {meta: {pagination: {pages: 2, total: 100}}}}, async () => { + const useTestQuery = createPaginatedQuery({ + dataType: 'test', + path: '/test/', + defaultSearchParams: {limit: '15'} + }); + + const {result} = renderHook(() => useTestQuery({}), {wrapper}); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.pagination.limit).toEqual(15); + expect(result.current.pagination.page).toEqual(1); + expect(result.current.pagination.pages).toEqual(2); + expect(result.current.pagination.total).toEqual(100); + }); + }); + + it('supports navigating pages', async function () { + await withMockFetch({json: {meta: {pagination: {pages: 2}}}}, async (mock) => { + const useTestQuery = createPaginatedQuery({ + dataType: 'test', + path: '/test/' + }); + + const {result} = renderHook(() => useTestQuery({}), {wrapper}); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(mock.calls.length).toBe(1); + expect(mock.calls[0][0]).toEqual('http://localhost:3000/ghost/api/admin/test/?page=1'); + + act(() => result.current.pagination.nextPage()); + + await waitFor(() => expect(mock.calls.length).toBe(2)); + + expect(mock.calls[1][0]).toEqual('http://localhost:3000/ghost/api/admin/test/?page=2'); + + act(() => result.current.pagination.prevPage()); + + await waitFor(() => expect(mock.calls.length).toBe(3)); + + expect(mock.calls[2][0]).toEqual('http://localhost:3000/ghost/api/admin/test/?page=1'); + + act(() => result.current.pagination.setPage(5)); + + await waitFor(() => expect(mock.calls.length).toBe(4)); + + expect(mock.calls[3][0]).toEqual('http://localhost:3000/ghost/api/admin/test/?page=5'); + }); + }); + }); + + describe('createInfiniteQuery', function () { + afterEach(function () { + queryClient.clear(); + }); + + it('makes a paginated API request', async function () { + await withMockFetch({ + json: {test: 1, pagination: {next: 2}} + }, async (mock) => { + const useTestQuery = createInfiniteQuery({ + dataType: 'test', + path: '/test/', + defaultNextPageParams: (lastPage, otherParams) => ({ + ...otherParams, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + page: ((lastPage as any).pagination.next || 1).toString() + }), + returnData: (originalData) => { + const {pages} = originalData as InfiniteData<{test: number}>; + return pages.map(page => page.test); + } + }); + + const {result} = renderHook(() => useTestQuery(), {wrapper}); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.data).toEqual([1]); + + expect(mock.calls.length).toBe(1); + expect(mock.calls[0]).toEqual(['http://localhost:3000/ghost/api/admin/test/', { + credentials: 'include', + headers: { + 'app-pragma': 'no-cache', + 'x-ghost-version': '5.x' + }, + method: 'GET', + mode: 'cors', + signal: expect.any(AbortSignal) + }]); + + await act(() => result.current.fetchNextPage()); + + await waitFor(() => expect(mock.calls.length).toBe(2)); + expect(mock.calls[1][0]).toEqual('http://localhost:3000/ghost/api/admin/test/?page=2'); + + await waitFor(() => expect(result.current.data).toEqual([1, 1])); + }); + }); + }); + + describe('createQueryWithId', function () { + afterEach(function () { + queryClient.clear(); + }); + + it('fills in the ID in the request', async function () { + await withMockFetch({ + json: {test: 1} + }, async (mock) => { + const useTestQuery = createQueryWithId({ + dataType: 'test', + path: id => `/test/${id}/` + }); + + const {result} = renderHook(() => useTestQuery('1'), {wrapper}); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.data).toEqual({test: 1}); + + expect(mock.calls.length).toBe(1); + expect(mock.calls[0][0]).toEqual('http://localhost:3000/ghost/api/admin/test/1/'); + }); + }); + }); + + describe('createMutation', function () { + afterEach(function () { + queryClient.clear(); + }); + + it('makes a request', async function () { + await withMockFetch({ + json: {test: 1} + }, async (mock) => { + const useTestMutation = createMutation({ + path: () => '/test/', + method: 'PUT' + }); + + const {result} = renderHook(() => useTestMutation(), {wrapper}); + + expect(await result.current.mutateAsync({})).toEqual({test: 1}); + + expect(mock.calls.length).toBe(1); + expect(mock.calls[0]).toEqual(['http://localhost:3000/ghost/api/admin/test/', { + credentials: 'include', + headers: { + 'app-pragma': 'no-cache', + 'x-ghost-version': '5.x' + }, + method: 'PUT', + mode: 'cors', + body: undefined, + signal: expect.any(AbortSignal) + }]); + }); + }); + + it('computes path, body, searchParams', async function () { + await withMockFetch({ + json: {test: 1} + }, async (mock) => { + const useTestMutation = createMutation({ + path: payload => `/test/${payload}/`, + searchParams: payload => ({a: `${payload}`}), + body: payload => ({b: `${payload}`}), + method: 'POST' + }); + + const {result} = renderHook(() => useTestMutation(), {wrapper}); + + expect(await result.current.mutateAsync('hello')).toEqual({test: 1}); + + expect(mock.calls.length).toBe(1); + expect(mock.calls[0]).toEqual(['http://localhost:3000/ghost/api/admin/test/hello/?a=hello', { + credentials: 'include', + headers: { + 'app-pragma': 'no-cache', + 'content-type': 'application/json', + 'x-ghost-version': '5.x' + }, + method: 'POST', + mode: 'cors', + body: '{"b":"hello"}', + signal: expect.any(AbortSignal) + }]); + }); + }); + + it('can invalidate queries in the cache', async function () { + await withMockFetch({ + json: {test: 1} + }, async (mock) => { + queryClient.setQueryData(['MyDataType', '1'], {test: 1}); + queryClient.setQueryData(['MyDataType', '2'], {test: 2}); + + const useTestMutation = createMutation({ + path: () => '/test/', + method: 'PUT', + invalidateQueries: {dataType: 'MyDataType'} + }); + + const {result} = renderHook(() => useTestMutation(), {wrapper}); + + await result.current.mutateAsync({}); + + expect(mock.calls.length).toBe(1); + + expect(queryClient.getQueryState(['MyDataType', '1'])?.isInvalidated).toBe(true); + expect(queryClient.getQueryState(['MyDataType', '2'])?.isInvalidated).toBe(true); + }); + }); + + it('can update queries in the cache', async function () { + await withMockFetch({ + json: {test: 10} + }, async (mock) => { + queryClient.setQueryData(['MyDataType', '1'], {test: 1}); + queryClient.setQueryData(['MyDataType', '2'], {test: 2}); + + const useTestMutation = createMutation({ + path: () => '/test/', + method: 'PUT', + updateQueries: { + emberUpdateType: 'skip', + dataType: 'MyDataType', + update: (newData, currentData) => { + return {test: (newData as {test: number}).test + (currentData as {test: number}).test}; + } + } + }); + + const {result} = renderHook(() => useTestMutation(), {wrapper}); + + await result.current.mutateAsync({}); + + expect(mock.calls.length).toBe(1); + + expect(queryClient.getQueryData(['MyDataType', '1'])).toEqual({test: 11}); + expect(queryClient.getQueryData(['MyDataType', '2'])).toEqual({test: 12}); + }); + }); + }); +}); diff --git a/apps/admin-x-framework/test/unit/utils/api/updateQueries.test.ts b/apps/admin-x-framework/test/unit/utils/api/updateQueries.test.ts new file mode 100644 index 0000000000..a9fef45699 --- /dev/null +++ b/apps/admin-x-framework/test/unit/utils/api/updateQueries.test.ts @@ -0,0 +1,97 @@ +import {deleteFromQueryCache, insertToQueryCache, updateQueryCache} from '../../../../src/utils/api/updateQueries'; + +describe('cache update functions', function () { + describe('insertToQueryCache', function () { + it('appends records from the new data', function () { + const newData = { + posts: [{id: '2'}] + }; + + const currentData = { + posts: [{id: '1'}] + }; + + const result = insertToQueryCache('posts')(newData, currentData); + + expect(result).toEqual({ + posts: [{id: '1'}, {id: '2'}] + }); + }); + + it('appends to the last page for paginated queries', function () { + const newData = { + posts: [{id: '3'}] + }; + + const currentData = { + pages: [{posts: [{id: '1'}]}, {posts: [{id: '2'}]}] + }; + + const result = insertToQueryCache('posts')(newData, currentData); + + expect(result).toEqual({ + pages: [{posts: [{id: '1'}]}, {posts: [{id: '2'}, {id: '3'}]}] + }); + }); + }); + + describe('updateQueryCache', function () { + it('updates based on the ID', function () { + const newData = { + posts: [{id: '2', title: 'New Title'}] + }; + + const currentData = { + posts: [{id: '1'}, {id: '2', title: 'Old Title'}] + }; + + const result = updateQueryCache('posts')(newData, currentData); + + expect(result).toEqual({ + posts: [{id: '1'}, {id: '2', title: 'New Title'}] + }); + }); + + it('updates nested records in paginated queries', function () { + const newData = { + posts: [{id: '2', title: 'New Title'}] + }; + + const currentData = { + pages: [{posts: [{id: '1'}]}, {posts: [{id: '2', title: 'Old Title'}]}] + }; + + const result = updateQueryCache('posts')(newData, currentData); + + expect(result).toEqual({ + pages: [{posts: [{id: '1'}]}, {posts: [{id: '2', title: 'New Title'}]}] + }); + }); + }); + + describe('deleteFromQueryCache', function () { + it('deletes based on the ID', function () { + const currentData = { + posts: [{id: '1'}, {id: '2'}] + }; + + const result = deleteFromQueryCache('posts')(null, currentData, '2'); + + expect(result).toEqual({ + posts: [{id: '1'}] + }); + }); + + it('deletes nested records in paginated queries', function () { + const currentData = { + pages: [{posts: [{id: '1'}]}, {posts: [{id: '2'}]}] + }; + + const result = deleteFromQueryCache('posts')(null, currentData, '2'); + + expect(result).toEqual({ + pages: [{posts: [{id: '1'}]}, {posts: []}] + }); + }); + }); +}); diff --git a/apps/admin-x-framework/test/utils/mockFetch.ts b/apps/admin-x-framework/test/utils/mockFetch.ts new file mode 100644 index 0000000000..55fade407d --- /dev/null +++ b/apps/admin-x-framework/test/utils/mockFetch.ts @@ -0,0 +1,23 @@ +import {MockContext, vi} from 'vitest'; + +const originalFetch = global.fetch; + +type FetchArgs = Parameters; + +export const withMockFetch = async ( + {json = {}, headers = {}, status = 200, ok = true}: {json?: unknown; headers?: Record; status?: number; ok?: boolean}, + callback: (mock: MockContext>) => void | Promise +) => { + const mockFetch = vi.fn>(() => Promise.resolve({ + json: () => Promise.resolve(json), + headers: new Headers(headers), + status, + ok + } as Response)); + + global.fetch = mockFetch as any; // eslint-disable-line @typescript-eslint/no-explicit-any + + await callback(mockFetch.mock); + + global.fetch = originalFetch; +}; diff --git a/apps/admin-x-settings/src/components/settings/advanced/integrations/CustomIntegrationModal.tsx b/apps/admin-x-settings/src/components/settings/advanced/integrations/CustomIntegrationModal.tsx index 7408459645..421692595e 100644 --- a/apps/admin-x-settings/src/components/settings/advanced/integrations/CustomIntegrationModal.tsx +++ b/apps/admin-x-settings/src/components/settings/advanced/integrations/CustomIntegrationModal.tsx @@ -2,7 +2,6 @@ import APIKeys from './APIKeys'; import NiceModal, {useModal} from '@ebay/nice-modal-react'; import React, {useEffect, useState} from 'react'; import WebhooksTable from './WebhooksTable'; -import useForm from '../../../../hooks/useForm'; import {APIKey, useRefreshAPIKey} from '@tryghost/admin-x-framework/api/apiKeys'; import {ConfirmationModal, Form, ImageUpload, Modal, TextField, showToast} from '@tryghost/admin-x-design-system'; import {Integration, useBrowseIntegrations, useEditIntegration} from '@tryghost/admin-x-framework/api/integrations'; @@ -10,7 +9,7 @@ import {RoutingModalProps, useRouting} from '@tryghost/admin-x-framework/routing import {getGhostPaths} from '@tryghost/admin-x-framework/helpers'; import {getImageUrl, useUploadImage} from '@tryghost/admin-x-framework/api/images'; import {toast} from 'react-hot-toast'; -import {useHandleError} from '@tryghost/admin-x-framework/hooks'; +import {useForm, useHandleError} from '@tryghost/admin-x-framework/hooks'; const CustomIntegrationModalContent: React.FC<{integration: Integration}> = ({integration}) => { const modal = useModal(); diff --git a/apps/admin-x-settings/src/components/settings/advanced/integrations/WebhookModal.tsx b/apps/admin-x-settings/src/components/settings/advanced/integrations/WebhookModal.tsx index 8dd18faf96..9b7a9fbd98 100644 --- a/apps/admin-x-settings/src/components/settings/advanced/integrations/WebhookModal.tsx +++ b/apps/admin-x-settings/src/components/settings/advanced/integrations/WebhookModal.tsx @@ -1,12 +1,11 @@ import NiceModal, {useModal} from '@ebay/nice-modal-react'; import React from 'react'; import toast from 'react-hot-toast'; -import useForm from '../../../../hooks/useForm'; import validator from 'validator'; import webhookEventOptions from './webhookEventOptions'; import {Form, Modal, Select, TextField, showToast} from '@tryghost/admin-x-design-system'; import {Webhook, useCreateWebhook, useEditWebhook} from '@tryghost/admin-x-framework/api/webhooks'; -import {useHandleError} from '@tryghost/admin-x-framework/hooks'; +import {useForm, useHandleError} from '@tryghost/admin-x-framework/hooks'; interface WebhookModalProps { webhook?: Webhook; diff --git a/apps/admin-x-settings/src/components/settings/email/newsletters/AddNewsletterModal.tsx b/apps/admin-x-settings/src/components/settings/email/newsletters/AddNewsletterModal.tsx index 89064bb64c..5aec83da90 100644 --- a/apps/admin-x-settings/src/components/settings/email/newsletters/AddNewsletterModal.tsx +++ b/apps/admin-x-settings/src/components/settings/email/newsletters/AddNewsletterModal.tsx @@ -1,13 +1,12 @@ import NiceModal, {useModal} from '@ebay/nice-modal-react'; import React, {useEffect} from 'react'; -import useForm from '../../../../hooks/useForm'; import {Form, LimitModal, Modal, TextArea, TextField, Toggle, showToast} from '@tryghost/admin-x-design-system'; import {HostLimitError, useLimiter} from '../../../../hooks/useLimiter'; import {RoutingModalProps, useRouting} from '@tryghost/admin-x-framework/routing'; import {toast} from 'react-hot-toast'; import {useAddNewsletter} from '@tryghost/admin-x-framework/api/newsletters'; import {useBrowseMembers} from '@tryghost/admin-x-framework/api/members'; -import {useHandleError} from '@tryghost/admin-x-framework/hooks'; +import {useForm, useHandleError} from '@tryghost/admin-x-framework/hooks'; const AddNewsletterModal: React.FC = () => { const modal = useModal(); diff --git a/apps/admin-x-settings/src/components/settings/email/newsletters/NewsletterDetailModal.tsx b/apps/admin-x-settings/src/components/settings/email/newsletters/NewsletterDetailModal.tsx index 2138ec181d..9faee5d6c4 100644 --- a/apps/admin-x-settings/src/components/settings/email/newsletters/NewsletterDetailModal.tsx +++ b/apps/admin-x-settings/src/components/settings/email/newsletters/NewsletterDetailModal.tsx @@ -2,10 +2,10 @@ import NewsletterPreview from './NewsletterPreview'; import NiceModal, {useModal} from '@ebay/nice-modal-react'; import React, {useEffect, useState} from 'react'; import useFeatureFlag from '../../../../hooks/useFeatureFlag'; -import useForm, {ErrorMessages} from '../../../../hooks/useForm'; import useSettingGroup from '../../../../hooks/useSettingGroup'; import validator from 'validator'; import {Button, ButtonGroup, ColorPickerField, ConfirmationModal, Form, Heading, Hint, HtmlField, Icon, ImageUpload, LimitModal, PreviewModalContent, Select, SelectOption, Separator, Tab, TabView, TextArea, TextField, Toggle, ToggleGroup, showToast} from '@tryghost/admin-x-design-system'; +import {ErrorMessages, useForm, useHandleError} from '@tryghost/admin-x-framework/hooks'; import {HostLimitError, useLimiter} from '../../../../hooks/useLimiter'; import {Newsletter, useBrowseNewsletters, useEditNewsletter} from '@tryghost/admin-x-framework/api/newsletters'; import {RoutingModalProps, useRouting} from '@tryghost/admin-x-framework/routing'; @@ -14,7 +14,6 @@ import {getImageUrl, useUploadImage} from '@tryghost/admin-x-framework/api/image import {getSettingValues} from '@tryghost/admin-x-framework/api/settings'; import {textColorForBackgroundColor} from '@tryghost/color-utils'; import {useGlobalData} from '../../../providers/GlobalDataProvider'; -import {useHandleError} from '@tryghost/admin-x-framework/hooks'; const Sidebar: React.FC<{ newsletter: Newsletter; diff --git a/apps/admin-x-settings/src/components/settings/general/UserDetailModal.tsx b/apps/admin-x-settings/src/components/settings/general/UserDetailModal.tsx index 85f384f767..5c70b8c4d3 100644 --- a/apps/admin-x-settings/src/components/settings/general/UserDetailModal.tsx +++ b/apps/admin-x-settings/src/components/settings/general/UserDetailModal.tsx @@ -6,18 +6,17 @@ import ProfileDetails from './users/ProfileDetails'; import React, {useCallback, useEffect} from 'react'; import StaffToken from './users/StaffToken'; import clsx from 'clsx'; -import useForm, {ErrorMessages} from '../../../hooks/useForm'; import usePinturaEditor from '../../../hooks/usePinturaEditor'; import useStaffUsers from '../../../hooks/useStaffUsers'; import validator from 'validator'; import {ConfirmationModal, Heading, Icon, ImageUpload, LimitModal, Menu, MenuItem, Modal, showToast} from '@tryghost/admin-x-design-system'; +import {ErrorMessages, useForm, useHandleError} from '@tryghost/admin-x-framework/hooks'; import {HostLimitError, useLimiter} from '../../../hooks/useLimiter'; import {RoutingModalProps, useRouting} from '@tryghost/admin-x-framework/routing'; import {User, canAccessSettings, hasAdminAccess, isAdminUser, isAuthorOrContributor, isEditorUser, isOwnerUser, useDeleteUser, useEditUser, useMakeOwner} from '@tryghost/admin-x-framework/api/users'; import {getImageUrl, useUploadImage} from '@tryghost/admin-x-framework/api/images'; import {toast} from 'react-hot-toast'; import {useGlobalData} from '../../providers/GlobalDataProvider'; -import {useHandleError} from '@tryghost/admin-x-framework/hooks'; import {validateFacebookUrl, validateTwitterUrl} from '../../../utils/socialUrls'; const validators: Record) => string> = { diff --git a/apps/admin-x-settings/src/components/settings/growth/offers/AddOfferModal.tsx b/apps/admin-x-settings/src/components/settings/growth/offers/AddOfferModal.tsx index 201298b130..cc6ddc65ba 100644 --- a/apps/admin-x-settings/src/components/settings/growth/offers/AddOfferModal.tsx +++ b/apps/admin-x-settings/src/components/settings/growth/offers/AddOfferModal.tsx @@ -1,12 +1,12 @@ import PortalFrame from '../../membership/portal/PortalFrame'; import useFeatureFlag from '../../../../hooks/useFeatureFlag'; -import useForm from '../../../../hooks/useForm'; import {Form, Icon, PreviewModalContent, Select, SelectOption, TextArea, TextField, showToast} from '@tryghost/admin-x-design-system'; import {getOfferPortalPreviewUrl, offerPortalPreviewUrlTypes} from '../../../../utils/getOffersPortalPreviewUrl'; import {getPaidActiveTiers, useBrowseTiers} from '@tryghost/admin-x-framework/api/tiers'; import {getTiersCadences} from '../../../../utils/getTiersCadences'; import {useAddOffer} from '@tryghost/admin-x-framework/api/offers'; import {useEffect, useState} from 'react'; +import {useForm} from '@tryghost/admin-x-framework/hooks'; import {useGlobalData} from '../../../providers/GlobalDataProvider'; import {useModal} from '@ebay/nice-modal-react'; import {useRouting} from '@tryghost/admin-x-framework/routing'; diff --git a/apps/admin-x-settings/src/components/settings/growth/offers/EditOfferModal.tsx b/apps/admin-x-settings/src/components/settings/growth/offers/EditOfferModal.tsx index d74ae14972..837c2bd6d4 100644 --- a/apps/admin-x-settings/src/components/settings/growth/offers/EditOfferModal.tsx +++ b/apps/admin-x-settings/src/components/settings/growth/offers/EditOfferModal.tsx @@ -1,14 +1,13 @@ import NiceModal, {useModal} from '@ebay/nice-modal-react'; import PortalFrame from '../../membership/portal/PortalFrame'; import useFeatureFlag from '../../../../hooks/useFeatureFlag'; -import useForm, {ErrorMessages} from '../../../../hooks/useForm'; import {Button, ConfirmationModal, Form, PreviewModalContent, TextArea, TextField, showToast} from '@tryghost/admin-x-design-system'; +import {ErrorMessages, useForm, useHandleError} from '@tryghost/admin-x-framework/hooks'; import {Offer, useBrowseOffersById, useEditOffer} from '@tryghost/admin-x-framework/api/offers'; import {getHomepageUrl} from '@tryghost/admin-x-framework/api/site'; import {getOfferPortalPreviewUrl, offerPortalPreviewUrlTypes} from '../../../../utils/getOffersPortalPreviewUrl'; import {useEffect, useState} from 'react'; import {useGlobalData} from '../../../providers/GlobalDataProvider'; -import {useHandleError} from '@tryghost/admin-x-framework/hooks'; import {useRouting} from '@tryghost/admin-x-framework/routing'; function formatTimestamp(timestamp: string): string { diff --git a/apps/admin-x-settings/src/components/settings/growth/recommendations/AddRecommendationModal.tsx b/apps/admin-x-settings/src/components/settings/growth/recommendations/AddRecommendationModal.tsx index a3de781311..37ad7d256a 100644 --- a/apps/admin-x-settings/src/components/settings/growth/recommendations/AddRecommendationModal.tsx +++ b/apps/admin-x-settings/src/components/settings/growth/recommendations/AddRecommendationModal.tsx @@ -1,9 +1,9 @@ import AddRecommendationModalConfirm from './AddRecommendationModalConfirm'; import NiceModal, {useModal} from '@ebay/nice-modal-react'; import React, {useEffect, useState} from 'react'; -import useForm, {ErrorMessages} from '../../../../hooks/useForm'; import {AlreadyExistsError} from '@tryghost/admin-x-framework/errors'; import {EditOrAddRecommendation, useCheckRecommendation} from '@tryghost/admin-x-framework/api/recommendations'; +import {ErrorMessages, useForm} from '@tryghost/admin-x-framework/hooks'; import {Form, LoadingIndicator, Modal, TextField, dismissAllToasts, formatUrl, showToast} from '@tryghost/admin-x-design-system'; import {RoutingModalProps, useRouting} from '@tryghost/admin-x-framework/routing'; import {trimSearchAndHash} from '../../../../utils/url'; diff --git a/apps/admin-x-settings/src/components/settings/growth/recommendations/AddRecommendationModalConfirm.tsx b/apps/admin-x-settings/src/components/settings/growth/recommendations/AddRecommendationModalConfirm.tsx index 248455902f..62f660229f 100644 --- a/apps/admin-x-settings/src/components/settings/growth/recommendations/AddRecommendationModalConfirm.tsx +++ b/apps/admin-x-settings/src/components/settings/growth/recommendations/AddRecommendationModalConfirm.tsx @@ -3,10 +3,9 @@ import NiceModal, {useModal} from '@ebay/nice-modal-react'; import React from 'react'; import RecommendationDescriptionForm, {validateDescriptionForm} from './RecommendationDescriptionForm'; import trackEvent from '../../../../utils/plausible'; -import useForm from '../../../../hooks/useForm'; import {EditOrAddRecommendation, useAddRecommendation} from '@tryghost/admin-x-framework/api/recommendations'; import {Modal, dismissAllToasts, showToast} from '@tryghost/admin-x-design-system'; -import {useHandleError} from '@tryghost/admin-x-framework/hooks'; +import {useForm, useHandleError} from '@tryghost/admin-x-framework/hooks'; import {useRouting} from '@tryghost/admin-x-framework/routing'; interface AddRecommendationModalProps { diff --git a/apps/admin-x-settings/src/components/settings/growth/recommendations/EditRecommendationModal.tsx b/apps/admin-x-settings/src/components/settings/growth/recommendations/EditRecommendationModal.tsx index c1b9e67730..2bdb282159 100644 --- a/apps/admin-x-settings/src/components/settings/growth/recommendations/EditRecommendationModal.tsx +++ b/apps/admin-x-settings/src/components/settings/growth/recommendations/EditRecommendationModal.tsx @@ -1,11 +1,10 @@ import NiceModal, {useModal} from '@ebay/nice-modal-react'; import React from 'react'; import RecommendationDescriptionForm, {validateDescriptionForm} from './RecommendationDescriptionForm'; -import useForm from '../../../../hooks/useForm'; import {ConfirmationModal, Modal, dismissAllToasts, showToast} from '@tryghost/admin-x-design-system'; import {Recommendation, useDeleteRecommendation, useEditRecommendation} from '@tryghost/admin-x-framework/api/recommendations'; import {RoutingModalProps, useRouting} from '@tryghost/admin-x-framework/routing'; -import {useHandleError} from '@tryghost/admin-x-framework/hooks'; +import {useForm, useHandleError} from '@tryghost/admin-x-framework/hooks'; interface EditRecommendationModalProps { recommendation: Recommendation, diff --git a/apps/admin-x-settings/src/components/settings/growth/recommendations/RecommendationDescriptionForm.tsx b/apps/admin-x-settings/src/components/settings/growth/recommendations/RecommendationDescriptionForm.tsx index a168323066..328f7fbb51 100644 --- a/apps/admin-x-settings/src/components/settings/growth/recommendations/RecommendationDescriptionForm.tsx +++ b/apps/admin-x-settings/src/components/settings/growth/recommendations/RecommendationDescriptionForm.tsx @@ -1,7 +1,7 @@ import React from 'react'; import RecommendationIcon from './RecommendationIcon'; import {EditOrAddRecommendation, Recommendation} from '@tryghost/admin-x-framework/api/recommendations'; -import {ErrorMessages} from '../../../../hooks/useForm'; +import {ErrorMessages} from '@tryghost/admin-x-framework/hooks'; import {Form, Heading, Hint, TextArea, TextField, URLTextField} from '@tryghost/admin-x-design-system'; interface Props { diff --git a/apps/admin-x-settings/src/components/settings/membership/portal/PortalModal.tsx b/apps/admin-x-settings/src/components/settings/membership/portal/PortalModal.tsx index 385b5e0543..74052ab475 100644 --- a/apps/admin-x-settings/src/components/settings/membership/portal/PortalModal.tsx +++ b/apps/admin-x-settings/src/components/settings/membership/portal/PortalModal.tsx @@ -4,14 +4,13 @@ import NiceModal from '@ebay/nice-modal-react'; import PortalPreview from './PortalPreview'; import React, {useEffect, useState} from 'react'; import SignupOptions from './SignupOptions'; -import useForm, {Dirtyable} from '../../../../hooks/useForm'; import useQueryParams from '../../../../hooks/useQueryParams'; import {ConfirmationModal, PreviewModalContent, Tab, TabView} from '@tryghost/admin-x-design-system'; +import {Dirtyable, useForm, useHandleError} from '@tryghost/admin-x-framework/hooks'; import {Setting, SettingValue, getSettingValues, useEditSettings} from '@tryghost/admin-x-framework/api/settings'; import {Tier, useBrowseTiers, useEditTier} from '@tryghost/admin-x-framework/api/tiers'; import {fullEmailAddress} from '@tryghost/admin-x-framework/api/site'; import {useGlobalData} from '../../../providers/GlobalDataProvider'; -import {useHandleError} from '@tryghost/admin-x-framework/hooks'; import {useRouting} from '@tryghost/admin-x-framework/routing'; import {verifyEmailToken} from '@tryghost/admin-x-framework/api/emailVerification'; diff --git a/apps/admin-x-settings/src/components/settings/membership/tiers/TierDetailModal.tsx b/apps/admin-x-settings/src/components/settings/membership/tiers/TierDetailModal.tsx index 8f58c138b6..8fe1b042cc 100644 --- a/apps/admin-x-settings/src/components/settings/membership/tiers/TierDetailModal.tsx +++ b/apps/admin-x-settings/src/components/settings/membership/tiers/TierDetailModal.tsx @@ -1,15 +1,14 @@ import NiceModal, {useModal} from '@ebay/nice-modal-react'; import React, {useEffect, useRef} from 'react'; import TierDetailPreview from './TierDetailPreview'; -import useForm, {ErrorMessages} from '../../../../hooks/useForm'; import useSettingGroup from '../../../../hooks/useSettingGroup'; import {Button, ButtonProps, ConfirmationModal, CurrencyField, Form, Heading, Icon, Modal, Select, SortableList, TextField, Toggle, URLTextField, showToast, useSortableIndexedList} from '@tryghost/admin-x-design-system'; +import {ErrorMessages, useForm, useHandleError} from '@tryghost/admin-x-framework/hooks'; import {RoutingModalProps, useRouting} from '@tryghost/admin-x-framework/routing'; import {Tier, useAddTier, useBrowseTiers, useEditTier} from '@tryghost/admin-x-framework/api/tiers'; import {currencies, currencySelectGroups, validateCurrencyAmount} from '../../../../utils/currency'; import {getSettingValues} from '@tryghost/admin-x-framework/api/settings'; import {toast} from 'react-hot-toast'; -import {useHandleError} from '@tryghost/admin-x-framework/hooks'; export type TierFormState = Partial> & { trial_days: string; diff --git a/apps/admin-x-settings/src/components/settings/site/DesignModal.tsx b/apps/admin-x-settings/src/components/settings/site/DesignModal.tsx index 0a987f4979..3ff5ed8aaa 100644 --- a/apps/admin-x-settings/src/components/settings/site/DesignModal.tsx +++ b/apps/admin-x-settings/src/components/settings/site/DesignModal.tsx @@ -2,15 +2,14 @@ import BrandSettings, {BrandSettingValues} from './designAndBranding/BrandSettin import React, {useEffect, useState} from 'react'; import ThemePreview from './designAndBranding/ThemePreview'; import ThemeSettings from './designAndBranding/ThemeSettings'; -import useForm from '../../../hooks/useForm'; import {CustomThemeSetting, useBrowseCustomThemeSettings, useEditCustomThemeSettings} from '@tryghost/admin-x-framework/api/customThemeSettings'; import {Icon, PreviewModalContent, StickyFooter, Tab, TabView} from '@tryghost/admin-x-design-system'; import {Setting, SettingValue, getSettingValues, useEditSettings} from '@tryghost/admin-x-framework/api/settings'; import {getHomepageUrl} from '@tryghost/admin-x-framework/api/site'; import {useBrowsePosts} from '@tryghost/admin-x-framework/api/posts'; import {useBrowseThemes} from '@tryghost/admin-x-framework/api/themes'; +import {useForm, useHandleError} from '@tryghost/admin-x-framework/hooks'; import {useGlobalData} from '../../providers/GlobalDataProvider'; -import {useHandleError} from '@tryghost/admin-x-framework/hooks'; import {useRouting} from '@tryghost/admin-x-framework/routing'; const Sidebar: React.FC<{ diff --git a/apps/admin-x-settings/src/hooks/useSettingGroup.tsx b/apps/admin-x-settings/src/hooks/useSettingGroup.tsx index 7ba7436d23..7d6272a8ad 100644 --- a/apps/admin-x-settings/src/hooks/useSettingGroup.tsx +++ b/apps/admin-x-settings/src/hooks/useSettingGroup.tsx @@ -1,11 +1,10 @@ import React, {useEffect, useRef, useState} from 'react'; -import useForm, {ErrorMessages, OkProps, SaveHandler, SaveState} from './useForm'; +import {ErrorMessages, OkProps, SaveHandler, SaveState, useForm, useHandleError} from '@tryghost/admin-x-framework/hooks'; import {Setting, SettingValue, useEditSettings} from '@tryghost/admin-x-framework/api/settings'; import {SiteData} from '@tryghost/admin-x-framework/api/site'; import {showToast, useGlobalDirtyState} from '@tryghost/admin-x-design-system'; import {toast} from 'react-hot-toast'; import {useGlobalData} from '../components/providers/GlobalDataProvider'; -import {useHandleError} from '@tryghost/admin-x-framework/hooks'; interface LocalSetting extends Setting { dirty?: boolean; diff --git a/ghost/bookshelf-repository/test/BookshelfRepository.test.ts b/ghost/bookshelf-repository/test/BookshelfRepository.test.ts index c46c3757e0..d56b86b396 100644 --- a/ghost/bookshelf-repository/test/BookshelfRepository.test.ts +++ b/ghost/bookshelf-repository/test/BookshelfRepository.test.ts @@ -1,7 +1,7 @@ -import assert from 'assert'; -import {BookshelfRepository, ModelClass, ModelInstance} from '../src/index'; -import {Knex} from 'knex'; import nql from '@tryghost/nql'; +import assert from 'assert/strict'; +import {Knex} from 'knex'; +import {BookshelfRepository, ModelClass, ModelInstance} from '../src/index'; type SimpleEntity = { id: string; diff --git a/ghost/collections/test/Collection.test.ts b/ghost/collections/test/Collection.test.ts index 27c19bd940..7c8206afb3 100644 --- a/ghost/collections/test/Collection.test.ts +++ b/ghost/collections/test/Collection.test.ts @@ -1,4 +1,4 @@ -import assert from 'assert'; +import assert from 'assert/strict'; import ObjectID from 'bson-objectid'; import {Collection} from '../src/index'; diff --git a/ghost/core/content/themes/source b/ghost/core/content/themes/source index 0798ee6e48..946e063117 160000 --- a/ghost/core/content/themes/source +++ b/ghost/core/content/themes/source @@ -1 +1 @@ -Subproject commit 0798ee6e48273ab912d8e8e7ceee287bf481a789 +Subproject commit 946e06311787f864cd347678fb9012f6d3abab22 diff --git a/ghost/in-memory-repository/test/InMemoryRepository.test.ts b/ghost/in-memory-repository/test/InMemoryRepository.test.ts index ec5d4ff757..2c57e3e35a 100644 --- a/ghost/in-memory-repository/test/InMemoryRepository.test.ts +++ b/ghost/in-memory-repository/test/InMemoryRepository.test.ts @@ -1,4 +1,4 @@ -import assert from 'assert'; +import assert from 'assert/strict'; import {InMemoryRepository} from '../src/index'; type SimpleEntity = { diff --git a/ghost/mail-events/test/MailEventService.test.ts b/ghost/mail-events/test/MailEventService.test.ts index 00a0351a50..a7282f56ca 100644 --- a/ghost/mail-events/test/MailEventService.test.ts +++ b/ghost/mail-events/test/MailEventService.test.ts @@ -1,8 +1,8 @@ -import assert from 'assert'; +import assert from 'assert/strict'; import sinon from 'sinon'; -import {MailEvent} from '../src/MailEvent'; import {InMemoryMailEventRepository as MailEventRepository} from '../src/InMemoryMailEventRepository'; +import {MailEvent} from '../src/MailEvent'; import {MailEventService} from '../src/MailEventService'; const makePayloadEvent = ( diff --git a/ghost/post-revisions/test/PostRevisions.test.ts b/ghost/post-revisions/test/PostRevisions.test.ts index 9cebe3d96f..d9d7e6a262 100644 --- a/ghost/post-revisions/test/PostRevisions.test.ts +++ b/ghost/post-revisions/test/PostRevisions.test.ts @@ -1,4 +1,4 @@ -import assert from 'assert'; +import assert from 'assert/strict'; import sinon from 'sinon'; import {PostRevisions} from '../src'; diff --git a/package.json b/package.json index 3b55d71c92..65ae5c8366 100644 --- a/package.json +++ b/package.json @@ -6,10 +6,13 @@ "repository": "https://github.com/TryGhost/Ghost", "author": "Ghost Foundation", "license": "MIT", - "workspaces": [ - "ghost/*", - "apps/*" - ], + "workspaces": { + "packages": [ + "ghost/*", + "apps/*" + ], + "nohoist": ["**/@testing-library/react"] + }, "monorepo": { "public": false, "internalPackages": true, @@ -108,7 +111,7 @@ "chalk": "4.1.2", "concurrently": "8.2.2", "eslint": "8.44.0", - "eslint-plugin-ghost": "3.3.2", + "eslint-plugin-ghost": "3.4.0", "eslint-plugin-react": "7.33.0", "husky": "8.0.3", "lint-staged": "14.0.1", diff --git a/yarn.lock b/yarn.lock index 5ed48bee07..2c57891108 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7408,6 +7408,15 @@ "@testing-library/dom" "^8.0.0" "@types/react-dom" "<18.0.0" +"@testing-library/react@14.1.0": + version "14.1.0" + resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-14.1.0.tgz#01d64915111db99b50f8361d51d7217606805989" + integrity sha512-hcvfZEEyO0xQoZeHmUbuMs7APJCGELpilL7bY+BaJaMP57aWc6q1etFwScnoZDheYjk4ESdlzPdQ33IbsKAK/A== + dependencies: + "@babel/runtime" "^7.12.5" + "@testing-library/dom" "^9.0.0" + "@types/react-dom" "^18.0.0" + "@testing-library/user-event@14.4.3": version "14.4.3" resolved "https://registry.yarnpkg.com/@testing-library/user-event/-/user-event-14.4.3.tgz#af975e367743fa91989cd666666aec31a8f50591" @@ -8654,7 +8663,7 @@ resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.4.tgz#cd667bcfdd025213aafb7ca5915a932590acdcdc" integrity sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw== -"@types/react-dom@18.2.15": +"@types/react-dom@18.2.15", "@types/react-dom@^18.0.0": version "18.2.15" resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.2.15.tgz#921af67f9ee023ac37ea84b1bc0cc40b898ea522" integrity sha512-HWMdW+7r7MR5+PZqJF6YFNSCtjz1T0dsvo/f1BV6HkV+6erD/nA7wd9NM00KVG83zf2nJ7uATPO9ttdIPvi3gg== @@ -8810,16 +8819,16 @@ dependencies: "@types/node" "*" -"@typescript-eslint/eslint-plugin@6.6.0": - version "6.6.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.6.0.tgz#19ba09aa34fd504696445100262e5a9e1b1d7024" - integrity sha512-CW9YDGTQnNYMIo5lMeuiIG08p4E0cXrXTbcZ2saT/ETE7dWUrNxlijsQeU04qAAKkILiLzdQz+cGFxCJjaZUmA== +"@typescript-eslint/eslint-plugin@6.9.1": + version "6.9.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.9.1.tgz#d8ce497dc0ed42066e195c8ecc40d45c7b1254f4" + integrity sha512-w0tiiRc9I4S5XSXXrMHOWgHgxbrBn1Ro+PmiYhSg2ZVdxrAJtQgzU5o2m1BfP6UOn7Vxcc6152vFjQfmZR4xEg== dependencies: "@eslint-community/regexpp" "^4.5.1" - "@typescript-eslint/scope-manager" "6.6.0" - "@typescript-eslint/type-utils" "6.6.0" - "@typescript-eslint/utils" "6.6.0" - "@typescript-eslint/visitor-keys" "6.6.0" + "@typescript-eslint/scope-manager" "6.9.1" + "@typescript-eslint/type-utils" "6.9.1" + "@typescript-eslint/utils" "6.9.1" + "@typescript-eslint/visitor-keys" "6.9.1" debug "^4.3.4" graphemer "^1.4.0" ignore "^5.2.4" @@ -8827,72 +8836,72 @@ semver "^7.5.4" ts-api-utils "^1.0.1" -"@typescript-eslint/parser@6.6.0": - version "6.6.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-6.6.0.tgz#fe323a7b4eafb6d5ea82b96216561810394a739e" - integrity sha512-setq5aJgUwtzGrhW177/i+DMLqBaJbdwGj2CPIVFFLE0NCliy5ujIdLHd2D1ysmlmsjdL2GWW+hR85neEfc12w== +"@typescript-eslint/parser@6.9.1": + version "6.9.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-6.9.1.tgz#4f685f672f8b9580beb38d5fb99d52fc3e34f7a3" + integrity sha512-C7AK2wn43GSaCUZ9do6Ksgi2g3mwFkMO3Cis96kzmgudoVaKyt62yNzJOktP0HDLb/iO2O0n2lBOzJgr6Q/cyg== dependencies: - "@typescript-eslint/scope-manager" "6.6.0" - "@typescript-eslint/types" "6.6.0" - "@typescript-eslint/typescript-estree" "6.6.0" - "@typescript-eslint/visitor-keys" "6.6.0" + "@typescript-eslint/scope-manager" "6.9.1" + "@typescript-eslint/types" "6.9.1" + "@typescript-eslint/typescript-estree" "6.9.1" + "@typescript-eslint/visitor-keys" "6.9.1" debug "^4.3.4" -"@typescript-eslint/scope-manager@6.6.0": - version "6.6.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-6.6.0.tgz#57105d4419d6de971f7d2c30a2ff4ac40003f61a" - integrity sha512-pT08u5W/GT4KjPUmEtc2kSYvrH8x89cVzkA0Sy2aaOUIw6YxOIjA8ilwLr/1fLjOedX1QAuBpG9XggWqIIfERw== +"@typescript-eslint/scope-manager@6.9.1": + version "6.9.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-6.9.1.tgz#e96afeb9a68ad1cd816dba233351f61e13956b75" + integrity sha512-38IxvKB6NAne3g/+MyXMs2Cda/Sz+CEpmm+KLGEM8hx/CvnSRuw51i8ukfwB/B/sESdeTGet1NH1Wj7I0YXswg== dependencies: - "@typescript-eslint/types" "6.6.0" - "@typescript-eslint/visitor-keys" "6.6.0" + "@typescript-eslint/types" "6.9.1" + "@typescript-eslint/visitor-keys" "6.9.1" -"@typescript-eslint/type-utils@6.6.0": - version "6.6.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-6.6.0.tgz#14f651d13b884915c4fca0d27adeb652a4499e86" - integrity sha512-8m16fwAcEnQc69IpeDyokNO+D5spo0w1jepWWY2Q6y5ZKNuj5EhVQXjtVAeDDqvW6Yg7dhclbsz6rTtOvcwpHg== +"@typescript-eslint/type-utils@6.9.1": + version "6.9.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-6.9.1.tgz#efd5db20ed35a74d3c7d8fba51b830ecba09ce32" + integrity sha512-eh2oHaUKCK58qIeYp19F5V5TbpM52680sB4zNSz29VBQPTWIlE/hCj5P5B1AChxECe/fmZlspAWFuRniep1Skg== dependencies: - "@typescript-eslint/typescript-estree" "6.6.0" - "@typescript-eslint/utils" "6.6.0" + "@typescript-eslint/typescript-estree" "6.9.1" + "@typescript-eslint/utils" "6.9.1" debug "^4.3.4" ts-api-utils "^1.0.1" -"@typescript-eslint/types@6.6.0": - version "6.6.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-6.6.0.tgz#95e7ea650a2b28bc5af5ea8907114a48f54618c2" - integrity sha512-CB6QpJQ6BAHlJXdwUmiaXDBmTqIE2bzGTDLADgvqtHWuhfNP3rAOK7kAgRMAET5rDRr9Utt+qAzRBdu3AhR3sg== +"@typescript-eslint/types@6.9.1": + version "6.9.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-6.9.1.tgz#a6cfc20db0fcedcb2f397ea728ef583e0ee72459" + integrity sha512-BUGslGOb14zUHOUmDB2FfT6SI1CcZEJYfF3qFwBeUrU6srJfzANonwRYHDpLBuzbq3HaoF2XL2hcr01c8f8OaQ== -"@typescript-eslint/typescript-estree@6.6.0": - version "6.6.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-6.6.0.tgz#373c420d2e12c28220f4a83352280a04823a91b7" - integrity sha512-hMcTQ6Al8MP2E6JKBAaSxSVw5bDhdmbCEhGW/V8QXkb9oNsFkA4SBuOMYVPxD3jbtQ4R/vSODBsr76R6fP3tbA== +"@typescript-eslint/typescript-estree@6.9.1": + version "6.9.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-6.9.1.tgz#8c77910a49a04f0607ba94d78772da07dab275ad" + integrity sha512-U+mUylTHfcqeO7mLWVQ5W/tMLXqVpRv61wm9ZtfE5egz7gtnmqVIw9ryh0mgIlkKk9rZLY3UHygsBSdB9/ftyw== dependencies: - "@typescript-eslint/types" "6.6.0" - "@typescript-eslint/visitor-keys" "6.6.0" + "@typescript-eslint/types" "6.9.1" + "@typescript-eslint/visitor-keys" "6.9.1" debug "^4.3.4" globby "^11.1.0" is-glob "^4.0.3" semver "^7.5.4" ts-api-utils "^1.0.1" -"@typescript-eslint/utils@6.6.0": - version "6.6.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-6.6.0.tgz#2d686c0f0786da6362d909e27a9de1c13ba2e7dc" - integrity sha512-mPHFoNa2bPIWWglWYdR0QfY9GN0CfvvXX1Sv6DlSTive3jlMTUy+an67//Gysc+0Me9pjitrq0LJp0nGtLgftw== +"@typescript-eslint/utils@6.9.1": + version "6.9.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-6.9.1.tgz#763da41281ef0d16974517b5f0d02d85897a1c1e" + integrity sha512-L1T0A5nFdQrMVunpZgzqPL6y2wVreSyHhKGZryS6jrEN7bD9NplVAyMryUhXsQ4TWLnZmxc2ekar/lSGIlprCA== dependencies: "@eslint-community/eslint-utils" "^4.4.0" "@types/json-schema" "^7.0.12" "@types/semver" "^7.5.0" - "@typescript-eslint/scope-manager" "6.6.0" - "@typescript-eslint/types" "6.6.0" - "@typescript-eslint/typescript-estree" "6.6.0" + "@typescript-eslint/scope-manager" "6.9.1" + "@typescript-eslint/types" "6.9.1" + "@typescript-eslint/typescript-estree" "6.9.1" semver "^7.5.4" -"@typescript-eslint/visitor-keys@6.6.0": - version "6.6.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-6.6.0.tgz#1109088b4346c8b2446f3845db526374d9a3bafc" - integrity sha512-L61uJT26cMOfFQ+lMZKoJNbAEckLe539VhTxiGHrWl5XSKQgA0RTBZJW2HFPy5T0ZvPVSD93QsrTKDkfNwJGyQ== +"@typescript-eslint/visitor-keys@6.9.1": + version "6.9.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-6.9.1.tgz#6753a9225a0ba00459b15d6456b9c2780b66707d" + integrity sha512-MUaPUe/QRLEffARsmNfmpghuQkW436DvESW+h+M52w0coICHRfD6Np9/K6PdACwnrq1HmuLl+cSPZaJmeVPkSw== dependencies: - "@typescript-eslint/types" "6.6.0" + "@typescript-eslint/types" "6.9.1" eslint-visitor-keys "^3.4.1" "@tyriar/fibonacci-heap@^2.0.7": @@ -16785,18 +16794,18 @@ eslint-plugin-filenames@allouis/eslint-plugin-filenames#15dc354f4e3d155fc2d6ae08 lodash.snakecase "4.1.1" lodash.upperfirst "4.3.1" -eslint-plugin-ghost@3.3.2: - version "3.3.2" - resolved "https://registry.yarnpkg.com/eslint-plugin-ghost/-/eslint-plugin-ghost-3.3.2.tgz#b789c20e645d285743bc8c852f92b9898474eb98" - integrity sha512-Ae3u4lTo2ApB8wBdaEShJVEuqNSYG1IiarAFvee8bSx8Ykwj0vRxpyy9MRgwaFc6Lre7dcaIJ60iWVBoO4MwwA== +eslint-plugin-ghost@3.4.0: + version "3.4.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-ghost/-/eslint-plugin-ghost-3.4.0.tgz#2a84e53e1bdc3ca722e2886e49d5de0dd2344d6f" + integrity sha512-uj/ClW5yyfm0tHikI7jyWJbMqLVnn3PTr5GwVc0NTciM9tgkfS7TxEi8jAEa1idLRGPou+gF+Tvt0QBI/29S4g== dependencies: "@kapouer/eslint-plugin-no-return-in-loop" "1.0.0" - "@typescript-eslint/eslint-plugin" "6.6.0" - "@typescript-eslint/parser" "6.6.0" + "@typescript-eslint/eslint-plugin" "6.9.1" + "@typescript-eslint/parser" "6.9.1" eslint-plugin-ember "11.11.1" eslint-plugin-filenames allouis/eslint-plugin-filenames#15dc354f4e3d155fc2d6ae082dbfc26377539a18 eslint-plugin-mocha "7.0.1" - eslint-plugin-n "16.0.2" + eslint-plugin-n "16.2.0" eslint-plugin-sort-imports-es6-autofix "0.6.0" eslint-plugin-unicorn "42.0.0" typescript "5.2.2" @@ -16817,14 +16826,15 @@ eslint-plugin-mocha@7.0.1: eslint-utils "^2.0.0" ramda "^0.27.0" -eslint-plugin-n@16.0.2: - version "16.0.2" - resolved "https://registry.yarnpkg.com/eslint-plugin-n/-/eslint-plugin-n-16.0.2.tgz#5b2c0ad8dd9b724244d30fad2cc49ff4308a2152" - integrity sha512-Y66uDfUNbBzypsr0kELWrIz+5skicECrLUqlWuXawNSLUq3ltGlCwu6phboYYOTSnoTdHgTLrc+5Ydo6KjzZog== +eslint-plugin-n@16.2.0: + version "16.2.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-n/-/eslint-plugin-n-16.2.0.tgz#3f98ca9fadd9f7bdaaf60068533118ecb685bfb5" + integrity sha512-AQER2jEyQOt1LG6JkGJCCIFotzmlcCZFur2wdKrp1JX2cNotC7Ae0BcD/4lLv3lUAArM9uNS8z/fsvXTd0L71g== dependencies: "@eslint-community/eslint-utils" "^4.4.0" builtins "^5.0.1" eslint-plugin-es-x "^7.1.0" + get-tsconfig "^4.7.0" ignore "^5.2.4" is-core-module "^2.12.1" minimatch "^3.1.2" @@ -18518,6 +18528,13 @@ get-symbol-description@^1.0.0: call-bind "^1.0.2" get-intrinsic "^1.1.1" +get-tsconfig@^4.7.0: + version "4.7.2" + resolved "https://registry.yarnpkg.com/get-tsconfig/-/get-tsconfig-4.7.2.tgz#0dcd6fb330391d46332f4c6c1bf89a6514c2ddce" + integrity sha512-wuMsz4leaj5hbGgg4IvDU0bqJagpftG5l5cXIAvo8uZrqn0NJqwtfupTN00VnkQJPcIRrxYrm1Ue24btpCha2A== + dependencies: + resolve-pkg-maps "^1.0.0" + get-value@^2.0.3, get-value@^2.0.6: version "2.0.6" resolved "https://registry.yarnpkg.com/get-value/-/get-value-2.0.6.tgz#dc15ca1c672387ca76bd37ac0a395ba2042a2c28" @@ -27727,6 +27744,11 @@ resolve-path@^1.4.0: http-errors "~1.6.2" path-is-absolute "1.0.1" +resolve-pkg-maps@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz#616b3dc2c57056b5588c31cdf4b3d64db133720f" + integrity sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw== + resolve-url-loader@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/resolve-url-loader/-/resolve-url-loader-5.0.0.tgz#ee3142fb1f1e0d9db9524d539cfa166e9314f795"