feat: Security Logs Page (#35218)
parent
3e34228268
commit
1eeb139158
@ -0,0 +1,9 @@ |
||||
--- |
||||
"@rocket.chat/meteor": minor |
||||
"@rocket.chat/i18n": minor |
||||
"@rocket.chat/mock-providers": minor |
||||
"@rocket.chat/ui-client": minor |
||||
"@rocket.chat/ui-contexts": minor |
||||
--- |
||||
|
||||
Adds a new admin page to audit settings changes in a server |
||||
@ -0,0 +1,20 @@ |
||||
import { type ReactElement } from 'react'; |
||||
import { useTranslation } from 'react-i18next'; |
||||
|
||||
import SecurityLogsTable from './components/SecurityLogsTable'; |
||||
import { Page, PageHeader, PageContent } from '../../components/Page'; |
||||
|
||||
const SecurityLogsPage = (): ReactElement => { |
||||
const { t } = useTranslation(); |
||||
|
||||
return ( |
||||
<Page> |
||||
<PageHeader title={t('Security_logs')} /> |
||||
<PageContent> |
||||
<SecurityLogsTable /> |
||||
</PageContent> |
||||
</Page> |
||||
); |
||||
}; |
||||
|
||||
export default SecurityLogsPage; |
||||
@ -0,0 +1,21 @@ |
||||
/* eslint-disable @typescript-eslint/naming-convention */ |
||||
import { mockAppRoot } from '@rocket.chat/mock-providers'; |
||||
import { composeStories } from '@storybook/react'; |
||||
import { render } from '@testing-library/react'; |
||||
import { axe } from 'jest-axe'; |
||||
|
||||
import * as stories from './AppInfoField.stories'; |
||||
|
||||
const testCases = Object.values(composeStories(stories)).map((Story) => [Story.storyName || 'Story', Story]); |
||||
|
||||
test.each(testCases)(`renders %s without crashing`, async (_storyname, Story) => { |
||||
const view = render(<Story />, { wrapper: mockAppRoot().build() }); |
||||
expect(view.baseElement).toMatchSnapshot(); |
||||
}); |
||||
|
||||
test.each(testCases)('%s should have no a11y violations', async (_storyname, Story) => { |
||||
const { container } = render(<Story />, { wrapper: mockAppRoot().build() }); |
||||
|
||||
const results = await axe(container); |
||||
expect(results).toHaveNoViolations(); |
||||
}); |
||||
@ -0,0 +1,89 @@ |
||||
import type { AppSubscriptionStatus } from '@rocket.chat/core-typings'; |
||||
import { mockAppRoot } from '@rocket.chat/mock-providers'; |
||||
import type { Meta, StoryFn } from '@storybook/react'; |
||||
|
||||
import { AppInfoField } from './AppInfoField'; |
||||
|
||||
export default { |
||||
title: 'views/Audit/AppInfoField', |
||||
component: AppInfoField, |
||||
args: { |
||||
appId: 'app-id', |
||||
}, |
||||
decorators: [ |
||||
mockAppRoot() |
||||
.withEndpoint('GET', '/apps/:id', () => ({ |
||||
app: { |
||||
name: 'App Name', |
||||
id: '', |
||||
iconFileData: '', |
||||
appRequestStats: { |
||||
appId: '', |
||||
totalSeen: 0, |
||||
totalUnseen: 0, |
||||
}, |
||||
author: { |
||||
name: '', |
||||
homepage: '', |
||||
support: '', |
||||
}, |
||||
description: '', |
||||
privacyPolicySummary: '', |
||||
detailedDescription: { |
||||
raw: '', |
||||
rendered: '', |
||||
}, |
||||
detailedChangelog: { |
||||
raw: '', |
||||
rendered: '', |
||||
}, |
||||
categories: [], |
||||
version: '', |
||||
price: 0, |
||||
purchaseType: 'buy' as const, |
||||
pricingPlans: [], |
||||
iconFileContent: '', |
||||
isSubscribed: false, |
||||
bundledIn: [], |
||||
marketplaceVersion: '', |
||||
// Recursive typem expect an App type here
|
||||
latest: undefined as any, |
||||
subscriptionInfo: { |
||||
typeOf: '', |
||||
status: 'Active' as AppSubscriptionStatus, |
||||
statusFromBilling: false, |
||||
isSeatBased: false, |
||||
seats: 0, |
||||
maxSeats: 0, |
||||
license: { |
||||
license: '', |
||||
version: 0, |
||||
expireDate: '', |
||||
}, |
||||
startDate: '', |
||||
periodEnd: '', |
||||
endDate: '', |
||||
externallyManaged: false, |
||||
isSubscribedViaBundle: false, |
||||
}, |
||||
tosLink: '', |
||||
privacyLink: '', |
||||
modifiedAt: '', |
||||
permissions: [], |
||||
languages: [], |
||||
createdDate: '', |
||||
private: false, |
||||
documentationUrl: '', |
||||
migrated: false, |
||||
}, |
||||
})) |
||||
.withTranslations('en', 'core', { App_name: 'App Name' }) |
||||
.buildStoryDecorator(), |
||||
], |
||||
} satisfies Meta<typeof AppInfoField>; |
||||
|
||||
export const Default: StoryFn<typeof AppInfoField> = (args) => <AppInfoField {...args} />; |
||||
|
||||
export const NoAppInfo: StoryFn<typeof AppInfoField> = (args) => <AppInfoField {...args} />; |
||||
|
||||
NoAppInfo.decorators = [mockAppRoot().withTranslations('en', 'core', { App_id: 'App Id' }).buildStoryDecorator()]; |
||||
@ -0,0 +1,40 @@ |
||||
import { Skeleton } from '@rocket.chat/fuselage'; |
||||
import { useEndpoint, useTranslation } from '@rocket.chat/ui-contexts'; |
||||
import { useQuery } from '@tanstack/react-query'; |
||||
|
||||
import AuditModalField from './AuditModalField'; |
||||
import AuditModalLabel from './AuditModalLabel'; |
||||
import AuditModalText from './AuditModalText'; |
||||
|
||||
type AppInfoFieldProps = { |
||||
appId: string; |
||||
}; |
||||
|
||||
// This is a separate component to encapsulate its logic and in the future expand it to a field that shows more info on the App
|
||||
export const AppInfoField = ({ appId }: AppInfoFieldProps) => { |
||||
const t = useTranslation(); |
||||
|
||||
const getAppInfo = useEndpoint('GET', `/apps/:id`, { id: appId }); |
||||
|
||||
const { data, isLoading, isSuccess } = useQuery({ |
||||
queryKey: ['getAppInfo', appId], |
||||
|
||||
queryFn: async () => { |
||||
return getAppInfo(); |
||||
}, |
||||
}); |
||||
|
||||
return ( |
||||
<> |
||||
<AuditModalField> |
||||
<AuditModalLabel>{t('Actor')}</AuditModalLabel> |
||||
<AuditModalText>{t('App')}</AuditModalText> |
||||
</AuditModalField> |
||||
<AuditModalField> |
||||
<AuditModalLabel>{isSuccess && data ? t('App_name') : t('App_id')}</AuditModalLabel> |
||||
{isLoading && <Skeleton />} |
||||
{isSuccess && data ? <AuditModalText>{data.app.name}</AuditModalText> : <AuditModalText>{appId}</AuditModalText>} |
||||
</AuditModalField> |
||||
</> |
||||
); |
||||
}; |
||||
@ -0,0 +1,8 @@ |
||||
import { Box } from '@rocket.chat/fuselage'; |
||||
import type { ComponentPropsWithoutRef } from 'react'; |
||||
|
||||
type AuditModalFieldProps = ComponentPropsWithoutRef<typeof Box>; |
||||
|
||||
const AuditModalField = (props: AuditModalFieldProps) => <Box mb={12} {...props} />; |
||||
|
||||
export default AuditModalField; |
||||
@ -0,0 +1,8 @@ |
||||
import { Box } from '@rocket.chat/fuselage'; |
||||
import type { ComponentPropsWithoutRef } from 'react'; |
||||
|
||||
type AuditModalLabelProps = ComponentPropsWithoutRef<typeof Box>; |
||||
|
||||
const AuditModalLabel = (props: AuditModalLabelProps) => <Box mbe={4} fontScale='p2m' color='titles-labels' {...props} />; |
||||
|
||||
export default AuditModalLabel; |
||||
@ -0,0 +1,13 @@ |
||||
import { css } from '@rocket.chat/css-in-js'; |
||||
import { Box } from '@rocket.chat/fuselage'; |
||||
import type { ComponentPropsWithoutRef } from 'react'; |
||||
|
||||
const wordBreak = css` |
||||
word-break: break-word; |
||||
`;
|
||||
|
||||
type AuditModalTextProps = ComponentPropsWithoutRef<typeof Box>; |
||||
|
||||
const AuditModalText = (props: AuditModalTextProps) => <Box fontScale='p2' color='default' className={wordBreak} {...props} />; |
||||
|
||||
export default AuditModalText; |
||||
@ -0,0 +1,21 @@ |
||||
/* eslint-disable @typescript-eslint/naming-convention */ |
||||
import { mockAppRoot } from '@rocket.chat/mock-providers'; |
||||
import { composeStories } from '@storybook/react'; |
||||
import { render } from '@testing-library/react'; |
||||
import { axe } from 'jest-axe'; |
||||
|
||||
import * as stories from './SecurityLogDisplayModal.stories'; |
||||
|
||||
const testCases = Object.values(composeStories(stories)).map((Story) => [Story.storyName || 'Story', Story]); |
||||
|
||||
test.each(testCases)(`renders %s without crashing`, async (_storyname, Story) => { |
||||
const view = render(<Story />, { wrapper: mockAppRoot().build() }); |
||||
expect(view.baseElement).toMatchSnapshot(); |
||||
}); |
||||
|
||||
test.each(testCases)('%s should have no a11y violations', async (_storyname, Story) => { |
||||
const { container } = render(<Story />, { wrapper: mockAppRoot().build() }); |
||||
|
||||
const results = await axe(container); |
||||
expect(results).toHaveNoViolations(); |
||||
}); |
||||
@ -0,0 +1,31 @@ |
||||
import type { Meta, StoryFn } from '@storybook/react'; |
||||
|
||||
import SecurityLogDisplayModal from './SecurityLogDisplayModal'; |
||||
|
||||
export default { |
||||
title: 'views/Audit/SecurityLogDisplay', |
||||
component: SecurityLogDisplayModal, |
||||
args: { |
||||
timestamp: 'Thursday, 20-Mar-25 17:17:46', |
||||
actor: { |
||||
type: 'user', |
||||
_id: 'user-id', |
||||
username: 'username', |
||||
useragent: 'useragent', |
||||
ip: '127.0.0.1', |
||||
}, |
||||
setting: 'Show_message_in_email_notification', |
||||
changedFrom: 'false', |
||||
changedTo: 'true', |
||||
}, |
||||
} satisfies Meta<typeof SecurityLogDisplayModal>; |
||||
|
||||
export const Default: StoryFn<typeof SecurityLogDisplayModal> = (args) => <SecurityLogDisplayModal {...args} />; |
||||
|
||||
export const system: StoryFn<typeof SecurityLogDisplayModal> = (args) => ( |
||||
<SecurityLogDisplayModal {...args} actor={{ type: 'system', reason: 'update' }} /> |
||||
); |
||||
|
||||
export const app: StoryFn<typeof SecurityLogDisplayModal> = (args) => ( |
||||
<SecurityLogDisplayModal {...args} actor={{ type: 'app', _id: 'app-id' }} /> |
||||
); |
||||
@ -0,0 +1,85 @@ |
||||
import type { IAuditServerUserActor, IAuditServerSystemActor, IAuditServerAppActor } from '@rocket.chat/core-typings'; |
||||
import { Box } from '@rocket.chat/fuselage'; |
||||
import { UserAvatar } from '@rocket.chat/ui-avatar'; |
||||
import { format } from 'date-fns'; |
||||
import { useTranslation } from 'react-i18next'; |
||||
|
||||
import { AppInfoField } from './AppInfoField'; |
||||
import AuditModalField from './AuditModalField'; |
||||
import AuditModalLabel from './AuditModalLabel'; |
||||
import AuditModalText from './AuditModalText'; |
||||
import GenericModal from '../../../components/GenericModal'; |
||||
|
||||
type SecurityLogDisplayProps = { |
||||
timestamp: string; |
||||
actor: IAuditServerUserActor | IAuditServerSystemActor | IAuditServerAppActor; |
||||
setting: string; |
||||
changedFrom: string; |
||||
changedTo: string; |
||||
onCancel: () => void; |
||||
}; |
||||
|
||||
const SecurityLogDisplayModal = ({ timestamp, actor, setting, changedFrom, changedTo, onCancel }: SecurityLogDisplayProps) => { |
||||
const { t } = useTranslation(); |
||||
|
||||
return ( |
||||
<GenericModal maxHeight={550} icon={null} onClose={onCancel} title={t('Setting_change')}> |
||||
<AuditModalField> |
||||
<AuditModalLabel>{t('Timestamp')}</AuditModalLabel> |
||||
<AuditModalText>{format(new Date(timestamp), 'MMMM d yyyy, h:mm:ss a')}</AuditModalText> |
||||
</AuditModalField> |
||||
|
||||
{actor.type === 'user' && ( |
||||
<AuditModalField> |
||||
<AuditModalLabel>{t('Actor')}</AuditModalLabel> |
||||
<Box display='flex' alignItems='center'> |
||||
{actor.type === 'user' && <UserAvatar size='x24' userId={actor._id} />} |
||||
<Box |
||||
mi={actor.type === 'user' ? 8 : 0} |
||||
fontScale='p2m' |
||||
display='flex' |
||||
flexDirection='column' |
||||
alignSelf='center' |
||||
withTruncatedText |
||||
> |
||||
{actor.username} |
||||
</Box> |
||||
</Box> |
||||
</AuditModalField> |
||||
)} |
||||
|
||||
{actor.type === 'app' && <AppInfoField appId={actor._id} />} |
||||
|
||||
{actor.type === 'system' && ( |
||||
<> |
||||
<AuditModalField> |
||||
<AuditModalLabel>{t('Actor')}</AuditModalLabel> |
||||
<AuditModalText>{t('System')}</AuditModalText> |
||||
</AuditModalField> |
||||
|
||||
<AuditModalField> |
||||
<AuditModalLabel>{t('Reason')}</AuditModalLabel> |
||||
<AuditModalText>{actor.reason}</AuditModalText> |
||||
</AuditModalField> |
||||
</> |
||||
)} |
||||
|
||||
<AuditModalField> |
||||
<AuditModalLabel>{t('Setting')}</AuditModalLabel> |
||||
<AuditModalText>{t(setting)}</AuditModalText> |
||||
</AuditModalField> |
||||
|
||||
<AuditModalField> |
||||
<AuditModalLabel>{t('Changed_from')}</AuditModalLabel> |
||||
<AuditModalText>{changedFrom}</AuditModalText> |
||||
</AuditModalField> |
||||
|
||||
<AuditModalField> |
||||
<AuditModalLabel>{t('Changed_to')}</AuditModalLabel> |
||||
<AuditModalText>{changedTo}</AuditModalText> |
||||
</AuditModalField> |
||||
</GenericModal> |
||||
); |
||||
}; |
||||
|
||||
export default SecurityLogDisplayModal; |
||||
@ -0,0 +1,219 @@ |
||||
import type { IAuditServerAppActor, IAuditServerSystemActor, IAuditServerUserActor } from '@rocket.chat/core-typings'; |
||||
import { Box, Button, ButtonGroup, Field, FieldLabel, Margins, Pagination } from '@rocket.chat/fuselage'; |
||||
import { UserAvatar } from '@rocket.chat/ui-avatar'; |
||||
import { useEndpoint, useSetModal } from '@rocket.chat/ui-contexts'; |
||||
import { useQuery } from '@tanstack/react-query'; |
||||
import { format } from 'date-fns'; |
||||
import { useState, type ReactElement } from 'react'; |
||||
import { useTranslation } from 'react-i18next'; |
||||
|
||||
import SecurityLogDisplayModal from './SecurityLogDisplayModal'; |
||||
import { SettingSelect } from './SettingSelect'; |
||||
import DateRangePicker from './forms/DateRangePicker'; |
||||
import GenericNoResults from '../../../components/GenericNoResults'; |
||||
import { |
||||
GenericTable, |
||||
GenericTableBody, |
||||
GenericTableCell, |
||||
GenericTableHeader, |
||||
GenericTableHeaderCell, |
||||
GenericTableLoadingRow, |
||||
GenericTableRow, |
||||
} from '../../../components/GenericTable'; |
||||
import { usePagination } from '../../../components/GenericTable/hooks/usePagination'; |
||||
import type { DateRange } from '../utils/dateRange'; |
||||
import { getTypeTranslation } from '../utils/getAppTypeTranslation'; |
||||
|
||||
const SecurityLogsTable = (): ReactElement => { |
||||
const { t } = useTranslation(); |
||||
const [setting, setSetting] = useState(''); |
||||
|
||||
const setModal = useSetModal(); |
||||
|
||||
const [dateRange, setDateRange] = useState<DateRange>(() => ({ |
||||
start: undefined, |
||||
end: undefined, |
||||
})); |
||||
|
||||
const [query, setQuery] = useState({ |
||||
start: new Date(0).toISOString(), |
||||
end: new Date().toISOString(), |
||||
settingId: '', |
||||
}); |
||||
|
||||
const { current, itemsPerPage, setItemsPerPage: onSetItemsPerPage, setCurrent: onSetCurrent, ...paginationProps } = usePagination(); |
||||
|
||||
const handleClearFilters = () => { |
||||
setSetting(''); |
||||
setDateRange({ start: undefined, end: undefined }); |
||||
setQuery({ |
||||
start: new Date(0).toISOString(), |
||||
end: new Date().toISOString(), |
||||
settingId: '', |
||||
}); |
||||
onSetCurrent(0); |
||||
}; |
||||
|
||||
const handleApplyFilters = () => { |
||||
const { start, end } = dateRange; |
||||
setQuery({ |
||||
start: start?.toISOString() ?? new Date(0).toISOString(), |
||||
end: end?.toISOString() ?? new Date().toISOString(), |
||||
settingId: setting, |
||||
}); |
||||
onSetCurrent(0); |
||||
}; |
||||
|
||||
const handleItemClick = ({ |
||||
actor, |
||||
timestamp, |
||||
setting, |
||||
changedFrom, |
||||
changedTo, |
||||
}: { |
||||
actor: IAuditServerUserActor | IAuditServerSystemActor | IAuditServerAppActor; |
||||
timestamp: string; |
||||
setting: unknown; |
||||
changedFrom: string; |
||||
changedTo: string; |
||||
}) => { |
||||
setModal( |
||||
<SecurityLogDisplayModal |
||||
timestamp={timestamp} |
||||
actor={actor} |
||||
setting={String(setting)} |
||||
changedFrom={changedFrom} |
||||
changedTo={changedTo} |
||||
onCancel={() => setModal(null)} |
||||
/>, |
||||
); |
||||
}; |
||||
|
||||
const getAudits = useEndpoint('GET', '/v1/audit.settings'); |
||||
|
||||
const { data, isLoading, isSuccess } = useQuery({ |
||||
queryKey: ['audit.settings', query, itemsPerPage, current], |
||||
|
||||
queryFn: async () => { |
||||
return getAudits({ ...query, ...(itemsPerPage && { count: itemsPerPage }), ...(current && { offset: current }) }); |
||||
}, |
||||
}); |
||||
|
||||
return ( |
||||
<> |
||||
<Box mb={16} display='flex' flexWrap='wrap' alignItems='flex-end'> |
||||
<Field width='unset' flexShrink={1} flexGrow={1}> |
||||
<Margins inline={6}> |
||||
<FieldLabel>{t('Date')}</FieldLabel> |
||||
<DateRangePicker display='flex' flexGrow={1} value={dateRange} onChange={setDateRange} /> |
||||
</Margins> |
||||
</Field> |
||||
<Field width={300}> |
||||
<Margins inline={6}> |
||||
<FieldLabel>{t('Setting')}</FieldLabel> |
||||
<SettingSelect value={setting} onChange={setSetting} /> |
||||
</Margins> |
||||
</Field> |
||||
<ButtonGroup> |
||||
<Margins blockStart={12} inline={6}> |
||||
<Button secondary onClick={handleClearFilters}> |
||||
{t('Clear_filters')} |
||||
</Button> |
||||
<Button primary onClick={handleApplyFilters}> |
||||
{t('Apply_filters')} |
||||
</Button> |
||||
</Margins> |
||||
</ButtonGroup> |
||||
</Box> |
||||
|
||||
{isLoading && ( |
||||
<GenericTable> |
||||
<GenericTableHeader> |
||||
<GenericTableHeaderCell>{t('Actor')}</GenericTableHeaderCell> |
||||
<GenericTableHeaderCell>{t('Timestamp')}</GenericTableHeaderCell> |
||||
<GenericTableHeaderCell>{t('Setting')}</GenericTableHeaderCell> |
||||
<GenericTableHeaderCell>{t('Changed_from')}</GenericTableHeaderCell> |
||||
<GenericTableHeaderCell>{t('Changed_to')}</GenericTableHeaderCell> |
||||
</GenericTableHeader> |
||||
<GenericTableBody> |
||||
<GenericTableLoadingRow cols={5} /> |
||||
</GenericTableBody> |
||||
</GenericTable> |
||||
)} |
||||
{isSuccess && data.total === 0 && ( |
||||
<GenericNoResults |
||||
title={t('No_results_found')} |
||||
description={t('Try_different_filters')} |
||||
buttonTitle={t('Clear_filters')} |
||||
buttonAction={handleClearFilters} |
||||
/> |
||||
)} |
||||
{isSuccess && data.total > 0 && ( |
||||
<GenericTable striped> |
||||
<GenericTableHeader> |
||||
<GenericTableHeaderCell>{t('Actor')}</GenericTableHeaderCell> |
||||
<GenericTableHeaderCell>{t('Timestamp')}</GenericTableHeaderCell> |
||||
<GenericTableHeaderCell>{t('Setting')}</GenericTableHeaderCell> |
||||
<GenericTableHeaderCell>{t('Changed_from')}</GenericTableHeaderCell> |
||||
<GenericTableHeaderCell>{t('Changed_to')}</GenericTableHeaderCell> |
||||
</GenericTableHeader> |
||||
<GenericTableBody> |
||||
{data.events.map((item) => { |
||||
const setting = item.data.find((item) => item.key === 'id')?.value; |
||||
const previous = item.data.find((item) => item.key === 'previous')?.value || t('Empty'); |
||||
const current = item.data.find((item) => item.key === 'current')?.value || t('Empty'); |
||||
return ( |
||||
<GenericTableRow |
||||
key={item._id} |
||||
role='link' |
||||
action |
||||
tabIndex={0} |
||||
height={44} |
||||
onClick={() => |
||||
handleItemClick({ |
||||
actor: item.actor, |
||||
timestamp: new Date(item.ts).toDateString(), |
||||
setting, |
||||
changedFrom: String(previous), |
||||
changedTo: String(current), |
||||
}) |
||||
} |
||||
> |
||||
<GenericTableCell withTruncatedText> |
||||
<Box display='flex' alignItems='center'> |
||||
{item.actor.type === 'user' && ( |
||||
<Box mie={4}> |
||||
<UserAvatar size='x24' userId={item.actor._id} /> |
||||
</Box> |
||||
)} |
||||
<Box fontScale='p2m' withTruncatedText color='default'> |
||||
{item.actor.type === 'user' ? item.actor.username : t(getTypeTranslation(item.actor.type))} |
||||
</Box> |
||||
</Box> |
||||
</GenericTableCell> |
||||
<GenericTableCell withTruncatedText>{format(new Date(item.ts), 'MMMM d yyyy, h:mm:ss a')}</GenericTableCell> |
||||
<GenericTableCell withTruncatedText title={setting && String(setting)}> |
||||
{setting && String(setting)} |
||||
</GenericTableCell> |
||||
<GenericTableCell withTruncatedText>{String(previous)}</GenericTableCell> |
||||
<GenericTableCell withTruncatedText>{String(current)}</GenericTableCell> |
||||
</GenericTableRow> |
||||
); |
||||
})} |
||||
</GenericTableBody> |
||||
</GenericTable> |
||||
)} |
||||
<Pagination |
||||
divider |
||||
current={current} |
||||
itemsPerPage={itemsPerPage} |
||||
count={data?.total || 0} |
||||
onSetItemsPerPage={onSetItemsPerPage} |
||||
onSetCurrent={onSetCurrent} |
||||
{...paginationProps} |
||||
/> |
||||
</> |
||||
); |
||||
}; |
||||
|
||||
export default SecurityLogsTable; |
||||
@ -0,0 +1,34 @@ |
||||
import { Option, PaginatedSelectFiltered } from '@rocket.chat/fuselage'; |
||||
import { useDebouncedValue } from '@rocket.chat/fuselage-hooks'; |
||||
import { useState } from 'react'; |
||||
import { useTranslation } from 'react-i18next'; |
||||
|
||||
import { useSettingSelectOptions } from '../hooks/useSettingSelectOptions'; |
||||
|
||||
export const SettingSelect = ({ value, onChange }: { value: string; onChange: (value: string) => void }) => { |
||||
const { t } = useTranslation(); |
||||
const [filter, setFilter] = useState<string>(''); |
||||
|
||||
const debouncedFilter = useDebouncedValue(filter, 500); |
||||
|
||||
const { data, fetchNextPage, isFetchingNextPage } = useSettingSelectOptions(debouncedFilter); |
||||
const flattenedData = data?.pages.flatMap((page) => page) || []; |
||||
|
||||
return ( |
||||
<PaginatedSelectFiltered |
||||
flexShrink={1} |
||||
value={value} |
||||
onChange={(val) => onChange(val)} |
||||
placeholder={t('All_Settings')} |
||||
filter={filter} |
||||
setFilter={setFilter as (value: string | number | undefined) => void} |
||||
options={flattenedData} |
||||
endReached={() => !isFetchingNextPage && fetchNextPage({ cancelRefetch: true })} |
||||
renderItem={({ label, ...props }) => ( |
||||
<Option {...props} title={t(label)}> |
||||
{label} |
||||
</Option> |
||||
)} |
||||
/> |
||||
); |
||||
}; |
||||
@ -0,0 +1,77 @@ |
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP |
||||
|
||||
exports[`renders Default without crashing 1`] = ` |
||||
<body> |
||||
<div> |
||||
<div |
||||
class="rcx-box rcx-box--full rcx-css-op9h3g" |
||||
> |
||||
<div |
||||
class="rcx-box rcx-box--full rcx-css-pwio3x" |
||||
> |
||||
Actor |
||||
</div> |
||||
<div |
||||
class="rcx-box rcx-box--full rcx-css-6hwe8l rcx-css-1cuayu0" |
||||
> |
||||
App |
||||
</div> |
||||
</div> |
||||
<div |
||||
class="rcx-box rcx-box--full rcx-css-op9h3g" |
||||
> |
||||
<div |
||||
class="rcx-box rcx-box--full rcx-css-pwio3x" |
||||
> |
||||
App_id |
||||
</div> |
||||
<span |
||||
class="rcx-skeleton rcx-skeleton--text" |
||||
/> |
||||
<div |
||||
class="rcx-box rcx-box--full rcx-css-6hwe8l rcx-css-1cuayu0" |
||||
> |
||||
app-id |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</body> |
||||
`; |
||||
|
||||
exports[`renders NoAppInfo without crashing 1`] = ` |
||||
<body> |
||||
<div> |
||||
<div |
||||
class="rcx-box rcx-box--full rcx-css-op9h3g" |
||||
> |
||||
<div |
||||
class="rcx-box rcx-box--full rcx-css-pwio3x" |
||||
> |
||||
Actor |
||||
</div> |
||||
<div |
||||
class="rcx-box rcx-box--full rcx-css-6hwe8l rcx-css-1cuayu0" |
||||
> |
||||
App |
||||
</div> |
||||
</div> |
||||
<div |
||||
class="rcx-box rcx-box--full rcx-css-op9h3g" |
||||
> |
||||
<div |
||||
class="rcx-box rcx-box--full rcx-css-pwio3x" |
||||
> |
||||
App Id |
||||
</div> |
||||
<span |
||||
class="rcx-skeleton rcx-skeleton--text" |
||||
/> |
||||
<div |
||||
class="rcx-box rcx-box--full rcx-css-6hwe8l rcx-css-1cuayu0" |
||||
> |
||||
app-id |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</body> |
||||
`; |
||||
@ -0,0 +1,412 @@ |
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP |
||||
|
||||
exports[`renders Default without crashing 1`] = ` |
||||
<body> |
||||
<div> |
||||
<dialog |
||||
aria-labelledby=":r0:-title" |
||||
aria-modal="true" |
||||
class="rcx-box rcx-box--full rcx-modal rcx-css-9ju0e2" |
||||
open="" |
||||
> |
||||
<div |
||||
class="rcx-box rcx-box--full rcx-modal__inner rcx-css-1e2ego0" |
||||
> |
||||
<header |
||||
class="rcx-box rcx-box--full rcx-modal__header" |
||||
> |
||||
<div |
||||
class="rcx-box rcx-box--full rcx-modal__header-inner" |
||||
> |
||||
<div |
||||
class="rcx-box rcx-box--full rcx-modal__header-text rcx-css-trljwa rcx-css-lma364" |
||||
> |
||||
<h2 |
||||
class="rcx-box rcx-box--full rcx-modal__title" |
||||
id=":r0:-title" |
||||
> |
||||
Setting_change |
||||
</h2> |
||||
</div> |
||||
</div> |
||||
</header> |
||||
<div |
||||
class="rcx-box rcx-box--full rcx-modal__content rcx-css-1vw7itl" |
||||
> |
||||
<div |
||||
class="rcx-box rcx-box--full rcx-modal__content-wrapper rcx-css-r1bpeb" |
||||
> |
||||
<div |
||||
class="rcx-box rcx-box--full rcx-css-op9h3g" |
||||
> |
||||
<div |
||||
class="rcx-box rcx-box--full rcx-css-pwio3x" |
||||
> |
||||
Timestamp |
||||
</div> |
||||
<div |
||||
class="rcx-box rcx-box--full rcx-css-6hwe8l rcx-css-1cuayu0" |
||||
> |
||||
March 20 2025, 5:17:46 PM |
||||
</div> |
||||
</div> |
||||
<div |
||||
class="rcx-box rcx-box--full rcx-css-op9h3g" |
||||
> |
||||
<div |
||||
class="rcx-box rcx-box--full rcx-css-pwio3x" |
||||
> |
||||
Actor |
||||
</div> |
||||
<div |
||||
class="rcx-box rcx-box--full rcx-css-127j9mz" |
||||
> |
||||
<figure |
||||
class="rcx-box rcx-box--full rcx-avatar rcx-avatar--x24" |
||||
> |
||||
<img |
||||
alt="" |
||||
aria-hidden="true" |
||||
class="rcx-avatar__element rcx-avatar__element--x24" |
||||
src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQYV2Oora39DwAFaQJ3y3rKeAAAAABJRU5ErkJggg==" |
||||
/> |
||||
</figure> |
||||
<div |
||||
class="rcx-box rcx-box--full rcx-css-2f8n8y" |
||||
> |
||||
username |
||||
</div> |
||||
</div> |
||||
</div> |
||||
<div |
||||
class="rcx-box rcx-box--full rcx-css-op9h3g" |
||||
> |
||||
<div |
||||
class="rcx-box rcx-box--full rcx-css-pwio3x" |
||||
> |
||||
Setting |
||||
</div> |
||||
<div |
||||
class="rcx-box rcx-box--full rcx-css-6hwe8l rcx-css-1cuayu0" |
||||
> |
||||
Show_message_in_email_notification |
||||
</div> |
||||
</div> |
||||
<div |
||||
class="rcx-box rcx-box--full rcx-css-op9h3g" |
||||
> |
||||
<div |
||||
class="rcx-box rcx-box--full rcx-css-pwio3x" |
||||
> |
||||
Changed_from |
||||
</div> |
||||
<div |
||||
class="rcx-box rcx-box--full rcx-css-6hwe8l rcx-css-1cuayu0" |
||||
> |
||||
false |
||||
</div> |
||||
</div> |
||||
<div |
||||
class="rcx-box rcx-box--full rcx-css-op9h3g" |
||||
> |
||||
<div |
||||
class="rcx-box rcx-box--full rcx-css-pwio3x" |
||||
> |
||||
Changed_to |
||||
</div> |
||||
<div |
||||
class="rcx-box rcx-box--full rcx-css-6hwe8l rcx-css-1cuayu0" |
||||
> |
||||
true |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
<div |
||||
class="rcx-box rcx-box--full rcx-modal__footer rcx-css-17mu816" |
||||
> |
||||
<div |
||||
class="rcx-button-group rcx-button-group--align-end" |
||||
role="group" |
||||
/> |
||||
</div> |
||||
</div> |
||||
</dialog> |
||||
</div> |
||||
</body> |
||||
`; |
||||
|
||||
exports[`renders app without crashing 1`] = ` |
||||
<body> |
||||
<div> |
||||
<dialog |
||||
aria-labelledby=":r1:-title" |
||||
aria-modal="true" |
||||
class="rcx-box rcx-box--full rcx-modal rcx-css-9ju0e2" |
||||
open="" |
||||
> |
||||
<div |
||||
class="rcx-box rcx-box--full rcx-modal__inner rcx-css-1e2ego0" |
||||
> |
||||
<header |
||||
class="rcx-box rcx-box--full rcx-modal__header" |
||||
> |
||||
<div |
||||
class="rcx-box rcx-box--full rcx-modal__header-inner" |
||||
> |
||||
<div |
||||
class="rcx-box rcx-box--full rcx-modal__header-text rcx-css-trljwa rcx-css-lma364" |
||||
> |
||||
<h2 |
||||
class="rcx-box rcx-box--full rcx-modal__title" |
||||
id=":r1:-title" |
||||
> |
||||
Setting_change |
||||
</h2> |
||||
</div> |
||||
</div> |
||||
</header> |
||||
<div |
||||
class="rcx-box rcx-box--full rcx-modal__content rcx-css-1vw7itl" |
||||
> |
||||
<div |
||||
class="rcx-box rcx-box--full rcx-modal__content-wrapper rcx-css-r1bpeb" |
||||
> |
||||
<div |
||||
class="rcx-box rcx-box--full rcx-css-op9h3g" |
||||
> |
||||
<div |
||||
class="rcx-box rcx-box--full rcx-css-pwio3x" |
||||
> |
||||
Timestamp |
||||
</div> |
||||
<div |
||||
class="rcx-box rcx-box--full rcx-css-6hwe8l rcx-css-1cuayu0" |
||||
> |
||||
March 20 2025, 5:17:46 PM |
||||
</div> |
||||
</div> |
||||
<div |
||||
class="rcx-box rcx-box--full rcx-css-op9h3g" |
||||
> |
||||
<div |
||||
class="rcx-box rcx-box--full rcx-css-pwio3x" |
||||
> |
||||
Actor |
||||
</div> |
||||
<div |
||||
class="rcx-box rcx-box--full rcx-css-6hwe8l rcx-css-1cuayu0" |
||||
> |
||||
App |
||||
</div> |
||||
</div> |
||||
<div |
||||
class="rcx-box rcx-box--full rcx-css-op9h3g" |
||||
> |
||||
<div |
||||
class="rcx-box rcx-box--full rcx-css-pwio3x" |
||||
> |
||||
App_id |
||||
</div> |
||||
<span |
||||
class="rcx-skeleton rcx-skeleton--text" |
||||
/> |
||||
<div |
||||
class="rcx-box rcx-box--full rcx-css-6hwe8l rcx-css-1cuayu0" |
||||
> |
||||
app-id |
||||
</div> |
||||
</div> |
||||
<div |
||||
class="rcx-box rcx-box--full rcx-css-op9h3g" |
||||
> |
||||
<div |
||||
class="rcx-box rcx-box--full rcx-css-pwio3x" |
||||
> |
||||
Setting |
||||
</div> |
||||
<div |
||||
class="rcx-box rcx-box--full rcx-css-6hwe8l rcx-css-1cuayu0" |
||||
> |
||||
Show_message_in_email_notification |
||||
</div> |
||||
</div> |
||||
<div |
||||
class="rcx-box rcx-box--full rcx-css-op9h3g" |
||||
> |
||||
<div |
||||
class="rcx-box rcx-box--full rcx-css-pwio3x" |
||||
> |
||||
Changed_from |
||||
</div> |
||||
<div |
||||
class="rcx-box rcx-box--full rcx-css-6hwe8l rcx-css-1cuayu0" |
||||
> |
||||
false |
||||
</div> |
||||
</div> |
||||
<div |
||||
class="rcx-box rcx-box--full rcx-css-op9h3g" |
||||
> |
||||
<div |
||||
class="rcx-box rcx-box--full rcx-css-pwio3x" |
||||
> |
||||
Changed_to |
||||
</div> |
||||
<div |
||||
class="rcx-box rcx-box--full rcx-css-6hwe8l rcx-css-1cuayu0" |
||||
> |
||||
true |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
<div |
||||
class="rcx-box rcx-box--full rcx-modal__footer rcx-css-17mu816" |
||||
> |
||||
<div |
||||
class="rcx-button-group rcx-button-group--align-end" |
||||
role="group" |
||||
/> |
||||
</div> |
||||
</div> |
||||
</dialog> |
||||
</div> |
||||
</body> |
||||
`; |
||||
|
||||
exports[`renders system without crashing 1`] = ` |
||||
<body> |
||||
<div> |
||||
<dialog |
||||
aria-labelledby=":r2:-title" |
||||
aria-modal="true" |
||||
class="rcx-box rcx-box--full rcx-modal rcx-css-9ju0e2" |
||||
open="" |
||||
> |
||||
<div |
||||
class="rcx-box rcx-box--full rcx-modal__inner rcx-css-1e2ego0" |
||||
> |
||||
<header |
||||
class="rcx-box rcx-box--full rcx-modal__header" |
||||
> |
||||
<div |
||||
class="rcx-box rcx-box--full rcx-modal__header-inner" |
||||
> |
||||
<div |
||||
class="rcx-box rcx-box--full rcx-modal__header-text rcx-css-trljwa rcx-css-lma364" |
||||
> |
||||
<h2 |
||||
class="rcx-box rcx-box--full rcx-modal__title" |
||||
id=":r2:-title" |
||||
> |
||||
Setting_change |
||||
</h2> |
||||
</div> |
||||
</div> |
||||
</header> |
||||
<div |
||||
class="rcx-box rcx-box--full rcx-modal__content rcx-css-1vw7itl" |
||||
> |
||||
<div |
||||
class="rcx-box rcx-box--full rcx-modal__content-wrapper rcx-css-r1bpeb" |
||||
> |
||||
<div |
||||
class="rcx-box rcx-box--full rcx-css-op9h3g" |
||||
> |
||||
<div |
||||
class="rcx-box rcx-box--full rcx-css-pwio3x" |
||||
> |
||||
Timestamp |
||||
</div> |
||||
<div |
||||
class="rcx-box rcx-box--full rcx-css-6hwe8l rcx-css-1cuayu0" |
||||
> |
||||
March 20 2025, 5:17:46 PM |
||||
</div> |
||||
</div> |
||||
<div |
||||
class="rcx-box rcx-box--full rcx-css-op9h3g" |
||||
> |
||||
<div |
||||
class="rcx-box rcx-box--full rcx-css-pwio3x" |
||||
> |
||||
Actor |
||||
</div> |
||||
<div |
||||
class="rcx-box rcx-box--full rcx-css-6hwe8l rcx-css-1cuayu0" |
||||
> |
||||
System |
||||
</div> |
||||
</div> |
||||
<div |
||||
class="rcx-box rcx-box--full rcx-css-op9h3g" |
||||
> |
||||
<div |
||||
class="rcx-box rcx-box--full rcx-css-pwio3x" |
||||
> |
||||
Reason |
||||
</div> |
||||
<div |
||||
class="rcx-box rcx-box--full rcx-css-6hwe8l rcx-css-1cuayu0" |
||||
> |
||||
update |
||||
</div> |
||||
</div> |
||||
<div |
||||
class="rcx-box rcx-box--full rcx-css-op9h3g" |
||||
> |
||||
<div |
||||
class="rcx-box rcx-box--full rcx-css-pwio3x" |
||||
> |
||||
Setting |
||||
</div> |
||||
<div |
||||
class="rcx-box rcx-box--full rcx-css-6hwe8l rcx-css-1cuayu0" |
||||
> |
||||
Show_message_in_email_notification |
||||
</div> |
||||
</div> |
||||
<div |
||||
class="rcx-box rcx-box--full rcx-css-op9h3g" |
||||
> |
||||
<div |
||||
class="rcx-box rcx-box--full rcx-css-pwio3x" |
||||
> |
||||
Changed_from |
||||
</div> |
||||
<div |
||||
class="rcx-box rcx-box--full rcx-css-6hwe8l rcx-css-1cuayu0" |
||||
> |
||||
false |
||||
</div> |
||||
</div> |
||||
<div |
||||
class="rcx-box rcx-box--full rcx-css-op9h3g" |
||||
> |
||||
<div |
||||
class="rcx-box rcx-box--full rcx-css-pwio3x" |
||||
> |
||||
Changed_to |
||||
</div> |
||||
<div |
||||
class="rcx-box rcx-box--full rcx-css-6hwe8l rcx-css-1cuayu0" |
||||
> |
||||
true |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
<div |
||||
class="rcx-box rcx-box--full rcx-modal__footer rcx-css-17mu816" |
||||
> |
||||
<div |
||||
class="rcx-button-group rcx-button-group--align-end" |
||||
role="group" |
||||
/> |
||||
</div> |
||||
</div> |
||||
</dialog> |
||||
</div> |
||||
</body> |
||||
`; |
||||
@ -0,0 +1,78 @@ |
||||
import { mockAppRoot } from '@rocket.chat/mock-providers'; |
||||
import { renderHook, waitFor } from '@testing-library/react'; |
||||
|
||||
import { useSettingSelectOptions } from './useSettingSelectOptions'; |
||||
|
||||
// TODO: check if the return of items matches the settings we mocked
|
||||
describe('useSettingSelectOptions', () => { |
||||
it('should return the ordered list of options', async () => { |
||||
const { result } = renderHook(() => useSettingSelectOptions(), { |
||||
wrapper: mockAppRoot() |
||||
.withSetting('test1', true) |
||||
.withSetting('test2', true) |
||||
.withSetting('test3', true) |
||||
.withSetting('test4', true) |
||||
.withSetting('test5', true) |
||||
.withSetting('test6', true) |
||||
.withSetting('test7', true) |
||||
.withSetting('test8', true) |
||||
.withSetting('test9', true) |
||||
.withSetting('test10', true) |
||||
.withSetting('test11', true) |
||||
.withSetting('test12', true) |
||||
.withSetting('test13', true) |
||||
.withSetting('test14', true) |
||||
.withSetting('test15', true) |
||||
.build(), |
||||
}); |
||||
|
||||
await waitFor(() => expect(result.current?.data?.pages[0][0]).toEqual({ _id: 'test1', label: 'test1', value: 'test1' })); |
||||
await waitFor(() => expect(result.current?.data?.pages[0][1]).toEqual({ _id: 'test2', label: 'test2', value: 'test2' })); |
||||
await waitFor(() => expect(result.current?.data?.pages[0][2]).toEqual({ _id: 'test3', label: 'test3', value: 'test3' })); |
||||
await waitFor(() => expect(result.current?.data?.pages[0][3]).toEqual({ _id: 'test4', label: 'test4', value: 'test4' })); |
||||
await waitFor(() => expect(result.current?.data?.pages[0][4]).toEqual({ _id: 'test5', label: 'test5', value: 'test5' })); |
||||
await waitFor(() => expect(result.current?.data?.pages[0][5]).toEqual({ _id: 'test6', label: 'test6', value: 'test6' })); |
||||
await waitFor(() => expect(result.current?.data?.pages[0][6]).toEqual({ _id: 'test7', label: 'test7', value: 'test7' })); |
||||
await waitFor(() => expect(result.current?.data?.pages[0][7]).toEqual({ _id: 'test8', label: 'test8', value: 'test8' })); |
||||
await waitFor(() => expect(result.current?.data?.pages[0][8]).toEqual({ _id: 'test9', label: 'test9', value: 'test9' })); |
||||
await waitFor(() => expect(result.current?.data?.pages[0][9]).toEqual({ _id: 'test10', label: 'test10', value: 'test10' })); |
||||
await waitFor(() => expect(result.current?.data?.pages[0][10]).toEqual({ _id: 'test11', label: 'test11', value: 'test11' })); |
||||
await waitFor(() => expect(result.current?.data?.pages[0][11]).toEqual({ _id: 'test12', label: 'test12', value: 'test12' })); |
||||
await waitFor(() => expect(result.current?.data?.pages[0][12]).toEqual({ _id: 'test13', label: 'test13', value: 'test13' })); |
||||
await waitFor(() => expect(result.current?.data?.pages[0][13]).toEqual({ _id: 'test14', label: 'test14', value: 'test14' })); |
||||
await waitFor(() => expect(result.current?.data?.pages[0][14]).toEqual({ _id: 'test15', label: 'test15', value: 'test15' })); |
||||
|
||||
await waitFor(() => expect(result.current?.data?.pages[0]).toHaveLength(15)); |
||||
}); |
||||
|
||||
it('should return the list of filtered options', async () => { |
||||
const { result } = renderHook(() => useSettingSelectOptions('TeSt1'), { |
||||
wrapper: mockAppRoot() |
||||
.withSetting('test1', true) |
||||
.withSetting('test2', true) |
||||
.withSetting('test3', true) |
||||
.withSetting('test4', true) |
||||
.withSetting('test5', true) |
||||
.withSetting('test6', true) |
||||
.withSetting('test7', true) |
||||
.withSetting('test8', true) |
||||
.withSetting('test9', true) |
||||
.withSetting('test10', true) |
||||
.withSetting('test11', true) |
||||
.withSetting('test12', true) |
||||
.withSetting('test13', true) |
||||
.withSetting('test14', true) |
||||
.withSetting('test15', true) |
||||
.build(), |
||||
}); |
||||
|
||||
await waitFor(() => expect(result.current?.data?.pages[0][0]).toEqual({ _id: 'test1', label: 'test1', value: 'test1' })); |
||||
await waitFor(() => expect(result.current?.data?.pages[0][1]).toEqual({ _id: 'test10', label: 'test10', value: 'test10' })); |
||||
await waitFor(() => expect(result.current?.data?.pages[0][2]).toEqual({ _id: 'test11', label: 'test11', value: 'test11' })); |
||||
await waitFor(() => expect(result.current?.data?.pages[0][3]).toEqual({ _id: 'test12', label: 'test12', value: 'test12' })); |
||||
await waitFor(() => expect(result.current?.data?.pages[0][4]).toEqual({ _id: 'test13', label: 'test13', value: 'test13' })); |
||||
await waitFor(() => expect(result.current?.data?.pages[0][5]).toEqual({ _id: 'test14', label: 'test14', value: 'test14' })); |
||||
await waitFor(() => expect(result.current?.data?.pages[0][6]).toEqual({ _id: 'test15', label: 'test15', value: 'test15' })); |
||||
await waitFor(() => expect(result.current?.data?.pages[0]).toHaveLength(7)); |
||||
}); |
||||
}); |
||||
@ -0,0 +1,41 @@ |
||||
import { useSettings } from '@rocket.chat/ui-contexts'; |
||||
import { useInfiniteQuery } from '@tanstack/react-query'; |
||||
import { useCallback } from 'react'; |
||||
|
||||
type SettingSelectOption = { |
||||
label: string; |
||||
value: string; |
||||
_id: string; |
||||
}; |
||||
|
||||
export const useSettingSelectOptions = (filter = '') => { |
||||
const settings = useSettings(); |
||||
|
||||
const fetchData = useCallback( |
||||
async (start = 0): Promise<SettingSelectOption[]> => { |
||||
return settings |
||||
.map(({ _id }) => ({ label: _id, value: _id, _id })) |
||||
.filter(({ label }) => label.toUpperCase().includes(filter.toUpperCase())) |
||||
.slice(start, start + 50); |
||||
}, |
||||
[filter, settings], |
||||
); |
||||
|
||||
return useInfiniteQuery({ |
||||
queryKey: ['settings', filter], |
||||
queryFn: ({ pageParam }) => fetchData(pageParam), |
||||
getNextPageParam: (lastPage, _allPages, lastPageParam) => { |
||||
if (lastPage.length === 0) { |
||||
return undefined; |
||||
} |
||||
return lastPageParam + 1; |
||||
}, |
||||
getPreviousPageParam: (_firstPage, _allPages, firstPageParam) => { |
||||
if (firstPageParam <= 1) { |
||||
return undefined; |
||||
} |
||||
return firstPageParam - 1; |
||||
}, |
||||
initialPageParam: 0, |
||||
}); |
||||
}; |
||||
@ -0,0 +1 @@ |
||||
export const getTypeTranslation = (type: 'app' | 'system') => (type === 'app' ? 'App' : 'System'); |
||||
Loading…
Reference in new issue