Skip to content
Merged
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
2 changes: 1 addition & 1 deletion src/actions/email-actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -352,7 +352,7 @@ export const getSentEmails =
createAction(RECEIVE_EMAILS),
`${window.EMAIL_API_BASE_URL}/api/v1/mails`,
authErrorHandler,
{ order, orderDir, term, filters }
{ order, orderDir, term, page, perPage, filters }
)(params)(dispatch).then(() => {
dispatch(stopLoading());
});
Expand Down
13 changes: 5 additions & 8 deletions src/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -3276,6 +3276,7 @@
}
},
"emails": {
"emails": "Emails",
"email_templates": "Email Templates",
"sent": "Sent",
"templates": "Templates",
Expand Down Expand Up @@ -3329,21 +3330,17 @@
"email_logs": {
"email_logs": "Email Logs",
"email_list": "Email List",
"apply_filters": "Apply Filters",
"is_sent_filter": "Is Sent?",
"template_filter": "Template",
"not_sent": "Not Sent",
"email_templates": "Email Templates",
"subject": "Subject",
"from_email": "From Email",
"to_email": "To Email",
"sent_date": "Sent Date",
"last_error": "Last Error",
"payload": "Payload",
"select_fields": "Show Columns",
"placeholders": {
"select_fields": "Select data to display",
"template": "Filter by Template",
"sent_date_from": "Filter Sent Date from",
"sent_date_to": "Filter Sent Date to"
}
"select_fields": "Show Columns"
},
"summitdoc": {
"summitdocs": "Event Docs",
Expand Down
60 changes: 60 additions & 0 deletions src/pages/emails/__tests__/email-log-list-page.helpers.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { OPERATORS } from "openstack-uicore-foundation/lib/components/mui/grid-filter";
import { buildEmailFilters } from "../email-log-list-page.helpers";

jest.mock("i18n-react/dist/i18n-react", () => ({
__esModule: true,
default: { translate: (key) => key }
}));

jest.mock("../../../actions/email-actions", () => ({
queryTemplates: jest.fn()
}));

describe("buildEmailFilters", () => {
test("maps AFTER to sent_date_filter[0] and BEFORE to sent_date_filter[1]", () => {
const result = buildEmailFilters([
{
criteria: "sent_date_filter",
operator: OPERATORS.BEFORE.value,
value: 200
},
{
criteria: "sent_date_filter",
operator: OPERATORS.AFTER.value,
value: 100
}
]);

expect(result.sent_date_filter).toEqual([100, 200]);
});

test("returns [null, null] when no date criteria is present", () => {
const result = buildEmailFilters([]);

expect(result.sent_date_filter).toEqual([null, null]);
});

test("unwraps template_filter from the async option object", () => {
const result = buildEmailFilters([
{
criteria: "template_filter",
operator: OPERATORS.IS.value,
value: { value: "welcome-email", label: "welcome-email" }
}
]);

expect(result.template_filter).toBe("welcome-email");
});

test("defaults template_filter to an empty string when absent", () => {
const result = buildEmailFilters([]);

expect(result.template_filter).toBe("");
});

test("defaults is_sent_filter to null when absent", () => {
const result = buildEmailFilters([]);

expect(result.is_sent_filter).toBeNull();
});
});
191 changes: 191 additions & 0 deletions src/pages/emails/__tests__/email-log-list-page.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import React from "react";
import { screen, act, render } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "@testing-library/jest-dom";
import { createStore, combineReducers, applyMiddleware } from "redux";
import thunk from "redux-thunk";
import { Provider } from "react-redux";
import { getRequest } from "openstack-uicore-foundation/lib/utils/actions";
import EmailLogListPage from "../email-log-list-page";
import emailLogListReducer from "../../../reducers/emails/email-log-list-reducer";
import * as methods from "../../../utils/methods";

jest.mock("openstack-uicore-foundation/lib/utils/actions", () => ({
__esModule: true,
...jest.requireActual("openstack-uicore-foundation/lib/utils/actions"),
getRequest: jest.fn()
}));

jest.mock("openstack-uicore-foundation/lib/components/mui/grid-filter", () => ({
__esModule: true,
...jest.requireActual(
"openstack-uicore-foundation/lib/components/mui/grid-filter"
),
GridFilter: () => null,
useGridFilter: () => ({ parsedFilter: [], filterValues: [] })
}));

jest.mock(
"openstack-uicore-foundation/lib/components/mui/search-input",
() => ({
__esModule: true,
default: () => null
})
);

jest.mock("openstack-uicore-foundation/lib/components/mui/table", () => ({
__esModule: true,
default: ({ data, columns, onPageChange, onPerPageChange }) => (
<div>
<button type="button" onClick={() => onPerPageChange(50)}>
set-per-page-50
</button>
<button type="button" onClick={() => onPageChange(2)}>
go-to-page-2
</button>
{data.map((row) => (
<div key={row.id}>
{columns.map((col) => (
<span key={col.columnKey} data-testid={`cell-${col.columnKey}`}>
{col.render ? col.render(row) : row[col.columnKey]}
</span>
))}
</div>
))}
</div>
)
}));

jest.mock("i18n-react/dist/i18n-react", () => ({
__esModule: true,
default: { translate: (key) => key }
}));

const buildStore = (emailLogListOverrides) =>
createStore(
combineReducers({
currentSummitState: (state = { currentSummit: { id: 1 } }) => state,
emailLogListState: emailLogListReducer
}),
{
emailLogListState: {
...emailLogListReducer(undefined, {}),
...emailLogListOverrides
}
},
applyMiddleware(thunk)
);

describe("SentEmailListPage", () => {
beforeEach(() => {
jest.spyOn(methods, "getAccessTokenSafely").mockResolvedValue("TOKEN");
getRequest.mockImplementation(
() => () => () =>
Promise.resolve({
response: { total: 0, last_page: 1, current_page: 1, data: [] }
})
);
});

afterEach(() => {
jest.restoreAllMocks();
});

describe("Show Columns", () => {
const mockPayloadObject = {
summit_reassign_ticket_till_date: "Monday 1 January 2027 12:00 AM PST",
order_owner_full_name: "Jane Doe",
order_owner_company: "Acme Corp",
order_owner_email: "jane.doe@acme.test",
owner_first_name: "John",
owner_last_name: "Smith",
owner_company: "Acme Corp",
owner_email: "john.smith@acme.test",
owner_full_name: "John Smith",
support_email: "support@example.test",
summit_virtual_site_oauth2_client_id: "abcXYZ123.client",
summit_marketing_site_oauth2_client_id: "abcXYZ123.client",
summit_marketing_site_oauth2_scopes:
"openid profile email offline_access",
summit_id: 99,
summit_name: "Test Summit 2027",
summit_logo: "https://example.test/logo.svg",
summit_virtual_site_url:
"https://idp.example.test/auth/password/set/token?client_id=abcXYZ123.client",
summit_marketing_site_url:
"https://idp.example.test/auth/password/set/token?client_id=abcXYZ123.client",
raw_summit_virtual_site_url: "https://virtual.example.test/a",
raw_summit_marketing_site_url: "https://marketing.example.test",
summit_date: "January 1, 2027",
summit_dates_label: "January 1-3, 2027",
summit_schedule_url: "",
summit_site_url: "https://marketing.example.test/",
registration_link: null,
virtual_event_site_link: "https://virtual.example.test/a",
main_venue_address: "123 Main St, Testville, TS",
summit_marketing_site_url_magic_link: "",
edit_ticket_link:
"https://marketing.example.test/#login=1&email=john.smith@acme.test&BackUrl=/a/my-tickets",
EMAIL_TEMPLATE_GENERIC_BANNER: "https://example.test/banner.png",
EMAIL_TEMPLATE_DRAFT_INSTRUCTIONS_URL:
"<p><a href=\"https://docs.example.test/draft\">https://docs.example.test/draft</a></p>",
EMAIL_TEMPLATE_DRAFT_DUE_DATE: "March 1 - 5",
EMAIL_TEMPLATE_FINAL_DUE_DATE: "April 1",
EMAIL_TEMPLATE_REVIEW_PERIOD: "March 1 - 5, 2027",
EMAIL_TEMPLATE_SPEAKER_PORTAL_URL: "https://speaker.example.test/plans",
EMAIL_TEMPLATE_SPEAKER_ACCEPTED_INTRO:
"<p>Thank you for your submission.</p>",
EMAIL_TEMPLATE_SPEAKER_ACCEPTED_NEXT_STEPS: "<p>Next steps go here.</p>",
EMAIL_TEMPLATE_GREETING: "Hello",
EMAIL_TEMPLATE_GENERIC_SPEAKER_BANNER:
"https://example.test/speaker-banner.png",
EMAIL_TEMPLATE_TICKET_TOP_GRAPHIC: "https://example.test/ticket-top.jpg",
EMAIL_TEMPLATE_TICKET_BOTTOM_GRAPHIC:
"https://example.test/ticket-bottom.jpg",
EMAIL_TEMPLATE_PRIMARY_COLOR: "#111111",
EMAIL_TEMPLATE_SECONDARY_COLOR: "#eeeeee"
};

// The reducer hands the page an already-serialized string, not the object.
const serializedPayload = JSON.stringify(mockPayloadObject);

it("renders the full serialized payload when Payload is selected in Show Columns", async () => {
const store = buildStore({
emails: [
{
id: 1,
template: "welcome-email",
subject: "Welcome",
from_email: "from@test.com",
to_email: "to@test.com",
sent_date: "2020-01-01",
last_error: "N/A",
payload: serializedPayload
}
],
totalEmails: 1
});

render(
<Provider store={store}>
<EmailLogListPage />
</Provider>
);

await act(async () => {
await userEvent.click(
screen.getByRole("combobox", { name: "email_logs.select_fields" })
);
});
await act(async () => {
await userEvent.click(
screen.getByRole("option", { name: "email_logs.payload" })
);
});

expect(screen.getByTestId("cell-payload")).toHaveTextContent(
serializedPayload
);
});
});
});
82 changes: 82 additions & 0 deletions src/pages/emails/email-log-list-page.helpers.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/**
* Copyright 2020 OpenStack Foundation
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
* */

import T from "i18n-react/dist/i18n-react";
import { OPERATORS } from "openstack-uicore-foundation/lib/components/mui/grid-filter";
import { queryTemplates } from "../../actions/email-actions";
import { DATE_FILTER_ARRAY_SIZE } from "../../utils/constants";

export const getCriterias = () => [
{
key: "is_sent_filter",
label: T.translate("email_logs.is_sent_filter"),
operators: [OPERATORS.IS],
values: {
type: "select",
props: {
options: [
{ value: "1", label: T.translate("emails.sent") },
{ value: "0", label: T.translate("email_logs.not_sent") }
]
}
}
},
{
key: "sent_date_filter",
label: T.translate("email_logs.sent_date"),
operators: [OPERATORS.BEFORE, OPERATORS.AFTER],
values: {
type: "datetime",
props: { mode: "datetime", timezone: "UTC" }
}
},
{
key: "template_filter",
label: T.translate("email_logs.template_filter"),
operators: [OPERATORS.IS],
values: {
type: "asyncSelect",
props: {
queryFunction: queryTemplates,
formatOption: (t) => ({ value: t.identifier, label: t.identifier }),
multiple: false
}
},
customParser: (f) => [`template_filter==${f.value.value}`]
}
];

export const buildEmailFilters = (filterValues) => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@tomrndom buildEmailFilters is the entire GridFilter → API mapping and it ships with no test, and the page it feeds has none either.

This is pure, dependency-free logic that decides which emails an admin sees, so a wrong mapping fails silently: the grid renders normally and just returns the wrong rows. The load-bearing part is the operator → slot mapping — AFTER must land in sent_date_filter[0] (which parseFilters sends as from_sent_date) and BEFORE in [1] (to_sent_date). Swapping them yields a plausible-looking but inverted range that no reviewer would catch by reading the diff.

Worth pinning specifically because the match is on operator strings that are not unique in uicore's OPERATORS table: BEFORE.value and LESS_OR_EQUAL.value are both "<=", and AFTER.value and GREATER_OR_EQUAL.value are both ">=". It's correct today because sent_date_filter only offers BEFORE/AFTER, but nothing in the code prevents a future criteria edit from breaking it, and nothing would fail if it did.

There is precedent for page-level coverage in this same directory — the sibling page migrated to MuiTable has src/pages/emails/__tests__/email-template-list-page.test.js. Per .claude/rules/summit-admin-testing-patterns.md, connected pages go through renderWithRedux from src/utils/test-utils.js and assert observable output (rendered rows, dispatched action arguments), not internals.

Three tests worth adding, each tied to a concrete break:

  1. buildEmailFilters unitAFTER → [0] / BEFORE → [1], template_filter unwrapped from the async option object (value.value), and [null, null] when no date criteria is present. Breaks if the operator constants or the slot order are ever swapped.
  2. Payload column renders row.payload when selected in Show Columns. This is ~6 lines against MuiTable and it does catch the empty-cell regression: with render: (row, data) the value is absent from the DOM, with render: (row) it is present.
  3. Page size survives a page changeonPerPageChange(50) followed by onPageChange(2) should dispatch per_page=50 on both calls. Fails today because perPage never returns to the reducer.

Run with yarn test.

const isSentEntry = filterValues.find((f) => f.criteria === "is_sent_filter");
const afterEntry = filterValues.find(
(f) =>
f.criteria === "sent_date_filter" && f.operator === OPERATORS.AFTER.value
);
const beforeEntry = filterValues.find(
(f) =>
f.criteria === "sent_date_filter" && f.operator === OPERATORS.BEFORE.value
);
const templateEntry = filterValues.find(
(f) => f.criteria === "template_filter"
);

const sentDateFilter = Array(DATE_FILTER_ARRAY_SIZE).fill(null);
sentDateFilter[0] = afterEntry?.value ?? null;
sentDateFilter[1] = beforeEntry?.value ?? null;

return {
is_sent_filter: isSentEntry?.value ?? null,
sent_date_filter: sentDateFilter,
template_filter: templateEntry?.value?.value ?? ""
};
};
Loading
Loading