Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ jest.mock("~features/organizations/details/events/Events", () => ({
OrganizationEventsDetails: () => <div data-testid="events" />,
}));

jest.mock("~features/organizations/details/event-logs/EventLogs", () => ({
OrganizationLogsDetails: () => <div data-testid="event-logs" />,
}));

jest.mock("~features/organizations/details/data-source/DataSourceDetails", () => ({
DataSourceDetails: () => <div data-testid="data-source-details" />,
}));
Expand Down
1 change: 1 addition & 0 deletions frontend/src/features/organizations/Organizations.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ describe("Organizations router", () => {
["data sources", "/organizations/org-1/data-sources", "data-sources"],
["users", "/organizations/org-1/users", "users"],
["events", "/organizations/org-1/events", "events"],
["logs", "/organizations/org-1/logs", "event-logs"],
[
"data source details",
"/organizations/org-1/data-sources/ds-1/details",
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/features/organizations/Organizations.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,11 @@ const DataSourceAdvancedDetails = lazy(() =>
default: m.DataSourceAdvancedDetails,
})),
);
const OrganizationLogsDetails = lazy(() =>
import("~features/organizations/details/event-logs/EventLogs").then((m) => ({
default: m.OrganizationLogsDetails,
})),
);

const allowedRoles = ["admin", "operations"] as const;

Expand All @@ -75,6 +80,7 @@ export function Organizations() {
<Route path={SEGMENTS.users} element={<OrganizationUsers />} />
<Route path={SEGMENTS.events} element={<OrganizationEventsDetails />} />
<Route path={SEGMENTS.termination} element={<OrganizationTerminationFeedback />} />
<Route path={SEGMENTS.logs} element={<OrganizationLogsDetails />} />
</Route>
</Routes>
</RouteGuard>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,21 @@ describe("useOrganizationsApi", () => {
});
});

it("lists event logs for an organization using the provided query", async () => {
const { result } = renderHook(() => useOrganizationsApi());

const query = withQuery("level=INFO") as unknown as Parameters<
ReturnType<typeof useOrganizationsApi>["listOrganizationLogs"]
>[1];

await result.current.listOrganizationLogs("org-1", query);

expect(httpMock).toHaveBeenCalledWith({
method: "GET",
url: "/ops/v1/organizations/org-1/events?level=INFO",
});
});

