diff --git a/.github/workflows/docker_build_stg_images.yml b/.github/workflows/docker_build_stg_images.yml
index 781b76f6d..887d7c1e1 100644
--- a/.github/workflows/docker_build_stg_images.yml
+++ b/.github/workflows/docker_build_stg_images.yml
@@ -34,13 +34,20 @@ jobs:
with:
images: os2display/display-api-service
+ - name: Set release timestamp
+ run: |
+ echo "APP_RELEASE_TIMESTAMP=$(echo $(date +%s))" >> $GITHUB_ENV
+ echo "APP_RELEASE_TIME=$(echo $(date))" >> $GITHUB_ENV
+
- name: Build and push (API)
uses: docker/build-push-action@v6
with:
context: ./infrastructure/display-api-service/
file: ./infrastructure/display-api-service/Dockerfile
build-args: |
- VERSION=${{ env.APP_VERSION }}
+ APP_VERSION=${{ env.APP_VERSION }}
+ APP_RELEASE_TIMESTAMP=${{ env.APP_RELEASE_TIMESTAMP }}
+ APP_RELEASE_TIME=${{ env.APP_RELEASE_TIME }}
push: true
tags: ${{ steps.meta-api.outputs.tags }}
labels: ${{ steps.meta-api.outputs.labels }}
diff --git a/.github/workflows/github_build_release.yml b/.github/workflows/github_build_release.yml
index ea6bbe7c5..975b32f49 100644
--- a/.github/workflows/github_build_release.yml
+++ b/.github/workflows/github_build_release.yml
@@ -35,6 +35,16 @@ jobs:
docker compose run --rm node npm install
docker compose run --rm node npm run build
+ - name: Set release timestamp
+ run: |
+ echo "APP_RELEASE_TIMESTAMP=$(echo $(date +%s))" >> $GITHUB_ENV
+ echo "APP_RELEASE_TIME=$(echo $(date))" >> $GITHUB_ENV
+
+ - name: Create release file
+ run: |
+ printf "{\n \"releaseTimestamp\": ${{ env.APP_RELEASE_TIMESTAMP }},\n \"releaseTime\": \"${{ env.APP_RELEASE_TIME }}\",\n \"releaseVersion\": \"${{ github.ref_name }}\"\n}" > public/release.json
+ cat public/release.json
+
- name: Cleanup after install
run: |
sudo chown -R runner:runner .
@@ -102,6 +112,11 @@ jobs:
tags: |
type=raw,value=${{ github.ref_name }}
+ - name: Set release timestamp
+ run: |
+ echo "APP_RELEASE_TIMESTAMP=$(echo $(date +%s))" >> $GITHUB_ENV
+ echo "APP_RELEASE_TIME=$(echo $(date))" >> $GITHUB_ENV
+
- name: Build and push Docker image (main)
id: push-main
uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4
@@ -110,6 +125,8 @@ jobs:
file: ./infrastructure/display-api-service/Dockerfile
build-args: |
APP_VERSION=${{ github.ref_name }}
+ APP_RELEASE_TIMESTAMP=${{ env.APP_RELEASE_TIMESTAMP }}
+ APP_RELEASE_TIME=${{ env.APP_RELEASE_TIME }}
push: true
tags: ${{ steps.meta-main.outputs.tags }}
labels: ${{ steps.meta-main.outputs.labels }}
@@ -132,6 +149,8 @@ jobs:
file: ./infrastructure/nginx/Dockerfile
build-args: |
APP_VERSION=${{ github.ref_name }}
+ APP_RELEASE_TIMESTAMP=${{ env.APP_RELEASE_TIMESTAMP }}
+ APP_RELEASE_TIME=${{ env.APP_RELEASE_TIME }}
APP_IMAGE=${{ env.IMAGE_NAME_MAIN }}
push: true
pull: true
diff --git a/.gitignore b/.gitignore
index 521b8cb58..40168e85a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -64,3 +64,5 @@ phpstan.neon
###> vincentlanglet/twig-cs-fixer ###
/.twig-cs-fixer.cache
###< vincentlanglet/twig-cs-fixer ###
+
+.claude/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 44f5ee5e0..b86e7b614 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -31,6 +31,10 @@ All notable changes to this project will be documented in this file.
- Added relations checksum feature flag.
- Fixes saving issues described in issue where saving resulted in infinite spinner.
- Fixed loading of routes containing null string values.
+- Fixed release.json creation in v3.
+- Fixed relations checksum test.
+- Optimized release data fetching.
+- Optimized list loading.
### NB! Prior to 3.x the project was split into separate repositories
diff --git a/README.md b/README.md
index 7f5c8da73..425b87728 100644
--- a/README.md
+++ b/README.md
@@ -509,9 +509,11 @@ CLIENT_DEBUG=false
waiting for being activated in the administration.
**Default**: 20 s.
-- CLIENT_REFRESH_TOKEN_TIMEOUT: How often (milliseconds) should it be checked whether the token needs to be refreshed?
+- CLIENT_RELEASE_TIMESTAMP_INTERVAL_TIMEOUT: How often (milliseconds) should it be checked whether a new release is
+ available?
+ Value should not be lower than 5 minutes, since release.json is only fetched with a minimum of 5 minutes interval.
- **Default**: 30 s.
+ **Default**: 10 m.
- CLIENT_REFRESH_TOKEN_TIMEOUT: How often (milliseconds) should it be checked whether the token needs to be refreshed?
**Default**: 60 s.
diff --git a/Taskfile.yml b/Taskfile.yml
index 088ee25f8..9ec773a3c 100644
--- a/Taskfile.yml
+++ b/Taskfile.yml
@@ -192,7 +192,7 @@ tasks:
desc: "Runs API tests (PHPUnit)."
cmds:
- task composer -- test-setup
- - task compose -- exec --env SYMFONY_DEPRECATIONS_HELPER=disabled phpfpm composer test
+ - task compose -- exec --env SYMFONY_DEPRECATIONS_HELPER=disabled phpfpm vendor/bin/phpunit --stop-on-failure {{.CLI_ARGS}}
test:frontend-built:
desc: "Runs frontend tests (Playwright) on the built files. This temporarily stops the node container."
diff --git a/assets/admin/components/groups/groups-columns.jsx b/assets/admin/components/groups/groups-columns.jsx
index 3d06dac1f..191df9414 100644
--- a/assets/admin/components/groups/groups-columns.jsx
+++ b/assets/admin/components/groups/groups-columns.jsx
@@ -1,7 +1,57 @@
import { useTranslation } from "react-i18next";
-import ListButton from "../util/list/list-button";
import ColumnHoc from "../util/column-hoc";
import SelectColumnHoc from "../util/select-column-hoc";
+import useModal from "../../context/modal-context/modal-context-hook.jsx";
+import { useDispatch } from "react-redux";
+import { enhancedApi } from "../../../shared/redux/enhanced-api.ts";
+import idFromUrl from "../util/helpers/id-from-url.jsx";
+import getAllPages from "../util/helpers/get-all-pages.js";
+import { Link } from "react-router-dom";
+import { Button } from "react-bootstrap";
+
+function ScreensButton({ group }) {
+ const { t } = useTranslation("common", { keyPrefix: "groups-columns" });
+ const { setModal } = useModal();
+ const dispatch = useDispatch();
+
+ const onClick = () => {
+ getAllPages(dispatch, enhancedApi.endpoints.getV2ScreenGroupsByIdScreens, {
+ id: idFromUrl(group.id),
+ }).then((screens) => {
+ const content = (
+
+ {screens.map((screen) => (
+ -
+
+ {screen.title}
+
+
+ ))}
+
+ );
+
+ setModal({
+ info: true,
+ modalTitle: t("screens-modal-title"),
+ content,
+ });
+ });
+ };
+
+ return (
+
+ );
+}
/**
* Columns for group lists.
@@ -18,14 +68,7 @@ function getGroupColumns({ apiCall, infoModalRedirect, infoModalTitle }) {
const columns = [
{
// eslint-disable-next-line react/prop-types
- content: ({ screens }) => (
-
- ),
+ content: (group) => ,
key: "screens",
label: t("screens"),
},
diff --git a/assets/admin/components/playlist/playlists-columns.jsx b/assets/admin/components/playlist/playlists-columns.jsx
index 5657bcc1f..d7c93be26 100644
--- a/assets/admin/components/playlist/playlists-columns.jsx
+++ b/assets/admin/components/playlist/playlists-columns.jsx
@@ -1,11 +1,59 @@
-import { useContext } from "react";
import { useTranslation } from "react-i18next";
import ColumnHoc from "../util/column-hoc";
import SelectColumnHoc from "../util/select-column-hoc";
-import UserContext from "../../context/user-context";
-import ListButton from "../util/list/list-button";
import DateValue from "../util/date-value";
import PublishingStatus from "../util/publishingStatus";
+import useModal from "../../context/modal-context/modal-context-hook.jsx";
+import { useDispatch } from "react-redux";
+import { enhancedApi } from "../../../shared/redux/enhanced-api.ts";
+import idFromUrl from "../util/helpers/id-from-url.jsx";
+import getAllPages from "../util/helpers/get-all-pages.js";
+import { Link } from "react-router-dom";
+import { Button } from "react-bootstrap";
+
+function SlidesButton({ playlist }) {
+ const { t } = useTranslation("common", { keyPrefix: "playlists-columns" });
+ const { setModal } = useModal();
+ const dispatch = useDispatch();
+
+ const onClick = () => {
+ getAllPages(dispatch, enhancedApi.endpoints.getV2PlaylistsByIdSlides, {
+ id: idFromUrl(playlist.id),
+ }).then((playlistSlides) => {
+ const content = (
+
+ {playlistSlides.map((playlistSlide) => (
+ -
+
+ {playlistSlide?.slide.title}
+
+
+ ))}
+
+ );
+
+ setModal({
+ info: true,
+ modalTitle: t("playlist-slide-modal-title"),
+ content,
+ });
+ });
+ };
+
+ return (
+
+ );
+}
/**
* Columns for playlists lists.
@@ -17,40 +65,16 @@ import PublishingStatus from "../util/publishingStatus";
* @param {string} props.dataKey The data key for mapping the data.
* @returns {object} The columns for the playlists lists.
*/
-function getPlaylistColumns({
- apiCall,
- infoModalRedirect,
- infoModalTitle,
- dataKey,
-}) {
- const context = useContext(UserContext);
+function getPlaylistColumns() {
const { t } = useTranslation("common", {
keyPrefix: "playlists-columns",
});
const columns = [
{
- key: "slides",
+ key: "playlist",
label: t("number-of-slides"),
- render: ({ tenants }) => {
- return (
- tenants?.length === 0 ||
- !tenants.find(
- (tenant) =>
- tenant.tenantKey === context.selectedTenant.get.tenantKey,
- )
- );
- },
- // eslint-disable-next-line react/prop-types
- content: ({ slides, playlistSlides }) => (
-
- ),
+ content: (playlist) => ,
},
{
key: "publishing-from",
diff --git a/assets/admin/components/screen/screen-list.jsx b/assets/admin/components/screen/screen-list.jsx
index 48e82ae9d..9c5c33dd2 100644
--- a/assets/admin/components/screen/screen-list.jsx
+++ b/assets/admin/components/screen/screen-list.jsx
@@ -11,7 +11,6 @@ import useModal from "../../context/modal-context/modal-context-hook";
import {
useGetV2ScreensQuery,
useDeleteV2ScreensByIdMutation,
- useGetV2ScreensByIdScreenGroupsQuery,
} from "../../../shared/redux/enhanced-api.ts";
import {
displaySuccess,
@@ -128,10 +127,6 @@ function ScreenList() {
// The columns for the table.
const columns = ScreenColumns({
- handleDelete,
- apiCall: useGetV2ScreensByIdScreenGroupsQuery,
- infoModalRedirect: "/group/edit",
- infoModalTitle: t("info-modal.screen-in-groups"),
displayStatus: showScreenStatus,
});
diff --git a/assets/admin/components/screen/screen-status.jsx b/assets/admin/components/screen/screen-status.jsx
index 972c39cf2..b6ee26645 100644
--- a/assets/admin/components/screen/screen-status.jsx
+++ b/assets/admin/components/screen/screen-status.jsx
@@ -18,6 +18,7 @@ import { enhancedApi } from "../../../shared/redux/enhanced-api.ts";
import { displayError } from "../util/list/toast-component/display-toast";
import FormInput from "../util/forms/form-input";
import AdminConfigLoader from "../util/admin-config-loader.js";
+import ReleaseLoader from "../../../shared/release-loader.js";
/**
* Displays screen status.
@@ -92,11 +93,7 @@ function ScreenStatus({ screen, handleInput = () => {}, mode = "default" }) {
useEffect(() => {
if (status) {
- const now = dayjs().startOf("minute").valueOf();
-
- fetch(`/release.json?ts=${now}`)
- .then((res) => res.json())
- .then((data) => setClientRelease(data));
+ ReleaseLoader.loadRelease().then((data) => setClientRelease(data));
}
}, [status]);
diff --git a/assets/admin/components/screen/util/campaign-icon.jsx b/assets/admin/components/screen/util/campaign-icon.jsx
deleted file mode 100644
index b61f13598..000000000
--- a/assets/admin/components/screen/util/campaign-icon.jsx
+++ /dev/null
@@ -1,105 +0,0 @@
-import { useEffect, useState } from "react";
-import { useDispatch } from "react-redux";
-import { useTranslation } from "react-i18next";
-import Spinner from "react-bootstrap/Spinner";
-import idFromUrl from "../../util/helpers/id-from-url";
-import calculateIsPublished from "../../util/helpers/calculate-is-published";
-import {
- enhancedApi,
- useGetV2ScreensByIdCampaignsQuery,
- useGetV2ScreensByIdScreenGroupsQuery,
-} from "../../../../shared/redux/enhanced-api.ts";
-
-/**
- * An icon to show if the screen has an active campaign.
- *
- * @param {object} props - The props.
- * @param {string} props.id The id of the screen.
- * @param {number} props.delay Delay the fetch.
- * @returns {object} The campaign icon.
- */
-function CampaignIcon({ id, delay = 1000 }) {
- const { t } = useTranslation("common", { keyPrefix: "campaign-icon" });
- const dispatch = useDispatch();
- const [isOverriddenByCampaign, setIsOverriddenByCampaign] = useState(null);
- const [screenCampaignsChecked, setScreenCampaignsChecked] = useState(false);
- const [allCampaigns, setAllCampaigns] = useState([]);
- const [getData, setGetData] = useState(false);
-
- const { data: campaigns, isLoading } = useGetV2ScreensByIdCampaignsQuery(
- { id },
- { skip: !getData || !id },
- );
- const { data: groups, isLoading: isLoadingScreenGroups } =
- useGetV2ScreensByIdScreenGroupsQuery({ id }, { skip: !getData || !id });
-
- useEffect(() => {
- if (campaigns) {
- setAllCampaigns(
- campaigns["hydra:member"].map(({ campaign }) => campaign),
- );
- setScreenCampaignsChecked(true);
- }
- }, [campaigns]);
-
- useEffect(() => {
- if (groups && !isOverriddenByCampaign && screenCampaignsChecked) {
- groups["hydra:member"].forEach((group) => {
- dispatch(
- enhancedApi.endpoints.getV2ScreenGroupsByIdCampaigns.initiate({
- id: idFromUrl(group["@id"]),
- }),
- ).then((result) => {
- let allCampaignsCopy = [...allCampaigns];
- if (allCampaignsCopy.length > 0 && result.data) {
- allCampaignsCopy = allCampaignsCopy.concat(
- result.data["hydra:member"].map(({ campaign }) => campaign),
- );
- }
- setAllCampaigns(allCampaignsCopy);
- });
- });
- }
- }, [groups, screenCampaignsChecked]);
-
- useEffect(() => {
- if (allCampaigns.length > 0 && !isOverriddenByCampaign) {
- allCampaigns.forEach(({ published }) => {
- if (calculateIsPublished(published)) {
- setIsOverriddenByCampaign(true);
- }
- });
- }
- }, [allCampaigns]);
-
- useEffect(() => {
- const timeout = setTimeout(() => {
- setGetData(true);
- }, delay);
-
- return () => {
- clearTimeout(timeout);
- };
- }, []);
-
- if (!getData || isLoading || isLoadingScreenGroups) {
- return (
-
-
-
- );
- }
-
- return isOverriddenByCampaign
- ? t("overridden-by-campaign")
- : t("not-overridden-by-campaign");
-}
-
-export default CampaignIcon;
diff --git a/assets/admin/components/screen/util/campaigns-button.jsx b/assets/admin/components/screen/util/campaigns-button.jsx
new file mode 100644
index 000000000..edb5657c3
--- /dev/null
+++ b/assets/admin/components/screen/util/campaigns-button.jsx
@@ -0,0 +1,165 @@
+import { useTranslation } from "react-i18next";
+import { useEffect, useState } from "react";
+import { Link } from "react-router-dom";
+import calculateIsPublished from "../../util/helpers/calculate-is-published.jsx";
+import idFromUrl from "../../util/helpers/id-from-url.jsx";
+import getAllPages from "../../util/helpers/get-all-pages.js";
+import { useDispatch } from "react-redux";
+import useModal from "../../../context/modal-context/modal-context-hook.jsx";
+import { enhancedApi } from "../../../../shared/redux/enhanced-api.ts";
+import { Button } from "react-bootstrap";
+
+function getAllScreenGroupCampaigns(dispatch, screenGroupIds = []) {
+ return screenGroupIds.reduce(
+ (promise, groupId) =>
+ promise.then((results) =>
+ getAllPages(
+ dispatch,
+ enhancedApi.endpoints.getV2ScreenGroupsByIdCampaigns,
+ { id: groupId },
+ ).then((campaigns) => [...results, ...campaigns]),
+ ),
+ Promise.resolve([]),
+ );
+}
+
+function getAllCampaigns(dispatch, campaignIds = [], results = []) {
+ return new Promise((resolve) => {
+ if (campaignIds.length === 0) {
+ resolve(results);
+ } else {
+ const campaignId = campaignIds[0];
+
+ dispatch(
+ enhancedApi.endpoints.getV2PlaylistsById.initiate({
+ id: campaignId,
+ }),
+ ).then(({ data }) => {
+ const newResults = [...results, data];
+
+ const newCampaignIds = campaignIds.filter((id) => id !== campaignId);
+
+ if (newCampaignIds.length === 0) {
+ resolve(newResults);
+ } else {
+ resolve(getAllCampaigns(dispatch, newCampaignIds, newResults));
+ }
+ });
+ }
+ });
+}
+
+function CampaignsButton({ screen }) {
+ const { t } = useTranslation("common", { keyPrefix: "screen-columns" });
+ const { setModal } = useModal();
+ const dispatch = useDispatch();
+ const [campaigns, setCampaigns] = useState([]);
+ const [loading, setLoading] = useState(false);
+
+ const onClick = () => {
+ setLoading(true);
+ // Fetch screen groups.
+ // Fetch screen group campaigns.
+ // Fetch screen campaigns.
+ // Merge campaign arrays.
+ // Set campaigns to trigger useEffect.
+ getAllPages(dispatch, enhancedApi.endpoints.getV2ScreensByIdScreenGroups, {
+ id: screen.id,
+ })
+ .then((screenGroups) => {
+ const screenGroupIds = screenGroups
+ .filter(({ campaignsLength }) => campaignsLength > 0)
+ .map((group) => idFromUrl(group["@id"]));
+
+ return getAllScreenGroupCampaigns(dispatch, screenGroupIds).then(
+ (screenGroupCampaigns) => {
+ return getAllPages(
+ dispatch,
+ enhancedApi.endpoints.getV2ScreensByIdCampaigns,
+ { id: screen.id },
+ ).then((screenCampaigns) => {
+ const campaignRelations = [
+ ...screenGroupCampaigns,
+ ...screenCampaigns,
+ ];
+ const campaigns = campaignRelations.map(
+ (campaignRelation) => campaignRelation.campaign,
+ );
+ const ids = new Set();
+ const uniqueCampaigns = campaigns.filter(
+ (campaign) =>
+ !ids.has(campaign["@id"]) && ids.add(campaign["@id"]),
+ );
+
+ return getAllCampaigns(
+ dispatch,
+ uniqueCampaigns.map((campaign) => idFromUrl(campaign["@id"])),
+ ).then((allCampaigns) => {
+ setCampaigns(allCampaigns);
+ setLoading(false);
+ });
+ });
+ },
+ );
+ })
+ .catch(() => setLoading(false));
+ };
+
+ useEffect(() => {
+ if (campaigns?.length > 0) {
+ const content = (
+
+ {campaigns.map((campaign) => (
+ -
+
+ {campaign.title}
+
+ {calculateIsPublished(campaign.published) && (
+ Aktiv
+ )}
+
+ ))}
+
+ );
+
+ setModal({
+ info: true,
+ modalTitle: t("campaigns-modal-title"),
+ content,
+ });
+ }
+ }, [campaigns]);
+
+ return (
+
+ );
+}
+
+export default CampaignsButton;
diff --git a/assets/admin/components/screen/util/screen-columns.jsx b/assets/admin/components/screen/util/screen-columns.jsx
index b556b76c8..3f359cc9f 100644
--- a/assets/admin/components/screen/util/screen-columns.jsx
+++ b/assets/admin/components/screen/util/screen-columns.jsx
@@ -1,43 +1,23 @@
import { useTranslation } from "react-i18next";
-import ListButton from "../../util/list/list-button";
-import CampaignIcon from "./campaign-icon";
import SelectColumnHoc from "../../util/select-column-hoc";
import ColumnHoc from "../../util/column-hoc";
-import idFromUrl from "../../util/helpers/id-from-url";
import ScreenStatus from "../screen-status";
+import CampaignsButton from "./campaigns-button.jsx";
+import ScreenGroupsButton from "./screen-groups-button.jsx";
/**
* Columns for screens lists.
*
* @param {object} props - The props.
- * @param {Function} props.apiCall - The api to call
- * @param {string} props.infoModalRedirect - The url for redirecting in the info modal.
- * @param {string} props.infoModalTitle - The info modal title.
- * @param {string} props.dataKey The data key for mapping the data.
* @param {boolean} props.displayStatus Should status be displayed?
* @returns {object} The columns for the screens lists.
*/
-function getScreenColumns({
- apiCall,
- infoModalRedirect,
- infoModalTitle,
- dataKey,
- displayStatus,
-}) {
+function getScreenColumns({ displayStatus }) {
const { t } = useTranslation("common", { keyPrefix: "screen-list" });
const columns = [
{
- content: (screen) => (
-
- ),
+ content: (screen) => ,
key: "groups",
label: t("columns.on-groups"),
},
@@ -48,8 +28,7 @@ function getScreenColumns({
{
key: "campaign",
label: t("columns.campaign"),
- // eslint-disable-next-line react/destructuring-assignment
- content: (d) => ,
+ content: (screen) => ,
},
];
@@ -57,9 +36,7 @@ function getScreenColumns({
columns.push({
path: "status",
label: t("columns.status"),
- content: (screen) => {
- return ;
- },
+ content: (screen) => ,
});
}
diff --git a/assets/admin/components/screen/util/screen-groups-button.jsx b/assets/admin/components/screen/util/screen-groups-button.jsx
new file mode 100644
index 000000000..dbd8d01b5
--- /dev/null
+++ b/assets/admin/components/screen/util/screen-groups-button.jsx
@@ -0,0 +1,54 @@
+import { useTranslation } from "react-i18next";
+import idFromUrl from "../../util/helpers/id-from-url";
+import getAllPages from "../../util/helpers/get-all-pages.js";
+import { Button } from "react-bootstrap";
+import useModal from "../../../context/modal-context/modal-context-hook.jsx";
+import { enhancedApi } from "../../../../shared/redux/enhanced-api.ts";
+import { useDispatch } from "react-redux";
+import { Link } from "react-router-dom";
+
+function ScreenGroupsButton({ screen }) {
+ const { t } = useTranslation("common", { keyPrefix: "screen-columns" });
+ const { setModal } = useModal();
+ const dispatch = useDispatch();
+
+ const onClick = () => {
+ getAllPages(dispatch, enhancedApi.endpoints.getV2ScreensByIdScreenGroups, {
+ id: idFromUrl(screen.id),
+ }).then((groups) => {
+ const content = (
+
+ {groups.map((group) => (
+ -
+
+ {group.title}
+
+
+ ))}
+
+ );
+
+ setModal({
+ info: true,
+ modalTitle: t("screen-groups-modal-title"),
+ content,
+ });
+ });
+ };
+
+ return (
+
+ );
+}
+
+export default ScreenGroupsButton;
diff --git a/assets/admin/components/util/helpers/get-all-pages.js b/assets/admin/components/util/helpers/get-all-pages.js
new file mode 100644
index 000000000..34524ba5b
--- /dev/null
+++ b/assets/admin/components/util/helpers/get-all-pages.js
@@ -0,0 +1,27 @@
+const MAX_PAGES = 100;
+
+async function getAllPages(dispatch, endpoint, params) {
+ const results = [];
+ let page = 1;
+
+ while (page <= MAX_PAGES) {
+ const { data } = await dispatch(endpoint.initiate({ ...params, page }));
+ const members = data["hydra:member"];
+
+ if (members.length === 0) {
+ break;
+ }
+
+ results.push(...members);
+
+ const hydraView = data["hydra:view"] ?? null;
+ if (hydraView === null || !(hydraView["hydra:next"] ?? false)) {
+ break;
+ }
+ page += 1;
+ }
+
+ return results;
+}
+
+export default getAllPages;
diff --git a/assets/admin/context/modal-context/info-modal.jsx b/assets/admin/context/modal-context/info-modal.jsx
index aaa0e8c19..1ef774279 100644
--- a/assets/admin/context/modal-context/info-modal.jsx
+++ b/assets/admin/context/modal-context/info-modal.jsx
@@ -25,6 +25,7 @@ function InfoModal({
modalTitle,
dataKey = "",
redirectTo,
+ content,
}) {
const { t } = useTranslation("common");
const [fetchedData, setFetchedData] = useState([]);
@@ -55,29 +56,32 @@ function InfoModal({
showAcceptButton={false}
declineText={t("info-modal.decline-text")}
>
-
- <>
- {Array.isArray(displayData) &&
- displayData.map((displayItem) => (
-
+ <>
+ {content}
+
+ <>
+ {Array.isArray(displayData) &&
+ displayData.map((displayItem) => (
+
+ ))}
+ {fetchedData.map((item) => (
+ -
+
+ {item.title}
+
+
))}
- {fetchedData.map((item) => (
- -
-
- {item.title}
-
-
- ))}
- >
-
+ >
+
+ >
);
diff --git a/assets/admin/context/modal-context/modal-provider.jsx b/assets/admin/context/modal-context/modal-provider.jsx
index b9cf8d4c1..95c0dd3a2 100644
--- a/assets/admin/context/modal-context/modal-provider.jsx
+++ b/assets/admin/context/modal-context/modal-provider.jsx
@@ -39,6 +39,7 @@ function ModalProvider({ children }) {
dataKey={modal.dataKey}
redirectTo={modal.redirectTo}
unSetModal={unSetModal}
+ content={modal.content}
/>
)}
diff --git a/assets/admin/translations/da/common.json b/assets/admin/translations/da/common.json
index 5ec02fbfd..2ae6cba56 100644
--- a/assets/admin/translations/da/common.json
+++ b/assets/admin/translations/da/common.json
@@ -86,9 +86,11 @@
"ok": "Aktiv"
},
"screen-list": {
+ "overridden-by-campaign": "Ja",
+ "not-overridden-by-campaign": "",
"columns": {
- "campaign": "Kampagne",
- "on-groups": "Tilknyttede grupper",
+ "campaign": "Kampagner",
+ "on-groups": "Grupper",
"location": "Lokation",
"status": "Status"
},
@@ -518,7 +520,8 @@
"publishing-from": "Udgivelse fra",
"publishing-to": "Udgivelse til",
"status": "Status",
- "number-of-slides": "Slides tilknyttede"
+ "number-of-slides": "Slides tilknyttede",
+ "playlist-slide-modal-title": "Slides"
},
"shared-playlists-columns": {
"name": "Navn",
@@ -1141,10 +1144,6 @@
"saving-activation-code": "Opretter aktiveringskode"
}
},
- "campaign-icon": {
- "overridden-by-campaign": "Ja",
- "not-overridden-by-campaign": ""
- },
"published-state": {
"active": "Aktiv",
"future": "Fremtidig",
@@ -1246,5 +1245,10 @@
"insert-hard-break": "Ny linje",
"redo": "Gentag",
"undo": "Fortryd"
+ },
+ "screen-columns": {
+ "screen-groups-modal-title": "Skærmgrupper",
+ "campaigns-modal-title": "Kampagner",
+ "active": "Aktiv"
}
}
diff --git a/assets/client/service/release-service.js b/assets/client/service/release-service.js
index ba2f7a175..0cc41a588 100644
--- a/assets/client/service/release-service.js
+++ b/assets/client/service/release-service.js
@@ -1,4 +1,3 @@
-import ReleaseLoader from "../util/release-loader";
import ClientConfigLoader from "../util/client-config-loader.js";
import defaults from "../util/defaults";
import idFromPath from "../util/id-from-path";
@@ -6,6 +5,7 @@ import appStorage from "../util/app-storage";
import logger from "../logger/logger";
import statusService from "./status-service";
import constants from "../util/constants";
+import ReleaseLoader from "../../shared/release-loader.js";
class ReleaseService {
releaseCheckInterval = null;
diff --git a/assets/client/util/release-loader.js b/assets/client/util/release-loader.js
deleted file mode 100644
index e6ec48bbc..000000000
--- a/assets/client/util/release-loader.js
+++ /dev/null
@@ -1,19 +0,0 @@
-/**
- * Release loader.
- */
-export default class ReleaseLoader {
- static async loadRelease() {
- const nowTimestamp = new Date().getTime();
- return fetch(`/release.json?ts=${nowTimestamp}`)
- .then((response) => response.json())
- .catch((err) => {
- /* eslint-disable-next-line no-console */
- console.warn("Could not find release.json. Returning defaults.", err);
-
- return {
- releaseTimestamp: null,
- releaseVersion: null,
- };
- });
- }
-}
diff --git a/assets/shared/redux/generated-api.ts b/assets/shared/redux/generated-api.ts
index 4e3282eab..63959ac3e 100644
--- a/assets/shared/redux/generated-api.ts
+++ b/assets/shared/redux/generated-api.ts
@@ -1950,6 +1950,7 @@ export type PlaylistJsonldCampaignsScreenGroupsRead = {
campaignScreenGroups?: CollectionJsonldCampaignsScreenGroupsRead | null;
tenants?: CollectionJsonldCampaignsScreenGroupsRead | null;
isCampaign?: boolean;
+ slidesLength?: number | null;
published?: string[];
relationsChecksum?: object;
};
@@ -1971,6 +1972,7 @@ export type PlaylistJsonldCampaignsScreenGroupsReadRead = {
campaignScreenGroups?: CollectionJsonldCampaignsScreenGroupsReadRead | null;
tenants?: CollectionJsonldCampaignsScreenGroupsReadRead | null;
isCampaign?: boolean;
+ slidesLength?: number | null;
published?: string[];
relationsChecksum?: object;
};
@@ -1979,6 +1981,8 @@ export type ScreenGroupJsonldCampaignsScreenGroupsRead = {
description?: string;
campaigns?: string;
screens?: string;
+ screensLength?: number | null;
+ campaignsLength?: number | null;
relationsChecksum?: object;
};
export type ScreenGroupJsonldCampaignsScreenGroupsReadRead = {
@@ -1995,6 +1999,8 @@ export type ScreenGroupJsonldCampaignsScreenGroupsReadRead = {
description?: string;
campaigns?: string;
screens?: string;
+ screensLength?: number | null;
+ campaignsLength?: number | null;
relationsChecksum?: object;
};
export type ScreenGroupCampaignJsonldCampaignsScreenGroupsRead = {
@@ -2030,6 +2036,7 @@ export type PlaylistJsonldCampaignsScreensRead = {
campaignScreenGroups?: CollectionJsonldCampaignsScreensRead | null;
tenants?: CollectionJsonldCampaignsScreensRead | null;
isCampaign?: boolean;
+ slidesLength?: number | null;
published?: string[];
relationsChecksum?: object;
};
@@ -2051,6 +2058,7 @@ export type PlaylistJsonldCampaignsScreensReadRead = {
campaignScreenGroups?: CollectionJsonldCampaignsScreensReadRead | null;
tenants?: CollectionJsonldCampaignsScreensReadRead | null;
isCampaign?: boolean;
+ slidesLength?: number | null;
published?: string[];
relationsChecksum?: object;
};
@@ -2068,6 +2076,9 @@ export type ScreenJsonldCampaignsScreensRead = {
screenUser?: string | null;
enableColorSchemeChange?: boolean | null;
status?: string[] | null;
+ activeCampaignsLength?: number | null;
+ campaignsLength?: number | null;
+ inScreenGroupsLength?: number | null;
relationsChecksum?: object;
};
export type ScreenJsonldCampaignsScreensReadRead = {
@@ -2093,6 +2104,9 @@ export type ScreenJsonldCampaignsScreensReadRead = {
screenUser?: string | null;
enableColorSchemeChange?: boolean | null;
status?: string[] | null;
+ activeCampaignsLength?: number | null;
+ campaignsLength?: number | null;
+ inScreenGroupsLength?: number | null;
relationsChecksum?: object;
};
export type ScreenCampaignJsonldCampaignsScreensRead = {
@@ -2116,6 +2130,7 @@ export type PlaylistPlaylistJsonld = {
campaignScreenGroups?: CollectionJsonld | null;
tenants?: CollectionJsonld | null;
isCampaign?: boolean;
+ slidesLength?: number | null;
published?: string[];
modifiedBy?: string;
createdBy?: string;
@@ -2142,6 +2157,7 @@ export type PlaylistPlaylistJsonldRead = {
campaignScreenGroups?: CollectionJsonldRead | null;
tenants?: CollectionJsonldRead | null;
isCampaign?: boolean;
+ slidesLength?: number | null;
published?: string[];
modifiedBy?: string;
createdBy?: string;
@@ -2207,6 +2223,8 @@ export type ScreenGroupScreenGroupJsonld = {
description?: string;
campaigns?: string;
screens?: string;
+ screensLength?: number | null;
+ campaignsLength?: number | null;
modifiedBy?: string;
createdBy?: string;
id?: string;
@@ -2228,6 +2246,8 @@ export type ScreenGroupScreenGroupJsonldRead = {
description?: string;
campaigns?: string;
screens?: string;
+ screensLength?: number | null;
+ campaignsLength?: number | null;
modifiedBy?: string;
createdBy?: string;
id?: string;
@@ -2261,6 +2281,9 @@ export type ScreenScreenJsonld = {
screenUser?: string | null;
enableColorSchemeChange?: boolean | null;
status?: string[] | null;
+ activeCampaignsLength?: number | null;
+ campaignsLength?: number | null;
+ inScreenGroupsLength?: number | null;
modifiedBy?: string;
createdBy?: string;
id?: string;
@@ -2291,6 +2314,9 @@ export type ScreenScreenJsonldRead = {
screenUser?: string | null;
enableColorSchemeChange?: boolean | null;
status?: string[] | null;
+ activeCampaignsLength?: number | null;
+ campaignsLength?: number | null;
+ inScreenGroupsLength?: number | null;
modifiedBy?: string;
createdBy?: string;
id?: string;
@@ -2334,6 +2360,7 @@ export type PlaylistJsonldPlaylistScreenRegionRead = {
campaignScreenGroups?: CollectionJsonldPlaylistScreenRegionRead | null;
tenants?: CollectionJsonldPlaylistScreenRegionRead | null;
isCampaign?: boolean;
+ slidesLength?: number | null;
published?: string[];
relationsChecksum?: object;
};
@@ -2355,6 +2382,7 @@ export type PlaylistJsonldPlaylistScreenRegionReadRead = {
campaignScreenGroups?: CollectionJsonldPlaylistScreenRegionReadRead | null;
tenants?: CollectionJsonldPlaylistScreenRegionReadRead | null;
isCampaign?: boolean;
+ slidesLength?: number | null;
published?: string[];
relationsChecksum?: object;
};
@@ -2375,6 +2403,8 @@ export type ScreenGroupScreenGroupJsonldScreensScreenGroupsRead = {
description?: string;
campaigns?: string;
screens?: string;
+ screensLength?: number | null;
+ campaignsLength?: number | null;
relationsChecksum?: object;
};
export type ScreenGroupScreenGroupJsonldScreensScreenGroupsReadRead = {
@@ -2384,6 +2414,8 @@ export type ScreenGroupScreenGroupJsonldScreensScreenGroupsReadRead = {
description?: string;
campaigns?: string;
screens?: string;
+ screensLength?: number | null;
+ campaignsLength?: number | null;
relationsChecksum?: object;
};
export type SlideSlideJsonld = {
diff --git a/assets/shared/release-loader.js b/assets/shared/release-loader.js
new file mode 100644
index 000000000..c93298d8c
--- /dev/null
+++ b/assets/shared/release-loader.js
@@ -0,0 +1,83 @@
+const DEFAULT_FETCH_INTERVAL = 5 * 60 * 1000;
+
+const DEFAULT_RELEASE = {
+ releaseTime: null,
+ releaseTimestamp: null,
+ releaseVersion: null,
+};
+
+class ReleaseLoader {
+ #releaseData = null;
+ #latestFetchTimestamp = null;
+ #activePromise = null;
+ #fetchFn;
+ #nowFn;
+ #fetchInterval;
+
+ /**
+ * @param {object} options
+ * @param {Function} options.fetchFn - Fetch implementation. Defaults to global fetch.
+ * @param {Function} options.nowFn - Returns current time in ms. Defaults to Date.now.
+ * @param {number} options.fetchInterval - Cache lifetime in ms. Defaults to 5 minutes.
+ */
+ constructor({
+ fetchFn = (...args) => fetch(...args),
+ nowFn = () => Date.now(),
+ fetchInterval = DEFAULT_FETCH_INTERVAL,
+ } = {}) {
+ this.#fetchFn = fetchFn;
+ this.#nowFn = nowFn;
+ this.#fetchInterval = fetchInterval;
+ }
+
+ async loadRelease() {
+ if (this.#activePromise !== null) {
+ return this.#activePromise;
+ }
+
+ const nowTimestamp = this.#nowFn();
+
+ // Return early without going through activePromise so the caller always
+ // receives a real promise, not null.
+ if (
+ this.#latestFetchTimestamp !== null &&
+ this.#latestFetchTimestamp + this.#fetchInterval >= nowTimestamp
+ ) {
+ return Promise.resolve(this.#releaseData);
+ }
+
+ this.#activePromise = this.#fetchFn(`/release.json?t=${nowTimestamp}`)
+ .then((response) => response.json())
+ .then((data) => {
+ this.#latestFetchTimestamp = nowTimestamp;
+ this.#releaseData = data;
+ return this.#releaseData;
+ })
+ .catch(() => {
+ if (this.#releaseData !== null) {
+ // Advance the timestamp so the next call uses the cache instead of
+ // immediately retrying after a failed fetch.
+ this.#latestFetchTimestamp = nowTimestamp;
+ return this.#releaseData;
+ }
+
+ /* eslint-disable-next-line no-console */
+ console.warn("Could not find release.json. Returning defaults.");
+
+ return DEFAULT_RELEASE;
+ })
+ .finally(() => {
+ // Always clear activePromise via finally so concurrent callers share a
+ // single in-flight fetch. It is cleared on both success and failure.
+ this.#activePromise = null;
+ });
+
+ return this.#activePromise;
+ }
+}
+
+// Default singleton for production use.
+const releaseLoader = new ReleaseLoader();
+export default releaseLoader;
+
+export { ReleaseLoader };
diff --git a/assets/tests/admin/campaigns-button-promise-chain.spec.js b/assets/tests/admin/campaigns-button-promise-chain.spec.js
new file mode 100644
index 000000000..1ec17d343
--- /dev/null
+++ b/assets/tests/admin/campaigns-button-promise-chain.spec.js
@@ -0,0 +1,140 @@
+import { test, expect } from "@playwright/test";
+
+/**
+ * Regression tests for the CampaignsButton.onClick promise chain.
+ *
+ * The onClick handler chains nested .then() calls. Inner promises must be
+ * returned so that rejections propagate to the outer .catch() and
+ * setLoading(false) is always called. Without the returns, a rejection in
+ * any inner call (getAllScreenGroupCampaigns, second getAllPages,
+ * getAllCampaigns) leaves the button in a permanent loading state.
+ *
+ * These tests replicate the promise structure from campaigns-button.jsx
+ * with controllable mocks.
+ */
+
+/**
+ * Replicates the onClick promise chain from CampaignsButton.
+ * Inner promises are returned so rejections propagate to the outer .catch().
+ */
+function onClick({
+ getAllPagesScreenGroups,
+ getAllScreenGroupCampaigns,
+ getAllPagesScreenCampaigns,
+ getAllCampaigns,
+ setLoading,
+ setCampaigns,
+}) {
+ setLoading(true);
+
+ getAllPagesScreenGroups()
+ .then((screenGroups) => {
+ const screenGroupIds = screenGroups
+ .filter(({ campaignsLength }) => campaignsLength > 0)
+ .map((group) => group.id);
+
+ return getAllScreenGroupCampaigns(screenGroupIds).then(
+ (screenGroupCampaigns) => {
+ return getAllPagesScreenCampaigns().then((screenCampaigns) => {
+ const campaignIds = [
+ ...screenGroupCampaigns,
+ ...screenCampaigns,
+ ].map((c) => c.id);
+
+ return getAllCampaigns(campaignIds).then((allCampaigns) => {
+ setCampaigns(allCampaigns);
+ setLoading(false);
+ });
+ });
+ },
+ );
+ })
+ .catch(() => setLoading(false));
+}
+
+function createMocks({ failAt } = {}) {
+ const state = { loading: false, campaigns: [] };
+
+ return {
+ state,
+ setLoading: (v) => {
+ state.loading = v;
+ },
+ setCampaigns: (v) => {
+ state.campaigns = v;
+ },
+ getAllPagesScreenGroups:
+ failAt === "screenGroups"
+ ? () => Promise.reject(new Error("screenGroups failed"))
+ : () =>
+ Promise.resolve([
+ { id: "group1", campaignsLength: 2, "@id": "/v2/groups/group1" },
+ ]),
+ getAllScreenGroupCampaigns:
+ failAt === "screenGroupCampaigns"
+ ? () => Promise.reject(new Error("screenGroupCampaigns failed"))
+ : () =>
+ Promise.resolve([
+ { id: "campaign1", campaign: { "@id": "/v2/playlists/c1" } },
+ ]),
+ getAllPagesScreenCampaigns:
+ failAt === "screenCampaigns"
+ ? () => Promise.reject(new Error("screenCampaigns failed"))
+ : () =>
+ Promise.resolve([
+ { id: "campaign2", campaign: { "@id": "/v2/playlists/c2" } },
+ ]),
+ getAllCampaigns:
+ failAt === "allCampaigns"
+ ? () => Promise.reject(new Error("allCampaigns failed"))
+ : (ids) => Promise.resolve(ids.map((id) => ({ "@id": id, title: id }))),
+ };
+}
+
+test.describe("CampaignsButton onClick promise chain", () => {
+ test("happy path resolves and clears loading", async () => {
+ const mocks = createMocks();
+ onClick(mocks);
+
+ await new Promise((resolve) => setTimeout(resolve, 50));
+
+ expect(mocks.state.loading).toBe(false);
+ expect(mocks.state.campaigns.length).toBeGreaterThan(0);
+ });
+
+ test("rejection in getAllPagesScreenGroups clears loading", async () => {
+ const mocks = createMocks({ failAt: "screenGroups" });
+ onClick(mocks);
+
+ await new Promise((resolve) => setTimeout(resolve, 50));
+
+ expect(mocks.state.loading).toBe(false);
+ });
+
+ test("rejection in getAllScreenGroupCampaigns clears loading", async () => {
+ const mocks = createMocks({ failAt: "screenGroupCampaigns" });
+ onClick(mocks);
+
+ await new Promise((resolve) => setTimeout(resolve, 50));
+
+ expect(mocks.state.loading).toBe(false);
+ });
+
+ test("rejection in getAllPagesScreenCampaigns clears loading", async () => {
+ const mocks = createMocks({ failAt: "screenCampaigns" });
+ onClick(mocks);
+
+ await new Promise((resolve) => setTimeout(resolve, 50));
+
+ expect(mocks.state.loading).toBe(false);
+ });
+
+ test("rejection in getAllCampaigns clears loading", async () => {
+ const mocks = createMocks({ failAt: "allCampaigns" });
+ onClick(mocks);
+
+ await new Promise((resolve) => setTimeout(resolve, 50));
+
+ expect(mocks.state.loading).toBe(false);
+ });
+});
diff --git a/assets/tests/admin/get-all-pages.spec.js b/assets/tests/admin/get-all-pages.spec.js
new file mode 100644
index 000000000..25b715887
--- /dev/null
+++ b/assets/tests/admin/get-all-pages.spec.js
@@ -0,0 +1,129 @@
+import { test, expect } from "@playwright/test";
+import getAllPages from "../../admin/components/util/helpers/get-all-pages.js";
+
+function createHydraResponse(members, hasNext = false) {
+ return {
+ data: {
+ "hydra:member": members,
+ "hydra:view": hasNext ? { "hydra:next": "/next" } : null,
+ },
+ };
+}
+
+function createMockEndpoint() {
+ return { initiate: (params) => params };
+}
+
+function createMockDispatch(responses) {
+ let callIndex = 0;
+ const fn = () => {
+ const response = responses[callIndex];
+ callIndex += 1;
+ return Promise.resolve(response);
+ };
+ fn.getCallCount = () => callIndex;
+ return fn;
+}
+
+test.describe("getAllPages", () => {
+ test("It returns results from a single page", async () => {
+ const dispatch = createMockDispatch([
+ createHydraResponse([{ id: 1 }, { id: 2 }]),
+ ]);
+
+ const result = await getAllPages(dispatch, createMockEndpoint(), {});
+
+ expect(result).toEqual([{ id: 1 }, { id: 2 }]);
+ expect(dispatch.getCallCount()).toBe(1);
+ });
+
+ test("It fetches multiple pages when hydra:next is present", async () => {
+ const dispatch = createMockDispatch([
+ createHydraResponse([{ id: 1 }], true),
+ createHydraResponse([{ id: 2 }], true),
+ createHydraResponse([{ id: 3 }], false),
+ ]);
+
+ const result = await getAllPages(dispatch, createMockEndpoint(), {});
+
+ expect(result).toEqual([{ id: 1 }, { id: 2 }, { id: 3 }]);
+ expect(dispatch.getCallCount()).toBe(3);
+ });
+
+ test("It passes page number and params to endpoint", async () => {
+ const calls = [];
+ const endpoint = {
+ initiate: (params) => {
+ calls.push(params);
+ return params;
+ },
+ };
+ const dispatch = createMockDispatch([
+ createHydraResponse([{ id: 1 }], true),
+ createHydraResponse([{ id: 2 }], false),
+ ]);
+
+ await getAllPages(dispatch, endpoint, { itemsPerPage: 10 });
+
+ expect(calls).toEqual([
+ { itemsPerPage: 10, page: 1 },
+ { itemsPerPage: 10, page: 2 },
+ ]);
+ });
+
+ test("It stops when hydra:view is null", async () => {
+ const dispatch = createMockDispatch([
+ {
+ data: {
+ "hydra:member": [{ id: 1 }],
+ "hydra:view": undefined,
+ },
+ },
+ ]);
+
+ const result = await getAllPages(dispatch, createMockEndpoint(), {});
+
+ expect(result).toEqual([{ id: 1 }]);
+ expect(dispatch.getCallCount()).toBe(1);
+ });
+
+ test("It stops when a page returns empty results", async () => {
+ const dispatch = createMockDispatch([
+ createHydraResponse([{ id: 1 }], true),
+ createHydraResponse([], true),
+ ]);
+
+ const result = await getAllPages(dispatch, createMockEndpoint(), {});
+
+ expect(result).toEqual([{ id: 1 }]);
+ expect(dispatch.getCallCount()).toBe(2);
+ });
+
+ test("It respects the max pages limit", async () => {
+ const responses = Array.from({ length: 101 }, (_, i) =>
+ createHydraResponse([{ id: i }], true),
+ );
+ const dispatch = createMockDispatch(responses);
+
+ const result = await getAllPages(dispatch, createMockEndpoint(), {});
+
+ expect(result).toHaveLength(100);
+ expect(dispatch.getCallCount()).toBe(100);
+ });
+
+ test("It propagates fetch errors", async () => {
+ const dispatch = () => Promise.reject(new Error("Network error"));
+
+ await expect(
+ getAllPages(dispatch, createMockEndpoint(), {}),
+ ).rejects.toThrow("Network error");
+ });
+
+ test("It returns empty array when first page has no results", async () => {
+ const dispatch = createMockDispatch([createHydraResponse([])]);
+
+ const result = await getAllPages(dispatch, createMockEndpoint(), {});
+
+ expect(result).toEqual([]);
+ });
+});
diff --git a/assets/tests/shared/release-loader.spec.js b/assets/tests/shared/release-loader.spec.js
new file mode 100644
index 000000000..98adb7475
--- /dev/null
+++ b/assets/tests/shared/release-loader.spec.js
@@ -0,0 +1,125 @@
+import { test, expect } from "@playwright/test";
+import { ReleaseLoader } from "../../shared/release-loader.js";
+
+const RELEASE_DATA = {
+ releaseTime: "2024-01-01T00:00:00Z",
+ releaseTimestamp: 1704067200,
+ releaseVersion: "1.0.0",
+};
+
+function createMockFetch(response = RELEASE_DATA) {
+ return () =>
+ Promise.resolve({
+ json: () => Promise.resolve(response),
+ });
+}
+
+function createFailingFetch() {
+ return () => Promise.reject(new Error("Network error"));
+}
+
+test.describe("ReleaseLoader", () => {
+ test("It fetches and returns release data", async () => {
+ const loader = new ReleaseLoader({ fetchFn: createMockFetch() });
+ const result = await loader.loadRelease();
+
+ expect(result).toEqual(RELEASE_DATA);
+ });
+
+ test("It returns cached data within the fetch interval", async () => {
+ let fetchCount = 0;
+ const fetchFn = () => {
+ fetchCount += 1;
+ return createMockFetch()();
+ };
+
+ const loader = new ReleaseLoader({ fetchFn, nowFn: () => 1000 });
+
+ await loader.loadRelease();
+ await loader.loadRelease();
+
+ expect(fetchCount).toBe(1);
+ });
+
+ test("It fetches again after the interval has passed", async () => {
+ let fetchCount = 0;
+ const fetchFn = () => {
+ fetchCount += 1;
+ return createMockFetch()();
+ };
+
+ let now = 0;
+ const loader = new ReleaseLoader({
+ fetchFn,
+ nowFn: () => now,
+ fetchInterval: 1000,
+ });
+
+ await loader.loadRelease();
+ now = 1001;
+ await loader.loadRelease();
+
+ expect(fetchCount).toBe(2);
+ });
+
+ test("It returns defaults when fetch fails and no cached data exists", async () => {
+ const loader = new ReleaseLoader({ fetchFn: createFailingFetch() });
+ const result = await loader.loadRelease();
+
+ expect(result).toEqual({
+ releaseTime: null,
+ releaseTimestamp: null,
+ releaseVersion: null,
+ });
+ });
+
+ test("It returns cached data when fetch fails after a successful fetch", async () => {
+ let shouldFail = false;
+ const fetchFn = () => {
+ if (shouldFail) {
+ return createFailingFetch()();
+ }
+ return createMockFetch()();
+ };
+
+ let now = 0;
+ const loader = new ReleaseLoader({
+ fetchFn,
+ nowFn: () => now,
+ fetchInterval: 1000,
+ });
+
+ await loader.loadRelease();
+
+ shouldFail = true;
+ now = 1001;
+ const result = await loader.loadRelease();
+
+ expect(result).toEqual(RELEASE_DATA);
+ });
+
+ test("It deduplicates concurrent calls", async () => {
+ let fetchCount = 0;
+ let resolveResponse;
+
+ const fetchFn = () => {
+ fetchCount += 1;
+ return new Promise((resolve) => {
+ resolveResponse = resolve;
+ });
+ };
+
+ const loader = new ReleaseLoader({ fetchFn });
+
+ const promise1 = loader.loadRelease();
+ const promise2 = loader.loadRelease();
+
+ resolveResponse({ json: () => Promise.resolve(RELEASE_DATA) });
+
+ const [result1, result2] = await Promise.all([promise1, promise2]);
+
+ expect(fetchCount).toBe(1);
+ expect(result1).toEqual(RELEASE_DATA);
+ expect(result2).toEqual(RELEASE_DATA);
+ });
+});
diff --git a/fixtures/playlist.yaml b/fixtures/playlist.yaml
index 93ea8ffa5..be710dfd3 100644
--- a/fixtures/playlist.yaml
+++ b/fixtures/playlist.yaml
@@ -15,7 +15,6 @@ App\Entity\Tenant\Playlist:
isCampaign: true
playlistSlides:
- "@playlist_slide_abc_1"
- - "@playlist_slide_abc_2"
- "@playlist_slide_abc_3"
- "@playlist_slide_abc_4"
- "@playlist_slide_abc_5"
diff --git a/fixtures/playlist_slide.yaml b/fixtures/playlist_slide.yaml
index 644455dfc..cdb92c36f 100644
--- a/fixtures/playlist_slide.yaml
+++ b/fixtures/playlist_slide.yaml
@@ -15,7 +15,7 @@ App\Entity\Tenant\PlaylistSlide:
slide: "@slide_abc_1"
tenant: "@tenant_abc"
playlist_slide_abc_{3..10} (extends playlist_slide):
- playlist: "@playlist_abc_3"
+ playlist: "@playlist_abc_1"
slide: "@slide_abc_"
tenant: "@tenant_abc"
playlist_slide_abc_{11..40} (extends playlist_slide):
diff --git a/infrastructure/build-n-push.sh b/infrastructure/build-n-push.sh
index feae48ab7..dc133e842 100755
--- a/infrastructure/build-n-push.sh
+++ b/infrastructure/build-n-push.sh
@@ -12,6 +12,8 @@ docker buildx build \
--no-cache \
--pull \
--build-arg APP_VERSION=${APP_VERSION} \
+ --build-arg APP_RELEASE_TIME="$(date)" \
+ --build-arg APP_RELEASE_TIMESTAMP="$(date +%s)" \
--tag=ghcr.io/itk-dev/os2display-api-service:${APP_VERSION} \
--file="display-api-service/Dockerfile" ../
diff --git a/infrastructure/display-api-service/Dockerfile b/infrastructure/display-api-service/Dockerfile
index 1f0d6db63..3d00a6beb 100644
--- a/infrastructure/display-api-service/Dockerfile
+++ b/infrastructure/display-api-service/Dockerfile
@@ -29,6 +29,8 @@ FROM --platform=$BUILDPLATFORM itkdev/php8.4-fpm:latest AS api_app_builder
LABEL maintainer="ITK Dev "
ARG APP_VERSION="develop"
+ARG APP_RELEASE_TIMESTAMP=0
+ARG APP_RELEASE_TIME=""
WORKDIR /app
@@ -40,6 +42,9 @@ RUN APP_ENV=prod composer install --no-dev --optimize-autoloader --classmap-auth
COPY --chown=deploy:deploy --from=assets_builder /app/public/build /app/public/build
+# Create release.json file in public folder
+RUN echo "{\"releaseTimestamp\": $APP_RELEASE_TIMESTAMP, \"releaseTime\": \"$APP_RELEASE_TIME\", \"releaseVersion\": \"$APP_VERSION\"}" > public/release.json
+
# Remove files we do not need to the final image
RUN rm -rf package* vite.config.js
diff --git a/public/api-spec-v2.json b/public/api-spec-v2.json
index 0a05b36eb..df8506599 100644
--- a/public/api-spec-v2.json
+++ b/public/api-spec-v2.json
@@ -9175,6 +9175,12 @@
"isCampaign": {
"type": "boolean"
},
+ "slidesLength": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
"published": {
"default": {
"from": "",
@@ -9268,6 +9274,12 @@
"isCampaign": {
"type": "boolean"
},
+ "slidesLength": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
"published": {
"default": {
"from": "",
@@ -9343,6 +9355,12 @@
"isCampaign": {
"type": "boolean"
},
+ "slidesLength": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
"published": {
"default": {
"from": "",
@@ -9418,6 +9436,12 @@
"isCampaign": {
"type": "boolean"
},
+ "slidesLength": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
"published": {
"default": {
"from": "",
@@ -9503,6 +9527,12 @@
"isCampaign": {
"type": "boolean"
},
+ "slidesLength": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
"published": {
"default": {
"from": "",
@@ -9578,6 +9608,12 @@
"isCampaign": {
"type": "boolean"
},
+ "slidesLength": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
"published": {
"default": {
"from": "",
@@ -9653,6 +9689,12 @@
"isCampaign": {
"type": "boolean"
},
+ "slidesLength": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
"published": {
"default": {
"from": "",
@@ -9728,6 +9770,12 @@
"isCampaign": {
"type": "boolean"
},
+ "slidesLength": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
"published": {
"default": {
"from": "",
@@ -9856,6 +9904,12 @@
"isCampaign": {
"type": "boolean"
},
+ "slidesLength": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
"published": {
"default": {
"from": "",
@@ -10068,6 +10122,12 @@
"isCampaign": {
"type": "boolean"
},
+ "slidesLength": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
"published": {
"default": {
"from": "",
@@ -10196,6 +10256,12 @@
"isCampaign": {
"type": "boolean"
},
+ "slidesLength": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
"published": {
"default": {
"from": "",
@@ -10306,6 +10372,12 @@
"isCampaign": {
"type": "boolean"
},
+ "slidesLength": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
"published": {
"default": {
"from": "",
@@ -10416,6 +10488,12 @@
"isCampaign": {
"type": "boolean"
},
+ "slidesLength": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
"published": {
"default": {
"from": "",
@@ -10571,6 +10649,12 @@
"isCampaign": {
"type": "boolean"
},
+ "slidesLength": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
"published": {
"default": {
"from": "",
@@ -10681,6 +10765,12 @@
"isCampaign": {
"type": "boolean"
},
+ "slidesLength": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
"published": {
"default": {
"from": "",
@@ -10791,6 +10881,12 @@
"isCampaign": {
"type": "boolean"
},
+ "slidesLength": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
"published": {
"default": {
"from": "",
@@ -11471,6 +11567,24 @@
"type": "string"
}
},
+ "activeCampaignsLength": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
+ "campaignsLength": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
+ "inScreenGroupsLength": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
"modifiedBy": {
"type": "string"
},
@@ -11555,6 +11669,24 @@
"type": "string"
}
},
+ "activeCampaignsLength": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
+ "campaignsLength": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
+ "inScreenGroupsLength": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
"relationsChecksum": {
"type": "object"
}
@@ -11631,6 +11763,24 @@
"type": "string"
}
},
+ "activeCampaignsLength": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
+ "campaignsLength": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
+ "inScreenGroupsLength": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
"modifiedBy": {
"type": "string"
},
@@ -11750,6 +11900,24 @@
"type": "string"
}
},
+ "activeCampaignsLength": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
+ "campaignsLength": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
+ "inScreenGroupsLength": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
"modifiedBy": {
"type": "string"
},
@@ -11973,6 +12141,24 @@
"type": "string"
}
},
+ "activeCampaignsLength": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
+ "campaignsLength": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
+ "inScreenGroupsLength": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
"modifiedBy": {
"type": "string"
},
@@ -12092,6 +12278,24 @@
"type": "string"
}
},
+ "activeCampaignsLength": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
+ "campaignsLength": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
+ "inScreenGroupsLength": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
"relationsChecksum": {
"type": "object"
}
@@ -12541,6 +12745,18 @@
"screens": {
"type": "string"
},
+ "screensLength": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
+ "campaignsLength": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
"modifiedBy": {
"type": "string"
},
@@ -12581,6 +12797,18 @@
"screens": {
"type": "string"
},
+ "screensLength": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
+ "campaignsLength": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
"relationsChecksum": {
"type": "object"
}
@@ -12603,6 +12831,18 @@
"screens": {
"type": "string"
},
+ "screensLength": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
+ "campaignsLength": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
"relationsChecksum": {
"type": "object"
}
@@ -12635,6 +12875,18 @@
"screens": {
"type": "string"
},
+ "screensLength": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
+ "campaignsLength": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
"relationsChecksum": {
"type": "object"
}
@@ -12657,6 +12909,18 @@
"screens": {
"type": "string"
},
+ "screensLength": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
+ "campaignsLength": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
"modifiedBy": {
"type": "string"
},
@@ -12707,6 +12971,18 @@
"screens": {
"type": "string"
},
+ "screensLength": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
+ "campaignsLength": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
"relationsChecksum": {
"type": "object"
}
@@ -12764,6 +13040,18 @@
"screens": {
"type": "string"
},
+ "screensLength": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
+ "campaignsLength": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
"modifiedBy": {
"type": "string"
},
@@ -12830,6 +13118,18 @@
"screens": {
"type": "string"
},
+ "screensLength": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
+ "campaignsLength": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
"relationsChecksum": {
"type": "object"
}
@@ -12913,6 +13213,18 @@
"screens": {
"type": "string"
},
+ "screensLength": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
+ "campaignsLength": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
"modifiedBy": {
"type": "string"
},
@@ -12988,6 +13300,18 @@
"screens": {
"type": "string"
},
+ "screensLength": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
+ "campaignsLength": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
"relationsChecksum": {
"type": "object"
}
@@ -13045,6 +13369,18 @@
"screens": {
"type": "string"
},
+ "screensLength": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
+ "campaignsLength": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
"relationsChecksum": {
"type": "object"
}
@@ -13093,6 +13429,18 @@
"screens": {
"type": "string"
},
+ "screensLength": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
+ "campaignsLength": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
"relationsChecksum": {
"type": "object"
}
diff --git a/public/api-spec-v2.yaml b/public/api-spec-v2.yaml
index 687d694aa..9c1b21eb1 100644
--- a/public/api-spec-v2.yaml
+++ b/public/api-spec-v2.yaml
@@ -6476,6 +6476,10 @@ components:
type: 'null'
isCampaign:
type: boolean
+ slidesLength:
+ type:
+ - integer
+ - 'null'
published:
default:
from: ''
@@ -6538,6 +6542,10 @@ components:
type: 'null'
isCampaign:
type: boolean
+ slidesLength:
+ type:
+ - integer
+ - 'null'
published:
default:
from: ''
@@ -6587,6 +6595,10 @@ components:
type: 'null'
isCampaign:
type: boolean
+ slidesLength:
+ type:
+ - integer
+ - 'null'
published:
default:
from: ''
@@ -6636,6 +6648,10 @@ components:
type: 'null'
isCampaign:
type: boolean
+ slidesLength:
+ type:
+ - integer
+ - 'null'
published:
default:
from: ''
@@ -6692,6 +6708,10 @@ components:
type: 'null'
isCampaign:
type: boolean
+ slidesLength:
+ type:
+ - integer
+ - 'null'
published:
default:
from: ''
@@ -6741,6 +6761,10 @@ components:
type: 'null'
isCampaign:
type: boolean
+ slidesLength:
+ type:
+ - integer
+ - 'null'
published:
default:
from: ''
@@ -6790,6 +6814,10 @@ components:
type: 'null'
isCampaign:
type: boolean
+ slidesLength:
+ type:
+ - integer
+ - 'null'
published:
default:
from: ''
@@ -6839,6 +6867,10 @@ components:
type: 'null'
isCampaign:
type: boolean
+ slidesLength:
+ type:
+ - integer
+ - 'null'
published:
default:
from: ''
@@ -6924,6 +6956,10 @@ components:
type: 'null'
isCampaign:
type: boolean
+ slidesLength:
+ type:
+ - integer
+ - 'null'
published:
default:
from: ''
@@ -7067,6 +7103,10 @@ components:
type: 'null'
isCampaign:
type: boolean
+ slidesLength:
+ type:
+ - integer
+ - 'null'
published:
default:
from: ''
@@ -7152,6 +7192,10 @@ components:
type: 'null'
isCampaign:
type: boolean
+ slidesLength:
+ type:
+ - integer
+ - 'null'
published:
default:
from: ''
@@ -7224,6 +7268,10 @@ components:
type: 'null'
isCampaign:
type: boolean
+ slidesLength:
+ type:
+ - integer
+ - 'null'
published:
default:
from: ''
@@ -7296,6 +7344,10 @@ components:
type: 'null'
isCampaign:
type: boolean
+ slidesLength:
+ type:
+ - integer
+ - 'null'
published:
default:
from: ''
@@ -7398,6 +7450,10 @@ components:
type: 'null'
isCampaign:
type: boolean
+ slidesLength:
+ type:
+ - integer
+ - 'null'
published:
default:
from: ''
@@ -7470,6 +7526,10 @@ components:
type: 'null'
isCampaign:
type: boolean
+ slidesLength:
+ type:
+ - integer
+ - 'null'
published:
default:
from: ''
@@ -7542,6 +7602,10 @@ components:
type: 'null'
isCampaign:
type: boolean
+ slidesLength:
+ type:
+ - integer
+ - 'null'
published:
default:
from: ''
@@ -8017,6 +8081,18 @@ components:
- 'null'
items:
type: string
+ activeCampaignsLength:
+ type:
+ - integer
+ - 'null'
+ campaignsLength:
+ type:
+ - integer
+ - 'null'
+ inScreenGroupsLength:
+ type:
+ - integer
+ - 'null'
modifiedBy:
type: string
createdBy:
@@ -8075,6 +8151,18 @@ components:
- 'null'
items:
type: string
+ activeCampaignsLength:
+ type:
+ - integer
+ - 'null'
+ campaignsLength:
+ type:
+ - integer
+ - 'null'
+ inScreenGroupsLength:
+ type:
+ - integer
+ - 'null'
relationsChecksum:
type: object
Screen-screen-campaigns.read:
@@ -8127,6 +8215,18 @@ components:
- 'null'
items:
type: string
+ activeCampaignsLength:
+ type:
+ - integer
+ - 'null'
+ campaignsLength:
+ type:
+ - integer
+ - 'null'
+ inScreenGroupsLength:
+ type:
+ - integer
+ - 'null'
modifiedBy:
type: string
createdBy:
@@ -8208,6 +8308,18 @@ components:
- 'null'
items:
type: string
+ activeCampaignsLength:
+ type:
+ - integer
+ - 'null'
+ campaignsLength:
+ type:
+ - integer
+ - 'null'
+ inScreenGroupsLength:
+ type:
+ - integer
+ - 'null'
modifiedBy:
type: string
createdBy:
@@ -8359,6 +8471,18 @@ components:
- 'null'
items:
type: string
+ activeCampaignsLength:
+ type:
+ - integer
+ - 'null'
+ campaignsLength:
+ type:
+ - integer
+ - 'null'
+ inScreenGroupsLength:
+ type:
+ - integer
+ - 'null'
modifiedBy:
type: string
createdBy:
@@ -8440,6 +8564,18 @@ components:
- 'null'
items:
type: string
+ activeCampaignsLength:
+ type:
+ - integer
+ - 'null'
+ campaignsLength:
+ type:
+ - integer
+ - 'null'
+ inScreenGroupsLength:
+ type:
+ - integer
+ - 'null'
relationsChecksum:
type: object
Screen.jsonld-screen-campaigns.read:
@@ -8755,6 +8891,14 @@ components:
type: string
screens:
type: string
+ screensLength:
+ type:
+ - integer
+ - 'null'
+ campaignsLength:
+ type:
+ - integer
+ - 'null'
modifiedBy:
type: string
createdBy:
@@ -8783,6 +8927,14 @@ components:
type: string
screens:
type: string
+ screensLength:
+ type:
+ - integer
+ - 'null'
+ campaignsLength:
+ type:
+ - integer
+ - 'null'
relationsChecksum:
type: object
ScreenGroup-screen-groups.campaigns.read:
@@ -8798,6 +8950,14 @@ components:
type: string
screens:
type: string
+ screensLength:
+ type:
+ - integer
+ - 'null'
+ campaignsLength:
+ type:
+ - integer
+ - 'null'
relationsChecksum:
type: object
ScreenGroup-screen-groups.screens.read:
@@ -8820,6 +8980,14 @@ components:
type: string
screens:
type: string
+ screensLength:
+ type:
+ - integer
+ - 'null'
+ campaignsLength:
+ type:
+ - integer
+ - 'null'
relationsChecksum:
type: object
ScreenGroup.ScreenGroup:
@@ -8835,6 +9003,14 @@ components:
type: string
screens:
type: string
+ screensLength:
+ type:
+ - integer
+ - 'null'
+ campaignsLength:
+ type:
+ - integer
+ - 'null'
modifiedBy:
type: string
createdBy:
@@ -8870,6 +9046,14 @@ components:
type: string
screens:
type: string
+ screensLength:
+ type:
+ - integer
+ - 'null'
+ campaignsLength:
+ type:
+ - integer
+ - 'null'
relationsChecksum:
type: object
ScreenGroup.ScreenGroup.jsonld:
@@ -8908,6 +9092,14 @@ components:
type: string
screens:
type: string
+ screensLength:
+ type:
+ - integer
+ - 'null'
+ campaignsLength:
+ type:
+ - integer
+ - 'null'
modifiedBy:
type: string
createdBy:
@@ -8955,6 +9147,14 @@ components:
type: string
screens:
type: string
+ screensLength:
+ type:
+ - integer
+ - 'null'
+ campaignsLength:
+ type:
+ - integer
+ - 'null'
relationsChecksum:
type: object
ScreenGroup.ScreenGroupInput:
@@ -9011,6 +9211,14 @@ components:
type: string
screens:
type: string
+ screensLength:
+ type:
+ - integer
+ - 'null'
+ campaignsLength:
+ type:
+ - integer
+ - 'null'
modifiedBy:
type: string
createdBy:
@@ -9062,6 +9270,14 @@ components:
type: string
screens:
type: string
+ screensLength:
+ type:
+ - integer
+ - 'null'
+ campaignsLength:
+ type:
+ - integer
+ - 'null'
relationsChecksum:
type: object
ScreenGroup.jsonld-screen-groups.campaigns.read:
@@ -9100,6 +9316,14 @@ components:
type: string
screens:
type: string
+ screensLength:
+ type:
+ - integer
+ - 'null'
+ campaignsLength:
+ type:
+ - integer
+ - 'null'
relationsChecksum:
type: object
ScreenGroup.jsonld-screen-groups.screens.read:
@@ -9134,6 +9358,14 @@ components:
type: string
screens:
type: string
+ screensLength:
+ type:
+ - integer
+ - 'null'
+ campaignsLength:
+ type:
+ - integer
+ - 'null'
relationsChecksum:
type: object
ScreenGroupCampaign:
diff --git a/src/Dto/Playlist.php b/src/Dto/Playlist.php
index 7c63c49c7..01764b6f6 100644
--- a/src/Dto/Playlist.php
+++ b/src/Dto/Playlist.php
@@ -43,6 +43,9 @@ class Playlist
#[Groups(['playlist-screen-region:read', 'screen-campaigns:read', 'campaigns/screens:read', 'slides/playlists:read', 'screen-groups/campaigns:read', 'campaigns/screen-groups:read'])]
public bool $isCampaign;
+ #[Groups(['playlist-screen-region:read', 'screen-campaigns:read', 'campaigns/screens:read', 'slides/playlists:read', 'screen-groups/campaigns:read', 'campaigns/screen-groups:read'])]
+ public ?int $slidesLength = null;
+
#[Groups(['playlist-screen-region:read', 'screen-campaigns:read', 'campaigns/screens:read', 'slides/playlists:read', 'screen-groups/campaigns:read', 'campaigns/screen-groups:read'])]
public array $published = [
'from' => '',
diff --git a/src/Dto/Screen.php b/src/Dto/Screen.php
index 825a013af..97e089dea 100644
--- a/src/Dto/Screen.php
+++ b/src/Dto/Screen.php
@@ -55,4 +55,13 @@ class Screen
#[Groups(['campaigns/screens:read', 'screen-groups/screens:read'])]
public ?array $status = null;
+
+ #[Groups(['campaigns/screens:read', 'screen-groups/screens:read'])]
+ public ?int $activeCampaignsLength = null;
+
+ #[Groups(['campaigns/screens:read', 'screen-groups/screens:read'])]
+ public ?int $campaignsLength = null;
+
+ #[Groups(['campaigns/screens:read', 'screen-groups/screens:read'])]
+ public ?int $inScreenGroupsLength = null;
}
diff --git a/src/Dto/ScreenGroup.php b/src/Dto/ScreenGroup.php
index b301db41f..2d2410e8d 100644
--- a/src/Dto/ScreenGroup.php
+++ b/src/Dto/ScreenGroup.php
@@ -28,4 +28,10 @@ class ScreenGroup
#[Groups(['screens/screen-groups:read', 'screen-groups/campaigns:read', 'campaigns/screen-groups:read'])]
public string $screens = '';
+
+ #[Groups(['screens/screen-groups:read', 'screen-groups/campaigns:read', 'campaigns/screen-groups:read'])]
+ public ?int $screensLength = null;
+
+ #[Groups(['screens/screen-groups:read', 'screen-groups/campaigns:read', 'campaigns/screen-groups:read'])]
+ public ?int $campaignsLength = null;
}
diff --git a/src/Entity/ScreenLayout.php b/src/Entity/ScreenLayout.php
index 89b66e5af..ca235edf7 100644
--- a/src/Entity/ScreenLayout.php
+++ b/src/Entity/ScreenLayout.php
@@ -33,13 +33,13 @@ class ScreenLayout extends AbstractBaseEntity implements MultiTenantInterface, R
/**
* @var Collection
*/
- #[ORM\OneToMany(targetEntity: Screen::class, mappedBy: 'screenLayout')]
+ #[ORM\OneToMany(mappedBy: 'screenLayout', targetEntity: Screen::class, fetch: 'EXTRA_LAZY')]
private Collection $screens;
/**
* @var Collection
*/
- #[ORM\OneToMany(targetEntity: ScreenLayoutRegions::class, mappedBy: 'screenLayout')]
+ #[ORM\OneToMany(targetEntity: ScreenLayoutRegions::class, fetch: 'EXTRA_LAZY', mappedBy: 'screenLayout')]
private Collection $regions;
public function __construct()
diff --git a/src/Entity/ScreenLayoutRegions.php b/src/Entity/ScreenLayoutRegions.php
index 0c5466511..a95711a2b 100644
--- a/src/Entity/ScreenLayoutRegions.php
+++ b/src/Entity/ScreenLayoutRegions.php
@@ -41,7 +41,7 @@ class ScreenLayoutRegions extends AbstractBaseEntity implements MultiTenantInter
/**
* @var Collection
*/
- #[ORM\OneToMany(targetEntity: PlaylistScreenRegion::class, cascade: ['persist', 'remove'], mappedBy: 'region', orphanRemoval: true)]
+ #[ORM\OneToMany(targetEntity: PlaylistScreenRegion::class, fetch: 'EXTRA_LAZY', cascade: ['persist', 'remove'], mappedBy: 'region', orphanRemoval: true)]
private Collection $playlistScreenRegions;
public function __construct()
diff --git a/src/Entity/Template.php b/src/Entity/Template.php
index cae53c7e4..9eea0f8dd 100644
--- a/src/Entity/Template.php
+++ b/src/Entity/Template.php
@@ -30,7 +30,7 @@ class Template extends AbstractBaseEntity implements MultiTenantInterface, Relat
/**
* @var Collection
*/
- #[ORM\OneToMany(targetEntity: Slide::class, mappedBy: 'template')]
+ #[ORM\OneToMany(mappedBy: 'template', targetEntity: Slide::class, fetch: 'EXTRA_LAZY')]
private Collection $slides;
public function __construct()
diff --git a/src/Entity/Tenant.php b/src/Entity/Tenant.php
index 91008f673..4a486763a 100644
--- a/src/Entity/Tenant.php
+++ b/src/Entity/Tenant.php
@@ -22,7 +22,7 @@ class Tenant extends AbstractBaseEntity implements \JsonSerializable
/**
* @var Collection
*/
- #[ORM\OneToMany(targetEntity: UserRoleTenant::class, mappedBy: 'tenant', orphanRemoval: true)]
+ #[ORM\OneToMany(mappedBy: 'tenant', targetEntity: UserRoleTenant::class, fetch: 'EXTRA_LAZY', orphanRemoval: true)]
private Collection $userRoleTenants;
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::STRING, nullable: true)]
diff --git a/src/Entity/Tenant/FeedSource.php b/src/Entity/Tenant/FeedSource.php
index b580bfe7d..d730b13a4 100644
--- a/src/Entity/Tenant/FeedSource.php
+++ b/src/Entity/Tenant/FeedSource.php
@@ -29,7 +29,7 @@ class FeedSource extends AbstractTenantScopedEntity implements RelationsChecksum
/**
* @var Collection
*/
- #[ORM\OneToMany(targetEntity: Feed::class, mappedBy: 'feedSource', orphanRemoval: true)]
+ #[ORM\OneToMany(mappedBy: 'feedSource', targetEntity: Feed::class, fetch: 'EXTRA_LAZY', orphanRemoval: true)]
private Collection $feeds;
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::STRING, length: 255)]
diff --git a/src/Entity/Tenant/Media.php b/src/Entity/Tenant/Media.php
index 442ccade6..7ba0883f5 100644
--- a/src/Entity/Tenant/Media.php
+++ b/src/Entity/Tenant/Media.php
@@ -52,7 +52,7 @@ class Media extends AbstractTenantScopedEntity implements RelationsChecksumInter
/**
* @var Collection
*/
- #[ORM\ManyToMany(targetEntity: Slide::class, mappedBy: 'media')]
+ #[ORM\ManyToMany(targetEntity: Slide::class, mappedBy: 'media', fetch: 'EXTRA_LAZY')]
private Collection $slides;
public function __construct()
diff --git a/src/Entity/Tenant/Playlist.php b/src/Entity/Tenant/Playlist.php
index d565ff84a..4504cbc85 100644
--- a/src/Entity/Tenant/Playlist.php
+++ b/src/Entity/Tenant/Playlist.php
@@ -31,32 +31,32 @@ class Playlist extends AbstractTenantScopedEntity implements MultiTenantInterfac
/**
* @var Collection
*/
- #[ORM\OneToMany(mappedBy: 'campaign', targetEntity: ScreenCampaign::class, orphanRemoval: true)]
+ #[ORM\OneToMany(mappedBy: 'campaign', targetEntity: ScreenCampaign::class, fetch: 'EXTRA_LAZY', orphanRemoval: true)]
private Collection $screenCampaigns;
/**
* @var Collection
*/
- #[ORM\OneToMany(mappedBy: 'campaign', targetEntity: ScreenGroupCampaign::class, orphanRemoval: true)]
+ #[ORM\OneToMany(mappedBy: 'campaign', targetEntity: ScreenGroupCampaign::class, fetch: 'EXTRA_LAZY', orphanRemoval: true)]
private Collection $screenGroupCampaigns;
/**
* @var Collection
*/
- #[ORM\OneToMany(mappedBy: 'playlist', targetEntity: PlaylistScreenRegion::class, orphanRemoval: true)]
+ #[ORM\OneToMany(mappedBy: 'playlist', targetEntity: PlaylistScreenRegion::class, fetch: 'EXTRA_LAZY', orphanRemoval: true)]
private Collection $playlistScreenRegions;
/**
* @var Collection
*/
- #[ORM\OneToMany(mappedBy: 'playlist', targetEntity: PlaylistSlide::class, orphanRemoval: true)]
+ #[ORM\OneToMany(mappedBy: 'playlist', targetEntity: PlaylistSlide::class, fetch: 'EXTRA_LAZY', orphanRemoval: true)]
#[ORM\OrderBy(['weight' => Order::Ascending->value])]
private Collection $playlistSlides;
/**
* @var Collection
*/
- #[ORM\OneToMany(mappedBy: 'playlist', targetEntity: Schedule::class, cascade: ['persist'], orphanRemoval: true)]
+ #[ORM\OneToMany(mappedBy: 'playlist', targetEntity: Schedule::class, cascade: ['persist'], fetch: 'EXTRA_LAZY', orphanRemoval: true)]
private Collection $schedules;
public function __construct()
diff --git a/src/Entity/Tenant/Screen.php b/src/Entity/Tenant/Screen.php
index 9fde5553e..660f59bc8 100644
--- a/src/Entity/Tenant/Screen.php
+++ b/src/Entity/Tenant/Screen.php
@@ -46,20 +46,20 @@ class Screen extends AbstractTenantScopedEntity implements RelationsChecksumInte
/**
* @var Collection
*/
- #[ORM\OneToMany(mappedBy: 'screen', targetEntity: ScreenCampaign::class, orphanRemoval: true)]
+ #[ORM\OneToMany(mappedBy: 'screen', targetEntity: ScreenCampaign::class, fetch: 'EXTRA_LAZY', orphanRemoval: true)]
private Collection $screenCampaigns;
/**
* @var Collection
*/
- #[ORM\OneToMany(mappedBy: 'screen', targetEntity: PlaylistScreenRegion::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
+ #[ORM\OneToMany(mappedBy: 'screen', targetEntity: PlaylistScreenRegion::class, cascade: ['persist', 'remove'], fetch: 'EXTRA_LAZY', orphanRemoval: true)]
#[ORM\OrderBy(['weight' => \Doctrine\Common\Collections\Order::Ascending->value])]
private Collection $playlistScreenRegions;
/**
* @var Collection
*/
- #[ORM\ManyToMany(targetEntity: ScreenGroup::class, mappedBy: 'screens')]
+ #[ORM\ManyToMany(targetEntity: ScreenGroup::class, mappedBy: 'screens', fetch: 'EXTRA_LAZY')]
private Collection $screenGroups;
public function __construct()
diff --git a/src/Entity/Tenant/ScreenGroup.php b/src/Entity/Tenant/ScreenGroup.php
index 228ebe82d..23fa34646 100644
--- a/src/Entity/Tenant/ScreenGroup.php
+++ b/src/Entity/Tenant/ScreenGroup.php
@@ -22,13 +22,13 @@ class ScreenGroup extends AbstractTenantScopedEntity implements RelationsChecksu
/**
* @var Collection
*/
- #[ORM\OneToMany(mappedBy: 'screenGroup', targetEntity: ScreenGroupCampaign::class, orphanRemoval: true)]
+ #[ORM\OneToMany(mappedBy: 'screenGroup', targetEntity: ScreenGroupCampaign::class, fetch: 'EXTRA_LAZY', orphanRemoval: true)]
private Collection $screenGroupCampaigns;
/**
* @var Collection
*/
- #[ORM\ManyToMany(targetEntity: Screen::class, inversedBy: 'screenGroups')]
+ #[ORM\ManyToMany(targetEntity: Screen::class, inversedBy: 'screenGroups', fetch: 'EXTRA_LAZY')]
private Collection $screens;
public function __construct()
diff --git a/src/Entity/Tenant/Slide.php b/src/Entity/Tenant/Slide.php
index 82e1fbd7f..335846915 100644
--- a/src/Entity/Tenant/Slide.php
+++ b/src/Entity/Tenant/Slide.php
@@ -42,7 +42,7 @@ class Slide extends AbstractTenantScopedEntity implements RelationsChecksumInter
/**
* @var Collection
*/
- #[ORM\ManyToMany(targetEntity: Media::class, inversedBy: 'slides')]
+ #[ORM\ManyToMany(targetEntity: Media::class, inversedBy: 'slides', fetch: 'EXTRA_LAZY')]
private Collection $media;
/**
diff --git a/src/Entity/Tenant/Theme.php b/src/Entity/Tenant/Theme.php
index 915e48c4d..a8f8ebdf5 100644
--- a/src/Entity/Tenant/Theme.php
+++ b/src/Entity/Tenant/Theme.php
@@ -29,7 +29,7 @@ class Theme extends AbstractTenantScopedEntity implements RelationsChecksumInter
/**
* @var Collection
*/
- #[ORM\OneToMany(mappedBy: 'theme', targetEntity: Slide::class)]
+ #[ORM\OneToMany(mappedBy: 'theme', targetEntity: Slide::class, fetch: 'EXTRA_LAZY')]
private Collection $slides;
public function __construct()
diff --git a/src/Entity/Traits/MultiTenantTrait.php b/src/Entity/Traits/MultiTenantTrait.php
index f05310a44..b99ce2eec 100644
--- a/src/Entity/Traits/MultiTenantTrait.php
+++ b/src/Entity/Traits/MultiTenantTrait.php
@@ -13,7 +13,7 @@ trait MultiTenantTrait
/**
* @var Collection
*/
- #[ORM\ManyToMany(targetEntity: Tenant::class)]
+ #[ORM\ManyToMany(targetEntity: Tenant::class, fetch: 'EXTRA_LAZY')]
private Collection $tenants;
/**
diff --git a/src/Entity/User.php b/src/Entity/User.php
index 6f5b191a9..53b6fdba7 100644
--- a/src/Entity/User.php
+++ b/src/Entity/User.php
@@ -42,7 +42,7 @@ class User extends AbstractBaseEntity implements UserInterface, PasswordAuthenti
/**
* @var Collection
*/
- #[ORM\OneToMany(targetEntity: UserRoleTenant::class, mappedBy: 'user', cascade: ['persist', 'remove'], orphanRemoval: true)]
+ #[ORM\OneToMany(mappedBy: 'user', targetEntity: UserRoleTenant::class, cascade: ['persist', 'remove'], fetch: 'EXTRA_LAZY', orphanRemoval: true)]
private Collection $userRoleTenants;
#[ORM\Column(type: Types::STRING)]
diff --git a/src/Repository/ScreenRepository.php b/src/Repository/ScreenRepository.php
index d469b79fa..d51afcb93 100644
--- a/src/Repository/ScreenRepository.php
+++ b/src/Repository/ScreenRepository.php
@@ -4,8 +4,13 @@
namespace App\Repository;
+use App\Entity\Tenant\Playlist;
use App\Entity\Tenant\Screen;
+use App\Entity\Tenant\ScreenCampaign;
+use App\Entity\Tenant\ScreenGroupCampaign;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
+use Doctrine\ORM\NonUniqueResultException;
+use Doctrine\ORM\NoResultException;
use Doctrine\ORM\Query\Expr\Join;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ManagerRegistry;
@@ -32,4 +37,50 @@ public function getScreensByScreenGroupId(Ulid $screenGroupUlid): QueryBuilder
return $queryBuilder;
}
+
+ /**
+ * Get total or active campaign counts for a screen via a single SQL query.
+ *
+ * Collects campaigns from both direct screen assignments (screen_campaign)
+ * and indirect assignments through screen groups (screen_group_campaign),
+ * then counts distinct campaigns and filters for currently active ones
+ * based on published_from/published_to dates if $active is true.
+ *
+ * @return int number of campaigns for the screen
+ *
+ * @throws NoResultException
+ * @throws NonUniqueResultException
+ */
+ public function getCampaignCountForScreen(Ulid $screenId, bool $activeCampaigns = false): int
+ {
+ $em = $this->getEntityManager();
+ $now = new \DateTime();
+
+ // Subquery: all campaign IDs for this screen (direct + via groups)
+ $directQb = $em->createQueryBuilder()
+ ->select('IDENTITY(sc.campaign)')
+ ->from(ScreenCampaign::class, 'sc')
+ ->where('sc.screen = :screenId');
+
+ $groupQb = $em->createQueryBuilder()
+ ->select('IDENTITY(sgc.campaign)')
+ ->from(ScreenGroupCampaign::class, 'sgc')
+ ->innerJoin('sgc.screenGroup', 'sg')
+ ->innerJoin('sg.screens', 's')
+ ->where('s.id = :screenId');
+
+ $qb = $em->createQueryBuilder()
+ ->select('COUNT(DISTINCT p.id)')
+ ->from(Playlist::class, 'p')
+ ->where('p.id IN ('.$directQb->getDQL().') OR p.id IN ('.$groupQb->getDQL().')')
+ ->setParameter('screenId', $screenId, 'ulid');
+
+ if ($activeCampaigns) {
+ $qb->andWhere('p.publishedFrom IS NULL OR p.publishedFrom < :now')
+ ->andWhere('p.publishedTo IS NULL OR p.publishedTo > :now')
+ ->setParameter('now', $now);
+ }
+
+ return (int) $qb->getQuery()->getSingleScalarResult();
+ }
}
diff --git a/src/State/PlaylistProvider.php b/src/State/PlaylistProvider.php
index 6e0fcf419..1c3a873d7 100644
--- a/src/State/PlaylistProvider.php
+++ b/src/State/PlaylistProvider.php
@@ -59,6 +59,8 @@ public function toOutput(object $object): PlaylistDTO
'to' => $object->getPublishedTo(),
];
+ $output->slidesLength = $object->getPlaylistSlides()->count();
+
$output->setRelationsChecksum($object->getRelationsChecksum());
return $output;
diff --git a/src/State/ScreenGroupProvider.php b/src/State/ScreenGroupProvider.php
index 721926723..a64371bb3 100644
--- a/src/State/ScreenGroupProvider.php
+++ b/src/State/ScreenGroupProvider.php
@@ -36,6 +36,9 @@ public function toOutput(object $object): ScreenGroupDTO
$output->campaigns = $iri.'/campaigns';
$output->screens = $iri.'/screens';
+ $output->screensLength = $object->getScreens()->count();
+ $output->campaignsLength = $object->getScreenGroupCampaigns()->count();
+
$output->setRelationsChecksum($object->getRelationsChecksum());
return $output;
diff --git a/src/State/ScreenProvider.php b/src/State/ScreenProvider.php
index f4015d9cf..977ee7fd3 100644
--- a/src/State/ScreenProvider.php
+++ b/src/State/ScreenProvider.php
@@ -16,10 +16,10 @@ class ScreenProvider extends AbstractProvider
public function __construct(
private readonly IriConverterInterface $iriConverter,
ProviderInterface $collectionProvider,
- ScreenRepository $entityRepository,
+ private readonly ScreenRepository $screenRepository,
private readonly bool $trackScreenInfo = false,
) {
- parent::__construct($collectionProvider, $entityRepository);
+ parent::__construct($collectionProvider, $screenRepository);
}
public function toOutput(object $object): ScreenDTO
@@ -49,11 +49,19 @@ public function toOutput(object $object): ScreenDTO
$iri = $this->iriConverter->getIriFromResource($object);
$output->campaigns = $iri.'/campaigns';
+ $id = $object->getId();
+
+ if (null !== $id) {
+ $output->campaignsLength = $this->screenRepository->getCampaignCountForScreen($id);
+ $output->activeCampaignsLength = $this->screenRepository->getCampaignCountForScreen($id, true);
+ }
+
$objectIri = $this->iriConverter->getIriFromResource($object);
foreach ($layout->getRegions() as $region) {
$output->regions[] = $objectIri.'/regions/'.$region->getId().'/playlists';
}
$output->inScreenGroups = $objectIri.'/screen-groups';
+ $output->inScreenGroupsLength = $object->getScreenGroups()->count();
$objectUser = $object->getScreenUser();
diff --git a/tests/Api/PlaylistsTest.php b/tests/Api/PlaylistsTest.php
index d0fabd239..4559a6cdd 100644
--- a/tests/Api/PlaylistsTest.php
+++ b/tests/Api/PlaylistsTest.php
@@ -5,6 +5,7 @@
namespace App\Tests\Api;
use App\Entity\Tenant\Playlist;
+use App\Entity\Tenant\Slide;
use App\Tests\AbstractBaseApiTestCase;
use Symfony\Component\HttpFoundation\Response;
@@ -33,6 +34,20 @@ public function testGetCollection(): void
$this->assertCount(5, $response->toArray()['hydra:member']);
}
+ public function testGetCollectionContainsLengthFields(): void
+ {
+ $response = $this->getAuthenticatedClient()->request('GET', '/v2/playlists?itemsPerPage=1', ['headers' => ['Content-Type' => 'application/ld+json']]);
+
+ $this->assertResponseIsSuccessful();
+
+ $members = $response->toArray()['hydra:member'];
+ $this->assertNotEmpty($members);
+
+ $firstMember = $members[0];
+ $this->assertArrayHasKey('slidesLength', $firstMember);
+ $this->assertIsInt($firstMember['slidesLength']);
+ }
+
public function testGetCampaigns(): void
{
$this->getAuthenticatedClient()->request('GET', '/v2/playlists?itemsPerPage=5&isCampaign=true', ['headers' => ['Content-Type' => 'application/ld+json']]);
@@ -388,6 +403,56 @@ public function testGetScreensList(): void
]);
}
+ public function testSlidesLength(): void
+ {
+ $client = $this->getAuthenticatedClient();
+
+ // A newly created playlist has no slides
+ $response = $client->request('POST', '/v2/playlists', [
+ 'json' => ['title' => 'Test slidesLength'],
+ 'headers' => ['Content-Type' => 'application/ld+json'],
+ ]);
+
+ $this->assertResponseStatusCodeSame(201);
+ $this->assertJsonContains(['slidesLength' => 0]);
+
+ $playlistIri = $response->toArray()['@id'];
+ $playlistUlid = $this->iriHelperUtils->getUlidFromIRI($playlistIri);
+
+ // Verify slidesLength = 0 on GET
+ $client->request('GET', $playlistIri, ['headers' => ['Content-Type' => 'application/ld+json']]);
+ $this->assertResponseIsSuccessful();
+ $this->assertJsonContains(['slidesLength' => 0]);
+
+ // Add one slide and verify count increases to 1
+ $slideIri = $this->findIriBy(Slide::class, ['tenant' => $this->tenant]);
+ $slideUlid = $this->iriHelperUtils->getUlidFromIRI($slideIri);
+
+ $client->request('PUT', '/v2/playlists/'.$playlistUlid.'/slides', [
+ 'json' => [(object) ['slide' => $slideUlid, 'weight' => 0]],
+ 'headers' => ['Content-Type' => 'application/ld+json'],
+ ]);
+ $this->assertResponseStatusCodeSame(201);
+
+ $client->request('GET', $playlistIri, ['headers' => ['Content-Type' => 'application/ld+json']]);
+ $this->assertResponseIsSuccessful();
+ $this->assertJsonContains(['slidesLength' => 1]);
+
+ // Clean up: remove all slides from the playlist
+ $client->request('PUT', '/v2/playlists/'.$playlistUlid.'/slides', [
+ 'json' => [],
+ 'headers' => ['Content-Type' => 'application/ld+json'],
+ ]);
+ $this->assertResponseStatusCodeSame(201);
+
+ $client->request('GET', $playlistIri, ['headers' => ['Content-Type' => 'application/ld+json']]);
+ $this->assertResponseIsSuccessful();
+ $this->assertJsonContains(['slidesLength' => 0]);
+
+ $client->request('DELETE', $playlistIri);
+ $this->assertResponseStatusCodeSame(204);
+ }
+
public function testSharedPlaylists(): void
{
$response = $this->getAuthenticatedClient()->request('GET', '/v2/playlists?itemsPerPage=20', ['headers' => ['Content-Type' => 'application/ld+json']]);
diff --git a/tests/Api/ScreenGroupsTest.php b/tests/Api/ScreenGroupsTest.php
index 4046955b8..e89a58985 100644
--- a/tests/Api/ScreenGroupsTest.php
+++ b/tests/Api/ScreenGroupsTest.php
@@ -34,6 +34,22 @@ public function testGetCollection(): void
$this->assertMatchesResourceCollectionJsonSchema(ScreenGroup::class);
}
+ public function testGetCollectionContainsLengthFields(): void
+ {
+ $response = $this->getAuthenticatedClient('ROLE_SCREEN')->request('GET', '/v2/screen-groups?itemsPerPage=1', ['headers' => ['Content-Type' => 'application/ld+json']]);
+
+ $this->assertResponseIsSuccessful();
+
+ $members = $response->toArray()['hydra:member'];
+ $this->assertNotEmpty($members);
+
+ $firstMember = $members[0];
+ $this->assertArrayHasKey('screensLength', $firstMember);
+ $this->assertArrayHasKey('campaignsLength', $firstMember);
+ $this->assertIsInt($firstMember['screensLength']);
+ $this->assertIsInt($firstMember['campaignsLength']);
+ }
+
public function testGetItem(): void
{
$client = $this->getAuthenticatedClient('ROLE_SCREEN');
@@ -135,6 +151,114 @@ public function testDeleteScreenGroup(): void
);
}
+ public function testScreensLength(): void
+ {
+ $client = $this->getAuthenticatedClient('ROLE_ADMIN');
+
+ // Create a new screen group with no screens
+ $screenGroupResponse = $client->request('POST', '/v2/screen-groups', [
+ 'json' => ['title' => 'Test screensLength group'],
+ 'headers' => ['Content-Type' => 'application/ld+json'],
+ ]);
+ $this->assertResponseStatusCodeSame(201);
+
+ $screenGroupIri = $screenGroupResponse->toArray()['@id'];
+ $screenGroupUlid = $this->iriHelperUtils->getUlidFromIRI($screenGroupIri);
+
+ // Initial state: screensLength is 0
+ $client->request('GET', $screenGroupIri, ['headers' => ['Content-Type' => 'application/ld+json']]);
+ $this->assertResponseIsSuccessful();
+ $this->assertJsonContains(['screensLength' => 0]);
+
+ // Add an existing screen to the group
+ $screenIri = $this->findIriBy(Screen::class, ['tenant' => $this->tenant]);
+ $screenUlid = $this->iriHelperUtils->getUlidFromIRI($screenIri);
+
+ $client->request('PUT', '/v2/screens/'.$screenUlid.'/screen-groups', [
+ 'json' => [$screenGroupUlid],
+ 'headers' => ['Content-Type' => 'application/ld+json'],
+ ]);
+ $this->assertResponseStatusCodeSame(201);
+
+ // screensLength should now be 1
+ $client->request('GET', $screenGroupIri, ['headers' => ['Content-Type' => 'application/ld+json']]);
+ $this->assertResponseIsSuccessful();
+ $this->assertJsonContains(['screensLength' => 1]);
+
+ // Cleanup
+ $client->request('DELETE', '/v2/screens/'.$screenUlid.'/screen-groups/'.$screenGroupUlid);
+ $this->assertResponseStatusCodeSame(204);
+
+ $client->request('DELETE', $screenGroupIri);
+ $this->assertResponseStatusCodeSame(204);
+ }
+
+ public function testCampaignsLength(): void
+ {
+ $client = $this->getAuthenticatedClient('ROLE_ADMIN');
+
+ // Create a new screen group with no campaigns
+ $screenGroupResponse = $client->request('POST', '/v2/screen-groups', [
+ 'json' => ['title' => 'Test campaignsLength group'],
+ 'headers' => ['Content-Type' => 'application/ld+json'],
+ ]);
+ $this->assertResponseStatusCodeSame(201);
+
+ $screenGroupIri = $screenGroupResponse->toArray()['@id'];
+ $screenGroupUlid = $this->iriHelperUtils->getUlidFromIRI($screenGroupIri);
+
+ // Initial state: campaignsLength is 0
+ $client->request('GET', $screenGroupIri, ['headers' => ['Content-Type' => 'application/ld+json']]);
+ $this->assertResponseIsSuccessful();
+ $this->assertJsonContains([
+ 'screensLength' => 0,
+ 'campaignsLength' => 0,
+ ]);
+
+ // Create a campaign
+ $campaignResponse = $client->request('POST', '/v2/playlists', [
+ 'json' => [
+ 'title' => 'Test campaign for group',
+ 'isCampaign' => true,
+ 'published' => [
+ 'from' => '2020-01-01T00:00:00.000Z',
+ 'to' => '2099-01-01T00:00:00.000Z',
+ ],
+ ],
+ 'headers' => ['Content-Type' => 'application/ld+json'],
+ ]);
+ $this->assertResponseStatusCodeSame(201);
+ $campaignIri = $campaignResponse->toArray()['@id'];
+ $campaignUlid = $this->iriHelperUtils->getUlidFromIRI($campaignIri);
+
+ // Link the campaign to the screen group
+ $client->request('PUT', '/v2/screen-groups/'.$campaignUlid.'/campaigns', [
+ 'json' => [(object) ['screengroup' => $screenGroupUlid]],
+ 'headers' => ['Content-Type' => 'application/ld+json'],
+ ]);
+ $this->assertResponseStatusCodeSame(201);
+
+ // campaignsLength should now be 1
+ $client->request('GET', $screenGroupIri, ['headers' => ['Content-Type' => 'application/ld+json']]);
+ $this->assertResponseIsSuccessful();
+ $this->assertJsonContains(['campaignsLength' => 1]);
+
+ // Cleanup
+ $client->request('DELETE', '/v2/screen-groups/'.$screenGroupUlid.'/campaigns/'.$campaignUlid);
+ $this->assertResponseStatusCodeSame(204);
+
+ // After removing the campaign, campaignsLength should be 0 again
+ $client->request('GET', $screenGroupIri, ['headers' => ['Content-Type' => 'application/ld+json']]);
+ $this->assertResponseIsSuccessful();
+ $this->assertJsonContains(['campaignsLength' => 0]);
+
+ $client->request('DELETE', $screenGroupIri);
+ $this->assertResponseStatusCodeSame(204);
+
+ $client->request('DELETE', $campaignIri);
+ $this->assertResponseStatusCodeSame(204);
+ }
+
public function testGetScreenGroupsScreenRelations(): void
{
$client = $this->getAuthenticatedClient('ROLE_SCREEN');
diff --git a/tests/Api/ScreensTest.php b/tests/Api/ScreensTest.php
index 1ea349674..5e4377e22 100644
--- a/tests/Api/ScreensTest.php
+++ b/tests/Api/ScreensTest.php
@@ -53,6 +53,24 @@ public function testGetCollection(): void
$this->assertMatchesResourceCollectionJsonSchema(Screen::class);
}
+ public function testGetCollectionContainsLengthFields(): void
+ {
+ $response = $this->getAuthenticatedClient('ROLE_ADMIN')->request('GET', '/v2/screens?itemsPerPage=1', ['headers' => ['Content-Type' => 'application/ld+json']]);
+
+ $this->assertResponseIsSuccessful();
+
+ $members = $response->toArray()['hydra:member'];
+ $this->assertNotEmpty($members);
+
+ $firstMember = $members[0];
+ $this->assertArrayHasKey('campaignsLength', $firstMember);
+ $this->assertArrayHasKey('activeCampaignsLength', $firstMember);
+ $this->assertArrayHasKey('inScreenGroupsLength', $firstMember);
+ $this->assertIsInt($firstMember['campaignsLength']);
+ $this->assertIsInt($firstMember['activeCampaignsLength']);
+ $this->assertIsInt($firstMember['inScreenGroupsLength']);
+ }
+
public function testGetItem(): void
{
$client = $this->getAuthenticatedClient('ROLE_ADMIN');
@@ -258,6 +276,245 @@ public function testUpdateScreen(): void
$this->assertEquals($playlistScreenRegionCountBefore - 1, $playlistScreenRegionCountAfter, 'PlaylistScreenRegion count should go 1 down');
}
+ public function testCampaignsLengthAndActiveCampaignsLength(): void
+ {
+ $client = $this->getAuthenticatedClient('ROLE_ADMIN');
+
+ $layoutIri = $this->findIriBy(ScreenLayout::class, ['title' => 'full screen']);
+
+ // Create a fresh screen with no campaigns and no screen groups
+ $screenResponse = $client->request('POST', '/v2/screens', [
+ 'json' => [
+ 'title' => 'Test campaignsLength screen',
+ 'layout' => $layoutIri,
+ ],
+ 'headers' => ['Content-Type' => 'application/ld+json'],
+ ]);
+ $this->assertResponseStatusCodeSame(201);
+
+ $screenIri = $screenResponse->toArray()['@id'];
+ $screenUlid = $this->iriHelperUtils->getUlidFromIRI($screenIri);
+
+ // Initial state: all counts are 0
+ $client->request('GET', $screenIri, ['headers' => ['Content-Type' => 'application/ld+json']]);
+ $this->assertResponseIsSuccessful();
+ $this->assertJsonContains([
+ 'campaignsLength' => 0,
+ 'activeCampaignsLength' => 0,
+ 'inScreenGroupsLength' => 0,
+ ]);
+
+ // Create an active campaign: publishedFrom in past, publishedTo in far future
+ $activeCampaignResponse = $client->request('POST', '/v2/playlists', [
+ 'json' => [
+ 'title' => 'Active campaign',
+ 'isCampaign' => true,
+ 'published' => [
+ 'from' => '2020-01-01T00:00:00.000Z',
+ 'to' => '2099-01-01T00:00:00.000Z',
+ ],
+ ],
+ 'headers' => ['Content-Type' => 'application/ld+json'],
+ ]);
+ $this->assertResponseStatusCodeSame(201);
+ $activeCampaignIri = $activeCampaignResponse->toArray()['@id'];
+ $activeCampaignUlid = $this->iriHelperUtils->getUlidFromIRI($activeCampaignIri);
+
+ // Create an inactive campaign: publishedFrom in far future (not yet started)
+ $inactiveCampaignResponse = $client->request('POST', '/v2/playlists', [
+ 'json' => [
+ 'title' => 'Inactive campaign',
+ 'isCampaign' => true,
+ 'published' => [
+ 'from' => '2099-01-01T00:00:00.000Z',
+ 'to' => '2099-06-01T00:00:00.000Z',
+ ],
+ ],
+ 'headers' => ['Content-Type' => 'application/ld+json'],
+ ]);
+ $this->assertResponseStatusCodeSame(201);
+ $inactiveCampaignIri = $inactiveCampaignResponse->toArray()['@id'];
+ $inactiveCampaignUlid = $this->iriHelperUtils->getUlidFromIRI($inactiveCampaignIri);
+
+ // Link active campaign to the screen.
+ // Note: PUT /v2/screens/{campaignId}/campaigns is campaign-centric — {id} is the campaign ULID,
+ // and the body specifies which screens should display it.
+ $client->request('PUT', '/v2/screens/'.$activeCampaignUlid.'/campaigns', [
+ 'json' => [(object) ['screen' => $screenUlid]],
+ 'headers' => ['Content-Type' => 'application/ld+json'],
+ ]);
+ $this->assertResponseStatusCodeSame(201);
+
+ $client->request('GET', $screenIri, ['headers' => ['Content-Type' => 'application/ld+json']]);
+ $this->assertResponseIsSuccessful();
+ $this->assertJsonContains([
+ 'campaignsLength' => 1,
+ 'activeCampaignsLength' => 1,
+ 'inScreenGroupsLength' => 0,
+ ]);
+
+ // Link inactive campaign to the screen
+ $client->request('PUT', '/v2/screens/'.$inactiveCampaignUlid.'/campaigns', [
+ 'json' => [(object) ['screen' => $screenUlid]],
+ 'headers' => ['Content-Type' => 'application/ld+json'],
+ ]);
+ $this->assertResponseStatusCodeSame(201);
+
+ $client->request('GET', $screenIri, ['headers' => ['Content-Type' => 'application/ld+json']]);
+ $this->assertResponseIsSuccessful();
+ $this->assertJsonContains([
+ 'campaignsLength' => 2,
+ 'activeCampaignsLength' => 1,
+ 'inScreenGroupsLength' => 0,
+ ]);
+
+ // Create an empty screen group (no campaigns) and add the screen to it
+ $screenGroupResponse = $client->request('POST', '/v2/screen-groups', [
+ 'json' => ['title' => 'Test screen group for inScreenGroupsLength'],
+ 'headers' => ['Content-Type' => 'application/ld+json'],
+ ]);
+ $this->assertResponseStatusCodeSame(201);
+ $screenGroupIri = $screenGroupResponse->toArray()['@id'];
+ $screenGroupUlid = $this->iriHelperUtils->getUlidFromIRI($screenGroupIri);
+
+ $client->request('PUT', '/v2/screens/'.$screenUlid.'/screen-groups', [
+ 'json' => [$screenGroupUlid],
+ 'headers' => ['Content-Type' => 'application/ld+json'],
+ ]);
+ $this->assertResponseStatusCodeSame(201);
+
+ $client->request('GET', $screenIri, ['headers' => ['Content-Type' => 'application/ld+json']]);
+ $this->assertResponseIsSuccessful();
+ $this->assertJsonContains([
+ 'campaignsLength' => 2,
+ 'activeCampaignsLength' => 1,
+ 'inScreenGroupsLength' => 1,
+ ]);
+
+ // Cleanup
+ $client->request('DELETE', '/v2/screens/'.$screenUlid.'/campaigns/'.$inactiveCampaignUlid);
+ $this->assertResponseStatusCodeSame(204);
+
+ $client->request('DELETE', '/v2/screens/'.$screenUlid.'/campaigns/'.$activeCampaignUlid);
+ $this->assertResponseStatusCodeSame(204);
+
+ $client->request('DELETE', '/v2/screens/'.$screenUlid.'/screen-groups/'.$screenGroupUlid);
+ $this->assertResponseStatusCodeSame(204);
+
+ $client->request('DELETE', $screenIri);
+ $this->assertResponseStatusCodeSame(204);
+
+ $client->request('DELETE', $screenGroupIri);
+ $this->assertResponseStatusCodeSame(204);
+
+ $client->request('DELETE', $activeCampaignIri);
+ $this->assertResponseStatusCodeSame(204);
+
+ $client->request('DELETE', $inactiveCampaignIri);
+ $this->assertResponseStatusCodeSame(204);
+ }
+
+ public function testCampaignsLengthViaScreenGroupAndDeduplication(): void
+ {
+ $client = $this->getAuthenticatedClient('ROLE_ADMIN');
+
+ $layoutIri = $this->findIriBy(ScreenLayout::class, ['title' => 'full screen']);
+
+ // Create a fresh screen
+ $screenResponse = $client->request('POST', '/v2/screens', [
+ 'json' => [
+ 'title' => 'Test indirect campaigns screen',
+ 'layout' => $layoutIri,
+ ],
+ 'headers' => ['Content-Type' => 'application/ld+json'],
+ ]);
+ $this->assertResponseStatusCodeSame(201);
+ $screenIri = $screenResponse->toArray()['@id'];
+ $screenUlid = $this->iriHelperUtils->getUlidFromIRI($screenIri);
+
+ // Create a screen group
+ $screenGroupResponse = $client->request('POST', '/v2/screen-groups', [
+ 'json' => ['title' => 'Test indirect campaigns group'],
+ 'headers' => ['Content-Type' => 'application/ld+json'],
+ ]);
+ $this->assertResponseStatusCodeSame(201);
+ $screenGroupIri = $screenGroupResponse->toArray()['@id'];
+ $screenGroupUlid = $this->iriHelperUtils->getUlidFromIRI($screenGroupIri);
+
+ // Create an active campaign
+ $campaignResponse = $client->request('POST', '/v2/playlists', [
+ 'json' => [
+ 'title' => 'Indirect campaign',
+ 'isCampaign' => true,
+ 'published' => [
+ 'from' => '2020-01-01T00:00:00.000Z',
+ 'to' => '2099-01-01T00:00:00.000Z',
+ ],
+ ],
+ 'headers' => ['Content-Type' => 'application/ld+json'],
+ ]);
+ $this->assertResponseStatusCodeSame(201);
+ $campaignIri = $campaignResponse->toArray()['@id'];
+ $campaignUlid = $this->iriHelperUtils->getUlidFromIRI($campaignIri);
+
+ // Add screen to the screen group
+ $client->request('PUT', '/v2/screens/'.$screenUlid.'/screen-groups', [
+ 'json' => [$screenGroupUlid],
+ 'headers' => ['Content-Type' => 'application/ld+json'],
+ ]);
+ $this->assertResponseStatusCodeSame(201);
+
+ // Link campaign to the screen group (indirect path)
+ $client->request('PUT', '/v2/screen-groups/'.$campaignUlid.'/campaigns', [
+ 'json' => [(object) ['screengroup' => $screenGroupUlid]],
+ 'headers' => ['Content-Type' => 'application/ld+json'],
+ ]);
+ $this->assertResponseStatusCodeSame(201);
+
+ // Verify the indirect campaign is counted
+ $client->request('GET', $screenIri, ['headers' => ['Content-Type' => 'application/ld+json']]);
+ $this->assertResponseIsSuccessful();
+ $this->assertJsonContains([
+ 'campaignsLength' => 1,
+ 'activeCampaignsLength' => 1,
+ ]);
+
+ // Now also link the same campaign directly to the screen
+ $client->request('PUT', '/v2/screens/'.$campaignUlid.'/campaigns', [
+ 'json' => [(object) ['screen' => $screenUlid]],
+ 'headers' => ['Content-Type' => 'application/ld+json'],
+ ]);
+ $this->assertResponseStatusCodeSame(201);
+
+ // Verify deduplication: campaign is linked both directly and via group,
+ // but COUNT(DISTINCT) should count it only once
+ $client->request('GET', $screenIri, ['headers' => ['Content-Type' => 'application/ld+json']]);
+ $this->assertResponseIsSuccessful();
+ $this->assertJsonContains([
+ 'campaignsLength' => 1,
+ 'activeCampaignsLength' => 1,
+ ]);
+
+ // Cleanup
+ $client->request('DELETE', '/v2/screens/'.$screenUlid.'/campaigns/'.$campaignUlid);
+ $this->assertResponseStatusCodeSame(204);
+
+ $client->request('DELETE', '/v2/screen-groups/'.$screenGroupUlid.'/campaigns/'.$campaignUlid);
+ $this->assertResponseStatusCodeSame(204);
+
+ $client->request('DELETE', '/v2/screens/'.$screenUlid.'/screen-groups/'.$screenGroupUlid);
+ $this->assertResponseStatusCodeSame(204);
+
+ $client->request('DELETE', $screenIri);
+ $this->assertResponseStatusCodeSame(204);
+
+ $client->request('DELETE', $screenGroupIri);
+ $this->assertResponseStatusCodeSame(204);
+
+ $client->request('DELETE', $campaignIri);
+ $this->assertResponseStatusCodeSame(204);
+ }
+
public function testDeleteScreen(): void
{
$client = $this->getAuthenticatedClient('ROLE_ADMIN');
diff --git a/tests/EventListener/RelationsChecksumListenerTest.php b/tests/EventListener/RelationsChecksumListenerTest.php
index ab294d678..b814d87c2 100644
--- a/tests/EventListener/RelationsChecksumListenerTest.php
+++ b/tests/EventListener/RelationsChecksumListenerTest.php
@@ -617,7 +617,7 @@ public function testPlaylistSlideRelation(): void
$playlistSlides = $playlist->getPlaylistSlides();
- $this->assertGreaterThanOrEqual(10, $playlistSlides->count(), 'Fixtures count does not match expected value');
+ $this->assertGreaterThanOrEqual(9, $playlistSlides->count(), 'Fixtures count does not match expected value');
$checksums = $playlist->getRelationsChecksum();
$this->assertArrayHasKey('slides', $checksums);