diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f94509d..a985fb5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +- Fixed playlist share-target dropdown silently truncating to 30 tenants; it now loads every page. - Refactored InteractiveController to use a typed `InteractiveSlideActionInput` DTO; regenerated API spec and RTK types. - Fixed multiple InstantBook bugs: interval boundary overlap, busy-interval timezone, per-resource spam-protect throttling, duration validation, error responses (409/4xx), resource cache TTL, and assorted diff --git a/assets/admin/components/playlist/playlist-form.jsx b/assets/admin/components/playlist/playlist-form.jsx index fd894738..7c8ac4c2 100644 --- a/assets/admin/components/playlist/playlist-form.jsx +++ b/assets/admin/components/playlist/playlist-form.jsx @@ -1,9 +1,11 @@ -import { useContext } from "react"; +import { useContext, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; +import { useDispatch } from "react-redux"; import { Alert } from "react-bootstrap"; import UserContext from "../../context/user-context"; import Schedule from "../util/schedule/schedule"; -import { useGetV2TenantsQuery } from "../../../shared/redux/enhanced-api.ts"; +import { enhancedApi } from "../../../shared/redux/enhanced-api.ts"; +import getAllPages from "../util/helpers/get-all-pages.js"; import ContentBody from "../util/content-body/content-body"; import TenantsDropdown from "../util/forms/multiselect-dropdown/tenants/tenants-dropdown"; @@ -24,10 +26,20 @@ function PlaylistForm({ }) { const { t } = useTranslation("common", { keyPrefix: "playlist-form" }); const context = useContext(UserContext); + const dispatch = useDispatch(); + const [tenants, setTenants] = useState(null); - const { data: tenants } = useGetV2TenantsQuery({ - itemsPerPage: 30, - }); + useEffect(() => { + let cancelled = false; + getAllPages(dispatch, enhancedApi.endpoints.getV2Tenants, { + itemsPerPage: 30, + }).then((all) => { + if (!cancelled) setTenants(all); + }); + return () => { + cancelled = true; + }; + }, [dispatch]); return ( <> @@ -51,7 +63,7 @@ function PlaylistForm({ name="tenants" handleTenantSelection={handleInput} selected={playlist.tenants} - data={tenants["hydra:member"].filter(({ tenantKey }) => { + data={tenants.filter(({ tenantKey }) => { return context.selectedTenant.get.tenantKey !== tenantKey; })} /> diff --git a/assets/tests/admin/playlist-form-tenants-pagination.test.jsx b/assets/tests/admin/playlist-form-tenants-pagination.test.jsx new file mode 100644 index 00000000..94ff44ff --- /dev/null +++ b/assets/tests/admin/playlist-form-tenants-pagination.test.jsx @@ -0,0 +1,127 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, cleanup, waitFor } from "@testing-library/react"; + +const { + capturedDropdownData, + initiateMock, + dispatchMock, + useGetV2TenantsQueryMock, +} = vi.hoisted(() => ({ + capturedDropdownData: { current: null }, + initiateMock: vi.fn(), + dispatchMock: vi.fn(), + useGetV2TenantsQueryMock: vi.fn(), +})); + +vi.mock( + "../../admin/components/util/forms/multiselect-dropdown/tenants/tenants-dropdown", + () => ({ + default: (props) => { + capturedDropdownData.current = props.data; + return null; + }, + }), +); + +vi.mock("../../admin/components/util/schedule/schedule", () => ({ + default: () => null, +})); + +vi.mock("../../admin/components/util/content-body/content-body", () => ({ + default: ({ children }) => children, +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key) => key }), +})); + +vi.mock("react-bootstrap", () => ({ + Alert: ({ children }) => children, +})); + +vi.mock("react-redux", () => ({ + useDispatch: () => dispatchMock, +})); + +vi.mock("../../shared/redux/enhanced-api.ts", () => ({ + enhancedApi: { + endpoints: { getV2Tenants: { initiate: initiateMock } }, + }, + useGetV2TenantsQuery: useGetV2TenantsQueryMock, +})); + +import UserContext from "../../admin/context/user-context"; +import PlaylistForm from "../../admin/components/playlist/playlist-form"; + +function makeTenants(start, count) { + return Array.from({ length: count }, (_, i) => ({ + "@id": `/v2/tenants/t${start + i}`, + "@type": "Tenant", + tenantKey: `tenant-${start + i}`, + title: `Tenant ${start + i}`, + description: "", + })); +} + +const page1 = makeTenants(1, 30); +const page2 = makeTenants(31, 5); +const allTenants = [...page1, ...page2]; + +beforeEach(() => { + capturedDropdownData.current = null; + initiateMock.mockReset(); + dispatchMock.mockReset(); + useGetV2TenantsQueryMock.mockReset(); + + initiateMock.mockImplementation((params) => ({ __initiate: true, params })); + dispatchMock.mockImplementation((action) => { + const params = action?.params ?? {}; + const page = params.page ?? 1; + if (page === 1) { + return Promise.resolve({ + data: { + "hydra:member": page1, + "hydra:view": { "hydra:next": "/v2/tenants?page=2" }, + }, + }); + } + if (page === 2) { + return Promise.resolve({ + data: { "hydra:member": page2, "hydra:view": {} }, + }); + } + return Promise.resolve({ + data: { "hydra:member": [], "hydra:view": {} }, + }); + }); + + useGetV2TenantsQueryMock.mockReturnValue({ + data: { "hydra:member": page1 }, + }); +}); + +afterEach(() => cleanup()); + +describe("PlaylistForm tenants picker pagination", () => { + it("populates the share-target dropdown with tenants from every page", async () => { + const playlist = { schedules: [], tenants: [] }; + const userContextValue = { + selectedTenant: { get: { tenantKey: "current" } }, + }; + + render( + + + , + ); + + await waitFor(() => { + expect(capturedDropdownData.current).not.toBeNull(); + }); + + expect(capturedDropdownData.current).toHaveLength(allTenants.length); + expect(capturedDropdownData.current.map((t) => t.tenantKey)).toEqual( + allTenants.map((t) => t.tenantKey), + ); + }); +});