✨(frontend) can restore from trashbin list actions
We can now restore a doc from the trashbin list actions.
This commit is contained in:
@@ -29,10 +29,12 @@ test.describe('Doc Trashbin', () => {
|
||||
const row1 = await getGridRow(page, title1);
|
||||
await clickInGridMenu(page, row1, 'Delete');
|
||||
await page.getByRole('button', { name: 'Delete document' }).click();
|
||||
await expect(row1.getByText(title1)).toBeHidden();
|
||||
|
||||
const row2 = await getGridRow(page, title2);
|
||||
await clickInGridMenu(page, row2, 'Delete');
|
||||
await page.getByRole('button', { name: 'Delete document' }).click();
|
||||
await expect(row2.getByText(title2)).toBeHidden();
|
||||
|
||||
await page.getByRole('link', { name: 'Trashbin' }).click();
|
||||
|
||||
@@ -51,5 +53,25 @@ test.describe('Doc Trashbin', () => {
|
||||
name: 'Open the sharing settings for the document',
|
||||
}),
|
||||
).toBeDisabled();
|
||||
|
||||
await clickInGridMenu(page, row2, 'Restore');
|
||||
|
||||
await expect(row2.getByText(title2)).toBeHidden();
|
||||
await page.getByRole('link', { name: 'All docs' }).click();
|
||||
const row2Restored = await getGridRow(page, title2);
|
||||
await expect(row2Restored.getByText(title2)).toBeVisible();
|
||||
await row2Restored.getByRole('link', { name: /Open document/ }).click();
|
||||
|
||||
await verifyDocName(page, title2);
|
||||
await page.getByRole('button', { name: 'Back to homepage' }).click();
|
||||
await expect(row2.getByText(title2)).toBeVisible();
|
||||
await expect(
|
||||
row2.getByRole('button', {
|
||||
name: 'Open the sharing settings for the document',
|
||||
}),
|
||||
).toBeEnabled();
|
||||
|
||||
await page.getByRole('link', { name: 'Trashbin' }).click();
|
||||
await expect(row2.getByText(title2)).toBeHidden();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,8 +25,6 @@ export const TextErrors = ({
|
||||
canClose = false,
|
||||
...textProps
|
||||
}: TextErrorsProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<AlertStyled
|
||||
canClose={canClose}
|
||||
@@ -34,31 +32,47 @@ export const TextErrors = ({
|
||||
icon={icon}
|
||||
className="--docs--text-errors"
|
||||
>
|
||||
<Box $direction="column" $gap="0.2rem">
|
||||
{causes &&
|
||||
causes.map((cause, i) => (
|
||||
<Text
|
||||
key={`causes-${i}`}
|
||||
$theme="danger"
|
||||
$variation="600"
|
||||
$textAlign="center"
|
||||
{...textProps}
|
||||
>
|
||||
{cause}
|
||||
</Text>
|
||||
))}
|
||||
<TextOnlyErrors
|
||||
causes={causes}
|
||||
defaultMessage={defaultMessage}
|
||||
{...textProps}
|
||||
/>
|
||||
</AlertStyled>
|
||||
);
|
||||
};
|
||||
|
||||
{!causes && (
|
||||
export const TextOnlyErrors = ({
|
||||
causes,
|
||||
defaultMessage,
|
||||
...textProps
|
||||
}: TextErrorsProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Box $direction="column" $gap="0.2rem">
|
||||
{causes &&
|
||||
causes.map((cause, i) => (
|
||||
<Text
|
||||
key={`causes-${i}`}
|
||||
$theme="danger"
|
||||
$variation="600"
|
||||
$textAlign="center"
|
||||
{...textProps}
|
||||
>
|
||||
{defaultMessage || t('Something bad happens, please retry.')}
|
||||
{cause}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</AlertStyled>
|
||||
))}
|
||||
|
||||
{!causes && (
|
||||
<Text
|
||||
$theme="danger"
|
||||
$variation="600"
|
||||
$textAlign="center"
|
||||
{...textProps}
|
||||
>
|
||||
{defaultMessage || t('Something bad happens, please retry.')}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import { HorizontalSeparator } from '@gouvfr-lasuite/ui-kit';
|
||||
import {
|
||||
Fragment,
|
||||
PropsWithChildren,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
@@ -15,7 +16,7 @@ import { useCunninghamTheme } from '@/cunningham';
|
||||
import { useDropdownKeyboardNav } from './hook/useDropdownKeyboardNav';
|
||||
|
||||
export type DropdownMenuOption = {
|
||||
icon?: string;
|
||||
icon?: ReactNode;
|
||||
label: string;
|
||||
testId?: string;
|
||||
value?: string;
|
||||
@@ -220,7 +221,7 @@ export const DropdownMenu = ({
|
||||
$align="center"
|
||||
$gap={spacingsTokens['base']}
|
||||
>
|
||||
{option.icon && (
|
||||
{option.icon && typeof option.icon === 'string' && (
|
||||
<Icon
|
||||
$size="20px"
|
||||
$theme="greyscale"
|
||||
@@ -229,6 +230,9 @@ export const DropdownMenu = ({
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
{option.icon &&
|
||||
typeof option.icon !== 'string' &&
|
||||
option.icon}
|
||||
<Text $variation={isDisabled ? '400' : '1000'}>
|
||||
{option.label}
|
||||
</Text>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
export * from './modal/AlertModal';
|
||||
export * from './Box';
|
||||
export * from './BoxButton';
|
||||
export * from './Card';
|
||||
@@ -9,7 +8,7 @@ export * from './Icon';
|
||||
export * from './InfiniteScroll';
|
||||
export * from './Link';
|
||||
export * from './Loading';
|
||||
export * from './modal/SideModal';
|
||||
export * from './modal';
|
||||
export * from './separators';
|
||||
export * from './Text';
|
||||
export * from './TextErrors';
|
||||
|
||||
@@ -3,7 +3,7 @@ import React from 'react';
|
||||
|
||||
import { Box } from '@/components';
|
||||
|
||||
const ButtonCloseModal = (props: ButtonProps) => {
|
||||
export const ButtonCloseModal = (props: ButtonProps) => {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
@@ -18,5 +18,3 @@ const ButtonCloseModal = (props: ButtonProps) => {
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ButtonCloseModal;
|
||||
|
||||
2
src/frontend/apps/impress/src/components/modal/index.ts
Normal file
2
src/frontend/apps/impress/src/components/modal/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './AlertModal';
|
||||
export * from './ButtonCloseModal';
|
||||
@@ -16,8 +16,7 @@ import { cloneElement, isValidElement, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
import { Box, Text } from '@/components';
|
||||
import ButtonCloseModal from '@/components/modal/ButtonCloseModal';
|
||||
import { Box, ButtonCloseModal, Text } from '@/components';
|
||||
import { useEditorStore } from '@/docs/doc-editor';
|
||||
import { Doc, useTrans } from '@/docs/doc-management';
|
||||
|
||||
|
||||
@@ -5,7 +5,8 @@ export * from './useDeleteFavoriteDoc';
|
||||
export * from './useDoc';
|
||||
export * from './useDocOptions';
|
||||
export * from './useDocs';
|
||||
export * from './useSubDocs';
|
||||
export * from './useDuplicateDoc';
|
||||
export * from './useRestoreDoc';
|
||||
export * from './useSubDocs';
|
||||
export * from './useUpdateDoc';
|
||||
export * from './useUpdateDocLink';
|
||||
|
||||
@@ -3,6 +3,7 @@ import { UseQueryOptions, useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
APIError,
|
||||
APIList,
|
||||
InfiniteQueryConfig,
|
||||
errorCauses,
|
||||
fetchAPI,
|
||||
useAPIInfiniteQuery,
|
||||
@@ -54,10 +55,10 @@ export const getDocs = async (params: DocsParams): Promise<DocsResponse> => {
|
||||
|
||||
export const KEY_LIST_DOC = 'docs';
|
||||
|
||||
export function useDocs(
|
||||
params: DocsParams,
|
||||
queryConfig?: UseQueryOptions<DocsResponse, APIError, DocsResponse>,
|
||||
) {
|
||||
type UseDocsOptions = UseQueryOptions<DocsResponse, APIError, DocsResponse>;
|
||||
type UseInfiniteDocsOptions = InfiniteQueryConfig<DocsResponse>;
|
||||
|
||||
export function useDocs(params: DocsParams, queryConfig?: UseDocsOptions) {
|
||||
return useQuery<DocsResponse, APIError, DocsResponse>({
|
||||
queryKey: [KEY_LIST_DOC, params],
|
||||
queryFn: () => getDocs(params),
|
||||
@@ -65,6 +66,9 @@ export function useDocs(
|
||||
});
|
||||
}
|
||||
|
||||
export const useInfiniteDocs = (params: DocsParams) => {
|
||||
return useAPIInfiniteQuery(KEY_LIST_DOC, getDocs, params);
|
||||
export const useInfiniteDocs = (
|
||||
params: DocsParams,
|
||||
queryConfig?: UseInfiniteDocsOptions,
|
||||
) => {
|
||||
return useAPIInfiniteQuery(KEY_LIST_DOC, getDocs, params, queryConfig);
|
||||
};
|
||||
|
||||
@@ -6,8 +6,6 @@ import {
|
||||
|
||||
import { APIError, errorCauses, fetchAPI } from '@/api';
|
||||
|
||||
import { KEY_LIST_DOC } from './useDocs';
|
||||
|
||||
interface RemoveDocProps {
|
||||
docId: string;
|
||||
}
|
||||
@@ -24,14 +22,22 @@ export const removeDoc = async ({ docId }: RemoveDocProps): Promise<void> => {
|
||||
|
||||
type UseRemoveDocOptions = UseMutationOptions<void, APIError, RemoveDocProps>;
|
||||
|
||||
export const useRemoveDoc = (options?: UseRemoveDocOptions) => {
|
||||
export const useRemoveDoc = ({
|
||||
listInvalidQueries,
|
||||
options,
|
||||
}: {
|
||||
listInvalidQueries?: string[];
|
||||
options?: UseRemoveDocOptions;
|
||||
}) => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<void, APIError, RemoveDocProps>({
|
||||
mutationFn: removeDoc,
|
||||
...options,
|
||||
onSuccess: (data, variables, context) => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [KEY_LIST_DOC],
|
||||
listInvalidQueries?.forEach((queryKey) => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [queryKey],
|
||||
});
|
||||
});
|
||||
if (options?.onSuccess) {
|
||||
void options.onSuccess(data, variables, context);
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
UseMutationOptions,
|
||||
useMutation,
|
||||
useQueryClient,
|
||||
} from '@tanstack/react-query';
|
||||
|
||||
import { APIError, errorCauses, fetchAPI } from '@/api';
|
||||
|
||||
interface RestoreDocProps {
|
||||
docId: string;
|
||||
}
|
||||
|
||||
export const restoreDoc = async ({ docId }: RestoreDocProps): Promise<void> => {
|
||||
const response = await fetchAPI(`documents/${docId}/restore/`, {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new APIError(
|
||||
'Failed to restore the doc',
|
||||
await errorCauses(response),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
type UseRestoreDocOptions = UseMutationOptions<void, APIError, RestoreDocProps>;
|
||||
|
||||
export const useRestoreDoc = ({
|
||||
listInvalidQueries,
|
||||
options,
|
||||
}: {
|
||||
listInvalidQueries?: string[];
|
||||
options?: UseRestoreDocOptions;
|
||||
}) => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<void, APIError, RestoreDocProps>({
|
||||
mutationFn: restoreDoc,
|
||||
...options,
|
||||
onSuccess: (data, variables, context) => {
|
||||
listInvalidQueries?.forEach((queryKey) => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [queryKey],
|
||||
});
|
||||
});
|
||||
if (options?.onSuccess) {
|
||||
void options.onSuccess(data, variables, context);
|
||||
}
|
||||
},
|
||||
onError: (error, variables, context) => {
|
||||
if (options?.onError) {
|
||||
void options.onError(error, variables, context);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -9,10 +9,13 @@ import { usePathname } from 'next/navigation';
|
||||
import { useRouter } from 'next/router';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
|
||||
import { Box, Text, TextErrors } from '@/components';
|
||||
import ButtonCloseModal from '@/components/modal/ButtonCloseModal';
|
||||
import { Box, ButtonCloseModal, Text, TextErrors } from '@/components';
|
||||
import { useConfig } from '@/core';
|
||||
import { KEY_LIST_DOC_TRASHBIN } from '@/docs/docs-grid';
|
||||
|
||||
import { KEY_LIST_DOC } from '../api/useDocs';
|
||||
import { useRemoveDoc } from '../api/useRemoveDoc';
|
||||
import { useDocUtils } from '../hooks';
|
||||
import { Doc } from '../types';
|
||||
|
||||
interface ModalRemoveDocProps {
|
||||
@@ -28,25 +31,31 @@ export const ModalRemoveDoc = ({
|
||||
}: ModalRemoveDocProps) => {
|
||||
const { toast } = useToastProvider();
|
||||
const { t } = useTranslation();
|
||||
const { data: config } = useConfig();
|
||||
const trashBinCutoffDays = config?.TRASHBIN_CUTOFF_DAYS || 30;
|
||||
const { push } = useRouter();
|
||||
const { hasChildren } = useDocUtils(doc);
|
||||
const pathname = usePathname();
|
||||
const {
|
||||
mutate: removeDoc,
|
||||
isError,
|
||||
error,
|
||||
} = useRemoveDoc({
|
||||
onSuccess: () => {
|
||||
if (onSuccess) {
|
||||
onSuccess(doc);
|
||||
} else if (pathname === '/') {
|
||||
onClose();
|
||||
} else {
|
||||
void push('/');
|
||||
}
|
||||
listInvalidQueries: [KEY_LIST_DOC, KEY_LIST_DOC_TRASHBIN],
|
||||
options: {
|
||||
onSuccess: () => {
|
||||
if (onSuccess) {
|
||||
onSuccess(doc);
|
||||
} else if (pathname === '/') {
|
||||
onClose();
|
||||
} else {
|
||||
void push('/');
|
||||
}
|
||||
|
||||
toast(t('The document has been deleted.'), VariantType.SUCCESS, {
|
||||
duration: 4000,
|
||||
});
|
||||
toast(t('The document has been deleted.'), VariantType.SUCCESS, {
|
||||
duration: 4000,
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -109,10 +118,18 @@ export const ModalRemoveDoc = ({
|
||||
<Box className="--docs--modal-remove-doc">
|
||||
{!isError && (
|
||||
<Text $size="sm" $variation="600" $display="inline-block" as="p">
|
||||
<Trans t={t}>
|
||||
This document and <strong>any sub-documents</strong> will be
|
||||
permanently deleted. This action is irreversible.
|
||||
</Trans>
|
||||
{hasChildren ? (
|
||||
<Trans t={t}>
|
||||
This document and <strong>any sub-documents</strong> will be
|
||||
placed in the trashbin. You can restore it within{' '}
|
||||
{{ days: trashBinCutoffDays }} days.
|
||||
</Trans>
|
||||
) : (
|
||||
t(
|
||||
'This document will be placed in the trashbin. You can restore it within {{days}} days.',
|
||||
{ days: trashBinCutoffDays },
|
||||
)
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
|
||||
@@ -5,8 +5,7 @@ import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
|
||||
import { Box, Text } from '@/components';
|
||||
import ButtonCloseModal from '@/components/modal/ButtonCloseModal';
|
||||
import { Box, ButtonCloseModal, Text } from '@/components';
|
||||
import { QuickSearch } from '@/components/quick-search';
|
||||
import { Doc, useDocUtils } from '@/docs/doc-management';
|
||||
import { useResponsiveStore } from '@/stores';
|
||||
|
||||
@@ -4,8 +4,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { createGlobalStyle, css } from 'styled-components';
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
|
||||
import { Box, HorizontalSeparator, Text } from '@/components';
|
||||
import ButtonCloseModal from '@/components/modal/ButtonCloseModal';
|
||||
import { Box, ButtonCloseModal, HorizontalSeparator, Text } from '@/components';
|
||||
import {
|
||||
QuickSearch,
|
||||
QuickSearchData,
|
||||
|
||||
@@ -3,8 +3,7 @@ import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { createGlobalStyle, css } from 'styled-components';
|
||||
|
||||
import { Box, Text } from '@/components';
|
||||
import ButtonCloseModal from '@/components/modal/ButtonCloseModal';
|
||||
import { Box, ButtonCloseModal, Text } from '@/components';
|
||||
import { DocEditor } from '@/docs/doc-editor';
|
||||
import { Doc } from '@/docs/doc-management';
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from './useDocsTrashbin';
|
||||
@@ -0,0 +1,60 @@
|
||||
import { UseQueryOptions, useQuery } from '@tanstack/react-query';
|
||||
|
||||
import {
|
||||
APIError,
|
||||
APIList,
|
||||
InfiniteQueryConfig,
|
||||
errorCauses,
|
||||
fetchAPI,
|
||||
useAPIInfiniteQuery,
|
||||
} from '@/api';
|
||||
import { Doc, DocsResponse } from '@/docs/doc-management';
|
||||
|
||||
export type DocsTrashbinParams = {
|
||||
page: number;
|
||||
};
|
||||
|
||||
export type DocsTrashbinResponse = APIList<Doc>;
|
||||
export const getDocsTrashbin = async (
|
||||
params: DocsTrashbinParams,
|
||||
): Promise<DocsTrashbinResponse> => {
|
||||
const response = await fetchAPI(`documents/trashbin/?page=${params.page}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new APIError('Failed to get the docs', await errorCauses(response));
|
||||
}
|
||||
|
||||
return response.json() as Promise<DocsTrashbinResponse>;
|
||||
};
|
||||
|
||||
export const KEY_LIST_DOC_TRASHBIN = 'docs_trashbin';
|
||||
|
||||
type UseDocsTrashbinOptions = UseQueryOptions<
|
||||
DocsResponse,
|
||||
APIError,
|
||||
DocsResponse
|
||||
>;
|
||||
type UseInfiniteDocsTrashbinOptions = InfiniteQueryConfig<DocsTrashbinResponse>;
|
||||
|
||||
export function useDocsTrashbin(
|
||||
params: DocsTrashbinParams,
|
||||
queryConfig?: UseDocsTrashbinOptions,
|
||||
) {
|
||||
return useQuery<DocsTrashbinResponse, APIError, DocsTrashbinResponse>({
|
||||
queryKey: [KEY_LIST_DOC_TRASHBIN, params],
|
||||
queryFn: () => getDocsTrashbin(params),
|
||||
...queryConfig,
|
||||
});
|
||||
}
|
||||
|
||||
export const useInfiniteDocsTrashbin = (
|
||||
params: DocsTrashbinParams,
|
||||
queryConfig?: UseInfiniteDocsTrashbinOptions,
|
||||
) => {
|
||||
return useAPIInfiniteQuery(
|
||||
KEY_LIST_DOC_TRASHBIN,
|
||||
getDocsTrashbin,
|
||||
params,
|
||||
queryConfig,
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,88 @@
|
||||
import { VariantType, useToastProvider } from '@openfun/cunningham-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
import { DropdownMenu, DropdownMenuOption, Icon } from '@/components';
|
||||
import { Doc, KEY_LIST_DOC, useRestoreDoc } from '@/docs/doc-management';
|
||||
|
||||
import { KEY_LIST_DOC_TRASHBIN } from '../api';
|
||||
|
||||
interface DocsGridTrashbinActionsProps {
|
||||
doc: Doc;
|
||||
}
|
||||
|
||||
export const DocsGridTrashbinActions = ({
|
||||
doc,
|
||||
}: DocsGridTrashbinActionsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToastProvider();
|
||||
const { mutate: restoreDoc, error } = useRestoreDoc({
|
||||
listInvalidQueries: [KEY_LIST_DOC, KEY_LIST_DOC_TRASHBIN],
|
||||
options: {
|
||||
onSuccess: (_data) => {
|
||||
toast(t('The document has been restored.'), VariantType.SUCCESS, {
|
||||
duration: 4000,
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
toast(
|
||||
t('An error occurred while restoring the document: {{error}}', {
|
||||
error: error?.message,
|
||||
}),
|
||||
VariantType.ERROR,
|
||||
{
|
||||
duration: 4000,
|
||||
},
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const options: DropdownMenuOption[] = [
|
||||
{
|
||||
label: t('Restore'),
|
||||
icon: (
|
||||
<Icon
|
||||
$size="20px"
|
||||
$theme="greyscale"
|
||||
$variation="1000"
|
||||
iconName="undo"
|
||||
aria-hidden="true"
|
||||
variant="symbols-outlined"
|
||||
/>
|
||||
),
|
||||
callback: () => {
|
||||
restoreDoc({
|
||||
docId: doc.id,
|
||||
});
|
||||
},
|
||||
testId: `docs-grid-actions-restore-${doc.id}`,
|
||||
},
|
||||
];
|
||||
|
||||
const documentTitle = doc.title || t('Untitled document');
|
||||
const menuLabel = t('Open the menu of actions for the document: {{title}}', {
|
||||
title: documentTitle,
|
||||
});
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
options={options}
|
||||
label={menuLabel}
|
||||
aria-label={t('More options')}
|
||||
>
|
||||
<Icon
|
||||
data-testid={`docs-grid-actions-button-${doc.id}`}
|
||||
iconName="more_horiz"
|
||||
$theme="primary"
|
||||
$variation="600"
|
||||
$css={css`
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
`}
|
||||
/>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
@@ -1 +1,2 @@
|
||||
export * from './api';
|
||||
export * from './components';
|
||||
|
||||
Reference in New Issue
Block a user