Folders: Create folder using app platform APIs (#110166)
This commit is contained in:
@@ -78,6 +78,36 @@ const getFolderHandler = () =>
|
||||
});
|
||||
});
|
||||
|
||||
const handlers = [listFoldersHandler(), getFolderHandler()];
|
||||
const createFolderHandler = () =>
|
||||
http.post<never, { title: string; parentUid?: string }>('/api/folders', async ({ request }) => {
|
||||
const body = await request.json();
|
||||
if (!body || !body.title) {
|
||||
return HttpResponse.json({ message: 'folder title cannot be empty' }, { status: 400 });
|
||||
}
|
||||
const random = Chance(body.title);
|
||||
const uid = random.string({ length: 10 });
|
||||
const id = random.integer({ min: 1, max: 1000 });
|
||||
|
||||
return HttpResponse.json({
|
||||
id,
|
||||
uid: uid,
|
||||
orgId: 1,
|
||||
title: body.title,
|
||||
url: `/dashboards/f/${uid}/${body.title}`,
|
||||
hasAcl: false,
|
||||
canSave: true,
|
||||
canEdit: true,
|
||||
canAdmin: true,
|
||||
canDelete: true,
|
||||
parentUid: body.parentUid,
|
||||
createdBy: 'admin',
|
||||
created: '2025-08-26T12:19:27+01:00',
|
||||
updatedBy: 'admin',
|
||||
updated: '2025-08-26T12:19:27+01:00',
|
||||
version: 1,
|
||||
});
|
||||
});
|
||||
|
||||
const handlers = [listFoldersHandler(), getFolderHandler(), createFolderHandler()];
|
||||
|
||||
export default handlers;
|
||||
|
||||
+61
-23
@@ -1,9 +1,18 @@
|
||||
import { Chance } from 'chance';
|
||||
import { HttpResponse, http } from 'msw';
|
||||
|
||||
import { wellFormedTree } from '../../../../fixtures/folders';
|
||||
import { getErrorResponse } from '../../../helpers';
|
||||
|
||||
const [mockTree] = wellFormedTree();
|
||||
|
||||
const baseResponse = {
|
||||
kind: 'Folder',
|
||||
apiVersion: 'folder.grafana.app/v1beta1',
|
||||
};
|
||||
|
||||
const folderNotFoundError = getErrorResponse('folder not found', 404);
|
||||
|
||||
const getFolderHandler = () =>
|
||||
http.get<{ folderUid: string; namespace: string }>(
|
||||
'/apis/folder.grafana.app/v1beta1/namespaces/:namespace/folders/:folderUid',
|
||||
@@ -14,22 +23,11 @@ const getFolderHandler = () =>
|
||||
});
|
||||
|
||||
if (!response) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
kind: 'Status',
|
||||
apiVersion: 'v1',
|
||||
metadata: {},
|
||||
status: 'Failure',
|
||||
message: 'folder not found',
|
||||
code: 404,
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
return HttpResponse.json(folderNotFoundError, { status: 404 });
|
||||
}
|
||||
|
||||
return HttpResponse.json({
|
||||
kind: 'Folder',
|
||||
apiVersion: 'folder.grafana.app/v1beta1',
|
||||
...baseResponse,
|
||||
metadata: {
|
||||
name: response.item.uid,
|
||||
namespace,
|
||||
@@ -63,14 +61,7 @@ const getFolderParentsHandler = () =>
|
||||
return item.kind === 'folder' && item.uid === folderUid;
|
||||
});
|
||||
if (!folder || folder.item.kind !== 'folder') {
|
||||
return HttpResponse.json({
|
||||
kind: 'Status',
|
||||
apiVersion: 'v1',
|
||||
metadata: {},
|
||||
status: 'Failure',
|
||||
message: 'folder not found',
|
||||
code: 404,
|
||||
});
|
||||
return HttpResponse.json(folderNotFoundError, { status: 404 });
|
||||
}
|
||||
|
||||
const findParents = (parents: Array<(typeof mockTree)[number]>, folderUid?: string) => {
|
||||
@@ -106,12 +97,59 @@ const getFolderParentsHandler = () =>
|
||||
}
|
||||
|
||||
return HttpResponse.json({
|
||||
...baseResponse,
|
||||
kind: 'FolderInfoList',
|
||||
apiVersion: 'folder.grafana.app/v1beta1',
|
||||
metadata: {},
|
||||
items: mapped,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
export default [getFolderHandler(), getFolderParentsHandler()];
|
||||
// TODO: Pull this from common API types rather than partially redefining here
|
||||
type PartialFolderPayload = { spec: { title: string }; metadata: { annotations: Record<string, string> } };
|
||||
|
||||
const createFolderHandler = () =>
|
||||
http.post<{ namespace: string }, PartialFolderPayload>(
|
||||
'/apis/folder.grafana.app/v1beta1/namespaces/:namespace/folders',
|
||||
async ({ params, request }) => {
|
||||
const { namespace } = params;
|
||||
const body = await request.json();
|
||||
const title = body?.spec?.title;
|
||||
if (!body || !title) {
|
||||
return HttpResponse.json(getErrorResponse('folder title cannot be empty', 400), { status: 400 });
|
||||
}
|
||||
|
||||
const parentUid = body?.metadata?.annotations?.['grafana.app/folder'];
|
||||
const random = Chance(title);
|
||||
const name = random.string({ length: 10 });
|
||||
const uid = random.string({ length: 45 });
|
||||
const id = random.integer({ min: 1, max: 1000 });
|
||||
|
||||
return HttpResponse.json({
|
||||
...baseResponse,
|
||||
metadata: {
|
||||
name,
|
||||
namespace,
|
||||
uid,
|
||||
resourceVersion: '1756207979831',
|
||||
generation: 1,
|
||||
creationTimestamp: '2025-08-26T11:32:59Z',
|
||||
labels: {
|
||||
'grafana.app/deprecatedInternalID': id,
|
||||
},
|
||||
annotations: {
|
||||
'grafana.app/createdBy': 'user:1',
|
||||
'grafana.app/folder': parentUid,
|
||||
'grafana.app/updatedBy': 'user:1',
|
||||
'grafana.app/updatedTimestamp': '2025-08-26T11:32:59Z',
|
||||
},
|
||||
},
|
||||
spec: {
|
||||
title,
|
||||
description: '',
|
||||
},
|
||||
status: {},
|
||||
});
|
||||
}
|
||||
);
|
||||
export default [getFolderHandler(), getFolderParentsHandler(), createFolderHandler()];
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
export const getErrorResponse = (message: string, code: number) => {
|
||||
return {
|
||||
kind: 'Status',
|
||||
apiVersion: 'v1',
|
||||
metadata: {},
|
||||
status: 'Failure',
|
||||
message,
|
||||
code,
|
||||
};
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { renderHook, getWrapper, waitFor } from 'test/test-utils';
|
||||
import { renderHook, getWrapper, waitFor, screen } from 'test/test-utils';
|
||||
|
||||
import { AppEvents } from '@grafana/data';
|
||||
import { config, setBackendSrv } from '@grafana/runtime';
|
||||
@@ -8,6 +8,7 @@ import { backendSrv } from 'app/core/services/backend_srv';
|
||||
import { useDeleteFoldersMutation as useDeleteFoldersMutationLegacy } from 'app/features/browse-dashboards/api/browseDashboardsAPI';
|
||||
|
||||
import { useGetFolderQueryFacade, useDeleteMultipleFoldersMutationFacade } from './hooks';
|
||||
import { setupCreateFolder } from './test-utils';
|
||||
|
||||
import { useDeleteFolderMutation } from './index';
|
||||
|
||||
@@ -185,3 +186,27 @@ describe('useDeleteMultipleFoldersMutationFacade', () => {
|
||||
expect(mockDeleteFolderLegacy).toHaveBeenCalledWith({ folderUIDs });
|
||||
});
|
||||
});
|
||||
|
||||
describe('useCreateFolder', () => {
|
||||
describe.each([
|
||||
// app platform
|
||||
true,
|
||||
// legacy
|
||||
false,
|
||||
])('folderAppPlatformAPI toggle set to: %s', (toggle) => {
|
||||
beforeEach(() => {
|
||||
config.featureToggles.foldersAppPlatformAPI = toggle;
|
||||
});
|
||||
afterEach(() => {
|
||||
config.featureToggles = originalToggles;
|
||||
});
|
||||
|
||||
it('creates a folder', async () => {
|
||||
const { user } = setupCreateFolder();
|
||||
|
||||
await user.click(screen.getByText('Create Folder'));
|
||||
|
||||
expect(await screen.findByText('Folder created')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,12 +4,15 @@ import { useEffect, useMemo } from 'react';
|
||||
import { AppEvents } from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { config, getAppEvents } from '@grafana/runtime';
|
||||
import { useAppNotification } from 'app/core/copy/appNotification';
|
||||
import {
|
||||
useDeleteFolderMutation as useDeleteFolderMutationLegacy,
|
||||
useGetFolderQuery as useGetFolderQueryLegacy,
|
||||
useDeleteFoldersMutation as useDeleteFoldersMutationLegacy,
|
||||
useNewFolderMutation as useLegacyNewFolderMutation,
|
||||
} from 'app/features/browse-dashboards/api/browseDashboardsAPI';
|
||||
import { FolderDTO } from 'app/types/folders';
|
||||
import { dispatch } from 'app/store/store';
|
||||
import { FolderDTO, NewFolder } from 'app/types/folders';
|
||||
|
||||
import kbn from '../../../../core/utils/kbn';
|
||||
import {
|
||||
@@ -30,7 +33,24 @@ import { useLazyGetDisplayMappingQuery } from '../../iam/v0alpha1';
|
||||
import { isProvisionedFolderCheck } from './utils';
|
||||
import { rootFolder, sharedWithMeFolder } from './virtualFolders';
|
||||
|
||||
import { useGetFolderQuery, useGetFolderParentsQuery, useDeleteFolderMutation } from './index';
|
||||
import {
|
||||
useGetFolderQuery,
|
||||
useGetFolderParentsQuery,
|
||||
useDeleteFolderMutation,
|
||||
useCreateFolderMutation,
|
||||
Folder,
|
||||
CreateFolderApiArg,
|
||||
} from './index';
|
||||
|
||||
/** Trigger necessary actions to ensure legacy folder stores are updated */
|
||||
function dispatchRefetchChildren(parentUID?: string) {
|
||||
dispatch(
|
||||
refetchChildren({
|
||||
parentUID: parentUID || GENERAL_FOLDER_UID,
|
||||
pageSize: PAGE_SIZE,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function getFolderUrl(uid: string, title: string): string {
|
||||
// mimics https://github.com/grafana/grafana/blob/79fe8a9902335c7a28af30e467b904a4ccfac503/pkg/services/dashboards/models.go#L188
|
||||
@@ -106,36 +126,23 @@ export function useGetFolderQueryFacade(uid?: string) {
|
||||
const updatedBy = resultFolder.data.metadata.annotations?.[AnnoKeyUpdatedBy];
|
||||
const createdBy = resultFolder.data.metadata.annotations?.[AnnoKeyCreatedBy];
|
||||
|
||||
const parsed = appPlatformFolderToLegacyFolder(resultFolder.data);
|
||||
|
||||
newData = {
|
||||
canAdmin: legacyFolderResult.data.canAdmin,
|
||||
canDelete: legacyFolderResult.data.canDelete,
|
||||
canEdit: legacyFolderResult.data.canEdit,
|
||||
canSave: legacyFolderResult.data.canSave,
|
||||
accessControl: legacyFolderResult.data.accessControl,
|
||||
created: resultFolder.data.metadata.creationTimestamp || '0001-01-01T00:00:00Z',
|
||||
|
||||
createdBy:
|
||||
(createdBy && resultUserDisplay.data?.display[resultUserDisplay.data?.keys.indexOf(createdBy)]?.displayName) ||
|
||||
'Anonymous',
|
||||
// Does not seem like this is set to true in the legacy API
|
||||
hasAcl: false,
|
||||
id: parseInt(resultFolder.data.metadata.labels?.[DeprecatedInternalId] || '0', 10) || 0,
|
||||
parentUid: resultFolder.data.metadata.annotations?.[AnnoKeyFolder],
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
managedBy: resultFolder.data.metadata.annotations?.[AnnoKeyManagerKind] as ManagerKind,
|
||||
|
||||
title: resultFolder.data.spec.title,
|
||||
uid: resultFolder.data.metadata.name!,
|
||||
updated: resultFolder.data.metadata.annotations?.[AnnoKeyUpdatedTimestamp] || '0001-01-01T00:00:00Z',
|
||||
updatedBy:
|
||||
(updatedBy && resultUserDisplay.data?.display[resultUserDisplay.data?.keys.indexOf(updatedBy)]?.displayName) ||
|
||||
'Anonymous',
|
||||
// Seems like this annotation is not populated
|
||||
// url: result.data.metadata.annotations?.[AnnoKeyFolderUrl] || '',
|
||||
// general folder does not come with url
|
||||
// see https://github.com/grafana/grafana/blob/8a05378ef3ae5545c6f7429eae5c174d3c0edbfe/pkg/services/folder/folderimpl/folder_unifiedstorage.go#L88
|
||||
url:
|
||||
uid === GENERAL_FOLDER_UID ? '' : getFolderUrl(resultFolder.data.metadata.name!, resultFolder.data.spec.title!),
|
||||
version: resultFolder.data.metadata.generation || 1,
|
||||
...parsed,
|
||||
};
|
||||
|
||||
if (resultParents.data.items?.length) {
|
||||
@@ -165,7 +172,7 @@ export function useGetFolderQueryFacade(uid?: string) {
|
||||
export function useDeleteFolderMutationFacade() {
|
||||
const [deleteFolder] = useDeleteFolderMutation();
|
||||
const [deleteFolderLegacy] = useDeleteFolderMutationLegacy();
|
||||
const dispatch = useDispatch();
|
||||
const notify = useAppNotification();
|
||||
|
||||
return async (folder: FolderDTO) => {
|
||||
if (config.featureToggles.foldersAppPlatformAPI) {
|
||||
@@ -174,18 +181,10 @@ export function useDeleteFolderMutationFacade() {
|
||||
// We need to update a legacy version of the folder storage for now until all is in the new API.
|
||||
// we could do it in the enhanceEndpoint method but we would also need to change the args as we need parentUID
|
||||
// here and so it seemed easier to do it here.
|
||||
dispatch(
|
||||
refetchChildren({
|
||||
parentUID: folder.parentUid || GENERAL_FOLDER_UID,
|
||||
pageSize: PAGE_SIZE,
|
||||
})
|
||||
);
|
||||
dispatchRefetchChildren(folder.parentUid);
|
||||
// Before this was done in backend srv automatically because the old API sent a message wiht 200 request. see
|
||||
// public/app/core/services/backend_srv.ts#L341-L361. New API does not do that so we do it here.
|
||||
getAppEvents().publish({
|
||||
type: AppEvents.alertSuccess.name,
|
||||
payload: [t('folders.api.folder-deleted-success', 'Folder deleted')],
|
||||
});
|
||||
notify.success(t('folders.api.folder-deleted-success', 'Folder deleted'));
|
||||
}
|
||||
return result;
|
||||
} else {
|
||||
@@ -226,6 +225,42 @@ export function useDeleteMultipleFoldersMutationFacade() {
|
||||
};
|
||||
}
|
||||
|
||||
export function useCreateFolder() {
|
||||
const [createFolder, result] = useCreateFolderMutation();
|
||||
const legacyHook = useLegacyNewFolderMutation();
|
||||
|
||||
if (!config.featureToggles.foldersAppPlatformAPI) {
|
||||
return legacyHook;
|
||||
}
|
||||
|
||||
const createFolderAppPlatform = async (folder: NewFolder) => {
|
||||
const payload: CreateFolderApiArg = {
|
||||
folder: {
|
||||
spec: {
|
||||
title: folder.title,
|
||||
},
|
||||
metadata: {
|
||||
generateName: 'f',
|
||||
annotations: {
|
||||
...(folder.parentUid && { [AnnoKeyFolder]: folder.parentUid }),
|
||||
},
|
||||
},
|
||||
status: {},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await createFolder(payload);
|
||||
dispatchRefetchChildren(folder.parentUid);
|
||||
|
||||
return {
|
||||
...result,
|
||||
data: result.data ? appPlatformFolderToLegacyFolder(result.data) : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
return [createFolderAppPlatform, result] as const;
|
||||
}
|
||||
|
||||
function combinedState(
|
||||
result: ReturnType<typeof useGetFolderQuery>,
|
||||
resultParents: ReturnType<typeof useGetFolderParentsQuery>,
|
||||
@@ -254,3 +289,29 @@ function getUserKeys(resultFolder: ReturnType<typeof useGetFolderQuery>): string
|
||||
].filter((v) => v !== undefined)
|
||||
: [];
|
||||
}
|
||||
|
||||
const appPlatformFolderToLegacyFolder = (
|
||||
folder: Folder
|
||||
): Omit<FolderDTO, 'parents' | 'canSave' | 'canEdit' | 'canAdmin' | 'canDelete' | 'createdBy' | 'updatedBy'> => {
|
||||
// Omits properties that we can't easily get solely from the app platform response
|
||||
// In some cases, these properties aren't used on the response of the hook,
|
||||
// so it's best to discourage from using them anyway
|
||||
|
||||
const { annotations, name = '', creationTimestamp, generation, labels } = folder.metadata;
|
||||
const { title = '' } = folder.spec;
|
||||
return {
|
||||
id: parseInt(labels?.[DeprecatedInternalId] || '0', 10) || 0,
|
||||
uid: name,
|
||||
title,
|
||||
// general folder does not come with url
|
||||
// see https://github.com/grafana/grafana/blob/8a05378ef3ae5545c6f7429eae5c174d3c0edbfe/pkg/services/folder/folderimpl/folder_unifiedstorage.go#L88
|
||||
url: name === GENERAL_FOLDER_UID ? '' : getFolderUrl(name, title),
|
||||
created: creationTimestamp || '0001-01-01T00:00:00Z',
|
||||
updated: annotations?.[AnnoKeyUpdatedTimestamp] || '0001-01-01T00:00:00Z',
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
managedBy: annotations?.[AnnoKeyManagerKind] as ManagerKind,
|
||||
parentUid: annotations?.[AnnoKeyFolder],
|
||||
version: generation || 1,
|
||||
hasAcl: false,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -21,7 +21,8 @@ export const folderAPIv1beta1 = generatedAPI.enhanceEndpoints({
|
||||
},
|
||||
});
|
||||
|
||||
export const { useGetFolderQuery, useGetFolderParentsQuery, useDeleteFolderMutation } = folderAPIv1beta1;
|
||||
export const { useGetFolderQuery, useGetFolderParentsQuery, useDeleteFolderMutation, useCreateFolderMutation } =
|
||||
folderAPIv1beta1;
|
||||
|
||||
// eslint-disable-next-line no-barrel-files/no-barrel-files
|
||||
export { type Folder, type FolderList } from './endpoints.gen';
|
||||
export { type Folder, type FolderList, type CreateFolderApiArg } from './endpoints.gen';
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { render } from 'test/test-utils';
|
||||
|
||||
import { getFolderFixtures } from '@grafana/test-utils/unstable';
|
||||
import { AppNotificationList } from 'app/core/components/AppNotifications/AppNotificationList';
|
||||
|
||||
import { useCreateFolder } from './hooks';
|
||||
|
||||
const [_, { folderA }] = getFolderFixtures();
|
||||
|
||||
const TestCreationComponent = () => {
|
||||
const [createFolder, result] = useCreateFolder();
|
||||
|
||||
return (
|
||||
<>
|
||||
<AppNotificationList />
|
||||
<button onClick={() => createFolder({ title: 'test', parentUid: folderA.item.uid })}>Create Folder</button>
|
||||
<div>{result.isSuccess ? 'Folder created' : 'Error creating folder'}</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
/** Renders test component with a button that will create a new folder */
|
||||
export const setupCreateFolder = () => render(<TestCreationComponent />);
|
||||
@@ -5,9 +5,9 @@ import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { Trans, t } from '@grafana/i18n';
|
||||
import { Button, Field, Input, Label, Modal, Stack, useStyles2 } from '@grafana/ui';
|
||||
import { useCreateFolder } from 'app/api/clients/folder/v1beta1/hooks';
|
||||
import { useAppNotification } from 'app/core/copy/appNotification';
|
||||
import { contextSrv } from 'app/core/core';
|
||||
import { useNewFolderMutation } from 'app/features/browse-dashboards/api/browseDashboardsAPI';
|
||||
import { AccessControlAction } from 'app/types/accessControl';
|
||||
|
||||
import { Folder } from '../../types/rule-form';
|
||||
@@ -49,7 +49,7 @@ function FolderCreationModal({
|
||||
const notifyApp = useAppNotification();
|
||||
const [title, setTitle] = useState('');
|
||||
const [isCreatingFolder, setIsCreatingFolder] = useState(false);
|
||||
const [createFolder] = useNewFolderMutation();
|
||||
const [createFolder] = useCreateFolder();
|
||||
|
||||
const onSubmit = async () => {
|
||||
setIsCreatingFolder(true);
|
||||
|
||||
@@ -100,7 +100,6 @@ export const browseDashboardsAPI = createApi({
|
||||
}),
|
||||
onQueryStarted: ({ parentUid }, { queryFulfilled, dispatch }) => {
|
||||
queryFulfilled.then(async ({ data: folder }) => {
|
||||
await contextSrv.fetchUserPermissions();
|
||||
dispatch(
|
||||
refetchChildren({
|
||||
parentUID: parentUid,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useLocation } from 'react-router-dom-v5-compat';
|
||||
import { locationUtil } from '@grafana/data';
|
||||
import { config, locationService, reportInteraction } from '@grafana/runtime';
|
||||
import { Button, Drawer, Dropdown, Icon, Menu, MenuItem } from '@grafana/ui';
|
||||
import { useCreateFolder } from 'app/api/clients/folder/v1beta1/hooks';
|
||||
import { useAppNotification } from 'app/core/copy/appNotification';
|
||||
import { RepoType } from 'app/features/provisioning/Wizard/types';
|
||||
import { NewProvisionedFolderForm } from 'app/features/provisioning/components/Folders/NewProvisionedFolderForm';
|
||||
@@ -18,7 +19,6 @@ import {
|
||||
import { FolderDTO } from 'app/types/folders';
|
||||
|
||||
import { ManagerKind } from '../../apiserver/types';
|
||||
import { useNewFolderMutation } from '../api/browseDashboardsAPI';
|
||||
|
||||
import { NewFolderForm } from './NewFolderForm';
|
||||
|
||||
@@ -39,7 +39,7 @@ export default function CreateNewButton({
|
||||
}: Props) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const location = useLocation();
|
||||
const [newFolder] = useNewFolderMutation();
|
||||
const [newFolder] = useCreateFolder();
|
||||
const [showNewFolderDrawer, setShowNewFolderDrawer] = useState(false);
|
||||
const notifyApp = useAppNotification();
|
||||
const isProvisionedInstance = useIsProvisionedInstance();
|
||||
|
||||
@@ -32,6 +32,9 @@ export interface FolderDTO extends WithAccessControlMetadata {
|
||||
version?: number;
|
||||
}
|
||||
|
||||
/** Minimal data required to create a new folder */
|
||||
export type NewFolder = Pick<FolderDTO, 'title' | 'parentUid'>;
|
||||
|
||||
export interface FolderState {
|
||||
id: number;
|
||||
uid: string;
|
||||
|
||||
Reference in New Issue
Block a user