it("requests a data-source reimport with the provided payload", async () => {
const { result } = renderHook(() => useOrganizationsApi());
const body = { last_import_at: "2026-03-15" };
Expand Down
15 changes: 15 additions & 0 deletions frontend/src/features/organizations/api/useOrganizationsApi.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
DatasourceForceReimport,
DatasourceRead,
EmployeeRead,
OrganizationEventRead,
OrganizationRead,
} from "~api/ffc-api-model";
import { useEntityApi } from "~shared/hooks/useEntityApi";
Expand Down Expand Up @@ -54,6 +55,18 @@ export function useOrganizationsApi() {
[],
);

const listOrganizationLogs = useCallback(
(organizationId: string, query?: RqlQuery<OrganizationEventRead>) => {
return http({
method: "GET",
url:
`${rootPath}/${organizationId}/events` +
getCustomQueryString<OrganizationEventRead>(query),
});
},
[],
);

const forceReimportDatasource = useCallback(
(organizationId: string, datasourceId: string, data?: DatasourceForceReimport) => {
return http<void>({
Expand All @@ -73,6 +86,7 @@ export function useOrganizationsApi() {
deleteOrganization,
listOrganizationEmployees,
listOrganizationDataSources,
listOrganizationLogs,
forceReimportDatasource,
}),
[
Expand All @@ -82,6 +96,7 @@ export function useOrganizationsApi() {
deleteOrganization,
listOrganizationEmployees,
listOrganizationDataSources,
listOrganizationLogs,
forceReimportDatasource,
],
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -135,26 +135,26 @@ describe("OrganizationDetailsContent", () => {
expect(screen.queryByTestId("organization-highlights")).not.toBeInTheDocument();
});

it("renders the top bar with items for general, dataSources, users, and events in order", () => {
it("renders the organization tabs in the top bar in order", () => {
primeEntity({ id: "org-1" });

renderWithOrganizationRoute(<OrganizationDetailsContent />, {
routePath: "/organizations/:organizationId/*",
});

const paths = mockTopBar.mock.lastCall![0].items!.map((item) => item.path);
expect(paths).toEqual(["general", "data-sources", "users", "events"]);
expect(paths).toEqual(["general", "data-sources", "users", "events", "logs"]);
});

it("shows the termination tab last when the organization has termination feedback", () => {
it("shows the termination tab before logs when the organization has termination feedback", () => {
primeEntity({ id: "org-1", termination_feedback: { id: "tf-1", reason: "other" } });

renderWithOrganizationRoute(<OrganizationDetailsContent />, {
routePath: "/organizations/:organizationId/*",
});

const paths = mockTopBar.mock.lastCall![0].items!.map((item) => item.path);
expect(paths).toEqual(["general", "data-sources", "users", "events", "termination"]);
expect(paths).toEqual(["general", "data-sources", "users", "events", "termination", "logs"]);
});

it.each([
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { ComponentProps } from "react";

import { screen } from "@testing-library/react";

import { renderWithOrganizationRoute, renderWithRouter } from "~test-utils";

import { OrganizationLogsDetails } from "./EventLogs";
import type { EventLogsGrid } from "./EventLogsGrid";

jest.mock("./EventLogsGrid", () => ({
EventLogsGrid: ({ organizationId }: ComponentProps<typeof EventLogsGrid>) => (
<div data-testid="event-logs-grid">{organizationId}</div>
),
}));

describe("OrganizationLogsDetails", () => {
it("renders the event logs grid for the organization from the route", () => {
renderWithOrganizationRoute(<OrganizationLogsDetails />, { id: "org-123" });

expect(screen.getByTestId("event-logs-grid")).toHaveTextContent("org-123");
});

it("does not render the grid when the organizationId route param is missing", () => {
renderWithRouter(<OrganizationLogsDetails />, {
initialUrl: "/organizations",
routePath: "/organizations",
});

expect(screen.queryByTestId("event-logs-grid")).not.toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { useParams } from "react-router-dom";

import { CardSection } from "~shared/components/CardSection";
import { useFixedT } from "~shared/hooks/useFixedT";

import { EventLogsGrid } from "./EventLogsGrid";

export function OrganizationLogsDetails() {
const { organizationId } = useParams();

const tSharedDetails = useFixedT("shared:details");

return (
<CardSection title={tSharedDetails("logs")}>
{organizationId && <EventLogsGrid organizationId={organizationId} />}
</CardSection>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import type { useGridInfoDialogConfiguration } from "~shared/hooks/useGridInfoDialogConfiguration";
import { mockDesignSystemGrid } from "~test-utils/mocks/designSystemGrid";
import {
mockGridIdentityModule,
mockGridInfoDialogConfigurationModule,
} from "~test-utils/mocks/gridHooks";

import type { useAsyncOptions } from "./hooks/useAsyncOptions";
import type { useColumns } from "./hooks/useColumns";
import type { useFields } from "./hooks/useFields";
import type { useViews } from "./hooks/useViews";

export { mockUseGridAsync } from "~test-utils/mocks/designSystemGrid";
export {
mockUseGridIdentity,
mockUseGridInfoDialogConfiguration,
} from "~test-utils/mocks/gridHooks";

export const mockUseColumns = jest.fn() as jest.MockedFunction<typeof useColumns>;
export const mockUseFields = jest.fn() as jest.MockedFunction<typeof useFields>;
export const mockUseViews = jest.fn() as jest.MockedFunction<typeof useViews>;
// useAsyncOptions wraps the generic useReactQueryRqlGrid — keep the spy loosely typed.
export const mockUseAsyncOptions = jest.fn() as jest.MockedFunction<
(organizationId: Parameters<typeof useAsyncOptions>[0]) => unknown
>;

export type GridInfoDialogConfig = ReturnType<typeof useGridInfoDialogConfiguration>;

jest.mock("@swo/design-system/grid", () => mockDesignSystemGrid);
jest.mock("~shared/hooks/useGridIdentity", () => mockGridIdentityModule);
jest.mock(
"~shared/hooks/useGridInfoDialogConfiguration",
() => mockGridInfoDialogConfigurationModule,
);

jest.mock("./hooks/useColumns", () => ({ useColumns: () => mockUseColumns() }));
jest.mock("./hooks/useFields", () => ({ useFields: () => mockUseFields() }));
jest.mock("./hooks/useViews", () => ({ useViews: () => mockUseViews() }));
jest.mock("./hooks/useAsyncOptions", () => ({
useAsyncOptions: (organizationId: string) => mockUseAsyncOptions(organizationId),
}));
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { renderHook } from "@testing-library/react";

import {
type GridInfoDialogConfig,
mockUseAsyncOptions,
mockUseColumns,
mockUseFields,
mockUseGridAsync,
mockUseGridIdentity,
mockUseGridInfoDialogConfiguration,
mockUseViews,
} from "./EventLogsGrid.config.spec.mocks";

import { useGridConfig } from "./EventLogsGrid.config";
import type { useColumns } from "./hooks/useColumns";
import type { useFields } from "./hooks/useFields";
import type { useViews } from "./hooks/useViews";

const columns = [{ name: "time" }] as unknown as ReturnType<typeof useColumns>;
const fields = [{ name: "time", title: "time" }] as ReturnType<typeof useFields>;
const views = [{ name: "logInfo" }] as unknown as ReturnType<typeof useViews>;
const silentRefresh = jest.fn();
const refresh = jest.fn();

function getLatestGridAsyncArg() {
return mockUseGridAsync.mock.lastCall![0];
}

describe("useGridConfig (organization event logs)", () => {
beforeEach(() => {
mockUseColumns.mockReturnValue(columns);
mockUseFields.mockReturnValue(fields);
mockUseViews.mockReturnValue(views);
mockUseAsyncOptions.mockReturnValue({ silentRefresh, refresh, onConfigChange: jest.fn() });
mockUseGridInfoDialogConfiguration.mockReturnValue({
noDataConfiguration: {},
} as GridInfoDialogConfig);
mockUseGridAsync.mockReturnValue({ onEvent: jest.fn() });
});

it("loads async options for the given organization", () => {
renderHook(() => useGridConfig("org-123"));

expect(mockUseAsyncOptions).toHaveBeenCalledWith("org-123");
});

it("uses a grid identity distinct from the users grid", () => {
renderHook(() => useGridConfig("org-123"));

expect(mockUseGridIdentity).toHaveBeenCalledWith("organizations-details-logs");
});

it("builds the grid from columns, fields, views, async options and info-dialog config", () => {
renderHook(() => useGridConfig("org-123"));

expect(getLatestGridAsyncArg()).toEqual(
expect.objectContaining({
id: "organizations-details-logs",
columns,
fields,
views,
isDefaultView: true,
selectedView: "default",
silentRefresh,
refresh,
noDataConfiguration: {},
}),
);
});

it("exposes silentRefresh and refresh alongside the useGridAsync result", () => {
const onEvent = jest.fn();
mockUseGridAsync.mockReturnValue({ onEvent });

const { result } = renderHook(() => useGridConfig("org-123"));

expect(result.current).toEqual(expect.objectContaining({ silentRefresh, refresh, onEvent }));
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { useMemo } from "react";

import { UseAsyncGridConfig, useGridAsync } from "@swo/design-system/grid";

import { OrganizationEventRead } from "~api/ffc-api-model";
import { useGridIdentity } from "~shared/hooks/useGridIdentity";
import { useGridInfoDialogConfiguration } from "~shared/hooks/useGridInfoDialogConfiguration";

import { useAsyncOptions } from "./hooks/useAsyncOptions";
import { useColumns } from "./hooks/useColumns";
import { useFields } from "./hooks/useFields";
import { useViews } from "./hooks/useViews";

export function useGridConfig(organizationId: string) {
const columns = useColumns();
const fields = useFields();
const views = useViews();
const asyncOptions = useAsyncOptions(organizationId);
const gridInfoDialogConfig = useGridInfoDialogConfiguration();
const identity = useGridIdentity("organizations-details-logs");
const config = useMemo(
() =>
({
...identity,
columns,
fields,
views,
isDefaultView: true,
selectedView: "default",
...asyncOptions,
...gridInfoDialogConfig,
}) as UseAsyncGridConfig<OrganizationEventRead>,
[identity, columns, fields, views, asyncOptions, gridInfoDialogConfig],
);

const gridProps = useGridAsync(config);
return { silentRefresh: asyncOptions.silentRefresh, refresh: asyncOptions.refresh, ...gridProps };
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { render } from "@testing-library/react";

import { mockDesignSystemGrid, mockGridProps } from "~test-utils/mocks/designSystemGrid";

import { EventLogsGrid } from "./EventLogsGrid";
import type { useGridConfig } from "./EventLogsGrid.config";

const mockUseGridConfig = jest.fn() as jest.MockedFunction<typeof useGridConfig>;

jest.mock("@swo/design-system/grid", () => mockDesignSystemGrid);
jest.mock("./EventLogsGrid.config", () => ({
useGridConfig: (...args: Parameters<typeof useGridConfig>) => mockUseGridConfig(...args),
}));

describe("EventLogsGrid", () => {
beforeEach(() => {
mockUseGridConfig.mockReturnValue({ id: "event-logs-grid" } as unknown as ReturnType<
typeof useGridConfig
>);
});

it("configures the grid for the given organization", () => {
render(<EventLogsGrid organizationId="org-123" />);

expect(mockUseGridConfig).toHaveBeenCalledWith("org-123");
});

it("renders the grid with the configured props", () => {
render(<EventLogsGrid organizationId="org-123" />);

expect(mockGridProps).toHaveBeenCalledWith(expect.objectContaining({ id: "event-logs-grid" }));
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Grid } from "@swo/design-system/grid";

import { OrganizationEventRead } from "~api/ffc-api-model";

import { useGridConfig } from "./EventLogsGrid.config";

export function EventLogsGrid({ organizationId }: { organizationId: string }) {
const gridProps = useGridConfig(organizationId);

return <Grid<OrganizationEventRead> {...gridProps} />;
}
Loading
Loading