refactor activity items and add query name to live_query activity type (#8740)

This commit is contained in:
Gabriel Hernandez 2022-11-17 14:25:40 +00:00 committed by GitHub
parent 267aaf0dbe
commit e7616dd422
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 471 additions and 230 deletions

View file

@ -0,0 +1 @@
- add the query name and and query modal for live query actions.

View file

@ -8,11 +8,6 @@ const DEFAULT_ACTIVITY_MOCK: IActivity = {
actor_gravatar: "",
actor_email: "rachel@fleetdm.com",
type: ActivityType.EditedAgentOptions,
details: {
global: false,
team_id: 1,
team_name: "Apples",
},
};
const createMockActivity = (overrides?: Partial<IActivity>): IActivity => {

View file

@ -0,0 +1,22 @@
import { IQuery } from "interfaces/query";
const DEFAULT_QUERY_MOCK: IQuery = {
created_at: "2022-11-03T17:22:14Z",
updated_at: "2022-11-03T17:22:14Z",
id: 1,
name: "Test Query",
description: "A test query",
query: "SELECT * FROM users",
saved: true,
author_id: 1,
author_name: "Rachel",
author_email: "rachel@fleetdm.com",
observer_can_run: false,
packs: [],
};
const createMockQuery = (overrides?: Partial<IQuery>): IQuery => {
return { ...DEFAULT_QUERY_MOCK, ...overrides };
};
export default createMockQuery;

View file

@ -0,0 +1,21 @@
import { ITeam, ITeamSummary } from "interfaces/team";
const DEFAULT_MOCK_TEAM_SUMMARY: ITeamSummary = {
id: 1,
name: "Team 1",
};
const DEFAUT_TEAM_MOCK: ITeam = {
...DEFAULT_MOCK_TEAM_SUMMARY,
};
export const createMockTeamSummary = (
overrides?: Partial<ITeamSummary>
): ITeamSummary => {
return { ...DEFAULT_MOCK_TEAM_SUMMARY, ...overrides };
};
const createMockTeam = (overrides?: Partial<ITeam>): ITeam => {
return { ...DEFAUT_TEAM_MOCK, ...overrides };
};
export default createMockTeam;

View file

@ -25,7 +25,7 @@ export type ButtonVariant =
export interface IButtonProps {
autofocus?: boolean;
children: React.ReactChild;
children: React.ReactNode;
className?: string;
disabled?: boolean;
size?: string;

View file

@ -39,6 +39,7 @@ export interface IActivityDetails {
policy_name?: string;
query_id?: number;
query_name?: string;
query_sql?: string;
team_id?: number;
team_name?: string;
teams?: ITeamSummary[];

View file

@ -4,10 +4,7 @@ import { noop } from "lodash";
import { createCustomRenderer } from "test/test-utils";
import mockServer from "test/mock-server";
import {
activityHandler2DaysAgo,
activityHandler9Activities,
} from "test/handlers/activity-handlers";
import { activityHandler9Activities } from "test/handlers/activity-handlers";
import ActivityFeed from "./ActivityFeed";
@ -84,23 +81,4 @@ describe("Activity Feed", () => {
expect(screen.getByRole("button", { name: "Previous" })).toBeEnabled();
});
it("renders avatar, actor name, timestamp", async () => {
mockServer.use(activityHandler2DaysAgo);
const render = createCustomRenderer({
withBackendMock: true,
});
render(<ActivityFeed setShowActivityFeedTitle={noop} />);
// waiting for the activity data to render
await screen.findByText("Rachel");
expect(screen.getByRole("img")).toHaveAttribute("alt", "User avatar");
expect(screen.getByText("Rachel")).toBeInTheDocument();
expect(screen.getByText("2 days ago")).toBeInTheDocument();
});
// TODO: Create unit size component for individual activities and
// test each activity type with different details at the unit level
});

View file

@ -1,21 +1,19 @@
import React, { useState } from "react";
import { useQuery } from "react-query";
import { find, isEmpty, lowerCase } from "lodash";
import formatDistanceToNowStrict from "date-fns/formatDistanceToNowStrict";
import { isEmpty } from "lodash";
import activitiesAPI, {
IActivitiesResponse,
} from "services/entities/activities";
import { addGravatarUrlToResource } from "utilities/helpers";
import { IActivity, ActivityType } from "interfaces/activity";
import { IActivity } from "interfaces/activity";
import DataError from "components/DataError";
import Avatar from "components/Avatar";
import Button from "components/buttons/Button";
import Spinner from "components/Spinner";
// @ts-ignore
import FleetIcon from "components/icons/FleetIcon";
import ActivityItem from "./ActivityItem";
const baseClass = "activity-feed";
@ -23,74 +21,8 @@ interface IActvityCardProps {
setShowActivityFeedTitle: (showActivityFeedTitle: boolean) => void;
}
interface IActivityDisplay extends IActivity {
key?: string;
}
const DEFAULT_GRAVATAR_URL =
"https://www.gravatar.com/avatar/00000000000000000000000000000000?d=blank&size=200";
const DEFAULT_PAGE_SIZE = 8;
const TAGGED_TEMPLATES = {
liveQueryActivityTemplate: (activity: IActivity) => {
const count = activity.details?.targets_count;
return typeof count === "undefined" || typeof count !== "number"
? "ran a live query"
: `ran a live query on ${count} ${count === 1 ? "host" : "hosts"}`;
},
editPackCtlActivityTemplate: () => {
return "edited a pack using fleetctl";
},
editPolicyCtlActivityTemplate: () => {
return "edited policies using fleetctl";
},
editQueryCtlActivityTemplate: (activity: IActivity) => {
const count = activity.details?.specs?.length;
return typeof count === "undefined" || typeof count !== "number"
? "edited a query using fleetctl"
: `edited ${count === 1 ? "a query" : "queries"} using fleetctl`;
},
editTeamCtlActivityTemplate: (activity: IActivity) => {
const count = activity.details?.teams?.length;
return count === 1 && activity.details?.teams ? (
<>
edited <b>{activity.details?.teams[0].name}</b> team using fleetctl
</>
) : (
"edited multiple teams using fleetctl"
);
},
userAddedBySSOTempalte: () => {
return `was added to Fleet by SSO`;
},
editAgentOptions: (activity: IActivity) => {
return activity.details?.global ? (
"edited agent options"
) : (
<>
edited agent options on <b>{activity.details?.team_name}</b> team
</>
);
},
defaultActivityTemplate: (activity: IActivity) => {
const entityName = find(activity.details, (_, key) =>
key.includes("_name")
);
const activityType = lowerCase(activity.type).replace(" saved", "");
return !entityName ? (
`${activityType}`
) : (
<span>
{activityType} <b>{entityName}</b>
</span>
);
},
};
const ActivityFeed = ({
setShowActivityFeedTitle,
}: IActvityCardProps): JSX.Element => {
@ -142,35 +74,6 @@ const ActivityFeed = ({
setPageIndex(pageIndex + 1);
};
const getDetail = (activity: IActivity) => {
switch (activity.type) {
case ActivityType.LiveQuery: {
return TAGGED_TEMPLATES.liveQueryActivityTemplate(activity);
}
case ActivityType.AppliedSpecPack: {
return TAGGED_TEMPLATES.editPackCtlActivityTemplate();
}
case ActivityType.AppliedSpecPolicy: {
return TAGGED_TEMPLATES.editPolicyCtlActivityTemplate();
}
case ActivityType.AppliedSpecSavedQuery: {
return TAGGED_TEMPLATES.editQueryCtlActivityTemplate(activity);
}
case ActivityType.AppliedSpecTeam: {
return TAGGED_TEMPLATES.editTeamCtlActivityTemplate(activity);
}
case ActivityType.UserAddedBySSO: {
return TAGGED_TEMPLATES.userAddedBySSOTempalte();
}
case ActivityType.EditedAgentOptions: {
return TAGGED_TEMPLATES.editAgentOptions(activity);
}
default: {
return TAGGED_TEMPLATES.defaultActivityTemplate(activity);
}
}
};
const renderError = () => {
return <DataError card />;
};
@ -188,36 +91,10 @@ const ActivityFeed = ({
);
};
const renderActivityBlock = (activity: IActivityDisplay) => {
const { actor_email, id, key } = activity;
const { gravatarURL } = actor_email
? addGravatarUrlToResource({ email: actor_email })
: { gravatarURL: DEFAULT_GRAVATAR_URL };
const renderActivityBlock = (activity: IActivity) => {
const { id } = activity;
return (
<div className={`${baseClass}__block`} key={key || id}>
<Avatar
className={`${baseClass}__avatar-image`}
user={{
gravatarURL,
}}
size="small"
/>
<div className={`${baseClass}__details`}>
<p>
<span className={`${baseClass}__details-topline`}>
<b>{activity.actor_full_name}</b> {getDetail(activity)}.
</span>
<br />
<span className={`${baseClass}__details-bottomline`}>
{formatDistanceToNowStrict(new Date(activity.created_at), {
addSuffix: true,
})}
</span>
</p>
</div>
</div>
);
return <ActivityItem activity={activity} key={id} />;
};
// Renders opaque information as activity feed is loading

View file

@ -0,0 +1,188 @@
import React from "react";
import { render, screen } from "@testing-library/react";
import createMockActivity from "__mocks__/activityMock";
import createMockQuery from "__mocks__/queryMock";
import { createMockTeamSummary } from "__mocks__/teamMock";
import { ActivityType } from "interfaces/activity";
import ActivityItem from ".";
describe("Activity Feed", () => {
it("renders avatar, actor name, timestamp", async () => {
const currentDate = new Date();
currentDate.setDate(currentDate.getDate() - 2);
const activity = createMockActivity({
created_at: currentDate.toISOString(),
});
render(<ActivityItem activity={activity} />);
// waiting for the activity data to render
await screen.findByText("Rachel");
expect(screen.getByRole("img")).toHaveAttribute("alt", "User avatar");
expect(screen.getByText("Rachel")).toBeInTheDocument();
expect(screen.getByText("2 days ago")).toBeInTheDocument();
});
it("renders a default activity for activities without a specific message", () => {
const activity = createMockActivity({
type: ActivityType.CreatedPack,
});
render(<ActivityItem activity={activity} />);
expect(screen.getByText("created pack.")).toBeInTheDocument();
});
it("renders a default activity for activities with a named property", () => {
const activity = createMockActivity({
type: ActivityType.CreatedPack,
details: { pack_name: "Test pack" },
});
render(<ActivityItem activity={activity} />);
expect(screen.getByText("created pack .")).toBeInTheDocument();
expect(screen.getByText("Test pack")).toBeInTheDocument();
});
it("renders a live_query type activity", () => {
const activity = createMockActivity({ type: ActivityType.LiveQuery });
render(<ActivityItem activity={activity} />);
expect(screen.getByText("ran a live query .")).toBeInTheDocument();
});
it("renders a live_query type activity with host count details", () => {
const activity = createMockActivity({
type: ActivityType.LiveQuery,
details: {
targets_count: 10,
},
});
render(<ActivityItem activity={activity} />);
expect(
screen.getByText("ran a live query on 10 hosts.")
).toBeInTheDocument();
});
it("renders a live_query type activity for a saved live query with targets", () => {
const activity = createMockActivity({
type: ActivityType.LiveQuery,
details: {
query_name: "Test Query",
query_sql: "SELECT * FROM users",
},
});
render(<ActivityItem activity={activity} />);
expect(
screen.getByText("ran the query as a live query .")
).toBeInTheDocument();
expect(screen.getByText("Test Query")).toBeInTheDocument();
});
it("renders an applied_spec_pack type activity", () => {
const activity = createMockActivity({
type: ActivityType.AppliedSpecPack,
});
render(<ActivityItem activity={activity} />);
expect(
screen.getByText("edited a pack using fleetctl.")
).toBeInTheDocument();
});
it("renders an applied_spec_policy type activity", () => {
const activity = createMockActivity({
type: ActivityType.AppliedSpecPolicy,
});
render(<ActivityItem activity={activity} />);
expect(
screen.getByText("edited policies using fleetctl.")
).toBeInTheDocument();
});
it("renders an applied_spec_saved_query type activity", () => {
const activity = createMockActivity({
type: ActivityType.AppliedSpecSavedQuery,
});
render(<ActivityItem activity={activity} />);
expect(
screen.getByText("edited a query using fleetctl.")
).toBeInTheDocument();
});
it("renders an applied_spec_saved_query type activity when run on multiple queries", () => {
const activity = createMockActivity({
type: ActivityType.AppliedSpecSavedQuery,
details: { specs: [createMockQuery(), createMockQuery()] },
});
render(<ActivityItem activity={activity} />);
expect(
screen.getByText("edited queries using fleetctl.")
).toBeInTheDocument();
});
it("renders an applied_spec_team type activity for a single team", () => {
const activity = createMockActivity({
type: ActivityType.AppliedSpecTeam,
details: { teams: [createMockTeamSummary()] },
});
render(<ActivityItem activity={activity} />);
expect(screen.getByText("edited team using fleetctl.")).toBeInTheDocument();
expect(screen.getByText("Team 1")).toBeInTheDocument();
});
it("renders an applied_spec_team type activity for multiple team", () => {
const activity = createMockActivity({
type: ActivityType.AppliedSpecTeam,
details: {
teams: [createMockTeamSummary(), createMockTeamSummary()],
},
});
render(<ActivityItem activity={activity} />);
expect(
screen.getByText("edited multiple teams using fleetctl.")
).toBeInTheDocument();
});
it("renders an user_added_by_sso type activity", () => {
const activity = createMockActivity({
type: ActivityType.UserAddedBySSO,
});
render(<ActivityItem activity={activity} />);
expect(screen.getByText("was added to Fleet by SSO.")).toBeInTheDocument();
});
it("renders an edited_agent_options type activity for a team", () => {
const activity = createMockActivity({
type: ActivityType.EditedAgentOptions,
details: { team_name: "Test Team 1" },
});
render(<ActivityItem activity={activity} />);
expect(
screen.getByText("edited agent options on team.")
).toBeInTheDocument();
expect(screen.getByText("Test Team 1")).toBeInTheDocument();
});
it("renders an edited_agent_options type activity globally", () => {
const activity = createMockActivity({
type: ActivityType.EditedAgentOptions,
details: { global: true },
});
render(<ActivityItem activity={activity} />);
expect(screen.getByText("edited agent options.")).toBeInTheDocument();
});
});

View file

@ -0,0 +1,173 @@
import React from "react";
import { find, lowerCase, noop } from "lodash";
import { formatDistanceToNowStrict } from "date-fns";
import { ActivityType, IActivity } from "interfaces/activity";
import { addGravatarUrlToResource } from "utilities/helpers";
import Avatar from "components/Avatar";
import Button from "components/buttons/Button";
import Icon from "components/Icon";
const baseClass = "activity-item";
const DEFAULT_GRAVATAR_URL =
"https://www.gravatar.com/avatar/00000000000000000000000000000000?d=blank&size=200";
const TAGGED_TEMPLATES = {
liveQueryActivityTemplate: (activity: IActivity) => {
const count = activity.details?.targets_count;
const queryName = activity.details?.query_name;
const querySql = activity.details?.query_sql;
const savedQueryName = queryName ? (
<>
the <b>{queryName}</b> query as
</>
) : (
<></>
);
const hostCount =
count !== undefined
? ` on ${count} ${count === 1 ? "host" : "hosts"}`
: "";
return (
<>
<span>
ran {savedQueryName} a live query {hostCount}.
</span>
{/* TODO: the API does not yet send back querySql yet so will implement
the onClick handler when we get it. We dont show this for now. */}
{false && (
<>
<Button
className={`${baseClass}__show-query-link`}
variant="text-link"
onClick={noop}
>
Show query{" "}
<Icon className={`${baseClass}__show-query-icon`} name="eye" />
</Button>
</>
)}
</>
);
},
editPackCtlActivityTemplate: () => {
return "edited a pack using fleetctl.";
},
editPolicyCtlActivityTemplate: () => {
return "edited policies using fleetctl.";
},
editQueryCtlActivityTemplate: (activity: IActivity) => {
const count = activity.details?.specs?.length;
return typeof count === "undefined" || count === 1
? "edited a query using fleetctl."
: "edited queries using fleetctl.";
},
editTeamCtlActivityTemplate: (activity: IActivity) => {
const count = activity.details?.teams?.length;
return count === 1 && activity.details?.teams ? (
<>
edited <b>{activity.details?.teams[0].name}</b> team using fleetctl.
</>
) : (
"edited multiple teams using fleetctl."
);
},
userAddedBySSOTempalte: () => {
return "was added to Fleet by SSO.";
},
editAgentOptions: (activity: IActivity) => {
return activity.details?.global ? (
"edited agent options."
) : (
<>
edited agent options on <b>{activity.details?.team_name}</b> team.
</>
);
},
defaultActivityTemplate: (activity: IActivity) => {
const entityName = find(activity.details, (_, key) =>
key.includes("_name")
);
const activityType = lowerCase(activity.type).replace(" saved", "");
return !entityName ? (
`${activityType}.`
) : (
<span>
{activityType} <b>{entityName}</b>.
</span>
);
},
};
const getDetail = (activity: IActivity) => {
switch (activity.type) {
case ActivityType.LiveQuery: {
return TAGGED_TEMPLATES.liveQueryActivityTemplate(activity);
}
case ActivityType.AppliedSpecPack: {
return TAGGED_TEMPLATES.editPackCtlActivityTemplate();
}
case ActivityType.AppliedSpecPolicy: {
return TAGGED_TEMPLATES.editPolicyCtlActivityTemplate();
}
case ActivityType.AppliedSpecSavedQuery: {
return TAGGED_TEMPLATES.editQueryCtlActivityTemplate(activity);
}
case ActivityType.AppliedSpecTeam: {
return TAGGED_TEMPLATES.editTeamCtlActivityTemplate(activity);
}
case ActivityType.UserAddedBySSO: {
return TAGGED_TEMPLATES.userAddedBySSOTempalte();
}
case ActivityType.EditedAgentOptions: {
return TAGGED_TEMPLATES.editAgentOptions(activity);
}
default: {
return TAGGED_TEMPLATES.defaultActivityTemplate(activity);
}
}
};
interface IActivityItemProps {
activity: IActivity;
}
const ActivityItem = ({ activity }: IActivityItemProps) => {
const { actor_email } = activity;
const { gravatarURL } = actor_email
? addGravatarUrlToResource({ email: actor_email })
: { gravatarURL: DEFAULT_GRAVATAR_URL };
return (
<div className={baseClass}>
<Avatar
className={`${baseClass}__avatar-image`}
user={{ gravatarURL }}
size="small"
/>
<div className={`${baseClass}__details`}>
<p>
<span className={`${baseClass}__details-topline`}>
<b>{activity.actor_full_name}</b> {getDetail(activity)}
</span>
<br />
<span className={`${baseClass}__details-bottomline`}>
{formatDistanceToNowStrict(new Date(activity.created_at), {
addSuffix: true,
})}
</span>
</p>
</div>
</div>
);
};
export default ActivityItem;

View file

@ -0,0 +1,55 @@
.activity-item {
display: flex;
flex-direction: row;
align-items: center;
padding-bottom: $pad-large;
.avatar-wrapper {
position: relative;
}
&:before {
content: "";
position: relative;
height: 36px !important;
z-index: 0;
top: 35px;
left: 17px;
border-left: 1px dashed $ui-fleet-blue-15;
height: 100%;
}
&:last-child:before {
border-left: 0;
}
&__avatar-image {
z-index: 2;
}
&__details {
padding-left: $pad-large;
p {
margin: 0;
line-height: 16px;
}
}
&__details-topline {
font-size: $x-small;
}
&__details-bottomline {
font-size: $xx-small;
color: $ui-fleet-black-25;
}
&__show-query-link {
margin-left: $pad-xsmall;
}
&__show-query-icon {
margin-left: $pad-xsmall;
}
}

View file

@ -0,0 +1 @@
export { default } from "./ActivityItem";

View file

@ -11,59 +11,6 @@
}
}
&__block {
display: flex;
flex-direction: row;
align-items: center;
padding-bottom: $pad-large;
.avatar-wrapper {
position: relative;
}
}
&__block:before {
content: "";
position: relative;
height: 36px !important;
z-index: 0;
top: 35px;
left: 17px;
border-left: 1px dashed $ui-fleet-blue-15;
height: 100%;
}
&__block:last-child:before {
border-left: 0;
}
&__avatar-frame {
display: flex;
width: 32px;
height: 32px;
}
&__avatar-image {
z-index: 2;
}
&__details {
padding-left: $pad-large;
p {
margin: 0;
line-height: 16px;
}
}
&__details-topline {
font-size: $x-small;
}
&__details-bottomline {
font-size: $xx-small;
color: $ui-fleet-black-25;
}
&__pagination {
position: absolute;
bottom: 0px;

View file

@ -1,6 +1,6 @@
import { rest } from "msw";
import createMockActivity from "__mocks__/activityFeedMock";
import createMockActivity from "__mocks__/activityMock";
import { baseUrl } from "test/test-utils";
export const defaultActivityHandler = rest.get(
@ -38,21 +38,3 @@ export const activityHandler9Activities = rest.get(
);
}
);
export const activityHandler2DaysAgo = rest.get(
baseUrl("/activities"),
(req, res, context) => {
const currentDate = new Date();
currentDate.setDate(currentDate.getDate() - 2);
return res(
context.json({
activities: [
createMockActivity({
created_at: currentDate.toISOString(),
}),
],
})
);
}
);