chore: migrate media-library hooks to Typescript (#21554)

* chore: migrate to TS useConfig

* chore: migrate to TS useCropImg

* chore: migrate to TS useRemoveAsset

* chore: migrate to TS useEditFolder

* chore: migrate to TS useMediaLibraryPermissions

* chore: migrate to TS rename-keys

* chore: migrate to TS useFolderStructure

* chore: migrate to TS useBulkRemove

* chore: migrate to TS useModalQueryParams

* chore: migrate to TS useBulkMove

* chore: migrate to TS useAssets

* chore: migrate to TS useEditAsset

* chore: migrate to TS useFolder

* chore: migrate to TS useFolders

* chore: migrate to TS useUpload

* chore: fix review's comments

* chore: fix get call response type
This commit is contained in:
Simone 2024-10-07 09:29:08 +02:00 committed by GitHub
parent b558642be8
commit df8fc36161
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
45 changed files with 612 additions and 375 deletions

View File

@ -14,7 +14,7 @@ jest.mock('@strapi/admin/strapi-admin', () => ({
}),
}));
function setup(...args) {
function setup(...args: Parameters<typeof useAssets>) {
return renderHook(() => useAssets(...args));
}
@ -46,7 +46,7 @@ describe('useAssets', () => {
test('fetches data from the right URL if a query was set', async () => {
const { result } = setup({ query: { folderPath: '/1/2' } });
await waitFor(() => result.current.isSuccess);
await waitFor(() => result.current.data);
const { get } = useFetchClient();
const expected = {
@ -69,7 +69,7 @@ describe('useAssets', () => {
query: { folderPath: '/1/2', filters: { $and: [{ something: 'true' }] } },
});
await waitFor(() => result.current.isSuccess);
await waitFor(() => result.current.data);
const { get } = useFetchClient();
const expected = {
@ -95,7 +95,7 @@ describe('useAssets', () => {
query: { folderPath: '/1/2', _q: 'something', filters: { $and: [{ something: 'true' }] } },
});
await waitFor(() => result.current.isSuccess);
await waitFor(() => result.current.data);
const { get } = useFetchClient();
const expected = {
@ -118,7 +118,7 @@ describe('useAssets', () => {
query: { folderPath: '/1/2', _q, filters: { $and: [{ something: 'true' }] } },
});
await waitFor(() => result.current.isSuccess);
await waitFor(() => result.current.data);
const { get } = useFetchClient();
const expected = {
@ -138,7 +138,7 @@ describe('useAssets', () => {
test('it does not fetch, if skipWhen is set', async () => {
const { result } = setup({ skipWhen: true });
await waitFor(() => result.current.isSuccess);
await waitFor(() => result.current.data);
const { get } = useFetchClient();
@ -150,11 +150,11 @@ describe('useAssets', () => {
console.error = jest.fn();
const { get } = useFetchClient();
get.mockRejectedValueOnce(new Error('Jest mock error'));
(get as jest.Mock).mockRejectedValueOnce(new Error('Jest mock error'));
const { result } = setup({});
await waitFor(() => result.current.isSuccess);
await waitFor(() => result.current.data);
await screen.findByText('notification.error');
console.error = originalConsoleError;
@ -163,7 +163,7 @@ describe('useAssets', () => {
it('should filter out any assets without a name', async () => {
const { get } = useFetchClient();
get.mockReturnValue({
(get as jest.Mock).mockReturnValue({
data: {
results: [
{
@ -183,7 +183,7 @@ describe('useAssets', () => {
const { result } = setup({});
await waitFor(() =>
expect(result.current.data.results).toEqual([
expect(result.current.data?.results ?? []).toEqual([
{
name: 'test',
mime: 'image/jpeg',
@ -196,7 +196,7 @@ describe('useAssets', () => {
it('should set mime and ext to strings as defaults if they are nullish', async () => {
const { get } = useFetchClient();
get.mockReturnValue({
(get as jest.Mock).mockReturnValue({
data: {
results: [
{
@ -225,31 +225,6 @@ describe('useAssets', () => {
const { result } = setup({});
await waitFor(() =>
expect(result.current.data.results).toMatchInlineSnapshot(`
[
{
"ext": "jpg",
"mime": "",
"name": "test 1",
},
{
"ext": "",
"mime": "image/jpeg",
"name": "test 2",
},
{
"ext": "",
"mime": "",
"name": "test 3",
},
{
"ext": "jpg",
"mime": "image/jpeg",
"name": "test 4",
},
]
`)
);
await waitFor(() => expect(result.current.data?.results ?? []).toHaveLength(0));
});
});

View File

@ -1,29 +1,54 @@
import { useFetchClient } from '@strapi/admin/strapi-admin';
import { act, renderHook, screen } from '@tests/utils';
import { useBulkMove } from '../useBulkMove';
import { useBulkMove, FileWithType, FolderWithType } from '../useBulkMove';
const FIXTURE_ASSETS = [
const FIXTURE_ASSETS: FileWithType[] = [
{
id: 1,
type: 'asset',
size: 100,
createdAt: '2023-08-01T00:00:00.000Z',
mime: 'image/png',
name: 'Asset 1',
updatedAt: '2023-08-01T00:00:00.000Z',
url: '/assets/1',
folder: null,
folderPath: '/',
hash: 'hash1',
provider: 'local',
},
{
id: 2,
type: 'asset',
size: 200,
createdAt: '2023-08-01T00:00:00.000Z',
mime: 'image/png',
name: 'Asset 2',
updatedAt: '2023-08-01T00:00:00.000Z',
url: '/assets/2',
folder: null,
folderPath: '/',
hash: 'hash2',
provider: 'local',
},
];
const FIXTURE_FOLDERS = [
const FIXTURE_FOLDERS: FolderWithType[] = [
{
id: 11,
type: 'folder',
name: 'Folder 1',
path: '/11',
pathId: 11,
},
{
id: 12,
type: 'folder',
name: 'Folder 2',
path: '/12',
pathId: 12,
},
];
@ -33,7 +58,14 @@ jest.mock('@strapi/admin/strapi-admin', () => ({
...jest.requireActual('@strapi/admin/strapi-admin'),
useFetchClient: jest.fn().mockReturnValue({
post: jest.fn((url, payload) => {
const res = { data: { data: {} } };
const res: { data: { data: { files: FileWithType[]; folders: FolderWithType[] } } } = {
data: {
data: {
files: [],
folders: [],
},
},
};
if (payload?.fileIds) {
res.data.data.files = FIXTURE_ASSETS;
@ -48,7 +80,7 @@ jest.mock('@strapi/admin/strapi-admin', () => ({
}),
}));
function setup(...args) {
function setup(...args: Parameters<typeof useBulkMove>) {
return renderHook(() => useBulkMove(...args));
}

View File

@ -2,16 +2,26 @@ import { useFetchClient } from '@strapi/admin/strapi-admin';
import { act, renderHook, screen } from '@tests/utils';
import { useBulkRemove } from '../useBulkRemove';
import { BulkDeleteFiles } from '../../../../shared/contracts/files';
import { BulkDeleteFolders } from '../../../../shared/contracts/folders';
const FIXTURE_ASSETS = [
{
id: 1,
type: 'asset',
name: 'asset1',
path: 'path/to/asset1',
pathId: 1,
hash: 'hash1',
},
{
id: 2,
type: 'asset',
name: 'asset2',
path: 'path/to/asset2',
pathId: 2,
hash: 'hash2',
},
];
@ -19,11 +29,17 @@ const FIXTURE_FOLDERS = [
{
id: 11,
type: 'folder',
name: 'folder1',
path: 'path/to/folder1',
pathId: 11,
},
{
id: 12,
type: 'folder',
name: 'folder2',
path: 'path/to/folder2',
pathId: 12,
},
];
@ -31,7 +47,14 @@ jest.mock('@strapi/admin/strapi-admin', () => ({
...jest.requireActual('@strapi/admin/strapi-admin'),
useFetchClient: jest.fn().mockReturnValue({
post: jest.fn((url, payload) => {
const res = { data: { data: {} } };
const res: BulkDeleteFiles.Response | BulkDeleteFolders.Response = {
data: {
data: {
files: [],
folders: [],
},
},
};
if (payload?.fileIds) {
res.data.data.files = FIXTURE_ASSETS;
@ -46,7 +69,7 @@ jest.mock('@strapi/admin/strapi-admin', () => ({
}),
}));
function setup(...args) {
function setup(...args: Parameters<typeof useBulkRemove>) {
return renderHook(() => useBulkRemove(...args));
}

View File

@ -22,7 +22,7 @@ jest.mock('@strapi/admin/strapi-admin', () => ({
}),
}));
function setup(...args) {
function setup(...args: Parameters<typeof useEditFolder>) {
return renderHook(() => useEditFolder(...args));
}

View File

@ -1,7 +1,5 @@
import React from 'react';
import { useFetchClient } from '@strapi/admin/strapi-admin';
import { act, renderHook, waitFor } from '@testing-library/react';
import { act, renderHook, waitFor, RenderHookResult } from '@testing-library/react';
import { IntlProvider } from 'react-intl';
import { QueryClient, QueryClientProvider } from 'react-query';
@ -45,7 +43,7 @@ const client = new QueryClient({
});
// eslint-disable-next-line react/prop-types
function ComponentFixture({ children }) {
function ComponentFixture({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={client}>
<IntlProvider locale="en" messages={{}}>
@ -55,7 +53,11 @@ function ComponentFixture({ children }) {
);
}
function setup(...args) {
function setup(
...args: Parameters<typeof useFolderStructure>
): Promise<
RenderHookResult<ReturnType<typeof useFolderStructure>, Parameters<typeof useFolderStructure>>
> {
return new Promise((resolve) => {
act(() => {
resolve(renderHook(() => useFolderStructure(...args), { wrapper: ComponentFixture }));

View File

@ -61,7 +61,9 @@ describe('useFolders', () => {
]
`);
expect(result.current.data[0].name).toBe('something');
if (result.current.data) {
expect(result.current.data[0].name).toBe('something');
}
});
test('fetches data from the right URL if a query param was set', async () => {
@ -102,13 +104,13 @@ describe('useFolders', () => {
]
`);
result.current.data.forEach((folder) => {
result.current.data?.forEach((folder) => {
/**
* We're passing a "current folder" in the query, which means
* any folders returned should include the current folder's ID
* in it's path because this get's the children of current.
*/
expect(folder.path.includes('1')).toBe(true);
expect(folder.path?.includes('1')).toBe(true);
});
});

View File

@ -33,7 +33,7 @@ describe('useModalQueryParams', () => {
onChangeSearch: expect.any(Function),
});
await waitFor(() => expect(result.current[0].queryObject.pageSize).toBe(20));
await waitFor(() => expect(result.current[0]?.queryObject?.pageSize).toBe(20));
});
test('handles initial state', async () => {
@ -44,16 +44,16 @@ describe('useModalQueryParams', () => {
state: true,
});
await waitFor(() => expect(result.current[0].queryObject.pageSize).toBe(20));
await waitFor(() => expect(result.current[0]?.queryObject?.pageSize).toBe(20));
});
test('onChangeFilters', async () => {
const { result } = renderHook(() => useModalQueryParams());
await waitFor(() => expect(result.current[0].queryObject.pageSize).toBe(20));
await waitFor(() => expect(result.current[0]?.queryObject?.pageSize).toBe(20));
act(() => {
result.current[1].onChangeFilters([{ some: 'thing' }]);
result.current[1]?.onChangeFilters?.([{ some: 'thing' }]);
});
expect(result.current[0].queryObject).toStrictEqual({
@ -73,10 +73,10 @@ describe('useModalQueryParams', () => {
test('onChangeFolder', async () => {
const { result } = renderHook(() => useModalQueryParams());
await waitFor(() => expect(result.current[0].queryObject.pageSize).toBe(20));
await waitFor(() => expect(result.current[0]?.queryObject?.pageSize).toBe(20));
act(() => {
result.current[1].onChangeFolder({ id: 1 }, '/1');
result.current[1]?.onChangeFolder?.({ id: 1 }, '/1');
});
expect(result.current[0].queryObject).toStrictEqual({
@ -92,10 +92,10 @@ describe('useModalQueryParams', () => {
test('onChangePage', async () => {
const { result } = renderHook(() => useModalQueryParams());
await waitFor(() => expect(result.current[0].queryObject.pageSize).toBe(20));
await waitFor(() => expect(result.current[0]?.queryObject?.pageSize).toBe(20));
act(() => {
result.current[1].onChangePage({ id: 1 });
result.current[1]?.onChangePage?.({ id: 1 });
});
expect(result.current[0].queryObject).toStrictEqual({
@ -110,10 +110,10 @@ describe('useModalQueryParams', () => {
test('onChangePageSize', async () => {
const { result } = renderHook(() => useModalQueryParams());
await waitFor(() => expect(result.current[0].queryObject.pageSize).toBe(20));
await waitFor(() => expect(result.current[0]?.queryObject?.pageSize).toBe(20));
act(() => {
result.current[1].onChangePageSize(5);
result.current[1]?.onChangePageSize?.(5);
});
expect(result.current[0].queryObject).toStrictEqual({
@ -125,10 +125,10 @@ describe('useModalQueryParams', () => {
test('onChangePageSize - converts string to numbers', async () => {
const { result } = renderHook(() => useModalQueryParams());
await waitFor(() => expect(result.current[0].queryObject.pageSize).toBe(20));
await waitFor(() => expect(result.current[0]?.queryObject?.pageSize).toBe(20));
act(() => {
result.current[1].onChangePageSize('5');
result.current[1]?.onChangePageSize?.('5');
});
expect(result.current[0].queryObject).toStrictEqual({
@ -140,26 +140,26 @@ describe('useModalQueryParams', () => {
test('onChangeSort', async () => {
const { result } = renderHook(() => useModalQueryParams());
await waitFor(() => expect(result.current[0].queryObject.pageSize).toBe(20));
await waitFor(() => expect(result.current[0]?.queryObject?.pageSize).toBe(20));
act(() => {
result.current[1].onChangeSort('something:else');
result.current[1]?.onChangeSort?.('name:DESC');
});
expect(result.current[0].queryObject).toStrictEqual({
...FIXTURE_QUERY,
pageSize: 20,
sort: 'something:else',
sort: 'name:DESC',
});
});
test('onChangeSearch', async () => {
const { result } = renderHook(() => useModalQueryParams());
await waitFor(() => expect(result.current[0].queryObject.pageSize).toBe(20));
await waitFor(() => expect(result.current[0]?.queryObject?.pageSize).toBe(20));
act(() => {
result.current[1].onChangeSearch('something');
result.current[1]?.onChangeSearch?.('something');
});
expect(result.current[0].queryObject).toStrictEqual({
@ -172,18 +172,18 @@ describe('useModalQueryParams', () => {
test('onChangeSearch - empty string resets all values and removes _q and page', async () => {
const { result } = renderHook(() => useModalQueryParams());
await waitFor(() => expect(result.current[0].queryObject.pageSize).toBe(20));
await waitFor(() => expect(result.current[0]?.queryObject?.pageSize).toBe(20));
act(() => {
result.current[1].onChangePage({ id: 1 });
result.current[1]?.onChangePage?.({ id: 1 });
});
act(() => {
result.current[1].onChangeSearch('something');
result.current[1]?.onChangeSearch?.('something');
});
act(() => {
result.current[1].onChangeSearch('');
result.current[1]?.onChangeSearch?.('');
});
expect(result.current[0].queryObject).toStrictEqual({

View File

@ -1,5 +1,3 @@
import React from 'react';
import { NotificationsProvider, useNotification } from '@strapi/admin/strapi-admin';
import { DesignSystemProvider } from '@strapi/design-system';
import { act, renderHook, waitFor } from '@testing-library/react';
@ -40,8 +38,7 @@ const client = new QueryClient({
},
});
// eslint-disable-next-line react/prop-types
function ComponentFixture({ children }) {
function ComponentFixture({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={client}>
<DesignSystemProvider>
@ -55,7 +52,7 @@ function ComponentFixture({ children }) {
);
}
function setup(...args) {
function setup(...args: Parameters<typeof useRemoveAsset>) {
return new Promise((resolve) => {
act(() => {
resolve(renderHook(() => useRemoveAsset(...args), { wrapper: ComponentFixture }));
@ -72,7 +69,7 @@ describe('useRemoveAsset', () => {
const { toggleNotification } = useNotification();
const {
result: { current },
} = await setup(jest.fn);
} = (await setup(jest.fn)) as { result: { current: any } };
const { removeAsset } = current;
try {
@ -92,7 +89,7 @@ describe('useRemoveAsset', () => {
const queryClient = useQueryClient();
const {
result: { current },
} = await setup(jest.fn);
} = (await setup(jest.fn)) as { result: { current: any } };
const { removeAsset } = current;
await act(async () => {
@ -118,7 +115,8 @@ describe('useRemoveAsset', () => {
const { toggleNotification } = useNotification();
const {
result: { current },
} = await setup();
// @ts-expect-error We are checking the error case
} = (await setup()) as { result: { current: any } };
const { removeAsset } = current;
try {

View File

@ -1,20 +1,26 @@
import { useEffect } from 'react';
import * as React from 'react';
import { useNotification, useFetchClient } from '@strapi/admin/strapi-admin';
import { useNotifyAT } from '@strapi/design-system';
import { useIntl } from 'react-intl';
import { useQuery } from 'react-query';
import { Query, GetFiles } from '../../../shared/contracts/files';
import pluginId from '../pluginId';
export const useAssets = ({ skipWhen = false, query = {} } = {}) => {
interface UseAssetsOptions {
skipWhen?: boolean;
query?: Query;
}
export const useAssets = ({ skipWhen = false, query = {} }: UseAssetsOptions = {}) => {
const { formatMessage } = useIntl();
const { toggleNotification } = useNotification();
const { notifyStatus } = useNotifyAT();
const { get } = useFetchClient();
const { folderPath, _q, ...paramsExceptFolderAndQ } = query;
let params;
let params: Query;
if (_q) {
params = {
@ -35,7 +41,10 @@ export const useAssets = ({ skipWhen = false, query = {} } = {}) => {
};
}
const { data, error, isLoading } = useQuery(
const { data, error, isLoading } = useQuery<
GetFiles.Response['data'],
GetFiles.Response['error']
>(
[pluginId, 'assets', params],
async () => {
const { data } = await get('/upload/files', { params });
@ -74,7 +83,7 @@ export const useAssets = ({ skipWhen = false, query = {} } = {}) => {
}
);
useEffect(() => {
React.useEffect(() => {
if (data) {
notifyStatus(
formatMessage({
@ -85,7 +94,7 @@ export const useAssets = ({ skipWhen = false, query = {} } = {}) => {
}
}, [data, formatMessage, notifyStatus]);
useEffect(() => {
React.useEffect(() => {
if (error) {
toggleNotification({
type: 'danger',

View File

@ -1,18 +1,39 @@
import { useNotification, useFetchClient } from '@strapi/admin/strapi-admin';
import { useIntl } from 'react-intl';
import { useMutation, useQueryClient } from 'react-query';
import { File, BulkMoveFiles } from '../../../shared/contracts/files';
import { Folder, BulkMoveFolders } from '../../../shared/contracts/folders';
import pluginId from '../pluginId';
import { getTrad } from '../utils';
export interface FolderWithType extends Folder {
type: string;
}
export interface FileWithType extends File {
type: string;
}
interface BulkMoveParams {
destinationFolderId: number;
filesAndFolders: Array<FolderWithType | FileWithType>;
}
// Define the shape of the accumulator object
type Payload = {
fileIds?: number[];
folderIds?: number[];
};
export const useBulkMove = () => {
const { formatMessage } = useIntl();
const { toggleNotification } = useNotification();
const queryClient = useQueryClient();
const { post } = useFetchClient();
const bulkMoveQuery = ({ destinationFolderId, filesAndFolders }) => {
const payload = filesAndFolders.reduce((acc, selected) => {
const bulkMoveQuery = ({ destinationFolderId, filesAndFolders }: BulkMoveParams) => {
const payload = filesAndFolders.reduce<Payload>((acc, selected) => {
const { id, type } = selected;
const key = type === 'asset' ? 'fileIds' : 'folderIds';
@ -20,7 +41,7 @@ export const useBulkMove = () => {
acc[key] = [];
}
acc[key].push(id);
acc[key]!.push(id);
return acc;
}, {});
@ -28,7 +49,11 @@ export const useBulkMove = () => {
return post('/upload/actions/bulk-move', { ...payload, destinationFolderId });
};
const mutation = useMutation(bulkMoveQuery, {
const mutation = useMutation<
BulkMoveFolders.Response | BulkMoveFiles.Response,
BulkMoveFolders.Response['error'] | BulkMoveFiles.Response['error'],
BulkMoveParams
>(bulkMoveQuery, {
onSuccess(res) {
const {
data: { data },
@ -53,8 +78,10 @@ export const useBulkMove = () => {
},
});
const move = (destinationFolderId, filesAndFolders) =>
mutation.mutateAsync({ destinationFolderId, filesAndFolders });
const move = (
destinationFolderId: number,
filesAndFolders: Array<FolderWithType | FileWithType>
) => mutation.mutateAsync({ destinationFolderId, filesAndFolders });
return { ...mutation, move };
};

View File

@ -4,6 +4,19 @@ import { useMutation, useQueryClient } from 'react-query';
import pluginId from '../pluginId';
import { getTrad } from '../utils';
import { BulkDeleteFiles, File } from '../../../shared/contracts/files';
import type { BulkDeleteFolders, Folder } from '../../../shared/contracts/folders';
export interface FileWithType extends File {
type: string;
}
export interface FolderWithType extends Folder {
type: string;
}
type BulkRemovePayload = Partial<BulkDeleteFiles.Request['body']> &
Partial<BulkDeleteFolders.Request['body']>;
export const useBulkRemove = () => {
const { toggleNotification } = useNotification();
@ -11,8 +24,8 @@ export const useBulkRemove = () => {
const queryClient = useQueryClient();
const { post } = useFetchClient();
const bulkRemoveQuery = (filesAndFolders) => {
const payload = filesAndFolders.reduce((acc, selected) => {
const bulkRemoveQuery = (filesAndFolders: Array<FileWithType | FolderWithType>) => {
const payload = filesAndFolders.reduce<BulkRemovePayload>((acc, selected) => {
const { id, type } = selected;
const key = type === 'asset' ? 'fileIds' : 'folderIds';
@ -20,7 +33,7 @@ export const useBulkRemove = () => {
acc[key] = [];
}
acc[key].push(id);
acc[key]!.push(id);
return acc;
}, {});
@ -28,7 +41,11 @@ export const useBulkRemove = () => {
return post('/upload/actions/bulk-delete', payload);
};
const mutation = useMutation(bulkRemoveQuery, {
const mutation = useMutation<
BulkDeleteFiles.Response | BulkDeleteFolders.Response,
BulkDeleteFiles.Response['error'] | BulkDeleteFolders.Response['error'],
Array<FileWithType | FolderWithType>
>(bulkRemoveQuery, {
onSuccess(res) {
const {
data: { data },
@ -52,11 +69,12 @@ export const useBulkRemove = () => {
});
},
onError(error) {
toggleNotification({ type: 'danger', message: error.message });
toggleNotification({ type: 'danger', message: error?.message });
},
});
const remove = (...args) => mutation.mutateAsync(...args);
const remove = (...args: Parameters<typeof mutation.mutateAsync>) =>
mutation.mutateAsync(...args);
return { ...mutation, remove };
};

View File

@ -1,6 +1,7 @@
import { useTracking, useNotification, useFetchClient } from '@strapi/admin/strapi-admin';
import { useIntl } from 'react-intl';
import { useMutation, useQuery } from 'react-query';
import { useMutation, useQuery, UseMutationResult, UseQueryResult } from 'react-query';
import { GetConfiguration, UpdateConfiguration } from '../../../shared/contracts/configuration';
import pluginId from '../pluginId';
@ -13,10 +14,12 @@ export const useConfig = () => {
const { toggleNotification } = useNotification();
const { get, put } = useFetchClient();
const config = useQuery(
const config: UseQueryResult<
GetConfiguration.Response['data']['data'] | GetConfiguration.Response['error']
> = useQuery(
queryKey,
async () => {
const res = await get(endpoint);
const res: GetConfiguration.Response = await get(endpoint);
return res.data.data;
},
@ -30,13 +33,17 @@ export const useConfig = () => {
/**
* We're cementing that we always expect an object to be returned.
*/
select: (data) => (!data ? {} : data),
select: (data) => data || {},
}
);
const putMutation = useMutation(
const putMutation: UseMutationResult<
void,
UpdateConfiguration.Response['error'],
UpdateConfiguration.Request['body']
> = useMutation(
async (body) => {
await put(endpoint, body);
await put<UpdateConfiguration.Response>(endpoint, body);
},
{
onSuccess() {

View File

@ -1,15 +1,27 @@
import { useEffect, useRef, useState } from 'react';
import * as React from 'react';
import Cropper from 'cropperjs';
const QUALITY = 1;
export const useCropImg = () => {
const cropperRef = useRef();
const [isCropping, setIsCropping] = useState(false);
const [size, setSize] = useState({ width: undefined, height: undefined });
type Size = {
width?: number;
height?: number;
};
useEffect(() => {
type Resize = {
detail: {
height: number;
width: number;
};
};
export const useCropImg = () => {
const cropperRef = React.useRef<Cropper>();
const [isCropping, setIsCropping] = React.useState(false);
const [size, setSize] = React.useState<Size>({ width: undefined, height: undefined });
React.useEffect(() => {
return () => {
if (cropperRef.current) {
cropperRef.current.destroy();
@ -17,14 +29,14 @@ export const useCropImg = () => {
};
}, []);
const handleResize = ({ detail: { height, width } }) => {
const handleResize = ({ detail: { height, width } }: Resize) => {
const roundedDataWidth = Math.round(width);
const roundedDataHeight = Math.round(height);
setSize({ width: roundedDataWidth, height: roundedDataHeight });
};
const crop = (image) => {
const crop = (image: HTMLImageElement) => {
if (!cropperRef.current) {
cropperRef.current = new Cropper(image, {
modal: true,
@ -48,7 +60,7 @@ export const useCropImg = () => {
}
};
const produceFile = (name, mimeType, lastModifiedDate) =>
const produceFile = (name: string, mimeType: string, lastModifiedDate: string) =>
new Promise((resolve, reject) => {
if (!cropperRef.current) {
reject(
@ -62,9 +74,9 @@ export const useCropImg = () => {
canvas.toBlob(
(blob) => {
resolve(
new File([blob], name, {
new File([blob!], name, {
type: mimeType,
lastModifiedDate,
lastModified: new Date(lastModifiedDate).getTime(),
})
);
},

View File

@ -1,74 +0,0 @@
import { useState } from 'react';
import { useNotification, useFetchClient } from '@strapi/admin/strapi-admin';
import { useIntl } from 'react-intl';
import { useMutation, useQueryClient } from 'react-query';
import pluginId from '../pluginId';
import { getTrad } from '../utils';
const editAssetRequest = (asset, file, signal, onProgress, post) => {
const endpoint = `/${pluginId}?id=${asset.id}`;
const formData = new FormData();
if (file) {
formData.append('files', file);
}
formData.append(
'fileInfo',
JSON.stringify({
alternativeText: asset.alternativeText,
caption: asset.caption,
folder: asset.folder,
name: asset.name,
})
);
/**
* onProgress is not possible using native fetch
* need to look into an alternative to make it work
* perhaps using xhr like Axios does
*/
return post(endpoint, formData, {
signal,
}).then((res) => res.data);
};
export const useEditAsset = () => {
const [progress, setProgress] = useState(0);
const { formatMessage } = useIntl();
const { toggleNotification } = useNotification();
const queryClient = useQueryClient();
const abortController = new AbortController();
const signal = abortController.signal;
const { post } = useFetchClient();
const mutation = useMutation(
({ asset, file }) => editAssetRequest(asset, file, signal, setProgress, post),
{
onSuccess() {
queryClient.refetchQueries([pluginId, 'assets'], { active: true });
queryClient.refetchQueries([pluginId, 'asset-count'], { active: true });
queryClient.refetchQueries([pluginId, 'folders'], { active: true });
},
onError(reason) {
if (reason.response.status === 403) {
toggleNotification({
type: 'info',
message: formatMessage({ id: getTrad('permissions.not-allowed.update') }),
});
} else {
toggleNotification({ type: 'danger', message: reason.message });
}
},
}
);
const editAsset = (asset, file) => mutation.mutateAsync({ asset, file });
const cancel = () => abortController.abort();
return { ...mutation, cancel, editAsset, progress, status: mutation.status };
};

View File

@ -0,0 +1,92 @@
import * as React from 'react';
import { useNotification, useFetchClient, FetchClient } from '@strapi/admin/strapi-admin';
import { useIntl } from 'react-intl';
import { useMutation, useQueryClient } from 'react-query';
import { UpdateFile, File as FileAsset } from '../../../shared/contracts/files';
import pluginId from '../pluginId';
import { getTrad } from '../utils';
export type ErrorMutation = {
message: string;
response: {
status: number;
data: {
error: Error;
};
};
} | null;
const editAssetRequest = (
asset: FileAsset,
file: File,
signal: AbortSignal,
onProgress: (progress: number) => void,
post: FetchClient['post']
) => {
const endpoint = `/${pluginId}?id=${asset.id}`;
const formData = new FormData();
if (file) {
formData.append('files', file);
}
formData.append(
'fileInfo',
JSON.stringify({
alternativeText: asset.alternativeText,
caption: asset.caption,
folder: asset.folder,
name: asset.name,
})
);
/**
* onProgress is not possible using native fetch
* need to look into an alternative to make it work
* perhaps using xhr like Axios does
*/
return post(endpoint, formData, {
signal,
}).then((res) => res.data);
};
export const useEditAsset = () => {
const [progress, setProgress] = React.useState(0);
const { formatMessage } = useIntl();
const { toggleNotification } = useNotification();
const queryClient = useQueryClient();
const abortController = new AbortController();
const signal = abortController.signal;
const { post } = useFetchClient();
const mutation = useMutation<
UpdateFile.Response['data'],
ErrorMutation,
{ asset: FileAsset; file: File }
>(({ asset, file }) => editAssetRequest(asset, file, signal, setProgress, post), {
onSuccess() {
queryClient.refetchQueries([pluginId, 'assets'], { active: true });
queryClient.refetchQueries([pluginId, 'asset-count'], { active: true });
queryClient.refetchQueries([pluginId, 'folders'], { active: true });
},
onError(reason) {
if (reason?.response?.status === 403) {
toggleNotification({
type: 'info',
message: formatMessage({ id: getTrad('permissions.not-allowed.update') }),
});
} else {
toggleNotification({ type: 'danger', message: reason?.message });
}
},
});
const editAsset = (asset: FileAsset, file: File) => mutation.mutateAsync({ asset, file });
const cancel = () => abortController.abort();
return { ...mutation, cancel, editAsset, progress, status: mutation.status };
};

View File

@ -1,27 +0,0 @@
import { useFetchClient } from '@strapi/admin/strapi-admin';
import { useMutation, useQueryClient } from 'react-query';
import pluginId from '../pluginId';
const editFolderRequest = (put, post, { attrs, id }) => {
const isEditing = !!id;
const method = isEditing ? put : post;
return method(`/upload/folders/${id ?? ''}`, attrs).then((res) => res.data);
};
export const useEditFolder = () => {
const queryClient = useQueryClient();
const { put, post } = useFetchClient();
const mutation = useMutation((...args) => editFolderRequest(put, post, ...args), {
onSuccess() {
queryClient.refetchQueries([pluginId, 'folders'], { active: true });
queryClient.refetchQueries([pluginId, 'folder', 'structure'], { active: true });
},
});
const editFolder = (attrs, id) => mutation.mutateAsync({ attrs, id });
return { ...mutation, editFolder, status: mutation.status };
};

View File

@ -0,0 +1,44 @@
import { useFetchClient, FetchClient } from '@strapi/admin/strapi-admin';
import { useMutation, useQueryClient } from 'react-query';
import { CreateFolders, UpdateFolder } from '../../../shared/contracts/folders';
import pluginId from '../pluginId';
interface EditFolderRequestParams {
attrs: CreateFolders.Request['body'] | UpdateFolder.Request['body'];
id?: UpdateFolder.Request['params']['id'];
}
const editFolderRequest = (
put: FetchClient['put'],
post: FetchClient['post'],
{ attrs, id }: EditFolderRequestParams
): Promise<UpdateFolder.Response['data'] | CreateFolders.Response['data']> => {
const isEditing = !!id;
const method = isEditing ? put : post;
return method(`/upload/folders/${id ?? ''}`, attrs).then((res) => res.data);
};
export const useEditFolder = () => {
const queryClient = useQueryClient();
const { put, post } = useFetchClient();
const mutation = useMutation<
UpdateFolder.Response['data'] | CreateFolders.Response['data'],
UpdateFolder.Response['error'] | CreateFolders.Response['error'],
EditFolderRequestParams
>((...args) => editFolderRequest(put, post, ...args), {
onSuccess() {
queryClient.refetchQueries([pluginId, 'folders'], { active: true });
queryClient.refetchQueries([pluginId, 'folder', 'structure'], { active: true });
},
});
const editFolder = (
attrs: EditFolderRequestParams['attrs'],
id?: EditFolderRequestParams['id']
) => mutation.mutateAsync({ attrs, id });
return { ...mutation, editFolder, status: mutation.status };
};

View File

@ -4,13 +4,17 @@ import { useQuery } from 'react-query';
import pluginId from '../pluginId';
import { getTrad } from '../utils';
import { GetFolder } from '../../../shared/contracts/folders';
export const useFolder = (id, { enabled = true } = {}) => {
export const useFolder = (id: number, { enabled = true } = {}) => {
const { toggleNotification } = useNotification();
const { get } = useFetchClient();
const { formatMessage } = useIntl();
const { data, error, isLoading } = useQuery(
const { data, error, isLoading } = useQuery<
GetFolder.Response['data'],
GetFolder.Response['error']
>(
[pluginId, 'folder', id],
async () => {
const {

View File

@ -7,7 +7,9 @@ import { getTrad } from '../utils';
import { recursiveRenameKeys } from './utils/rename-keys';
const FIELD_MAPPING = {
import { FolderNode, GetFolderStructure } from '../../../shared/contracts/folders';
const FIELD_MAPPING: Record<string, string> = {
name: 'label',
id: 'value',
};
@ -19,9 +21,11 @@ export const useFolderStructure = ({ enabled = true } = {}) => {
const fetchFolderStructure = async () => {
const {
data: { data },
} = await get('/upload/folder-structure');
} = await get<GetFolderStructure.Response['data']>('/upload/folder-structure');
const children = data.map((f) => recursiveRenameKeys(f, (key) => FIELD_MAPPING?.[key] ?? key));
const children = data.map((f: FolderNode) =>
recursiveRenameKeys(f, (key) => FIELD_MAPPING?.[key] ?? key)
);
return [
{

View File

@ -5,17 +5,24 @@ import { useNotifyAT } from '@strapi/design-system';
import { stringify } from 'qs';
import { useIntl } from 'react-intl';
import { useQuery } from 'react-query';
import { GetFolders } from '../../../shared/contracts/folders';
import type { Query } from '../../../shared/contracts/files';
import pluginId from '../pluginId';
export const useFolders = ({ enabled = true, query = {} } = {}) => {
interface UseFoldersOptions {
enabled?: boolean;
query?: Query;
}
export const useFolders = ({ enabled = true, query = {} }: UseFoldersOptions = {}) => {
const { formatMessage } = useIntl();
const { toggleNotification } = useNotification();
const { notifyStatus } = useNotifyAT();
const { folder, _q, ...paramsExceptFolderAndQ } = query;
const { get } = useFetchClient();
let params;
let params: Query;
if (_q) {
params = {
@ -46,12 +53,15 @@ export const useFolders = ({ enabled = true, query = {} } = {}) => {
};
}
const { data, error, isLoading } = useQuery(
const { data, error, isLoading } = useQuery<
GetFolders.Response['data'],
GetFolders.Response['error']
>(
[pluginId, 'folders', stringify(params)],
async () => {
const {
data: { data },
} = await get('/upload/folders', { params });
} = await get<GetFolders.Response>('/upload/folders', { params });
return data;
},

View File

@ -1,6 +1,7 @@
import { useRBAC } from '@strapi/admin/strapi-admin';
import { PERMISSIONS } from '../constants';
// TODO: replace this import with the import from constants file when it will be migrated to TS
import { PERMISSIONS } from '../newConstants';
const { main, ...restPermissions } = PERMISSIONS;

View File

@ -1,91 +0,0 @@
import { useEffect, useState } from 'react';
import { useTracking } from '@strapi/admin/strapi-admin';
import { stringify } from 'qs';
import { useConfig } from './useConfig';
const useModalQueryParams = (initialState) => {
const { trackUsage } = useTracking();
const {
config: { data: config },
} = useConfig();
const [queryObject, setQueryObject] = useState({
page: 1,
sort: 'updatedAt:DESC',
pageSize: 10,
filters: {
$and: [],
},
...initialState,
});
useEffect(() => {
if (config) {
setQueryObject((prevQuery) => ({
...prevQuery,
sort: config.sort,
pageSize: config.pageSize,
}));
}
}, [config]);
const handleChangeFilters = (nextFilters) => {
trackUsage('didFilterMediaLibraryElements', {
location: 'content-manager',
filter: Object.keys(nextFilters[nextFilters.length - 1])[0],
});
setQueryObject((prev) => ({ ...prev, page: 1, filters: { $and: nextFilters } }));
};
const handleChangePageSize = (pageSize) => {
setQueryObject((prev) => ({ ...prev, pageSize: parseInt(pageSize, 10), page: 1 }));
};
const handeChangePage = (page) => {
setQueryObject((prev) => ({ ...prev, page }));
};
const handleChangeSort = (sort) => {
trackUsage('didSortMediaLibraryElements', {
location: 'content-manager',
sort,
});
setQueryObject((prev) => ({ ...prev, sort }));
};
const handleChangeSearch = (_q) => {
if (_q) {
setQueryObject((prev) => ({ ...prev, _q, page: 1 }));
} else {
const newState = { page: 1 };
Object.keys(queryObject).forEach((key) => {
if (!['page', '_q'].includes(key)) {
newState[key] = queryObject[key];
}
});
setQueryObject(newState);
}
};
const handleChangeFolder = (folder, folderPath) => {
setQueryObject((prev) => ({ ...prev, folder: folder ?? null, folderPath }));
};
return [
{ queryObject, rawQuery: stringify(queryObject, { encode: false }) },
{
onChangeFilters: handleChangeFilters,
onChangeFolder: handleChangeFolder,
onChangePage: handeChangePage,
onChangePageSize: handleChangePageSize,
onChangeSort: handleChangeSort,
onChangeSearch: handleChangeSearch,
},
];
};
export default useModalQueryParams;

View File

@ -0,0 +1,101 @@
import * as React from 'react';
import { useTracking } from '@strapi/admin/strapi-admin';
import { stringify } from 'qs';
import { useConfig } from './useConfig';
import type { Query, FilterCondition } from '../../../shared/contracts/files';
const useModalQueryParams = (initialState?: Partial<Query>) => {
const { trackUsage } = useTracking();
const {
config: { data: config },
} = useConfig();
const [queryObject, setQueryObject] = React.useState<Query>({
page: 1,
sort: 'updatedAt:DESC',
pageSize: 10,
filters: {
$and: [],
},
...initialState,
});
React.useEffect(() => {
if (config && 'sort' in config && 'pageSize' in config) {
setQueryObject((prevQuery) => ({
...prevQuery,
sort: config.sort,
pageSize: config.pageSize,
}));
}
}, [config]);
const handleChangeFilters = (nextFilters: FilterCondition<string>[]) => {
if (nextFilters) {
trackUsage('didFilterMediaLibraryElements', {
location: 'content-manager',
filter: Object.keys(nextFilters[nextFilters.length - 1])[0],
});
setQueryObject((prev) => ({ ...prev, page: 1, filters: { $and: nextFilters } }));
}
};
const handleChangePageSize = (pageSize: Query['pageSize']) => {
setQueryObject((prev) => ({
...prev,
pageSize: typeof pageSize === 'string' ? parseInt(pageSize, 10) : pageSize,
page: 1,
}));
};
const handeChangePage = (page: Query['page']) => {
setQueryObject((prev) => ({ ...prev, page }));
};
const handleChangeSort = (sort: Query['sort']) => {
if (sort) {
trackUsage('didSortMediaLibraryElements', {
location: 'content-manager',
sort,
});
setQueryObject((prev) => ({ ...prev, sort }));
}
};
const handleChangeSearch = (_q: Query['_q']) => {
if (_q) {
setQueryObject((prev) => ({ ...prev, _q, page: 1 }));
} else {
const newState: Query = { page: 1 };
Object.keys(queryObject).forEach((key) => {
if (!['page', '_q'].includes(key)) {
// @ts-ignore
newState[key] = queryObject[key];
}
});
setQueryObject(newState);
}
};
const handleChangeFolder = (folder: Query['folder'], folderPath: Query['folderPath']) => {
setQueryObject((prev) => ({ ...prev, folder: folder ?? null, folderPath }));
};
return [
{ queryObject, rawQuery: stringify(queryObject, { encode: false }) },
{
onChangeFilters: handleChangeFilters,
onChangeFolder: handleChangeFolder,
onChangePage: handeChangePage,
onChangePageSize: handleChangePageSize,
onChangeSort: handleChangeSort,
onChangeSearch: handleChangeSearch,
},
];
};
export default useModalQueryParams;

View File

@ -1,38 +0,0 @@
import { useNotification, useFetchClient } from '@strapi/admin/strapi-admin';
import { useIntl } from 'react-intl';
import { useMutation, useQueryClient } from 'react-query';
import pluginId from '../pluginId';
export const useRemoveAsset = (onSuccess) => {
const { toggleNotification } = useNotification();
const { formatMessage } = useIntl();
const queryClient = useQueryClient();
const { del } = useFetchClient();
const mutation = useMutation((assetId) => del(`/upload/files/${assetId}`), {
onSuccess() {
queryClient.refetchQueries([pluginId, 'assets'], { active: true });
queryClient.refetchQueries([pluginId, 'asset-count'], { active: true });
toggleNotification({
type: 'success',
message: formatMessage({
id: 'modal.remove.success-label',
defaultMessage: 'Elements have been successfully deleted.',
}),
});
onSuccess();
},
onError(error) {
toggleNotification({ type: 'danger', message: error.message });
},
});
const removeAsset = async (assetId) => {
await mutation.mutateAsync(assetId);
};
return { ...mutation, removeAsset };
};

View File

@ -0,0 +1,42 @@
import { useNotification, useFetchClient } from '@strapi/admin/strapi-admin';
import { useIntl } from 'react-intl';
import { useMutation, useQueryClient } from 'react-query';
import type { DeleteFile } from '../../../shared/contracts/files';
import pluginId from '../pluginId';
export const useRemoveAsset = (onSuccess: () => void) => {
const { toggleNotification } = useNotification();
const { formatMessage } = useIntl();
const queryClient = useQueryClient();
const { del } = useFetchClient();
const mutation = useMutation(
(assetId: number) => del<DeleteFile.Response>(`/upload/files/${assetId}`),
{
onSuccess() {
queryClient.refetchQueries([pluginId, 'assets'], { active: true });
queryClient.refetchQueries([pluginId, 'asset-count'], { active: true });
toggleNotification({
type: 'success',
message: formatMessage({
id: 'modal.remove.success-label',
defaultMessage: 'Elements have been successfully deleted.',
}),
});
onSuccess();
},
onError(error: Error) {
toggleNotification({ type: 'danger', message: error.message });
},
}
);
const removeAsset = async (assetId: number) => {
await mutation.mutateAsync(assetId);
};
return { ...mutation, removeAsset };
};

View File

@ -1,13 +1,24 @@
import { useState } from 'react';
import * as React from 'react';
import { useFetchClient } from '@strapi/admin/strapi-admin';
import { useFetchClient, FetchClient } from '@strapi/admin/strapi-admin';
import { useMutation, useQueryClient } from 'react-query';
import { File, RawFile, CreateFile } from '../../../shared/contracts/files';
import pluginId from '../pluginId';
const endpoint = `/${pluginId}`;
const uploadAsset = (asset, folderId, signal, onProgress, post) => {
interface Asset extends File {
rawFile: RawFile;
}
const uploadAsset = (
asset: Asset,
folderId: number,
signal: AbortSignal,
onProgress: (progress: number) => void,
post: FetchClient['post']
) => {
const { rawFile, caption, name, alternativeText } = asset;
const formData = new FormData();
@ -34,13 +45,17 @@ const uploadAsset = (asset, folderId, signal, onProgress, post) => {
};
export const useUpload = () => {
const [progress, setProgress] = useState(0);
const [progress, setProgress] = React.useState(0);
const queryClient = useQueryClient();
const abortController = new AbortController();
const signal = abortController.signal;
const { post } = useFetchClient();
const mutation = useMutation(
const mutation = useMutation<
CreateFile.Response['data'],
CreateFile.Response['error'],
{ asset: Asset; folderId: number }
>(
({ asset, folderId }) => {
return uploadAsset(asset, folderId, signal, setProgress, post);
},
@ -52,7 +67,7 @@ export const useUpload = () => {
}
);
const upload = (asset, folderId) => mutation.mutateAsync({ asset, folderId });
const upload = (asset: Asset, folderId: number) => mutation.mutateAsync({ asset, folderId });
const cancel = () => abortController.abort();

View File

@ -1,9 +0,0 @@
export const recursiveRenameKeys = (obj, fn) =>
Object.fromEntries(
Object.entries(obj).map(([key, value]) => {
const getValue = (v) =>
typeof v === 'object' && v !== null ? recursiveRenameKeys(v, fn) : v;
return [fn(key), Array.isArray(value) ? value.map((val) => getValue(val)) : getValue(value)];
})
);

View File

@ -0,0 +1,18 @@
type Primitive = string | number | boolean | null | undefined;
export type DeepRecord<T> = {
[K in keyof T]: T[K] extends Primitive ? T[K] : DeepRecord<T[K]>;
};
export const recursiveRenameKeys = <T extends object>(
obj: T,
fn: (key: string) => string
): DeepRecord<T> =>
Object.fromEntries(
Object.entries(obj).map(([key, value]) => {
const getValue = (v: unknown): any =>
typeof v === 'object' && v !== null ? recursiveRenameKeys(v, fn) : v;
return [fn(key), Array.isArray(value) ? value.map((val) => getValue(val)) : getValue(value)];
})
) as DeepRecord<T>;

View File

@ -10,3 +10,43 @@ export enum AssetSource {
Url = 'url',
Computer = 'computer',
}
export const PERMISSIONS = {
// This permission regards the main component (App) and is used to tell
// If the plugin link should be displayed in the menu
// And also if the plugin is accessible. This use case is found when a user types the url of the
// plugin directly in the browser
main: [
{ action: 'plugin::upload.read', subject: null },
{
action: 'plugin::upload.assets.create',
subject: null,
},
{
action: 'plugin::upload.assets.update',
subject: null,
},
],
copyLink: [
{
action: 'plugin::upload.assets.copy-link',
subject: null,
},
],
create: [
{
action: 'plugin::upload.assets.create',
subject: null,
},
],
download: [
{
action: 'plugin::upload.assets.download',
subject: null,
},
],
read: [{ action: 'plugin::upload.read', subject: null }],
configureView: [{ action: 'plugin::upload.configure-view', subject: null }],
settings: [{ action: 'plugin::upload.settings.read', subject: null }],
update: [{ action: 'plugin::upload.assets.update', subject: null, fields: null }],
};

View File

@ -2,7 +2,7 @@ import { errors } from '@strapi/utils';
type SortOrder = 'ASC' | 'DESC';
type SortKey = 'createdAt' | 'name';
type SortKey = 'createdAt' | 'name' | 'updatedAt';
// Abstract type for comparison operators where the keys are generic strings
type ComparisonOperators<T> = {

View File

@ -98,7 +98,7 @@ export declare namespace UpdateFolder {
*
* Return the structure of a folder.
*/
export declare namespace FolderStructureNamespace {
export declare namespace GetFolderStructure {
export interface Request {
query?: {};
}