From 8d5baeca6558f14e1bb347a4e14f9bea7eb9f25b Mon Sep 17 00:00:00 2001
From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com>
Date: Thu, 26 Mar 2026 16:11:06 +0100
Subject: [PATCH 01/31] 6871: Added screen list optimatizations
---
.../admin/components/screen/screen-list.jsx | 4 -
.../components/screen/util/screen-columns.jsx | 78 ++++++++++++-------
.../context/modal-context/info-modal.jsx | 65 +++++++++-------
.../context/modal-context/modal-provider.jsx | 1 +
assets/admin/translations/da/common.json | 5 ++
src/Dto/Screen.php | 6 ++
src/State/ScreenProvider.php | 35 +++++++++
7 files changed, 133 insertions(+), 61 deletions(-)
diff --git a/assets/admin/components/screen/screen-list.jsx b/assets/admin/components/screen/screen-list.jsx
index 48e82ae9d..f01d4fb8b 100644
--- a/assets/admin/components/screen/screen-list.jsx
+++ b/assets/admin/components/screen/screen-list.jsx
@@ -128,10 +128,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/util/screen-columns.jsx b/assets/admin/components/screen/util/screen-columns.jsx
index b556b76c8..cd582a2bf 100644
--- a/assets/admin/components/screen/util/screen-columns.jsx
+++ b/assets/admin/components/screen/util/screen-columns.jsx
@@ -1,56 +1,80 @@
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 { 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 = () => {
+ dispatch(
+ enhancedApi.endpoints.getV2ScreensByIdScreenGroups.initiate({
+ id: idFromUrl(screen.id)
+ }))
+ .then(({ data }) => {
+ const content =
+ {data["hydra:member"].map((group) =>
+ -
+
+ {group.title}
+
+
+ )}
+
;
+
+ setModal({
+ info: true,
+ modalTitle: t('screen-groups-modal-title'),
+ content
+ });
+ });
+ };
+
+ return ;
+}
/**
* 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) => (
-
+
),
key: "groups",
- label: t("columns.on-groups"),
+ label: t("columns.on-groups")
},
{
path: "location",
- label: t("columns.location"),
+ label: t("columns.location")
},
{
key: "campaign",
label: t("columns.campaign"),
- // eslint-disable-next-line react/destructuring-assignment
- content: (d) => ,
- },
+ content: (screen) => {
+ if (screen.activeCampaignsLength > 0)
+ return t("overridden-by-campaign");
+ return t("not-overridden-by-campaign");
+ }
+ }
];
if (displayStatus) {
@@ -59,7 +83,7 @@ function getScreenColumns({
label: t("columns.status"),
content: (screen) => {
return ;
- },
+ }
});
}
diff --git a/assets/admin/context/modal-context/info-modal.jsx b/assets/admin/context/modal-context/info-modal.jsx
index aaa0e8c19..95a7fb85c 100644
--- a/assets/admin/context/modal-context/info-modal.jsx
+++ b/assets/admin/context/modal-context/info-modal.jsx
@@ -19,20 +19,22 @@ import idFromUrl from "../../components/util/helpers/id-from-url";
* @returns {object} The modal.
*/
function InfoModal({
- unSetModal,
- apiCall,
- displayData = [],
- modalTitle,
- dataKey = "",
- redirectTo,
-}) {
+ unSetModal,
+ apiCall,
+ displayData = [],
+ modalTitle,
+ dataKey = "",
+ redirectTo,
+ content
+ }) {
+
const { t } = useTranslation("common");
const [fetchedData, setFetchedData] = useState([]);
let data;
if (!Array.isArray(displayData)) {
data = apiCall({
id: idFromUrl(displayData),
- itemsPerPage: 30,
+ itemsPerPage: 30
});
}
@@ -55,29 +57,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..4cf7abe67 100644
--- a/assets/admin/translations/da/common.json
+++ b/assets/admin/translations/da/common.json
@@ -86,6 +86,8 @@
"ok": "Aktiv"
},
"screen-list": {
+ "overridden-by-campaign": "Ja",
+ "not-overridden-by-campaign": "",
"columns": {
"campaign": "Kampagne",
"on-groups": "Tilknyttede grupper",
@@ -1246,5 +1248,8 @@
"insert-hard-break": "Ny linje",
"redo": "Gentag",
"undo": "Fortryd"
+ },
+ "screen-columns": {
+ "screen-groups-modal-title": "Skærmgrupper"
}
}
diff --git a/src/Dto/Screen.php b/src/Dto/Screen.php
index 825a013af..2e93364a9 100644
--- a/src/Dto/Screen.php
+++ b/src/Dto/Screen.php
@@ -55,4 +55,10 @@ 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 $inScreenGroupsLength = null;
}
diff --git a/src/State/ScreenProvider.php b/src/State/ScreenProvider.php
index f4015d9cf..f0ae4934e 100644
--- a/src/State/ScreenProvider.php
+++ b/src/State/ScreenProvider.php
@@ -9,6 +9,8 @@
use App\Dto\Screen as ScreenDTO;
use App\Entity\ScreenUser;
use App\Entity\Tenant\Screen;
+use App\Entity\Tenant\ScreenGroup;
+use App\Entity\Tenant\ScreenGroupCampaign;
use App\Repository\ScreenRepository;
class ScreenProvider extends AbstractProvider
@@ -49,11 +51,44 @@ public function toOutput(object $object): ScreenDTO
$iri = $this->iriConverter->getIriFromResource($object);
$output->campaigns = $iri.'/campaigns';
+ $screenCampaigns = $object->getScreenCampaigns();
+ $screenGroups = $object->getScreenGroups();
+
+ $campaigns = [];
+
+ foreach ($screenGroups as $screenGroup) {
+ foreach ($screenGroup->getScreenGroupCampaigns() as $screenGroupCampaign) {
+ $campaigns[] = $screenGroupCampaign->getCampaign();
+ }
+ }
+
+ foreach ($screenCampaigns as $screenCampaign) {
+ $campaigns[] = $screenCampaign->getCampaign();
+ }
+
+ $activeCampaigns = [];
+
+ foreach ($campaigns as $campaign) {
+ $publishedFrom = $campaign->getPublishedFrom();
+ $publishedTo = $campaign->getPublishedTo();
+
+ $now = new \DateTime();
+
+ if ($publishedFrom === null || $publishedFrom < $now) {
+ if ($publishedTo === null || $publishedTo > $now) {
+ $activeCampaigns[] = $campaign->getId();
+ }
+ }
+ }
+
+ $output->activeCampaignsLength = count(array_unique($activeCampaigns));
+
$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();
From ecb5928c515e3cfc3232ae8d9fa72e84be025301 Mon Sep 17 00:00:00 2001
From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com>
Date: Fri, 27 Mar 2026 10:24:38 +0100
Subject: [PATCH 02/31] 6871: Added playlist list optimizations
---
.../components/playlist/playlists-columns.jsx | 74 +++++++++++--------
.../admin/components/screen/screen-list.jsx | 1 -
assets/admin/translations/da/common.json | 3 +-
src/Dto/Playlist.php | 3 +
src/State/PlaylistProvider.php | 2 +
5 files changed, 51 insertions(+), 32 deletions(-)
diff --git a/assets/admin/components/playlist/playlists-columns.jsx b/assets/admin/components/playlist/playlists-columns.jsx
index 5657bcc1f..85d60614f 100644
--- a/assets/admin/components/playlist/playlists-columns.jsx
+++ b/assets/admin/components/playlist/playlists-columns.jsx
@@ -1,11 +1,49 @@
-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 { 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 = () => {
+ dispatch(
+ enhancedApi.endpoints.getV2PlaylistsByIdSlides.initiate({
+ id: idFromUrl(playlist.id)
+ }))
+ .then(({ data }) => {
+ const content =
+ {data["hydra:member"].map((playlistSlide) =>
+ -
+
+ {playlistSlide?.slide.title}
+
+
+ )}
+
;
+
+ setModal({
+ info: true,
+ modalTitle: t('playlist-slide-modal-title'),
+ content
+ });
+ });
+ };
+
+ return ;
+}
/**
* Columns for playlists lists.
@@ -17,40 +55,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 f01d4fb8b..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,
diff --git a/assets/admin/translations/da/common.json b/assets/admin/translations/da/common.json
index 4cf7abe67..32cf9c4e9 100644
--- a/assets/admin/translations/da/common.json
+++ b/assets/admin/translations/da/common.json
@@ -520,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",
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/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;
From 0a47efd048183f04461884966b4264795503f637 Mon Sep 17 00:00:00 2001
From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com>
Date: Mon, 30 Mar 2026 12:47:53 +0200
Subject: [PATCH 03/31] 6871: Added release.json generation to builds
---
.github/workflows/github_build_release.yml | 5 +++++
CHANGELOG.md | 1 +
infrastructure/build-n-push.sh | 2 ++
infrastructure/display-api-service/Dockerfile | 5 +++++
4 files changed, 13 insertions(+)
diff --git a/.github/workflows/github_build_release.yml b/.github/workflows/github_build_release.yml
index ea6bbe7c5..a59008b09 100644
--- a/.github/workflows/github_build_release.yml
+++ b/.github/workflows/github_build_release.yml
@@ -35,6 +35,11 @@ jobs:
docker compose run --rm node npm install
docker compose run --rm node npm run build
+ - name: Create release file
+ run: |
+ printf "{\n \"releaseTimestamp\": $(date +%s),\n \"releaseTime\": \"$(date)\",\n \"releaseVersion\": \"${{ github.ref_name }}\"\n}" > public/release.json
+ cat public/release.json
+
- name: Cleanup after install
run: |
sudo chown -R runner:runner .
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 44f5ee5e0..8d6f5f582 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -31,6 +31,7 @@ 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.
### NB! Prior to 3.x the project was split into separate repositories
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..c4fb14170 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_RELEASE_VERSION\"}" > public/release.json
+
# Remove files we do not need to the final image
RUN rm -rf package* vite.config.js
From 660246b7e29fb98fb4060e4d1ebb987018051db1 Mon Sep 17 00:00:00 2001
From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com>
Date: Mon, 30 Mar 2026 13:04:32 +0200
Subject: [PATCH 04/31] 6871: Added release variables to GitHub workflows
---
.github/workflows/docker_build_stg_images.yml | 9 ++++++++-
.github/workflows/github_build_release.yml | 9 +++++++++
2 files changed, 17 insertions(+), 1 deletion(-)
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 a59008b09..191dcc70a 100644
--- a/.github/workflows/github_build_release.yml
+++ b/.github/workflows/github_build_release.yml
@@ -107,6 +107,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
@@ -115,6 +120,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 }}
@@ -137,6 +144,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
From 5c0ae1c898d68298444b7167593c0686a7fd85b7 Mon Sep 17 00:00:00 2001
From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com>
Date: Mon, 30 Mar 2026 13:06:05 +0200
Subject: [PATCH 05/31] 6871: Fixed releaseVersion value
---
infrastructure/display-api-service/Dockerfile | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/infrastructure/display-api-service/Dockerfile b/infrastructure/display-api-service/Dockerfile
index c4fb14170..3d00a6beb 100644
--- a/infrastructure/display-api-service/Dockerfile
+++ b/infrastructure/display-api-service/Dockerfile
@@ -43,7 +43,7 @@ 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_RELEASE_VERSION\"}" > public/release.json
+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
From b6e8a793c947d919f06b3be490877e63ae87be5c Mon Sep 17 00:00:00 2001
From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com>
Date: Mon, 30 Mar 2026 14:30:20 +0200
Subject: [PATCH 06/31] 6871: Added screen dto campaigns length
---
src/Dto/Screen.php | 3 +++
src/State/ScreenProvider.php | 5 +++--
2 files changed, 6 insertions(+), 2 deletions(-)
diff --git a/src/Dto/Screen.php b/src/Dto/Screen.php
index 2e93364a9..97e089dea 100644
--- a/src/Dto/Screen.php
+++ b/src/Dto/Screen.php
@@ -59,6 +59,9 @@ class Screen
#[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/State/ScreenProvider.php b/src/State/ScreenProvider.php
index f0ae4934e..38b3df42f 100644
--- a/src/State/ScreenProvider.php
+++ b/src/State/ScreenProvider.php
@@ -58,12 +58,12 @@ public function toOutput(object $object): ScreenDTO
foreach ($screenGroups as $screenGroup) {
foreach ($screenGroup->getScreenGroupCampaigns() as $screenGroupCampaign) {
- $campaigns[] = $screenGroupCampaign->getCampaign();
+ $campaigns[$screenGroupCampaign->getCampaign()->getId()->toBase32()] = $screenGroupCampaign->getCampaign();
}
}
foreach ($screenCampaigns as $screenCampaign) {
- $campaigns[] = $screenCampaign->getCampaign();
+ $campaigns[$screenCampaign->getCampaign()->getId()->toBase32()] = $screenCampaign->getCampaign();
}
$activeCampaigns = [];
@@ -81,6 +81,7 @@ public function toOutput(object $object): ScreenDTO
}
}
+ $output->campaignsLength = count($campaigns);
$output->activeCampaignsLength = count(array_unique($activeCampaigns));
$objectIri = $this->iriConverter->getIriFromResource($object);
From 631e7ef3b90aec009967f4e3e9cc5bb4964144bc Mon Sep 17 00:00:00 2001
From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com>
Date: Tue, 31 Mar 2026 07:04:47 +0200
Subject: [PATCH 07/31] 6871: Fixed screen group list button
---
.../components/groups/groups-columns.jsx | 50 ++++++++++++++++---
src/Dto/ScreenGroup.php | 3 ++
src/State/ScreenGroupProvider.php | 2 +
3 files changed, 47 insertions(+), 8 deletions(-)
diff --git a/assets/admin/components/groups/groups-columns.jsx b/assets/admin/components/groups/groups-columns.jsx
index 3d06dac1f..72baed7b3 100644
--- a/assets/admin/components/groups/groups-columns.jsx
+++ b/assets/admin/components/groups/groups-columns.jsx
@@ -2,6 +2,47 @@ 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 { 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 = () => {
+ dispatch(
+ enhancedApi.endpoints.getV2ScreenGroupsByIdScreens.initiate({
+ id: idFromUrl(group.id)
+ }))
+ .then(({ data }) => {
+ const content =
+ {data["hydra:member"].map((screen) =>
+ -
+
+ {screen.title}
+
+
+ )}
+
;
+
+ setModal({
+ info: true,
+ modalTitle: t('screens-modal-title'),
+ content
+ });
+ });
+ };
+
+ return ;
+}
/**
* Columns for group lists.
@@ -18,14 +59,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/src/Dto/ScreenGroup.php b/src/Dto/ScreenGroup.php
index b301db41f..ba6db7891 100644
--- a/src/Dto/ScreenGroup.php
+++ b/src/Dto/ScreenGroup.php
@@ -28,4 +28,7 @@ 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;
}
diff --git a/src/State/ScreenGroupProvider.php b/src/State/ScreenGroupProvider.php
index 721926723..b5bc831cf 100644
--- a/src/State/ScreenGroupProvider.php
+++ b/src/State/ScreenGroupProvider.php
@@ -36,6 +36,8 @@ public function toOutput(object $object): ScreenGroupDTO
$output->campaigns = $iri.'/campaigns';
$output->screens = $iri.'/screens';
+ $output->screensLength = $object->getScreens()->count();
+
$output->setRelationsChecksum($object->getRelationsChecksum());
return $output;
From 968e0ac5aef20a96c203ee9de0fd3374aa12d2bc Mon Sep 17 00:00:00 2001
From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com>
Date: Tue, 31 Mar 2026 07:43:52 +0200
Subject: [PATCH 08/31] 6871: Fixed release loader to only fetch every 5
minutes and avoid multiple simultaneous fetches. Unified admin and config
release loader
---
README.md | 6 +-
.../admin/components/screen/screen-status.jsx | 7 +-
.../components/screen/util/campaign-icon.jsx | 105 ------------------
assets/client/service/release-service.js | 2 +-
assets/client/util/release-loader.js | 19 ----
assets/shared/release-loader.js | 58 ++++++++++
6 files changed, 65 insertions(+), 132 deletions(-)
delete mode 100644 assets/admin/components/screen/util/campaign-icon.jsx
delete mode 100644 assets/client/util/release-loader.js
create mode 100644 assets/shared/release-loader.js
diff --git a/README.md b/README.md
index 379c63fab..b3aab4b02 100644
--- a/README.md
+++ b/README.md
@@ -495,9 +495,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/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/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/release-loader.js b/assets/shared/release-loader.js
new file mode 100644
index 000000000..3f7bf70e3
--- /dev/null
+++ b/assets/shared/release-loader.js
@@ -0,0 +1,58 @@
+// Only fetch new release.json if more than 5 minutes have passed.
+const configFetchInterval = 5 * 60 * 1000;
+
+// Defaults.
+let releaseData = null;
+
+// Last time the config was fetched.
+let latestFetchTimestamp = 0;
+
+let activePromise = null;
+
+const ReleaseLoader = {
+ async loadRelease() {
+ if (activePromise) {
+ return activePromise;
+ }
+
+ activePromise = new Promise((resolve, reject) => {
+ const nowTimestamp = new Date().getTime();
+
+ if (
+ latestFetchTimestamp + configFetchInterval >= nowTimestamp
+ ) {
+ resolve(releaseData);
+ } else {
+ fetch(`/release.json?t=${nowTimestamp}`)
+ .then((response) => response.json())
+ .then((data) => {
+ latestFetchTimestamp = nowTimestamp;
+ releaseData = data;
+ resolve(releaseData);
+ })
+ .catch((err) => {
+ if (releaseData !== null) {
+ resolve(releaseData);
+ } else {
+ /* eslint-disable-next-line no-console */
+ console.warn("Could not find release.json. Returning defaults.");
+
+ return {
+ releaseTimestamp: null,
+ releaseVersion: null,
+ };
+ }
+ })
+ .finally(() => {
+ activePromise = null;
+ });
+ }
+ });
+
+ return activePromise;
+ },
+};
+
+Object.freeze(ReleaseLoader);
+
+export default ReleaseLoader;
From e44056ca15192fb78849c4fbf99efaad21a3287b Mon Sep 17 00:00:00 2001
From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com>
Date: Tue, 31 Mar 2026 07:47:51 +0200
Subject: [PATCH 09/31] 6871: Updated API specification and generated api
---
assets/shared/redux/generated-api.ts | 26 +++
assets/shared/release-loader.js | 4 +-
public/api-spec-v2.json | 276 +++++++++++++++++++++++++++
public/api-spec-v2.yaml | 184 ++++++++++++++++++
4 files changed, 488 insertions(+), 2 deletions(-)
diff --git a/assets/shared/redux/generated-api.ts b/assets/shared/redux/generated-api.ts
index 4e3282eab..eea427d55 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,7 @@ export type ScreenGroupJsonldCampaignsScreenGroupsRead = {
description?: string;
campaigns?: string;
screens?: string;
+ screensLength?: number | null;
relationsChecksum?: object;
};
export type ScreenGroupJsonldCampaignsScreenGroupsReadRead = {
@@ -1995,6 +1998,7 @@ export type ScreenGroupJsonldCampaignsScreenGroupsReadRead = {
description?: string;
campaigns?: string;
screens?: string;
+ screensLength?: number | null;
relationsChecksum?: object;
};
export type ScreenGroupCampaignJsonldCampaignsScreenGroupsRead = {
@@ -2030,6 +2034,7 @@ export type PlaylistJsonldCampaignsScreensRead = {
campaignScreenGroups?: CollectionJsonldCampaignsScreensRead | null;
tenants?: CollectionJsonldCampaignsScreensRead | null;
isCampaign?: boolean;
+ slidesLength?: number | null;
published?: string[];
relationsChecksum?: object;
};
@@ -2051,6 +2056,7 @@ export type PlaylistJsonldCampaignsScreensReadRead = {
campaignScreenGroups?: CollectionJsonldCampaignsScreensReadRead | null;
tenants?: CollectionJsonldCampaignsScreensReadRead | null;
isCampaign?: boolean;
+ slidesLength?: number | null;
published?: string[];
relationsChecksum?: object;
};
@@ -2068,6 +2074,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 +2102,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 +2128,7 @@ export type PlaylistPlaylistJsonld = {
campaignScreenGroups?: CollectionJsonld | null;
tenants?: CollectionJsonld | null;
isCampaign?: boolean;
+ slidesLength?: number | null;
published?: string[];
modifiedBy?: string;
createdBy?: string;
@@ -2142,6 +2155,7 @@ export type PlaylistPlaylistJsonldRead = {
campaignScreenGroups?: CollectionJsonldRead | null;
tenants?: CollectionJsonldRead | null;
isCampaign?: boolean;
+ slidesLength?: number | null;
published?: string[];
modifiedBy?: string;
createdBy?: string;
@@ -2207,6 +2221,7 @@ export type ScreenGroupScreenGroupJsonld = {
description?: string;
campaigns?: string;
screens?: string;
+ screensLength?: number | null;
modifiedBy?: string;
createdBy?: string;
id?: string;
@@ -2228,6 +2243,7 @@ export type ScreenGroupScreenGroupJsonldRead = {
description?: string;
campaigns?: string;
screens?: string;
+ screensLength?: number | null;
modifiedBy?: string;
createdBy?: string;
id?: string;
@@ -2261,6 +2277,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 +2310,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 +2356,7 @@ export type PlaylistJsonldPlaylistScreenRegionRead = {
campaignScreenGroups?: CollectionJsonldPlaylistScreenRegionRead | null;
tenants?: CollectionJsonldPlaylistScreenRegionRead | null;
isCampaign?: boolean;
+ slidesLength?: number | null;
published?: string[];
relationsChecksum?: object;
};
@@ -2355,6 +2378,7 @@ export type PlaylistJsonldPlaylistScreenRegionReadRead = {
campaignScreenGroups?: CollectionJsonldPlaylistScreenRegionReadRead | null;
tenants?: CollectionJsonldPlaylistScreenRegionReadRead | null;
isCampaign?: boolean;
+ slidesLength?: number | null;
published?: string[];
relationsChecksum?: object;
};
@@ -2375,6 +2399,7 @@ export type ScreenGroupScreenGroupJsonldScreensScreenGroupsRead = {
description?: string;
campaigns?: string;
screens?: string;
+ screensLength?: number | null;
relationsChecksum?: object;
};
export type ScreenGroupScreenGroupJsonldScreensScreenGroupsReadRead = {
@@ -2384,6 +2409,7 @@ export type ScreenGroupScreenGroupJsonldScreensScreenGroupsReadRead = {
description?: string;
campaigns?: string;
screens?: string;
+ screensLength?: number | null;
relationsChecksum?: object;
};
export type SlideSlideJsonld = {
diff --git a/assets/shared/release-loader.js b/assets/shared/release-loader.js
index 3f7bf70e3..d6ba5b17d 100644
--- a/assets/shared/release-loader.js
+++ b/assets/shared/release-loader.js
@@ -1,10 +1,10 @@
// Only fetch new release.json if more than 5 minutes have passed.
const configFetchInterval = 5 * 60 * 1000;
-// Defaults.
+// Fetched release data.
let releaseData = null;
-// Last time the config was fetched.
+// Last time the release was fetched.
let latestFetchTimestamp = 0;
let activePromise = null;
diff --git a/public/api-spec-v2.json b/public/api-spec-v2.json
index 0a05b36eb..3329b2735 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,12 @@
"screens": {
"type": "string"
},
+ "screensLength": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
"modifiedBy": {
"type": "string"
},
@@ -12581,6 +12791,12 @@
"screens": {
"type": "string"
},
+ "screensLength": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
"relationsChecksum": {
"type": "object"
}
@@ -12603,6 +12819,12 @@
"screens": {
"type": "string"
},
+ "screensLength": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
"relationsChecksum": {
"type": "object"
}
@@ -12635,6 +12857,12 @@
"screens": {
"type": "string"
},
+ "screensLength": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
"relationsChecksum": {
"type": "object"
}
@@ -12657,6 +12885,12 @@
"screens": {
"type": "string"
},
+ "screensLength": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
"modifiedBy": {
"type": "string"
},
@@ -12707,6 +12941,12 @@
"screens": {
"type": "string"
},
+ "screensLength": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
"relationsChecksum": {
"type": "object"
}
@@ -12764,6 +13004,12 @@
"screens": {
"type": "string"
},
+ "screensLength": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
"modifiedBy": {
"type": "string"
},
@@ -12830,6 +13076,12 @@
"screens": {
"type": "string"
},
+ "screensLength": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
"relationsChecksum": {
"type": "object"
}
@@ -12913,6 +13165,12 @@
"screens": {
"type": "string"
},
+ "screensLength": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
"modifiedBy": {
"type": "string"
},
@@ -12988,6 +13246,12 @@
"screens": {
"type": "string"
},
+ "screensLength": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
"relationsChecksum": {
"type": "object"
}
@@ -13045,6 +13309,12 @@
"screens": {
"type": "string"
},
+ "screensLength": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
"relationsChecksum": {
"type": "object"
}
@@ -13093,6 +13363,12 @@
"screens": {
"type": "string"
},
+ "screensLength": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
"relationsChecksum": {
"type": "object"
}
diff --git a/public/api-spec-v2.yaml b/public/api-spec-v2.yaml
index 687d694aa..9fcbf6999 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,10 @@ components:
type: string
screens:
type: string
+ screensLength:
+ type:
+ - integer
+ - 'null'
modifiedBy:
type: string
createdBy:
@@ -8783,6 +8923,10 @@ components:
type: string
screens:
type: string
+ screensLength:
+ type:
+ - integer
+ - 'null'
relationsChecksum:
type: object
ScreenGroup-screen-groups.campaigns.read:
@@ -8798,6 +8942,10 @@ components:
type: string
screens:
type: string
+ screensLength:
+ type:
+ - integer
+ - 'null'
relationsChecksum:
type: object
ScreenGroup-screen-groups.screens.read:
@@ -8820,6 +8968,10 @@ components:
type: string
screens:
type: string
+ screensLength:
+ type:
+ - integer
+ - 'null'
relationsChecksum:
type: object
ScreenGroup.ScreenGroup:
@@ -8835,6 +8987,10 @@ components:
type: string
screens:
type: string
+ screensLength:
+ type:
+ - integer
+ - 'null'
modifiedBy:
type: string
createdBy:
@@ -8870,6 +9026,10 @@ components:
type: string
screens:
type: string
+ screensLength:
+ type:
+ - integer
+ - 'null'
relationsChecksum:
type: object
ScreenGroup.ScreenGroup.jsonld:
@@ -8908,6 +9068,10 @@ components:
type: string
screens:
type: string
+ screensLength:
+ type:
+ - integer
+ - 'null'
modifiedBy:
type: string
createdBy:
@@ -8955,6 +9119,10 @@ components:
type: string
screens:
type: string
+ screensLength:
+ type:
+ - integer
+ - 'null'
relationsChecksum:
type: object
ScreenGroup.ScreenGroupInput:
@@ -9011,6 +9179,10 @@ components:
type: string
screens:
type: string
+ screensLength:
+ type:
+ - integer
+ - 'null'
modifiedBy:
type: string
createdBy:
@@ -9062,6 +9234,10 @@ components:
type: string
screens:
type: string
+ screensLength:
+ type:
+ - integer
+ - 'null'
relationsChecksum:
type: object
ScreenGroup.jsonld-screen-groups.campaigns.read:
@@ -9100,6 +9276,10 @@ components:
type: string
screens:
type: string
+ screensLength:
+ type:
+ - integer
+ - 'null'
relationsChecksum:
type: object
ScreenGroup.jsonld-screen-groups.screens.read:
@@ -9134,6 +9314,10 @@ components:
type: string
screens:
type: string
+ screensLength:
+ type:
+ - integer
+ - 'null'
relationsChecksum:
type: object
ScreenGroupCampaign:
From a9e48bbdc2de436a5161076fe91400aa28b6c17f Mon Sep 17 00:00:00 2001
From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com>
Date: Tue, 31 Mar 2026 08:44:45 +0200
Subject: [PATCH 10/31] 6871: Added tests for API additions
---
CHANGELOG.md | 2 +
Taskfile.yml | 2 +-
.../components/groups/groups-columns.jsx | 32 ++--
.../components/playlist/playlists-columns.jsx | 32 ++--
.../components/screen/util/screen-columns.jsx | 46 +++---
.../context/modal-context/info-modal.jsx | 19 ++-
assets/shared/release-loader.js | 4 +-
src/State/ScreenProvider.php | 6 +-
tests/Api/PlaylistsTest.php | 48 ++++++
tests/Api/ScreenGroupsTest.php | 42 ++++++
tests/Api/ScreensTest.php | 138 ++++++++++++++++++
11 files changed, 306 insertions(+), 65 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 561ad259e..b3733355e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -30,6 +30,8 @@ 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.
+- Optimized release data fetching.
+- Optimized list loading.
### NB! Prior to 3.x the project was split into separate repositories
diff --git a/Taskfile.yml b/Taskfile.yml
index d0bd361af..2b7ff4b09 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 72baed7b3..f64279ad4 100644
--- a/assets/admin/components/groups/groups-columns.jsx
+++ b/assets/admin/components/groups/groups-columns.jsx
@@ -17,11 +17,12 @@ function ScreensButton({ group }) {
const onClick = () => {
dispatch(
enhancedApi.endpoints.getV2ScreenGroupsByIdScreens.initiate({
- id: idFromUrl(group.id)
- }))
- .then(({ data }) => {
- const content =
- {data["hydra:member"].map((screen) =>
+ id: idFromUrl(group.id),
+ }),
+ ).then(({ data }) => {
+ const content = (
+
+ {data["hydra:member"].map((screen) => (
-
- )}
-
;
+ ))}
+
+ );
- setModal({
- info: true,
- modalTitle: t('screens-modal-title'),
- content
- });
+ setModal({
+ info: true,
+ modalTitle: t("screens-modal-title"),
+ content,
});
+ });
};
- return ;
+ return (
+
+ );
}
/**
diff --git a/assets/admin/components/playlist/playlists-columns.jsx b/assets/admin/components/playlist/playlists-columns.jsx
index 85d60614f..bfa129db0 100644
--- a/assets/admin/components/playlist/playlists-columns.jsx
+++ b/assets/admin/components/playlist/playlists-columns.jsx
@@ -18,11 +18,12 @@ function SlidesButton({ playlist }) {
const onClick = () => {
dispatch(
enhancedApi.endpoints.getV2PlaylistsByIdSlides.initiate({
- id: idFromUrl(playlist.id)
- }))
- .then(({ data }) => {
- const content =
- {data["hydra:member"].map((playlistSlide) =>
+ id: idFromUrl(playlist.id),
+ }),
+ ).then(({ data }) => {
+ const content = (
+
+ {data["hydra:member"].map((playlistSlide) => (
-
- )}
-
;
+ ))}
+
+ );
- setModal({
- info: true,
- modalTitle: t('playlist-slide-modal-title'),
- content
- });
+ setModal({
+ info: true,
+ modalTitle: t("playlist-slide-modal-title"),
+ content,
});
+ });
};
- return ;
+ return (
+
+ );
}
/**
diff --git a/assets/admin/components/screen/util/screen-columns.jsx b/assets/admin/components/screen/util/screen-columns.jsx
index cd582a2bf..b30ea8bd8 100644
--- a/assets/admin/components/screen/util/screen-columns.jsx
+++ b/assets/admin/components/screen/util/screen-columns.jsx
@@ -17,11 +17,12 @@ function ScreenGroupsButton({ screen }) {
const onClick = () => {
dispatch(
enhancedApi.endpoints.getV2ScreensByIdScreenGroups.initiate({
- id: idFromUrl(screen.id)
- }))
- .then(({ data }) => {
- const content =
- {data["hydra:member"].map((group) =>
+ id: idFromUrl(screen.id),
+ }),
+ ).then(({ data }) => {
+ const content = (
+
+ {data["hydra:member"].map((group) => (
-
- )}
-
;
+ ))}
+
+ );
- setModal({
- info: true,
- modalTitle: t('screen-groups-modal-title'),
- content
- });
+ setModal({
+ info: true,
+ modalTitle: t("screen-groups-modal-title"),
+ content,
});
+ });
};
- return ;
+ return (
+
+ );
}
/**
@@ -56,15 +62,13 @@ function getScreenColumns({ displayStatus }) {
const columns = [
{
- content: (screen) => (
-
- ),
+ content: (screen) => ,
key: "groups",
- label: t("columns.on-groups")
+ label: t("columns.on-groups"),
},
{
path: "location",
- label: t("columns.location")
+ label: t("columns.location"),
},
{
key: "campaign",
@@ -73,8 +77,8 @@ function getScreenColumns({ displayStatus }) {
if (screen.activeCampaignsLength > 0)
return t("overridden-by-campaign");
return t("not-overridden-by-campaign");
- }
- }
+ },
+ },
];
if (displayStatus) {
@@ -83,7 +87,7 @@ function getScreenColumns({ displayStatus }) {
label: t("columns.status"),
content: (screen) => {
return ;
- }
+ },
});
}
diff --git a/assets/admin/context/modal-context/info-modal.jsx b/assets/admin/context/modal-context/info-modal.jsx
index 95a7fb85c..1ef774279 100644
--- a/assets/admin/context/modal-context/info-modal.jsx
+++ b/assets/admin/context/modal-context/info-modal.jsx
@@ -19,22 +19,21 @@ import idFromUrl from "../../components/util/helpers/id-from-url";
* @returns {object} The modal.
*/
function InfoModal({
- unSetModal,
- apiCall,
- displayData = [],
- modalTitle,
- dataKey = "",
- redirectTo,
- content
- }) {
-
+ unSetModal,
+ apiCall,
+ displayData = [],
+ modalTitle,
+ dataKey = "",
+ redirectTo,
+ content,
+}) {
const { t } = useTranslation("common");
const [fetchedData, setFetchedData] = useState([]);
let data;
if (!Array.isArray(displayData)) {
data = apiCall({
id: idFromUrl(displayData),
- itemsPerPage: 30
+ itemsPerPage: 30,
});
}
diff --git a/assets/shared/release-loader.js b/assets/shared/release-loader.js
index d6ba5b17d..51bac7470 100644
--- a/assets/shared/release-loader.js
+++ b/assets/shared/release-loader.js
@@ -18,9 +18,7 @@ const ReleaseLoader = {
activePromise = new Promise((resolve, reject) => {
const nowTimestamp = new Date().getTime();
- if (
- latestFetchTimestamp + configFetchInterval >= nowTimestamp
- ) {
+ if (latestFetchTimestamp + configFetchInterval >= nowTimestamp) {
resolve(releaseData);
} else {
fetch(`/release.json?t=${nowTimestamp}`)
diff --git a/src/State/ScreenProvider.php b/src/State/ScreenProvider.php
index 38b3df42f..a865cdc54 100644
--- a/src/State/ScreenProvider.php
+++ b/src/State/ScreenProvider.php
@@ -9,8 +9,6 @@
use App\Dto\Screen as ScreenDTO;
use App\Entity\ScreenUser;
use App\Entity\Tenant\Screen;
-use App\Entity\Tenant\ScreenGroup;
-use App\Entity\Tenant\ScreenGroupCampaign;
use App\Repository\ScreenRepository;
class ScreenProvider extends AbstractProvider
@@ -74,8 +72,8 @@ public function toOutput(object $object): ScreenDTO
$now = new \DateTime();
- if ($publishedFrom === null || $publishedFrom < $now) {
- if ($publishedTo === null || $publishedTo > $now) {
+ if (null === $publishedFrom || $publishedFrom < $now) {
+ if (null === $publishedTo || $publishedTo > $now) {
$activeCampaigns[] = $campaign->getId();
}
}
diff --git a/tests/Api/PlaylistsTest.php b/tests/Api/PlaylistsTest.php
index d0fabd239..6435597c6 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;
@@ -388,6 +389,53 @@ 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]);
+ }
+
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..6231c6cb8 100644
--- a/tests/Api/ScreenGroupsTest.php
+++ b/tests/Api/ScreenGroupsTest.php
@@ -135,6 +135,48 @@ 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 testGetScreenGroupsScreenRelations(): void
{
$client = $this->getAuthenticatedClient('ROLE_SCREEN');
diff --git a/tests/Api/ScreensTest.php b/tests/Api/ScreensTest.php
index 1ea349674..59d9715fe 100644
--- a/tests/Api/ScreensTest.php
+++ b/tests/Api/ScreensTest.php
@@ -258,6 +258,144 @@ 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 testDeleteScreen(): void
{
$client = $this->getAuthenticatedClient('ROLE_ADMIN');
From f669a57b04c8827d4c29964fa344f1f0d77f783d Mon Sep 17 00:00:00 2001
From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com>
Date: Tue, 31 Mar 2026 09:04:11 +0200
Subject: [PATCH 11/31] 6871: Fixed test
---
tests/Api/PlaylistsTest.php | 3 +++
1 file changed, 3 insertions(+)
diff --git a/tests/Api/PlaylistsTest.php b/tests/Api/PlaylistsTest.php
index 6435597c6..1fdec7152 100644
--- a/tests/Api/PlaylistsTest.php
+++ b/tests/Api/PlaylistsTest.php
@@ -434,6 +434,9 @@ public function testSlidesLength(): void
$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
From 8610bf3ab331ecf3aa76e8495b6e6e8ce52b2250 Mon Sep 17 00:00:00 2001
From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com>
Date: Tue, 31 Mar 2026 10:07:00 +0200
Subject: [PATCH 12/31] 6871: Fixed disabled button when no results
---
assets/admin/components/groups/groups-columns.jsx | 5 ++---
assets/admin/components/playlist/playlists-columns.jsx | 2 +-
assets/admin/components/screen/util/screen-columns.jsx | 2 +-
3 files changed, 4 insertions(+), 5 deletions(-)
diff --git a/assets/admin/components/groups/groups-columns.jsx b/assets/admin/components/groups/groups-columns.jsx
index f64279ad4..c3a5fedac 100644
--- a/assets/admin/components/groups/groups-columns.jsx
+++ b/assets/admin/components/groups/groups-columns.jsx
@@ -1,5 +1,4 @@
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";
@@ -44,8 +43,8 @@ function ScreensButton({ group }) {
};
return (
-