regression: Apply right filters to action buttons and convert `applyButtonFilters` to `useApplyButtonFilters` (#29822)
parent
841ec6678e
commit
5dac62ced6
@ -1,58 +0,0 @@ |
||||
/* Style disabled as having some arrow functions in one-line hurts readability */ |
||||
/* eslint-disable arrow-body-style */ |
||||
|
||||
import { Meteor } from 'meteor/meteor'; |
||||
import type { IUIActionButton } from '@rocket.chat/apps-engine/definition/ui'; |
||||
import { RoomTypeFilter } from '@rocket.chat/apps-engine/definition/ui'; |
||||
import type { IRoom } from '@rocket.chat/core-typings'; |
||||
import { |
||||
isDirectMessageRoom, |
||||
isMultipleDirectMessageRoom, |
||||
isOmnichannelRoom, |
||||
isPrivateDiscussion, |
||||
isPrivateTeamRoom, |
||||
isPublicDiscussion, |
||||
isPublicTeamRoom, |
||||
} from '@rocket.chat/core-typings'; |
||||
|
||||
import { hasAtLeastOnePermission, hasPermission, hasRole, hasAnyRole } from '../../../../authorization/client'; |
||||
|
||||
const applyAuthFilter = (button: IUIActionButton, room?: IRoom, ignoreSubscriptions = false): boolean => { |
||||
const { hasAllPermissions, hasOnePermission, hasAllRoles, hasOneRole } = button.when || {}; |
||||
|
||||
const userId = Meteor.userId(); |
||||
|
||||
const hasAllPermissionsResult = hasAllPermissions ? hasPermission(hasAllPermissions) : true; |
||||
const hasOnePermissionResult = hasOnePermission ? hasAtLeastOnePermission(hasOnePermission) : true; |
||||
const hasAllRolesResult = hasAllRoles |
||||
? !!userId && hasAllRoles.every((role) => hasRole(userId, role, room?._id, ignoreSubscriptions)) |
||||
: true; |
||||
const hasOneRoleResult = hasOneRole ? !!userId && hasAnyRole(userId, hasOneRole, room?._id, ignoreSubscriptions) : true; |
||||
|
||||
return hasAllPermissionsResult && hasOnePermissionResult && hasAllRolesResult && hasOneRoleResult; |
||||
}; |
||||
|
||||
const enumToFilter: { [k in RoomTypeFilter]: (room: IRoom) => boolean } = { |
||||
[RoomTypeFilter.PUBLIC_CHANNEL]: (room) => room.t === 'c', |
||||
[RoomTypeFilter.PRIVATE_CHANNEL]: (room) => room.t === 'p', |
||||
[RoomTypeFilter.PUBLIC_TEAM]: isPublicTeamRoom, |
||||
[RoomTypeFilter.PRIVATE_TEAM]: isPrivateTeamRoom, |
||||
[RoomTypeFilter.PUBLIC_DISCUSSION]: isPublicDiscussion, |
||||
[RoomTypeFilter.PRIVATE_DISCUSSION]: isPrivateDiscussion, |
||||
[RoomTypeFilter.DIRECT]: isDirectMessageRoom, |
||||
[RoomTypeFilter.DIRECT_MULTIPLE]: isMultipleDirectMessageRoom, |
||||
[RoomTypeFilter.LIVE_CHAT]: isOmnichannelRoom, |
||||
}; |
||||
|
||||
const applyRoomFilter = (button: IUIActionButton, room: IRoom): boolean => { |
||||
const { roomTypes } = button.when || {}; |
||||
return !roomTypes || roomTypes.some((filter): boolean => enumToFilter[filter]?.(room)); |
||||
}; |
||||
|
||||
export const applyButtonFilters = (button: IUIActionButton, room?: IRoom): boolean => { |
||||
return applyAuthFilter(button, room) && (!room || applyRoomFilter(button, room)); |
||||
}; |
||||
|
||||
export const applyDropdownActionButtonFilters = (button: IUIActionButton): boolean => { |
||||
return applyAuthFilter(button, undefined, true); |
||||
}; |
||||
@ -0,0 +1,65 @@ |
||||
import type { IUIActionButton } from '@rocket.chat/apps-engine/definition/ui'; |
||||
import { RoomTypeFilter } from '@rocket.chat/apps-engine/definition/ui'; |
||||
import type { IRoom } from '@rocket.chat/core-typings'; |
||||
import { |
||||
isDirectMessageRoom, |
||||
isMultipleDirectMessageRoom, |
||||
isOmnichannelRoom, |
||||
isPrivateDiscussion, |
||||
isPrivateTeamRoom, |
||||
isPublicDiscussion, |
||||
isPublicTeamRoom, |
||||
} from '@rocket.chat/core-typings'; |
||||
import { AuthorizationContext, useUserId } from '@rocket.chat/ui-contexts'; |
||||
import { useCallback, useContext } from 'react'; |
||||
|
||||
import { useRoom } from '../views/room/contexts/RoomContext'; |
||||
|
||||
const enumToFilter: { [k in RoomTypeFilter]: (room: IRoom) => boolean } = { |
||||
[RoomTypeFilter.PUBLIC_CHANNEL]: (room) => room.t === 'c', |
||||
[RoomTypeFilter.PRIVATE_CHANNEL]: (room) => room.t === 'p', |
||||
[RoomTypeFilter.PUBLIC_TEAM]: isPublicTeamRoom, |
||||
[RoomTypeFilter.PRIVATE_TEAM]: isPrivateTeamRoom, |
||||
[RoomTypeFilter.PUBLIC_DISCUSSION]: isPublicDiscussion, |
||||
[RoomTypeFilter.PRIVATE_DISCUSSION]: isPrivateDiscussion, |
||||
[RoomTypeFilter.DIRECT]: isDirectMessageRoom, |
||||
[RoomTypeFilter.DIRECT_MULTIPLE]: isMultipleDirectMessageRoom, |
||||
[RoomTypeFilter.LIVE_CHAT]: isOmnichannelRoom, |
||||
}; |
||||
|
||||
const applyRoomFilter = (button: IUIActionButton, room: IRoom): boolean => { |
||||
const { roomTypes } = button.when || {}; |
||||
return !roomTypes || roomTypes.some((filter): boolean => enumToFilter[filter]?.(room)); |
||||
}; |
||||
|
||||
export const useApplyButtonFilters = (): ((button: IUIActionButton) => boolean) => { |
||||
const room = useRoom(); |
||||
if (!room) { |
||||
throw new Error('useApplyButtonFilters must be used inside a room context'); |
||||
} |
||||
const applyAuthFilter = useApplyButtonAuthFilter(); |
||||
return useCallback( |
||||
(button: IUIActionButton) => applyAuthFilter(button) && (!room || applyRoomFilter(button, room)), |
||||
[applyAuthFilter, room], |
||||
); |
||||
}; |
||||
|
||||
export const useApplyButtonAuthFilter = (): ((button: IUIActionButton) => boolean) => { |
||||
const uid = useUserId(); |
||||
|
||||
const { queryAllPermissions, queryAtLeastOnePermission, queryRole } = useContext(AuthorizationContext); |
||||
|
||||
return useCallback( |
||||
(button: IUIActionButton, room?: IRoom) => { |
||||
const { hasAllPermissions, hasOnePermission, hasAllRoles, hasOneRole } = button.when || {}; |
||||
|
||||
const hasAllPermissionsResult = hasAllPermissions ? queryAllPermissions(hasAllPermissions)[1]() : true; |
||||
const hasOnePermissionResult = hasOnePermission ? queryAtLeastOnePermission(hasOnePermission)[1]() : true; |
||||
const hasAllRolesResult = hasAllRoles ? !!uid && hasAllRoles.every((role) => queryRole(role, room?._id)) : true; |
||||
const hasOneRoleResult = hasOneRole ? !!uid && hasOneRole.some((role) => queryRole(role, room?._id)[1]()) : true; |
||||
|
||||
return hasAllPermissionsResult && hasOnePermissionResult && hasAllRolesResult && hasOneRoleResult; |
||||
}, |
||||
[queryAllPermissions, queryAtLeastOnePermission, queryRole, uid], |
||||
); |
||||
}; |
||||
@ -0,0 +1,488 @@ |
||||
/* eslint-disable react/no-multi-comp */ |
||||
import { MockedAuthorizationContext } from '@rocket.chat/mock-providers/src/MockedAuthorizationContext'; |
||||
import { MockedServerContext } from '@rocket.chat/mock-providers/src/MockedServerContext'; |
||||
import { MockedSettingsContext } from '@rocket.chat/mock-providers/src/MockedSettingsContext'; |
||||
import { MockedUserContext } from '@rocket.chat/mock-providers/src/MockedUserContext'; |
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; |
||||
import { renderHook } from '@testing-library/react-hooks'; |
||||
import React from 'react'; |
||||
|
||||
import { ActionManagerContext } from '../../../../contexts/ActionManagerContext'; |
||||
import { useAppsItems } from './useAppsItems'; |
||||
|
||||
it('should return and empty array if the user does not have `manage-apps` and `access-marketplace` permission', () => { |
||||
const { result } = renderHook( |
||||
() => { |
||||
return useAppsItems(); |
||||
}, |
||||
{ |
||||
wrapper: ({ children }) => ( |
||||
<QueryClientProvider client={queryClient}> |
||||
<MockedServerContext |
||||
handleRequest={(args) => { |
||||
if (args.method === 'GET' && args.pathPattern === '/apps/actionButtons') { |
||||
return [] as any; |
||||
} |
||||
}} |
||||
> |
||||
<MockedSettingsContext settings={{}}> |
||||
<MockedUserContext userPreferences={{}}> |
||||
<MockedUiKitActionManager>{children}</MockedUiKitActionManager> |
||||
</MockedUserContext> |
||||
</MockedSettingsContext> |
||||
</MockedServerContext> |
||||
</QueryClientProvider> |
||||
), |
||||
}, |
||||
); |
||||
|
||||
expect(result.all[0]).toEqual([]); |
||||
}); |
||||
|
||||
it('should return `marketplace` and `installed` items if the user has `access-marketplace` permission', () => { |
||||
const { result } = renderHook( |
||||
() => { |
||||
return useAppsItems(); |
||||
}, |
||||
{ |
||||
wrapper: ({ children }) => ( |
||||
<QueryClientProvider client={queryClient}> |
||||
<MockedServerContext |
||||
handleRequest={(args) => { |
||||
if (args.method === 'GET' && args.pathPattern === '/apps/actionButtons') { |
||||
return [] as any; |
||||
} |
||||
}} |
||||
> |
||||
<MockedAuthorizationContext permissions={['access-marketplace']}> |
||||
<MockedSettingsContext settings={{}}> |
||||
<MockedUserContext userPreferences={{}}> |
||||
<MockedUiKitActionManager>{children}</MockedUiKitActionManager> |
||||
</MockedUserContext> |
||||
</MockedSettingsContext> |
||||
</MockedAuthorizationContext> |
||||
</MockedServerContext> |
||||
</QueryClientProvider> |
||||
), |
||||
}, |
||||
); |
||||
|
||||
expect(result.current[0]).toEqual( |
||||
expect.objectContaining({ |
||||
id: 'marketplace', |
||||
}), |
||||
); |
||||
expect(result.current[1]).toEqual( |
||||
expect.objectContaining({ |
||||
id: 'installed', |
||||
}), |
||||
); |
||||
}); |
||||
|
||||
it('should return `marketplace` and `installed` items if the user has `manage-apps` permission', () => { |
||||
const { result } = renderHook( |
||||
() => { |
||||
return useAppsItems(); |
||||
}, |
||||
{ |
||||
wrapper: ({ children }) => ( |
||||
<QueryClientProvider client={queryClient}> |
||||
<MockedServerContext |
||||
handleRequest={async (args) => { |
||||
if (args.method === 'GET' && args.pathPattern === '/apps/app-request/stats') { |
||||
return { |
||||
data: { |
||||
totalSeen: 0, |
||||
totalUnseen: 1, |
||||
}, |
||||
} as any; |
||||
} |
||||
if (args.method === 'GET' && args.pathPattern === '/apps/actionButtons') { |
||||
return [] as any; |
||||
} |
||||
|
||||
throw new Error('Method not mocked'); |
||||
}} |
||||
> |
||||
<MockedAuthorizationContext permissions={['manage-apps']}> |
||||
<MockedSettingsContext settings={{}}> |
||||
<MockedUserContext userPreferences={{}}> |
||||
<MockedUiKitActionManager>{children}</MockedUiKitActionManager> |
||||
</MockedUserContext> |
||||
</MockedSettingsContext> |
||||
</MockedAuthorizationContext> |
||||
</MockedServerContext> |
||||
</QueryClientProvider> |
||||
), |
||||
}, |
||||
); |
||||
|
||||
expect(result.current[0]).toEqual( |
||||
expect.objectContaining({ |
||||
id: 'marketplace', |
||||
}), |
||||
); |
||||
expect(result.current[1]).toEqual( |
||||
expect.objectContaining({ |
||||
id: 'installed', |
||||
}), |
||||
); |
||||
|
||||
expect(result.current[2]).toEqual( |
||||
expect.objectContaining({ |
||||
id: 'requested-apps', |
||||
}), |
||||
); |
||||
}); |
||||
|
||||
it('should return one action from the server with no conditions', async () => { |
||||
const { result, waitForValueToChange } = renderHook( |
||||
() => { |
||||
return useAppsItems(); |
||||
}, |
||||
{ |
||||
wrapper: ({ children }) => ( |
||||
<QueryClientProvider client={queryClient}> |
||||
<MockedServerContext |
||||
handleRequest={async (args) => { |
||||
if (args.method === 'GET' && args.pathPattern === '/apps/app-request/stats') { |
||||
return { |
||||
data: { |
||||
totalSeen: 0, |
||||
totalUnseen: 1, |
||||
}, |
||||
} as any; |
||||
} |
||||
if (args.method === 'GET' && args.pathPattern === '/apps/actionButtons') { |
||||
return [ |
||||
{ |
||||
appId: 'APP_ID', |
||||
actionId: 'ACTION_ID', |
||||
labelI18n: 'LABEL_I18N', |
||||
context: 'userDropdownAction', |
||||
}, |
||||
] as any; |
||||
} |
||||
|
||||
throw new Error('Method not mocked'); |
||||
}} |
||||
> |
||||
<MockedAuthorizationContext permissions={['manage-apps']}> |
||||
<MockedSettingsContext settings={{}}> |
||||
<MockedUserContext userPreferences={{}}> |
||||
<MockedUiKitActionManager>{children}</MockedUiKitActionManager> |
||||
</MockedUserContext> |
||||
</MockedSettingsContext> |
||||
</MockedAuthorizationContext> |
||||
</MockedServerContext> |
||||
</QueryClientProvider> |
||||
), |
||||
}, |
||||
); |
||||
|
||||
expect(result.current[0]).toEqual( |
||||
expect.objectContaining({ |
||||
id: 'marketplace', |
||||
}), |
||||
); |
||||
expect(result.current[1]).toEqual( |
||||
expect.objectContaining({ |
||||
id: 'installed', |
||||
}), |
||||
); |
||||
|
||||
await waitForValueToChange(() => result.current[3]); |
||||
|
||||
expect(result.current[3]).toEqual( |
||||
expect.objectContaining({ |
||||
id: 'APP_ID_ACTION_ID', |
||||
}), |
||||
); |
||||
}); |
||||
|
||||
describe('User Dropdown actions with role conditions', () => { |
||||
it('should return the action if the user has admin role', async () => { |
||||
const { result, waitForValueToChange } = renderHook( |
||||
() => { |
||||
return useAppsItems(); |
||||
}, |
||||
{ |
||||
wrapper: ({ children }) => ( |
||||
<QueryClientProvider client={queryClient}> |
||||
<MockedServerContext |
||||
handleRequest={async (args) => { |
||||
if (args.method === 'GET' && args.pathPattern === '/apps/app-request/stats') { |
||||
return { |
||||
data: { |
||||
totalSeen: 0, |
||||
totalUnseen: 1, |
||||
}, |
||||
} as any; |
||||
} |
||||
if (args.method === 'GET' && args.pathPattern === '/apps/actionButtons') { |
||||
return [ |
||||
{ |
||||
appId: 'APP_ID', |
||||
actionId: 'ACTION_ID', |
||||
labelI18n: 'LABEL_I18N', |
||||
context: 'userDropdownAction', |
||||
when: { |
||||
hasOneRole: ['admin'], |
||||
}, |
||||
}, |
||||
] as any; |
||||
} |
||||
|
||||
throw new Error('Method not mocked'); |
||||
}} |
||||
> |
||||
<MockedAuthorizationContext permissions={['manage-apps']} roles={['admin']}> |
||||
<MockedSettingsContext settings={{}}> |
||||
<MockedUserContext userPreferences={{}}> |
||||
<MockedUiKitActionManager>{children}</MockedUiKitActionManager> |
||||
</MockedUserContext> |
||||
</MockedSettingsContext> |
||||
</MockedAuthorizationContext> |
||||
</MockedServerContext> |
||||
</QueryClientProvider> |
||||
), |
||||
}, |
||||
); |
||||
|
||||
await waitForValueToChange(() => { |
||||
return queryClient.isFetching(); |
||||
}); |
||||
|
||||
expect(result.current[0]).toEqual( |
||||
expect.objectContaining({ |
||||
id: 'marketplace', |
||||
}), |
||||
); |
||||
expect(result.current[1]).toEqual( |
||||
expect.objectContaining({ |
||||
id: 'installed', |
||||
}), |
||||
); |
||||
|
||||
expect(result.current[3]).toEqual( |
||||
expect.objectContaining({ |
||||
id: 'APP_ID_ACTION_ID', |
||||
}), |
||||
); |
||||
}); |
||||
|
||||
it('should return filter the action if the user doesn`t have admin role', async () => { |
||||
const { result, waitForValueToChange } = renderHook( |
||||
() => { |
||||
return useAppsItems(); |
||||
}, |
||||
{ |
||||
wrapper: ({ children }) => ( |
||||
<QueryClientProvider client={queryClient}> |
||||
<MockedServerContext |
||||
handleRequest={async (args) => { |
||||
if (args.method === 'GET' && args.pathPattern === '/apps/app-request/stats') { |
||||
return { |
||||
data: { |
||||
totalSeen: 0, |
||||
totalUnseen: 1, |
||||
}, |
||||
} as any; |
||||
} |
||||
if (args.method === 'GET' && args.pathPattern === '/apps/actionButtons') { |
||||
return [ |
||||
{ |
||||
appId: 'APP_ID', |
||||
actionId: 'ACTION_ID', |
||||
labelI18n: 'LABEL_I18N', |
||||
context: 'userDropdownAction', |
||||
when: { |
||||
hasOneRole: ['admin'], |
||||
}, |
||||
}, |
||||
] as any; |
||||
} |
||||
|
||||
throw new Error('Method not mocked'); |
||||
}} |
||||
> |
||||
<MockedAuthorizationContext permissions={['manage-apps']}> |
||||
<MockedSettingsContext settings={{}}> |
||||
<MockedUserContext userPreferences={{}}> |
||||
<MockedUiKitActionManager>{children}</MockedUiKitActionManager> |
||||
</MockedUserContext> |
||||
</MockedSettingsContext> |
||||
</MockedAuthorizationContext> |
||||
</MockedServerContext> |
||||
</QueryClientProvider> |
||||
), |
||||
}, |
||||
); |
||||
|
||||
await waitForValueToChange(() => { |
||||
return queryClient.isFetching(); |
||||
}); |
||||
|
||||
expect(result.current[0]).toEqual( |
||||
expect.objectContaining({ |
||||
id: 'marketplace', |
||||
}), |
||||
); |
||||
expect(result.current[1]).toEqual( |
||||
expect.objectContaining({ |
||||
id: 'installed', |
||||
}), |
||||
); |
||||
|
||||
expect(result.current[2]).toEqual( |
||||
expect.objectContaining({ |
||||
id: 'requested-apps', |
||||
}), |
||||
); |
||||
|
||||
expect(result.current[3]).toEqual(undefined); |
||||
}); |
||||
}); |
||||
|
||||
describe('User Dropdown actions with permission conditions', () => { |
||||
it('should return the action if the user has manage-apps permission', async () => { |
||||
const { result, waitForValueToChange } = renderHook( |
||||
() => { |
||||
return useAppsItems(); |
||||
}, |
||||
{ |
||||
wrapper: ({ children }) => ( |
||||
<QueryClientProvider client={queryClient}> |
||||
<MockedServerContext |
||||
handleRequest={async (args) => { |
||||
if (args.method === 'GET' && args.pathPattern === '/apps/app-request/stats') { |
||||
return { |
||||
data: { |
||||
totalSeen: 0, |
||||
totalUnseen: 1, |
||||
}, |
||||
} as any; |
||||
} |
||||
if (args.method === 'GET' && args.pathPattern === '/apps/actionButtons') { |
||||
return [ |
||||
{ |
||||
appId: 'APP_ID', |
||||
actionId: 'ACTION_ID', |
||||
labelI18n: 'LABEL_I18N', |
||||
context: 'userDropdownAction', |
||||
when: { |
||||
hasOnePermission: ['manage-apps'], |
||||
}, |
||||
}, |
||||
] as any; |
||||
} |
||||
|
||||
throw new Error('Method not mocked'); |
||||
}} |
||||
> |
||||
<MockedAuthorizationContext permissions={['manage-apps']}> |
||||
<MockedSettingsContext settings={{}}> |
||||
<MockedUserContext userPreferences={{}}> |
||||
<MockedUiKitActionManager>{children}</MockedUiKitActionManager> |
||||
</MockedUserContext> |
||||
</MockedSettingsContext> |
||||
</MockedAuthorizationContext> |
||||
</MockedServerContext> |
||||
</QueryClientProvider> |
||||
), |
||||
}, |
||||
); |
||||
|
||||
await waitForValueToChange(() => { |
||||
return queryClient.isFetching(); |
||||
}); |
||||
|
||||
expect(result.current[0]).toEqual( |
||||
expect.objectContaining({ |
||||
id: 'marketplace', |
||||
}), |
||||
); |
||||
expect(result.current[1]).toEqual( |
||||
expect.objectContaining({ |
||||
id: 'installed', |
||||
}), |
||||
); |
||||
|
||||
expect(result.current[3]).toEqual( |
||||
expect.objectContaining({ |
||||
id: 'APP_ID_ACTION_ID', |
||||
}), |
||||
); |
||||
}); |
||||
|
||||
it('should return filter the action if the user doesn`t have `any` permission', async () => { |
||||
const { result, waitForValueToChange } = renderHook( |
||||
() => { |
||||
return useAppsItems(); |
||||
}, |
||||
{ |
||||
wrapper: ({ children }) => ( |
||||
<QueryClientProvider client={queryClient}> |
||||
<MockedServerContext |
||||
handleRequest={async (args) => { |
||||
if (args.method === 'GET' && args.pathPattern === '/apps/app-request/stats') { |
||||
return { |
||||
data: { |
||||
totalSeen: 0, |
||||
totalUnseen: 1, |
||||
}, |
||||
} as any; |
||||
} |
||||
if (args.method === 'GET' && args.pathPattern === '/apps/actionButtons') { |
||||
return [ |
||||
{ |
||||
appId: 'APP_ID', |
||||
actionId: 'ACTION_ID', |
||||
labelI18n: 'LABEL_I18N', |
||||
context: 'userDropdownAction', |
||||
when: { |
||||
hasOnePermission: ['any'], |
||||
}, |
||||
}, |
||||
] as any; |
||||
} |
||||
|
||||
throw new Error('Method not mocked'); |
||||
}} |
||||
> |
||||
<MockedAuthorizationContext permissions={['manage-apps']}> |
||||
<MockedSettingsContext settings={{}}> |
||||
<MockedUserContext userPreferences={{}}> |
||||
<MockedUiKitActionManager>{children}</MockedUiKitActionManager> |
||||
</MockedUserContext> |
||||
</MockedSettingsContext> |
||||
</MockedAuthorizationContext> |
||||
</MockedServerContext> |
||||
</QueryClientProvider> |
||||
), |
||||
}, |
||||
); |
||||
|
||||
await waitForValueToChange(() => { |
||||
return queryClient.isFetching(); |
||||
}); |
||||
|
||||
expect(result.current[3]).toEqual(undefined); |
||||
}); |
||||
}); |
||||
|
||||
export const MockedUiKitActionManager = ({ children }: { children: React.ReactNode }) => { |
||||
return <ActionManagerContext.Provider value={{} as any}>{children}</ActionManagerContext.Provider>; |
||||
}; |
||||
|
||||
const queryClient = new QueryClient({ |
||||
defaultOptions: { |
||||
queries: { |
||||
// ✅ turns retries off
|
||||
retry: false, |
||||
}, |
||||
}, |
||||
}); |
||||
afterEach(() => { |
||||
queryClient.clear(); |
||||
}); |
||||
Loading…
Reference in new issue