feat(site): add new filter to audit logs (#7878)

This commit is contained in:
Bruno Quaresma
2023-06-07 14:10:58 +00:00
committed by GitHub
parent ab3a649520
commit d793564937
13 changed files with 463 additions and 340 deletions
+100
View File
@@ -0,0 +1,100 @@
import { useMe } from "hooks"
import { BaseOption } from "./options"
import { getUsers } from "api/api"
import { UseFilterMenuOptions, useFilterMenu } from "./menu"
import { FilterSearchMenu, OptionItem } from "./filter"
import { UserAvatar } from "components/UserAvatar/UserAvatar"
export type UserOption = BaseOption & {
avatarUrl?: string
}
export const useUserFilterMenu = ({
value,
onChange,
enabled,
}: Pick<
UseFilterMenuOptions<UserOption>,
"value" | "onChange" | "enabled"
>) => {
const me = useMe()
const addMeAsFirstOption = (options: UserOption[]) => {
options = options.filter((option) => option.value !== me.username)
return [
{ label: me.username, value: me.username, avatarUrl: me.avatar_url },
...options,
]
}
return useFilterMenu({
onChange,
enabled,
value,
id: "owner",
getSelectedOption: async () => {
const usersRes = await getUsers({ q: value, limit: 1 })
const firstUser = usersRes.users.at(0)
if (firstUser && firstUser.username === value) {
return {
label: firstUser.username,
value: firstUser.username,
avatarUrl: firstUser.avatar_url,
}
}
return null
},
getOptions: async (query) => {
const usersRes = await getUsers({ q: query, limit: 25 })
let options: UserOption[] = usersRes.users.map((user) => ({
label: user.username,
value: user.username,
avatarUrl: user.avatar_url,
}))
options = addMeAsFirstOption(options)
return options
},
})
}
export type UserFilterMenu = ReturnType<typeof useUserFilterMenu>
export const UserMenu = (menu: UserFilterMenu) => {
return (
<FilterSearchMenu
id="users-menu"
menu={menu}
label={
menu.selectedOption ? (
<UserOptionItem option={menu.selectedOption} />
) : (
"All users"
)
}
>
{(itemProps) => <UserOptionItem {...itemProps} />}
</FilterSearchMenu>
)
}
const UserOptionItem = ({
option,
isSelected,
}: {
option: UserOption
isSelected?: boolean
}) => {
return (
<OptionItem
option={option}
isSelected={isSelected}
left={
<UserAvatar
username={option.label}
avatarURL={option.avatarUrl}
sx={{ width: 16, height: 16, fontSize: 8 }}
/>
}
/>
)
}
+1
View File
@@ -175,6 +175,7 @@ export const Filter = ({
}
size="small"
InputProps={{
"aria-label": "Filter",
name: "query",
placeholder: "Search...",
value: searchQuery,
+167
View File
@@ -0,0 +1,167 @@
import { AuditActions, ResourceTypes } from "api/typesGenerated"
import { UserFilterMenu, UserMenu } from "components/Filter/UserFilter"
import {
Filter,
FilterMenu,
MenuSkeleton,
OptionItem,
SearchFieldSkeleton,
useFilter,
} from "components/Filter/filter"
import { UseFilterMenuOptions, useFilterMenu } from "components/Filter/menu"
import { BaseOption } from "components/Filter/options"
import capitalize from "lodash/capitalize"
const PRESET_FILTERS = [
{
query: "resource_type:workspace action:create",
name: "Created workspaces",
},
{ query: "resource_type:template action:create", name: "Added templates" },
{ query: "resource_type:user action:delete", name: "Deleted users" },
{
query: "resource_type:workspace_build action:start build_reason:initiator",
name: "Builds started by a user",
},
{
query: "resource_type:api_key action:login",
name: "User logins",
},
]
export const AuditFilter = ({
filter,
error,
menus,
}: {
filter: ReturnType<typeof useFilter>
error?: unknown
menus: {
user: UserFilterMenu
action: ActionFilterMenu
resourceType: ResourceTypeFilterMenu
}
}) => {
return (
<Filter
learnMoreLink="https://coder.com/docs/v2/latest/admin/audit-logs#filtering-logs"
presets={PRESET_FILTERS}
isLoading={menus.user.isInitializing}
filter={filter}
error={error}
options={
<>
<ResourceTypeMenu {...menus.resourceType} />
<ActionMenu {...menus.action} />
<UserMenu {...menus.user} />
</>
}
skeleton={
<>
<SearchFieldSkeleton />
<MenuSkeleton />
<MenuSkeleton />
<MenuSkeleton />
</>
}
/>
)
}
export const useActionFilterMenu = ({
value,
onChange,
}: Pick<UseFilterMenuOptions<BaseOption>, "value" | "onChange">) => {
const actionOptions: BaseOption[] = AuditActions.map((action) => ({
value: action,
label: capitalize(action),
}))
return useFilterMenu({
onChange,
value,
id: "status",
getSelectedOption: async () =>
actionOptions.find((option) => option.value === value) ?? null,
getOptions: async () => actionOptions,
})
}
export type ActionFilterMenu = ReturnType<typeof useActionFilterMenu>
const ActionMenu = (menu: ActionFilterMenu) => {
return (
<FilterMenu
id="action-menu"
menu={menu}
label={
menu.selectedOption ? (
<OptionItem option={menu.selectedOption} />
) : (
"All actions"
)
}
>
{(itemProps) => <OptionItem {...itemProps} />}
</FilterMenu>
)
}
export const useResourceTypeFilterMenu = ({
value,
onChange,
}: Pick<UseFilterMenuOptions<BaseOption>, "value" | "onChange">) => {
const actionOptions: BaseOption[] = ResourceTypes.map((type) => {
let label = capitalize(type)
if (type === "api_key") {
label = "API Key"
}
if (type === "git_ssh_key") {
label = "Git SSH Key"
}
if (type === "template_version") {
label = "Template Version"
}
if (type === "workspace_build") {
label = "Workspace Build"
}
return {
value: type,
label,
}
})
return useFilterMenu({
onChange,
value,
id: "resourceType",
getSelectedOption: async () =>
actionOptions.find((option) => option.value === value) ?? null,
getOptions: async () => actionOptions,
})
}
export type ResourceTypeFilterMenu = ReturnType<
typeof useResourceTypeFilterMenu
>
const ResourceTypeMenu = (menu: ResourceTypeFilterMenu) => {
return (
<FilterMenu
id="resource-type-menu"
menu={menu}
label={
menu.selectedOption ? (
<OptionItem option={menu.selectedOption} />
) : (
"All resource types"
)
}
>
{(itemProps) => <OptionItem {...itemProps} />}
</FilterMenu>
)
}
+3 -14
View File
@@ -68,18 +68,6 @@ describe("AuditPage", () => {
})
describe("Filtering", () => {
it("filters by typing", async () => {
await renderPage()
await screen.findByText("updated", { exact: false })
const filterField = screen.getByLabelText("Filter")
const query = "resource_type:workspace action:create"
await userEvent.type(filterField, query)
await screen.findByText("created", { exact: false })
const editWorkspace = screen.queryByText("updated", { exact: false })
expect(editWorkspace).not.toBeInTheDocument()
})
it("filters by URL", async () => {
const getAuditLogsSpy = jest
.spyOn(API, "getAuditLogs")
@@ -88,13 +76,14 @@ describe("AuditPage", () => {
const query = "resource_type:workspace action:create"
await renderPage({ filter: query })
expect(getAuditLogsSpy).toBeCalledWith({ limit: 25, offset: 0, q: query })
expect(getAuditLogsSpy).toBeCalledWith({ limit: 25, offset: 1, q: query })
})
it("resets page to 1 when filter is changed", async () => {
await renderPage({ page: 2 })
const getAuditLogsSpy = jest.spyOn(API, "getAuditLogs")
getAuditLogsSpy.mockClear()
const filterField = screen.getByLabelText("Filter")
const query = "resource_type:workspace action:create"
@@ -103,7 +92,7 @@ describe("AuditPage", () => {
await waitFor(() =>
expect(getAuditLogsSpy).toBeCalledWith({
limit: 25,
offset: 0,
offset: 1,
q: query,
}),
)
+71 -29
View File
@@ -1,34 +1,63 @@
import { useMachine } from "@xstate/react"
import {
getPaginationContext,
nonInitialPage,
} from "components/PaginationWidget/utils"
import { nonInitialPage } from "components/PaginationWidget/utils"
import { useFeatureVisibility } from "hooks/useFeatureVisibility"
import { FC } from "react"
import { Helmet } from "react-helmet-async"
import { useSearchParams } from "react-router-dom"
import { pageTitle } from "utils/page"
import { auditMachine } from "xServices/audit/auditXService"
import { PaginationMachineRef } from "xServices/pagination/paginationXService"
import { AuditPageView } from "./AuditPageView"
import { useUserFilterMenu } from "components/Filter/UserFilter"
import { useFilter } from "components/Filter/filter"
import { useDashboard } from "components/Dashboard/DashboardProvider"
import { usePagination } from "hooks"
import { useQuery } from "@tanstack/react-query"
import { getAuditLogs } from "api/api"
import { useActionFilterMenu, useResourceTypeFilterMenu } from "./AuditFilter"
const AuditPage: FC = () => {
const [searchParams, setSearchParams] = useSearchParams()
const filter = searchParams.get("filter") ?? ""
const [auditState, auditSend] = useMachine(auditMachine, {
context: {
filter,
paginationContext: getPaginationContext(searchParams),
},
actions: {
updateURL: (context, event) =>
setSearchParams({ page: event.page, filter: context.filter }),
const dashboard = useDashboard()
const searchParamsResult = useSearchParams()
const pagination = usePagination({ searchParamsResult })
const filter = useFilter({
searchParamsResult,
onUpdate: () => {
pagination.goToPage(1)
},
})
const { auditLogs, count, apiError } = auditState.context
const paginationRef = auditState.context.paginationRef as PaginationMachineRef
const userMenu = useUserFilterMenu({
value: filter.values.username,
onChange: (option) =>
filter.update({
...filter.values,
username: option?.value,
}),
})
const actionMenu = useActionFilterMenu({
value: filter.values.action,
onChange: (option) =>
filter.update({
...filter.values,
action: option?.value,
}),
})
const resourceTypeMenu = useResourceTypeFilterMenu({
value: filter.values["resource_type"],
onChange: (option) =>
filter.update({
...filter.values,
resource_type: option?.value,
}),
})
const { audit_log: isAuditLogVisible } = useFeatureVisibility()
const { data, error } = useQuery({
queryKey: ["auditLogs", filter.query, pagination.page],
queryFn: () => {
return getAuditLogs({
offset: pagination.page,
limit: 25,
q: filter.query,
})
},
})
return (
<>
@@ -36,16 +65,29 @@ const AuditPage: FC = () => {
<title>{pageTitle("Audit")}</title>
</Helmet>
<AuditPageView
filter={filter}
auditLogs={auditLogs}
count={count}
onFilter={(filter) => {
auditSend("FILTER", { filter })
}}
paginationRef={paginationRef}
isNonInitialPage={nonInitialPage(searchParams)}
auditLogs={data?.audit_logs}
count={data?.count}
page={pagination.page}
limit={pagination.limit}
onPageChange={pagination.goToPage}
isNonInitialPage={nonInitialPage(searchParamsResult[0])}
isAuditLogVisible={isAuditLogVisible}
error={apiError}
error={error}
filterProps={
dashboard.experiments.includes("workspace_filter")
? {
filter,
menus: {
user: userMenu,
action: actionMenu,
resourceType: resourceTypeMenu,
},
}
: {
filter: filter.query,
onFilter: filter.update,
}
}
/>
</>
)
@@ -1,51 +1,91 @@
import { ComponentMeta, Story } from "@storybook/react"
import { createPaginationRef } from "components/PaginationWidget/utils"
import { MockAuditLog, MockAuditLog2 } from "testHelpers/entities"
import { AuditPageView, AuditPageViewProps } from "./AuditPageView"
/* eslint-disable eslint-comments/disable-enable-pair -- ignore */
/* eslint-disable @typescript-eslint/no-explicit-any -- We don't care about any here */
import { Meta, StoryObj } from "@storybook/react"
import { MockAuditLog, MockAuditLog2, MockUser } from "testHelpers/entities"
import { AuditPageView } from "./AuditPageView"
import { action } from "@storybook/addon-actions"
import { WorkspacesPageView } from "pages/WorkspacesPage/WorkspacesPageView"
import { ComponentProps } from "react"
export default {
const mockMenu = {
initialOption: undefined,
isInitializing: false,
isSearching: false,
query: "",
searchOptions: [],
selectedOption: undefined,
selectOption: action("selectOption"),
setQuery: action("updateQuery"),
}
const defaultFilterProps = {
filter: {
query: `owner:me`,
update: () => action("update"),
debounceUpdate: action("debounce") as any,
used: false,
values: {
username: MockUser.username,
action: undefined,
resource_type: undefined,
},
},
menus: {
user: mockMenu,
action: mockMenu,
resourceType: mockMenu,
},
} as ComponentProps<typeof AuditPageView>["filterProps"]
const meta: Meta<typeof AuditPageView> = {
title: "pages/AuditPageView",
component: AuditPageView,
args: {
auditLogs: [MockAuditLog, MockAuditLog2],
count: 1000,
paginationRef: createPaginationRef({ page: 1, limit: 25 }),
page: 1,
limit: 25,
isAuditLogVisible: true,
filterProps: defaultFilterProps,
},
} as ComponentMeta<typeof AuditPageView>
const Template: Story<AuditPageViewProps> = (args) => (
<AuditPageView {...args} />
)
export const AuditPage = Template.bind({})
export const Loading = Template.bind({})
Loading.args = {
auditLogs: undefined,
count: undefined,
isNonInitialPage: false,
}
export const EmptyPage = Template.bind({})
EmptyPage.args = {
auditLogs: [],
isNonInitialPage: true,
export default meta
type Story = StoryObj<typeof WorkspacesPageView>
export const AuditPage: Story = {}
export const Loading = {
args: {
auditLogs: undefined,
count: undefined,
isNonInitialPage: false,
},
}
export const NoLogs = Template.bind({})
NoLogs.args = {
auditLogs: [],
count: 0,
isNonInitialPage: false,
export const EmptyPage = {
args: {
auditLogs: [],
isNonInitialPage: true,
},
}
export const NotVisible = Template.bind({})
NotVisible.args = {
isAuditLogVisible: false,
export const NoLogs = {
args: {
auditLogs: [],
count: 0,
isNonInitialPage: false,
},
}
export const AuditPageSmallViewport = Template.bind({})
AuditPageSmallViewport.parameters = {
chromatic: { viewports: [600] },
export const NotVisible = {
args: {
isAuditLogVisible: false,
},
}
export const AuditPageSmallViewport = {
parameters: {
chromatic: { viewports: [600] },
},
}
+38 -16
View File
@@ -13,16 +13,17 @@ import {
PageHeaderSubtitle,
PageHeaderTitle,
} from "components/PageHeader/PageHeader"
import { PaginationWidget } from "components/PaginationWidget/PaginationWidget"
import { SearchBarWithFilter } from "components/SearchBarWithFilter/SearchBarWithFilter"
import { Stack } from "components/Stack/Stack"
import { TableLoader } from "components/TableLoader/TableLoader"
import { Timeline } from "components/Timeline/Timeline"
import { AuditHelpTooltip } from "components/Tooltips"
import { FC } from "react"
import { ComponentProps, FC } from "react"
import { useTranslation } from "react-i18next"
import { PaginationMachineRef } from "xServices/pagination/paginationXService"
import { AuditPaywall } from "./AuditPaywall"
import { AuditFilter } from "./AuditFilter"
import { PaginationStatus } from "components/PaginationStatus/PaginationStatus"
import { PaginationWidgetBase } from "components/PaginationWidget/PaginationWidgetBase"
export const Language = {
title: "Audit",
@@ -49,23 +50,27 @@ const presetFilters = [
export interface AuditPageViewProps {
auditLogs?: AuditLog[]
count?: number
filter: string
onFilter: (filter: string) => void
paginationRef: PaginationMachineRef
page: number
limit: number
onPageChange: (page: number) => void
isNonInitialPage: boolean
isAuditLogVisible: boolean
error?: Error | unknown
filterProps:
| ComponentProps<typeof SearchBarWithFilter>
| ComponentProps<typeof AuditFilter>
}
export const AuditPageView: FC<AuditPageViewProps> = ({
auditLogs,
count,
filter,
onFilter,
paginationRef,
page,
limit,
onPageChange,
isNonInitialPage,
isAuditLogVisible,
error,
filterProps,
}) => {
const { t } = useTranslation("auditLog")
@@ -86,12 +91,22 @@ export const AuditPageView: FC<AuditPageViewProps> = ({
<ChooseOne>
<Cond condition={isAuditLogVisible}>
<SearchBarWithFilter
docs="https://coder.com/docs/coder-oss/latest/admin/audit-logs#filtering-logs"
filter={filter}
onFilter={onFilter}
presetFilters={presetFilters}
error={error}
{"onFilter" in filterProps ? (
<SearchBarWithFilter
{...filterProps}
docs="https://coder.com/docs/coder-oss/latest/admin/audit-logs#filtering-logs"
presetFilters={presetFilters}
error={error}
/>
) : (
<AuditFilter {...filterProps} />
)}
<PaginationStatus
isLoading={Boolean(isLoading)}
showing={auditLogs?.length}
total={count}
label="audit logs"
/>
<TableContainer>
@@ -143,7 +158,14 @@ export const AuditPageView: FC<AuditPageViewProps> = ({
</Table>
</TableContainer>
<PaginationWidget numRecords={count} paginationRef={paginationRef} />
{count !== undefined && (
<PaginationWidgetBase
count={count}
limit={limit}
onChange={onPageChange}
page={page}
/>
)}
</Cond>
<Cond>
@@ -5,14 +5,11 @@ import { pageTitle } from "utils/page"
import { useWorkspacesData, useWorkspaceUpdate } from "./data"
import { WorkspacesPageView } from "./WorkspacesPageView"
import { useOrganizationId, usePermissions } from "hooks"
import {
useUserFilterMenu,
useTemplateFilterMenu,
useStatusFilterMenu,
} from "./filter/menus"
import { useTemplateFilterMenu, useStatusFilterMenu } from "./filter/menus"
import { useSearchParams } from "react-router-dom"
import { useDashboard } from "components/Dashboard/DashboardProvider"
import { useFilter } from "components/Filter/filter"
import { useUserFilterMenu } from "components/Filter/UserFilter"
const WorkspacesPage: FC = () => {
const orgId = useOrganizationId()
@@ -67,6 +64,7 @@ const WorkspacesPage: FC = () => {
count={data?.count}
page={pagination.page}
limit={pagination.limit}
onPageChange={pagination.goToPage}
filterProps={{
filter,
menus: {
@@ -75,7 +73,6 @@ const WorkspacesPage: FC = () => {
status: statusMenu,
},
}}
onPageChange={pagination.goToPage}
onUpdateWorkspace={(workspace) => {
updateWorkspace.mutate(workspace)
}}
@@ -50,9 +50,9 @@ export interface WorkspacesPageViewProps {
workspaces?: Workspace[]
count?: number
useNewFilter?: boolean
filterProps: ComponentProps<typeof WorkspacesFilter>
page: number
limit: number
filterProps: ComponentProps<typeof WorkspacesFilter>
onPageChange: (page: number) => void
onUpdateWorkspace: (workspace: Workspace) => void
}
@@ -1,10 +1,9 @@
import { FC } from "react"
import Box from "@mui/material/Box"
import { UserAvatar } from "components/UserAvatar/UserAvatar"
import { Avatar, AvatarProps } from "components/Avatar/Avatar"
import { Palette, PaletteColor } from "@mui/material/styles"
import { UserFilterMenu, TemplateFilterMenu, StatusFilterMenu } from "./menus"
import { UserOption, TemplateOption, StatusOption } from "./options"
import { TemplateFilterMenu, StatusFilterMenu } from "./menus"
import { TemplateOption, StatusOption } from "./options"
import {
Filter,
FilterMenu,
@@ -14,6 +13,7 @@ import {
SearchFieldSkeleton,
useFilter,
} from "components/Filter/filter"
import { UserFilterMenu, UserMenu } from "components/Filter/UserFilter"
import { workspaceFilterQuery } from "utils/filters"
const PRESET_FILTERS = [
@@ -68,46 +68,6 @@ export const WorkspacesFilter = ({
)
}
const UserMenu = (menu: UserFilterMenu) => {
return (
<FilterSearchMenu
id="users-menu"
menu={menu}
label={
menu.selectedOption ? (
<UserOptionItem option={menu.selectedOption} />
) : (
"All users"
)
}
>
{(itemProps) => <UserOptionItem {...itemProps} />}
</FilterSearchMenu>
)
}
const UserOptionItem = ({
option,
isSelected,
}: {
option: UserOption
isSelected?: boolean
}) => {
return (
<OptionItem
option={option}
isSelected={isSelected}
left={
<UserAvatar
username={option.label}
avatarURL={option.avatarUrl}
sx={{ width: 16, height: 16, fontSize: 8 }}
/>
}
/>
)
}
const TemplateMenu = (menu: TemplateFilterMenu) => {
return (
<FilterSearchMenu
+2 -61
View File
@@ -1,68 +1,9 @@
import { UserOption, StatusOption, TemplateOption } from "./options"
import { getTemplates, getUsers } from "api/api"
import { StatusOption, TemplateOption } from "./options"
import { getTemplates } from "api/api"
import { WorkspaceStatuses } from "api/typesGenerated"
import { getDisplayWorkspaceStatus } from "utils/workspace"
import { useMe } from "hooks"
import { UseFilterMenuOptions, useFilterMenu } from "components/Filter/menu"
export const useUserFilterMenu = ({
value,
onChange,
enabled,
}: Pick<
UseFilterMenuOptions<UserOption>,
"value" | "onChange" | "enabled"
>) => {
const me = useMe()
const addMeAsFirstOption = (options: UserOption[]) => {
options = options.filter((option) => option.value !== me.username)
return [
{ label: me.username, value: me.username, avatarUrl: me.avatar_url },
...options,
]
}
return useFilterMenu({
onChange,
enabled,
value,
id: "owner",
getSelectedOption: async () => {
if (value === "me") {
return {
label: me.username,
value: me.username,
avatarUrl: me.avatar_url,
}
}
const usersRes = await getUsers({ q: value, limit: 1 })
const firstUser = usersRes.users.at(0)
if (firstUser && firstUser.username === value) {
return {
label: firstUser.username,
value: firstUser.username,
avatarUrl: firstUser.avatar_url,
}
}
return null
},
getOptions: async (query) => {
const usersRes = await getUsers({ q: query, limit: 25 })
let options: UserOption[] = usersRes.users.map((user) => ({
label: user.username,
value: user.username,
avatarUrl: user.avatar_url,
}))
options = addMeAsFirstOption(options)
return options
},
})
}
export type UserFilterMenu = ReturnType<typeof useUserFilterMenu>
export const useTemplateFilterMenu = ({
value,
onChange,
@@ -1,9 +1,5 @@
import { BaseOption } from "components/Filter/options"
export type UserOption = BaseOption & {
avatarUrl?: string
}
export type StatusOption = BaseOption & {
color: string
}
-132
View File
@@ -1,132 +0,0 @@
import { getAuditLogs } from "api/api"
import { getErrorMessage } from "api/errors"
import { AuditLog, AuditLogResponse } from "api/typesGenerated"
import { displayError } from "components/GlobalSnackbar/utils"
import { getPaginationData } from "components/PaginationWidget/utils"
import {
PaginationContext,
PaginationMachineRef,
paginationMachine,
} from "xServices/pagination/paginationXService"
import { assign, createMachine, spawn, send } from "xstate"
const auditPaginationId = "auditPagination"
interface AuditContext {
auditLogs?: AuditLog[]
count?: number
filter: string
paginationContext: PaginationContext
paginationRef?: PaginationMachineRef
apiError?: Error | unknown
}
export const auditMachine = createMachine(
{
id: "auditMachine",
predictableActionArguments: true,
tsTypes: {} as import("./auditXService.typegen").Typegen0,
schema: {
context: {} as AuditContext,
services: {} as {
loadAuditLogsAndCount: {
data: AuditLogResponse
}
},
events: {} as
| {
type: "UPDATE_PAGE"
page: string
}
| {
type: "FILTER"
filter: string
},
},
initial: "startPagination",
states: {
startPagination: {
entry: "assignPaginationRef",
always: "loading",
},
loading: {
// Right now, XState doesn't a good job with state + context typing so
// this forces the AuditPageView to showing the loading state when the
// loading state is called again by cleaning up the audit logs data
entry: ["clearPreviousAuditLogs", "clearError"],
invoke: {
src: "loadAuditLogsAndCount",
onDone: {
target: "idle",
actions: ["assignAuditLogsAndCount"],
},
onError: {
target: "idle",
actions: ["displayApiError", "assignError"],
},
},
onDone: "idle",
},
idle: {
on: {
UPDATE_PAGE: {
actions: ["updateURL"],
target: "loading",
},
FILTER: {
actions: ["assignFilter", "sendResetPage"],
},
},
},
},
},
{
actions: {
clearPreviousAuditLogs: assign({
auditLogs: (_) => undefined,
}),
assignAuditLogsAndCount: assign({
auditLogs: (_, event) => event.data.audit_logs,
count: (_, event) => event.data.count,
}),
assignPaginationRef: assign({
paginationRef: (context) =>
spawn(
paginationMachine.withContext(context.paginationContext),
auditPaginationId,
),
}),
assignFilter: assign({
filter: (_, { filter }) => filter,
}),
assignError: assign({
apiError: (_, event) => event.data,
}),
clearError: assign({
apiError: (_) => undefined,
}),
displayApiError: (_, event) => {
const message = getErrorMessage(
event.data,
"Error on loading audit logs.",
)
displayError(message)
},
sendResetPage: send({ type: "RESET_PAGE" }, { to: auditPaginationId }),
},
services: {
loadAuditLogsAndCount: async (context) => {
if (context.paginationRef) {
const { offset, limit } = getPaginationData(context.paginationRef)
return getAuditLogs({
offset,
limit,
q: context.filter,
})
} else {
throw new Error("Cannot get audit logs without pagination data")
}
},
},
},
)