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 = ; + + 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 ( - ); } diff --git a/assets/admin/components/playlist/playlists-columns.jsx b/assets/admin/components/playlist/playlists-columns.jsx index bfa129db0..98ed3c214 100644 --- a/assets/admin/components/playlist/playlists-columns.jsx +++ b/assets/admin/components/playlist/playlists-columns.jsx @@ -45,7 +45,7 @@ function SlidesButton({ playlist }) { }; return ( - ); diff --git a/assets/admin/components/screen/util/screen-columns.jsx b/assets/admin/components/screen/util/screen-columns.jsx index b30ea8bd8..93c5901b8 100644 --- a/assets/admin/components/screen/util/screen-columns.jsx +++ b/assets/admin/components/screen/util/screen-columns.jsx @@ -44,7 +44,7 @@ function ScreenGroupsButton({ screen }) { }; return ( - ); From c7ba7c618cb51ce6b4df44db148f2f57077dd393 Mon Sep 17 00:00:00 2001 From: jekuaitk Date: Wed, 1 Apr 2026 09:58:32 +0200 Subject: [PATCH 13/31] 6871: Moved campaign count logic to sql query --- src/Repository/ScreenRepository.php | 49 +++++++++++++++++++++++++++++ src/State/ScreenProvider.php | 39 +++-------------------- 2 files changed, 54 insertions(+), 34 deletions(-) diff --git a/src/Repository/ScreenRepository.php b/src/Repository/ScreenRepository.php index d469b79fa..6a0beacc9 100644 --- a/src/Repository/ScreenRepository.php +++ b/src/Repository/ScreenRepository.php @@ -32,4 +32,53 @@ public function getScreensByScreenGroupId(Ulid $screenGroupUlid): QueryBuilder return $queryBuilder; } + + /** + * Get total and 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. + * + * @return array{total: int, active: int} + */ + public function getCampaignCountsForScreen(Ulid $screenId): array + { + $conn = $this->getEntityManager()->getConnection(); + $now = (new \DateTime())->format('Y-m-d H:i:s'); + + $sql = <<<'SQL' + SELECT + COUNT(DISTINCT p.id) AS total, + COUNT(DISTINCT CASE + WHEN (p.published_from IS NULL OR p.published_from < :now) + AND (p.published_to IS NULL OR p.published_to > :now) + THEN p.id + END) AS active + FROM ( + -- Campaigns directly assigned to the screen + SELECT sc.campaign_id + FROM screen_campaign sc + WHERE sc.screen_id = :screenId + UNION + -- Campaigns assigned via screen groups the screen belongs to + SELECT sgc.campaign_id + FROM screen_group_campaign sgc + INNER JOIN screen_group_screen sgs ON sgs.screen_group_id = sgc.screen_group_id + WHERE sgs.screen_id = :screenId + ) AS all_campaigns + INNER JOIN playlist p ON p.id = all_campaigns.campaign_id + SQL; + + $result = $conn->executeQuery($sql, [ + 'screenId' => $screenId->toBinary(), + 'now' => $now, + ])->fetchAssociative(); + + return [ + 'total' => (int) $result['total'], + 'active' => (int) $result['active'], + ]; + } } diff --git a/src/State/ScreenProvider.php b/src/State/ScreenProvider.php index a865cdc54..aa75ec89e 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,38 +49,9 @@ 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()->getId()->toBase32()] = $screenGroupCampaign->getCampaign(); - } - } - - foreach ($screenCampaigns as $screenCampaign) { - $campaigns[$screenCampaign->getCampaign()->getId()->toBase32()] = $screenCampaign->getCampaign(); - } - - $activeCampaigns = []; - - foreach ($campaigns as $campaign) { - $publishedFrom = $campaign->getPublishedFrom(); - $publishedTo = $campaign->getPublishedTo(); - - $now = new \DateTime(); - - if (null === $publishedFrom || $publishedFrom < $now) { - if (null === $publishedTo || $publishedTo > $now) { - $activeCampaigns[] = $campaign->getId(); - } - } - } - - $output->campaignsLength = count($campaigns); - $output->activeCampaignsLength = count(array_unique($activeCampaigns)); + $campaignCounts = $this->screenRepository->getCampaignCountsForScreen($object->getId()); + $output->campaignsLength = $campaignCounts['total'] ?? 0; + $output->activeCampaignsLength = $campaignCounts['active'] ?? 0; $objectIri = $this->iriConverter->getIriFromResource($object); foreach ($layout->getRegions() as $region) { From 6aa6b4a58dcd2d5561032f69273a259764dc7231 Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Wed, 1 Apr 2026 14:03:20 +0200 Subject: [PATCH 14/31] 6871: Fixed campaigns button --- .../components/screen/util/screen-columns.jsx | 191 ++++++++++++++++-- assets/admin/translations/da/common.json | 12 +- src/Dto/ScreenGroup.php | 3 + src/Repository/ScreenRepository.php | 74 +++---- src/State/ScreenGroupProvider.php | 1 + src/State/ScreenProvider.php | 5 +- 6 files changed, 228 insertions(+), 58 deletions(-) diff --git a/assets/admin/components/screen/util/screen-columns.jsx b/assets/admin/components/screen/util/screen-columns.jsx index 93c5901b8..545aeb001 100644 --- a/assets/admin/components/screen/util/screen-columns.jsx +++ b/assets/admin/components/screen/util/screen-columns.jsx @@ -8,6 +8,8 @@ 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"; +import { useEffect, useState } from "react"; +import calculateIsPublished from "../../util/helpers/calculate-is-published.jsx"; function ScreenGroupsButton({ screen }) { const { t } = useTranslation("common", { keyPrefix: "screen-columns" }); @@ -17,8 +19,8 @@ function ScreenGroupsButton({ screen }) { const onClick = () => { dispatch( enhancedApi.endpoints.getV2ScreensByIdScreenGroups.initiate({ - id: idFromUrl(screen.id), - }), + id: idFromUrl(screen.id) + }) ).then(({ data }) => { const content = (
    @@ -38,7 +40,7 @@ function ScreenGroupsButton({ screen }) { setModal({ info: true, modalTitle: t("screen-groups-modal-title"), - content, + content }); }); }; @@ -50,6 +52,175 @@ function ScreenGroupsButton({ screen }) { ); } +function getAllScreenGroups(dispatch, screenId = null, results = [], page = 1) { + return new Promise((resolve, reject) => { + if (screenId === null) { + resolve(results); + } else { + dispatch(enhancedApi.endpoints.getV2ScreensByIdScreenGroups.initiate({ + id: screenId, + page: page + })).then(({ data }) => { + const newResults = [...results, ...data["hydra:member"]]; + const hydraView = data["hydra:view"] ?? null; + + if (hydraView !== null && (hydraView["hydra:next"] ?? false)) { + resolve(getAllScreenGroups(dispatch, screenId, newResults, page + 1)); + } else { + resolve(newResults); + } + }); + } + }); +} + +function getAllScreenGroupCampaigns(dispatch, screenGroupId = null, screenGroupIds = [], results = [], page = 1) { + return new Promise((resolve, reject) => { + if (screenGroupId === null) { + resolve(results); + } else { + dispatch(enhancedApi.endpoints.getV2ScreenGroupsByIdCampaigns.initiate({ + id: screenGroupId, + page: page + })).then(({ data }) => { + const newResults = [...results, ...data["hydra:member"]]; + const hydraView = data["hydra:view"] ?? null; + + if (hydraView !== null && (hydraView["hydra:next"] ?? false)) { + resolve(getAllScreenGroupCampaigns(dispatch, screenGroupId, screenGroupIds, newResults, page + 1)); + } else { + const newScreenGroupIds = screenGroupIds.filter((id) => id !== screenGroupId); + if (newScreenGroupIds.length === 0) { + resolve(newResults); + } else { + resolve(getAllScreenGroupCampaigns(dispatch, newScreenGroupIds[0], newScreenGroupIds, newResults, 1)); + } + } + }); + } + }); +} + +function getAllScreenCampaigns(dispatch, screenId = null, results = [], page = 1) { + return new Promise((resolve, reject) => { + if (screenId === null) { + resolve(results); + } else { + dispatch(enhancedApi.endpoints.getV2ScreensByIdCampaigns.initiate({ + id: screenId, + page: page + })).then(({ data }) => { + const newResults = [...results, ...data["hydra:member"]]; + const hydraView = data["hydra:view"] ?? null; + + if (hydraView !== null && (hydraView["hydra:next"] ?? false)) { + resolve(getAllScreenCampaigns(dispatch, screenId, newResults, page + 1)); + } else { + resolve(newResults); + } + }); + } + }); +} + +function getAllCampaigns(dispatch, campaignIds = [], results = []) { + return new Promise((resolve, reject) => { + 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. + getAllScreenGroups(dispatch, screen.id).then((screenGroups) => { + const screenGroupIds = screenGroups.filter(({ campaignsLength }) => campaignsLength > 0).map((group) => idFromUrl(group["@id"])); + + getAllScreenGroupCampaigns(dispatch, screenGroupIds[0] ?? null, screenGroupIds).then((screenGroupCampaigns) => { + getAllScreenCampaigns(dispatch, 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"])); + + getAllCampaigns(dispatch, uniqueCampaigns.map((campaign) => idFromUrl(campaign["@id"]))).then((campaigns) => { + setCampaigns(campaigns); + 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 ( + + ); +} + /** * Columns for screens lists. * @@ -64,21 +235,17 @@ function getScreenColumns({ displayStatus }) { { 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"), - content: (screen) => { - if (screen.activeCampaignsLength > 0) - return t("overridden-by-campaign"); - return t("not-overridden-by-campaign"); - }, - }, + content: (screen) => + } ]; if (displayStatus) { @@ -87,7 +254,7 @@ function getScreenColumns({ displayStatus }) { label: t("columns.status"), content: (screen) => { return ; - }, + } }); } diff --git a/assets/admin/translations/da/common.json b/assets/admin/translations/da/common.json index 32cf9c4e9..cbfe76ac4 100644 --- a/assets/admin/translations/da/common.json +++ b/assets/admin/translations/da/common.json @@ -89,8 +89,8 @@ "overridden-by-campaign": "Ja", "not-overridden-by-campaign": "", "columns": { - "campaign": "Kampagne", - "on-groups": "Tilknyttede grupper", + "campaign": "Kampagner", + "on-groups": "Grupper", "location": "Lokation", "status": "Status" }, @@ -1144,10 +1144,6 @@ "saving-activation-code": "Opretter aktiveringskode" } }, - "campaign-icon": { - "overridden-by-campaign": "Ja", - "not-overridden-by-campaign": "" - }, "published-state": { "active": "Aktiv", "future": "Fremtidig", @@ -1251,6 +1247,8 @@ "undo": "Fortryd" }, "screen-columns": { - "screen-groups-modal-title": "Skærmgrupper" + "screen-groups-modal-title": "Skærmgrupper", + "campaigns-modal-title": "Kampagner", + "active": "aktive" } } diff --git a/src/Dto/ScreenGroup.php b/src/Dto/ScreenGroup.php index ba6db7891..2d2410e8d 100644 --- a/src/Dto/ScreenGroup.php +++ b/src/Dto/ScreenGroup.php @@ -31,4 +31,7 @@ class ScreenGroup #[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/Repository/ScreenRepository.php b/src/Repository/ScreenRepository.php index 6a0beacc9..da0c2b564 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; @@ -34,51 +39,48 @@ public function getScreensByScreenGroupId(Ulid $screenGroupUlid): QueryBuilder } /** - * Get total and active campaign counts for a screen via a single SQL query. + * 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. + * based on published_from/published_to dates if $active is true. * - * @return array{total: int, active: int} + * @throws NoResultException + * @throws NonUniqueResultException + * + * @return int Number of campaigns for the screen. */ - public function getCampaignCountsForScreen(Ulid $screenId): array + public function getCampaignCountForScreen(Ulid $screenId, bool $activeCampaigns = false): int { - $conn = $this->getEntityManager()->getConnection(); - $now = (new \DateTime())->format('Y-m-d H:i:s'); + $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'); - $sql = <<<'SQL' - SELECT - COUNT(DISTINCT p.id) AS total, - COUNT(DISTINCT CASE - WHEN (p.published_from IS NULL OR p.published_from < :now) - AND (p.published_to IS NULL OR p.published_to > :now) - THEN p.id - END) AS active - FROM ( - -- Campaigns directly assigned to the screen - SELECT sc.campaign_id - FROM screen_campaign sc - WHERE sc.screen_id = :screenId - UNION - -- Campaigns assigned via screen groups the screen belongs to - SELECT sgc.campaign_id - FROM screen_group_campaign sgc - INNER JOIN screen_group_screen sgs ON sgs.screen_group_id = sgc.screen_group_id - WHERE sgs.screen_id = :screenId - ) AS all_campaigns - INNER JOIN playlist p ON p.id = all_campaigns.campaign_id - SQL; + $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'); - $result = $conn->executeQuery($sql, [ - 'screenId' => $screenId->toBinary(), - 'now' => $now, - ])->fetchAssociative(); + 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 [ - 'total' => (int) $result['total'], - 'active' => (int) $result['active'], - ]; + return (int) $qb->getQuery()->getSingleScalarResult(); } } diff --git a/src/State/ScreenGroupProvider.php b/src/State/ScreenGroupProvider.php index b5bc831cf..a64371bb3 100644 --- a/src/State/ScreenGroupProvider.php +++ b/src/State/ScreenGroupProvider.php @@ -37,6 +37,7 @@ public function toOutput(object $object): ScreenGroupDTO $output->screens = $iri.'/screens'; $output->screensLength = $object->getScreens()->count(); + $output->campaignsLength = $object->getScreenGroupCampaigns()->count(); $output->setRelationsChecksum($object->getRelationsChecksum()); diff --git a/src/State/ScreenProvider.php b/src/State/ScreenProvider.php index aa75ec89e..da58d7d5a 100644 --- a/src/State/ScreenProvider.php +++ b/src/State/ScreenProvider.php @@ -49,9 +49,8 @@ public function toOutput(object $object): ScreenDTO $iri = $this->iriConverter->getIriFromResource($object); $output->campaigns = $iri.'/campaigns'; - $campaignCounts = $this->screenRepository->getCampaignCountsForScreen($object->getId()); - $output->campaignsLength = $campaignCounts['total'] ?? 0; - $output->activeCampaignsLength = $campaignCounts['active'] ?? 0; + $output->campaignsLength = $this->screenRepository->getCampaignCountForScreen($object->getId()); + $output->activeCampaignsLength = $this->screenRepository->getCampaignCountForScreen($object->getId(), true); $objectIri = $this->iriConverter->getIriFromResource($object); foreach ($layout->getRegions() as $region) { From 6e01f09ae474d0d060b6f28534409347183d6f27 Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Wed, 1 Apr 2026 14:19:13 +0200 Subject: [PATCH 15/31] 6871: Moved button code to separate files --- .../screen/util/campaigns-button.jsx | 180 ++++++++++++++ .../components/screen/util/screen-columns.jsx | 224 +----------------- .../screen/util/screen-groups-button.jsx | 50 ++++ assets/admin/translations/da/common.json | 2 +- 4 files changed, 234 insertions(+), 222 deletions(-) create mode 100644 assets/admin/components/screen/util/campaigns-button.jsx create mode 100644 assets/admin/components/screen/util/screen-groups-button.jsx 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..8dd210eed --- /dev/null +++ b/assets/admin/components/screen/util/campaigns-button.jsx @@ -0,0 +1,180 @@ +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 { 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 getAllScreenGroups(dispatch, screenId = null, results = [], page = 1) { + return new Promise((resolve, reject) => { + if (screenId === null) { + resolve(results); + } else { + dispatch(enhancedApi.endpoints.getV2ScreensByIdScreenGroups.initiate({ + id: screenId, + page: page + })).then(({ data }) => { + const newResults = [...results, ...data["hydra:member"]]; + const hydraView = data["hydra:view"] ?? null; + + if (hydraView !== null && (hydraView["hydra:next"] ?? false)) { + resolve(getAllScreenGroups(dispatch, screenId, newResults, page + 1)); + } else { + resolve(newResults); + } + }); + } + }); +} + +function getAllScreenGroupCampaigns(dispatch, screenGroupId = null, screenGroupIds = [], results = [], page = 1) { + return new Promise((resolve, reject) => { + if (screenGroupId === null) { + resolve(results); + } else { + dispatch(enhancedApi.endpoints.getV2ScreenGroupsByIdCampaigns.initiate({ + id: screenGroupId, + page: page + })).then(({ data }) => { + const newResults = [...results, ...data["hydra:member"]]; + const hydraView = data["hydra:view"] ?? null; + + if (hydraView !== null && (hydraView["hydra:next"] ?? false)) { + resolve(getAllScreenGroupCampaigns(dispatch, screenGroupId, screenGroupIds, newResults, page + 1)); + } else { + const newScreenGroupIds = screenGroupIds.filter((id) => id !== screenGroupId); + if (newScreenGroupIds.length === 0) { + resolve(newResults); + } else { + resolve(getAllScreenGroupCampaigns(dispatch, newScreenGroupIds[0], newScreenGroupIds, newResults, 1)); + } + } + }); + } + }); +} + +function getAllScreenCampaigns(dispatch, screenId = null, results = [], page = 1) { + return new Promise((resolve, reject) => { + if (screenId === null) { + resolve(results); + } else { + dispatch(enhancedApi.endpoints.getV2ScreensByIdCampaigns.initiate({ + id: screenId, + page: page + })).then(({ data }) => { + const newResults = [...results, ...data["hydra:member"]]; + const hydraView = data["hydra:view"] ?? null; + + if (hydraView !== null && (hydraView["hydra:next"] ?? false)) { + resolve(getAllScreenCampaigns(dispatch, screenId, newResults, page + 1)); + } else { + resolve(newResults); + } + }); + } + }); +} + +function getAllCampaigns(dispatch, campaignIds = [], results = []) { + return new Promise((resolve, reject) => { + 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. + getAllScreenGroups(dispatch, screen.id).then((screenGroups) => { + const screenGroupIds = screenGroups.filter(({ campaignsLength }) => campaignsLength > 0).map((group) => idFromUrl(group["@id"])); + + getAllScreenGroupCampaigns(dispatch, screenGroupIds[0] ?? null, screenGroupIds).then((screenGroupCampaigns) => { + getAllScreenCampaigns(dispatch, 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"])); + + getAllCampaigns(dispatch, uniqueCampaigns.map((campaign) => idFromUrl(campaign["@id"]))).then((campaigns) => { + setCampaigns(campaigns); + 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 545aeb001..af0d8a5ad 100644 --- a/assets/admin/components/screen/util/screen-columns.jsx +++ b/assets/admin/components/screen/util/screen-columns.jsx @@ -1,225 +1,9 @@ import { useTranslation } from "react-i18next"; 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"; -import { useEffect, useState } from "react"; -import calculateIsPublished from "../../util/helpers/calculate-is-published.jsx"; - -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 ( - - ); -} - -function getAllScreenGroups(dispatch, screenId = null, results = [], page = 1) { - return new Promise((resolve, reject) => { - if (screenId === null) { - resolve(results); - } else { - dispatch(enhancedApi.endpoints.getV2ScreensByIdScreenGroups.initiate({ - id: screenId, - page: page - })).then(({ data }) => { - const newResults = [...results, ...data["hydra:member"]]; - const hydraView = data["hydra:view"] ?? null; - - if (hydraView !== null && (hydraView["hydra:next"] ?? false)) { - resolve(getAllScreenGroups(dispatch, screenId, newResults, page + 1)); - } else { - resolve(newResults); - } - }); - } - }); -} - -function getAllScreenGroupCampaigns(dispatch, screenGroupId = null, screenGroupIds = [], results = [], page = 1) { - return new Promise((resolve, reject) => { - if (screenGroupId === null) { - resolve(results); - } else { - dispatch(enhancedApi.endpoints.getV2ScreenGroupsByIdCampaigns.initiate({ - id: screenGroupId, - page: page - })).then(({ data }) => { - const newResults = [...results, ...data["hydra:member"]]; - const hydraView = data["hydra:view"] ?? null; - - if (hydraView !== null && (hydraView["hydra:next"] ?? false)) { - resolve(getAllScreenGroupCampaigns(dispatch, screenGroupId, screenGroupIds, newResults, page + 1)); - } else { - const newScreenGroupIds = screenGroupIds.filter((id) => id !== screenGroupId); - if (newScreenGroupIds.length === 0) { - resolve(newResults); - } else { - resolve(getAllScreenGroupCampaigns(dispatch, newScreenGroupIds[0], newScreenGroupIds, newResults, 1)); - } - } - }); - } - }); -} - -function getAllScreenCampaigns(dispatch, screenId = null, results = [], page = 1) { - return new Promise((resolve, reject) => { - if (screenId === null) { - resolve(results); - } else { - dispatch(enhancedApi.endpoints.getV2ScreensByIdCampaigns.initiate({ - id: screenId, - page: page - })).then(({ data }) => { - const newResults = [...results, ...data["hydra:member"]]; - const hydraView = data["hydra:view"] ?? null; - - if (hydraView !== null && (hydraView["hydra:next"] ?? false)) { - resolve(getAllScreenCampaigns(dispatch, screenId, newResults, page + 1)); - } else { - resolve(newResults); - } - }); - } - }); -} - -function getAllCampaigns(dispatch, campaignIds = [], results = []) { - return new Promise((resolve, reject) => { - 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. - getAllScreenGroups(dispatch, screen.id).then((screenGroups) => { - const screenGroupIds = screenGroups.filter(({ campaignsLength }) => campaignsLength > 0).map((group) => idFromUrl(group["@id"])); - - getAllScreenGroupCampaigns(dispatch, screenGroupIds[0] ?? null, screenGroupIds).then((screenGroupCampaigns) => { - getAllScreenCampaigns(dispatch, 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"])); - - getAllCampaigns(dispatch, uniqueCampaigns.map((campaign) => idFromUrl(campaign["@id"]))).then((campaigns) => { - setCampaigns(campaigns); - 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 ( - - ); -} +import CampaignsButton from "./campaigns-button.jsx"; +import ScreenGroupsButton from "./screen-groups-button.jsx"; /** * Columns for screens lists. @@ -252,9 +36,7 @@ function getScreenColumns({ displayStatus }) { 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..ce1cddba0 --- /dev/null +++ b/assets/admin/components/screen/util/screen-groups-button.jsx @@ -0,0 +1,50 @@ +import { useTranslation } from "react-i18next"; +import idFromUrl from "../../util/helpers/id-from-url"; +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 ( + + ); +} + +export default ScreenGroupsButton; diff --git a/assets/admin/translations/da/common.json b/assets/admin/translations/da/common.json index cbfe76ac4..2ae6cba56 100644 --- a/assets/admin/translations/da/common.json +++ b/assets/admin/translations/da/common.json @@ -1249,6 +1249,6 @@ "screen-columns": { "screen-groups-modal-title": "Skærmgrupper", "campaigns-modal-title": "Kampagner", - "active": "aktive" + "active": "Aktiv" } } From 3d2b183d5aeb5c415a5d06b58e4e9df172517922 Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Wed, 1 Apr 2026 14:28:41 +0200 Subject: [PATCH 16/31] 6871: Applied coding standards --- .../components/groups/groups-columns.jsx | 7 +- .../components/playlist/playlists-columns.jsx | 7 +- .../screen/util/campaigns-button.jsx | 164 +++++++++++++----- .../components/screen/util/screen-columns.jsx | 8 +- .../screen/util/screen-groups-button.jsx | 13 +- src/Repository/ScreenRepository.php | 4 +- src/State/ScreenProvider.php | 8 +- 7 files changed, 152 insertions(+), 59 deletions(-) diff --git a/assets/admin/components/groups/groups-columns.jsx b/assets/admin/components/groups/groups-columns.jsx index c3a5fedac..1880c1f03 100644 --- a/assets/admin/components/groups/groups-columns.jsx +++ b/assets/admin/components/groups/groups-columns.jsx @@ -43,7 +43,12 @@ function ScreensButton({ group }) { }; return ( - ); diff --git a/assets/admin/components/playlist/playlists-columns.jsx b/assets/admin/components/playlist/playlists-columns.jsx index 98ed3c214..8dc3c35a9 100644 --- a/assets/admin/components/playlist/playlists-columns.jsx +++ b/assets/admin/components/playlist/playlists-columns.jsx @@ -45,7 +45,12 @@ function SlidesButton({ playlist }) { }; return ( - ); diff --git a/assets/admin/components/screen/util/campaigns-button.jsx b/assets/admin/components/screen/util/campaigns-button.jsx index 8dd210eed..d14dda6f3 100644 --- a/assets/admin/components/screen/util/campaigns-button.jsx +++ b/assets/admin/components/screen/util/campaigns-button.jsx @@ -13,10 +13,12 @@ function getAllScreenGroups(dispatch, screenId = null, results = [], page = 1) { if (screenId === null) { resolve(results); } else { - dispatch(enhancedApi.endpoints.getV2ScreensByIdScreenGroups.initiate({ - id: screenId, - page: page - })).then(({ data }) => { + dispatch( + enhancedApi.endpoints.getV2ScreensByIdScreenGroups.initiate({ + id: screenId, + page: page, + }), + ).then(({ data }) => { const newResults = [...results, ...data["hydra:member"]]; const hydraView = data["hydra:view"] ?? null; @@ -30,26 +32,52 @@ function getAllScreenGroups(dispatch, screenId = null, results = [], page = 1) { }); } -function getAllScreenGroupCampaigns(dispatch, screenGroupId = null, screenGroupIds = [], results = [], page = 1) { +function getAllScreenGroupCampaigns( + dispatch, + screenGroupId = null, + screenGroupIds = [], + results = [], + page = 1, +) { return new Promise((resolve, reject) => { if (screenGroupId === null) { resolve(results); } else { - dispatch(enhancedApi.endpoints.getV2ScreenGroupsByIdCampaigns.initiate({ - id: screenGroupId, - page: page - })).then(({ data }) => { + dispatch( + enhancedApi.endpoints.getV2ScreenGroupsByIdCampaigns.initiate({ + id: screenGroupId, + page: page, + }), + ).then(({ data }) => { const newResults = [...results, ...data["hydra:member"]]; const hydraView = data["hydra:view"] ?? null; if (hydraView !== null && (hydraView["hydra:next"] ?? false)) { - resolve(getAllScreenGroupCampaigns(dispatch, screenGroupId, screenGroupIds, newResults, page + 1)); + resolve( + getAllScreenGroupCampaigns( + dispatch, + screenGroupId, + screenGroupIds, + newResults, + page + 1, + ), + ); } else { - const newScreenGroupIds = screenGroupIds.filter((id) => id !== screenGroupId); + const newScreenGroupIds = screenGroupIds.filter( + (id) => id !== screenGroupId, + ); if (newScreenGroupIds.length === 0) { resolve(newResults); } else { - resolve(getAllScreenGroupCampaigns(dispatch, newScreenGroupIds[0], newScreenGroupIds, newResults, 1)); + resolve( + getAllScreenGroupCampaigns( + dispatch, + newScreenGroupIds[0], + newScreenGroupIds, + newResults, + 1, + ), + ); } } }); @@ -57,20 +85,29 @@ function getAllScreenGroupCampaigns(dispatch, screenGroupId = null, screenGroupI }); } -function getAllScreenCampaigns(dispatch, screenId = null, results = [], page = 1) { +function getAllScreenCampaigns( + dispatch, + screenId = null, + results = [], + page = 1, +) { return new Promise((resolve, reject) => { if (screenId === null) { resolve(results); } else { - dispatch(enhancedApi.endpoints.getV2ScreensByIdCampaigns.initiate({ - id: screenId, - page: page - })).then(({ data }) => { + dispatch( + enhancedApi.endpoints.getV2ScreensByIdCampaigns.initiate({ + id: screenId, + page: page, + }), + ).then(({ data }) => { const newResults = [...results, ...data["hydra:member"]]; const hydraView = data["hydra:view"] ?? null; if (hydraView !== null && (hydraView["hydra:next"] ?? false)) { - resolve(getAllScreenCampaigns(dispatch, screenId, newResults, page + 1)); + resolve( + getAllScreenCampaigns(dispatch, screenId, newResults, page + 1), + ); } else { resolve(newResults); } @@ -86,9 +123,11 @@ function getAllCampaigns(dispatch, campaignIds = [], results = []) { } else { const campaignId = campaignIds[0]; - dispatch(enhancedApi.endpoints.getV2PlaylistsById.initiate({ - id: campaignId - })).then(({ data }) => { + dispatch( + enhancedApi.endpoints.getV2PlaylistsById.initiate({ + id: campaignId, + }), + ).then(({ data }) => { const newResults = [...results, data]; const newCampaignIds = campaignIds.filter((id) => id !== campaignId); @@ -117,23 +156,42 @@ function CampaignsButton({ screen }) { // Fetch screen campaigns. // Merge campaign arrays. // Set campaigns to trigger useEffect. - getAllScreenGroups(dispatch, screen.id).then((screenGroups) => { - const screenGroupIds = screenGroups.filter(({ campaignsLength }) => campaignsLength > 0).map((group) => idFromUrl(group["@id"])); - - getAllScreenGroupCampaigns(dispatch, screenGroupIds[0] ?? null, screenGroupIds).then((screenGroupCampaigns) => { - getAllScreenCampaigns(dispatch, 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"])); - - getAllCampaigns(dispatch, uniqueCampaigns.map((campaign) => idFromUrl(campaign["@id"]))).then((campaigns) => { - setCampaigns(campaigns); - setLoading(false); + getAllScreenGroups(dispatch, screen.id) + .then((screenGroups) => { + const screenGroupIds = screenGroups + .filter(({ campaignsLength }) => campaignsLength > 0) + .map((group) => idFromUrl(group["@id"])); + + getAllScreenGroupCampaigns( + dispatch, + screenGroupIds[0] ?? null, + screenGroupIds, + ).then((screenGroupCampaigns) => { + getAllScreenCampaigns(dispatch, 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"]), + ); + + getAllCampaigns( + dispatch, + uniqueCampaigns.map((campaign) => idFromUrl(campaign["@id"])), + ).then((campaigns) => { + setCampaigns(campaigns); + setLoading(false); + }); }); }); - }); - }).catch(() => setLoading(false)); + }) + .catch(() => setLoading(false)); }; useEffect(() => { @@ -148,9 +206,9 @@ function CampaignsButton({ screen }) { > {campaign.title} - { - calculateIsPublished(campaign.published) && Aktiv - } + {calculateIsPublished(campaign.published) && ( + Aktiv + )} ))}
@@ -159,18 +217,34 @@ function CampaignsButton({ screen }) { setModal({ info: true, modalTitle: t("campaigns-modal-title"), - content + content, }); } }, [campaigns]); return ( - diff --git a/assets/admin/components/screen/util/screen-columns.jsx b/assets/admin/components/screen/util/screen-columns.jsx index af0d8a5ad..3f359cc9f 100644 --- a/assets/admin/components/screen/util/screen-columns.jsx +++ b/assets/admin/components/screen/util/screen-columns.jsx @@ -19,17 +19,17 @@ function getScreenColumns({ displayStatus }) { { 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"), - content: (screen) => - } + content: (screen) => , + }, ]; if (displayStatus) { diff --git a/assets/admin/components/screen/util/screen-groups-button.jsx b/assets/admin/components/screen/util/screen-groups-button.jsx index ce1cddba0..4d4dc89a0 100644 --- a/assets/admin/components/screen/util/screen-groups-button.jsx +++ b/assets/admin/components/screen/util/screen-groups-button.jsx @@ -14,8 +14,8 @@ function ScreenGroupsButton({ screen }) { const onClick = () => { dispatch( enhancedApi.endpoints.getV2ScreensByIdScreenGroups.initiate({ - id: idFromUrl(screen.id) - }) + id: idFromUrl(screen.id), + }), ).then(({ data }) => { const content = (
    @@ -35,13 +35,18 @@ function ScreenGroupsButton({ screen }) { setModal({ info: true, modalTitle: t("screen-groups-modal-title"), - content + content, }); }); }; return ( - ); diff --git a/src/Repository/ScreenRepository.php b/src/Repository/ScreenRepository.php index da0c2b564..d51afcb93 100644 --- a/src/Repository/ScreenRepository.php +++ b/src/Repository/ScreenRepository.php @@ -46,10 +46,10 @@ public function getScreensByScreenGroupId(Ulid $screenGroupUlid): QueryBuilder * 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 - * - * @return int Number of campaigns for the screen. */ public function getCampaignCountForScreen(Ulid $screenId, bool $activeCampaigns = false): int { diff --git a/src/State/ScreenProvider.php b/src/State/ScreenProvider.php index da58d7d5a..977ee7fd3 100644 --- a/src/State/ScreenProvider.php +++ b/src/State/ScreenProvider.php @@ -49,8 +49,12 @@ public function toOutput(object $object): ScreenDTO $iri = $this->iriConverter->getIriFromResource($object); $output->campaigns = $iri.'/campaigns'; - $output->campaignsLength = $this->screenRepository->getCampaignCountForScreen($object->getId()); - $output->activeCampaignsLength = $this->screenRepository->getCampaignCountForScreen($object->getId(), true); + $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) { From a47c9d4a53071842537695f010f2960292ff47af Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Wed, 1 Apr 2026 19:05:04 +0200 Subject: [PATCH 17/31] 6871: Generated API specification --- assets/shared/redux/generated-api.ts | 6 +++ public/api-spec-v2.json | 72 ++++++++++++++++++++++++++++ public/api-spec-v2.yaml | 48 +++++++++++++++++++ 3 files changed, 126 insertions(+) diff --git a/assets/shared/redux/generated-api.ts b/assets/shared/redux/generated-api.ts index eea427d55..63959ac3e 100644 --- a/assets/shared/redux/generated-api.ts +++ b/assets/shared/redux/generated-api.ts @@ -1982,6 +1982,7 @@ export type ScreenGroupJsonldCampaignsScreenGroupsRead = { campaigns?: string; screens?: string; screensLength?: number | null; + campaignsLength?: number | null; relationsChecksum?: object; }; export type ScreenGroupJsonldCampaignsScreenGroupsReadRead = { @@ -1999,6 +2000,7 @@ export type ScreenGroupJsonldCampaignsScreenGroupsReadRead = { campaigns?: string; screens?: string; screensLength?: number | null; + campaignsLength?: number | null; relationsChecksum?: object; }; export type ScreenGroupCampaignJsonldCampaignsScreenGroupsRead = { @@ -2222,6 +2224,7 @@ export type ScreenGroupScreenGroupJsonld = { campaigns?: string; screens?: string; screensLength?: number | null; + campaignsLength?: number | null; modifiedBy?: string; createdBy?: string; id?: string; @@ -2244,6 +2247,7 @@ export type ScreenGroupScreenGroupJsonldRead = { campaigns?: string; screens?: string; screensLength?: number | null; + campaignsLength?: number | null; modifiedBy?: string; createdBy?: string; id?: string; @@ -2400,6 +2404,7 @@ export type ScreenGroupScreenGroupJsonldScreensScreenGroupsRead = { campaigns?: string; screens?: string; screensLength?: number | null; + campaignsLength?: number | null; relationsChecksum?: object; }; export type ScreenGroupScreenGroupJsonldScreensScreenGroupsReadRead = { @@ -2410,6 +2415,7 @@ export type ScreenGroupScreenGroupJsonldScreensScreenGroupsReadRead = { campaigns?: string; screens?: string; screensLength?: number | null; + campaignsLength?: number | null; relationsChecksum?: object; }; export type SlideSlideJsonld = { diff --git a/public/api-spec-v2.json b/public/api-spec-v2.json index 3329b2735..df8506599 100644 --- a/public/api-spec-v2.json +++ b/public/api-spec-v2.json @@ -12751,6 +12751,12 @@ "null" ] }, + "campaignsLength": { + "type": [ + "integer", + "null" + ] + }, "modifiedBy": { "type": "string" }, @@ -12797,6 +12803,12 @@ "null" ] }, + "campaignsLength": { + "type": [ + "integer", + "null" + ] + }, "relationsChecksum": { "type": "object" } @@ -12825,6 +12837,12 @@ "null" ] }, + "campaignsLength": { + "type": [ + "integer", + "null" + ] + }, "relationsChecksum": { "type": "object" } @@ -12863,6 +12881,12 @@ "null" ] }, + "campaignsLength": { + "type": [ + "integer", + "null" + ] + }, "relationsChecksum": { "type": "object" } @@ -12891,6 +12915,12 @@ "null" ] }, + "campaignsLength": { + "type": [ + "integer", + "null" + ] + }, "modifiedBy": { "type": "string" }, @@ -12947,6 +12977,12 @@ "null" ] }, + "campaignsLength": { + "type": [ + "integer", + "null" + ] + }, "relationsChecksum": { "type": "object" } @@ -13010,6 +13046,12 @@ "null" ] }, + "campaignsLength": { + "type": [ + "integer", + "null" + ] + }, "modifiedBy": { "type": "string" }, @@ -13082,6 +13124,12 @@ "null" ] }, + "campaignsLength": { + "type": [ + "integer", + "null" + ] + }, "relationsChecksum": { "type": "object" } @@ -13171,6 +13219,12 @@ "null" ] }, + "campaignsLength": { + "type": [ + "integer", + "null" + ] + }, "modifiedBy": { "type": "string" }, @@ -13252,6 +13306,12 @@ "null" ] }, + "campaignsLength": { + "type": [ + "integer", + "null" + ] + }, "relationsChecksum": { "type": "object" } @@ -13315,6 +13375,12 @@ "null" ] }, + "campaignsLength": { + "type": [ + "integer", + "null" + ] + }, "relationsChecksum": { "type": "object" } @@ -13369,6 +13435,12 @@ "null" ] }, + "campaignsLength": { + "type": [ + "integer", + "null" + ] + }, "relationsChecksum": { "type": "object" } diff --git a/public/api-spec-v2.yaml b/public/api-spec-v2.yaml index 9fcbf6999..9c1b21eb1 100644 --- a/public/api-spec-v2.yaml +++ b/public/api-spec-v2.yaml @@ -8895,6 +8895,10 @@ components: type: - integer - 'null' + campaignsLength: + type: + - integer + - 'null' modifiedBy: type: string createdBy: @@ -8927,6 +8931,10 @@ components: type: - integer - 'null' + campaignsLength: + type: + - integer + - 'null' relationsChecksum: type: object ScreenGroup-screen-groups.campaigns.read: @@ -8946,6 +8954,10 @@ components: type: - integer - 'null' + campaignsLength: + type: + - integer + - 'null' relationsChecksum: type: object ScreenGroup-screen-groups.screens.read: @@ -8972,6 +8984,10 @@ components: type: - integer - 'null' + campaignsLength: + type: + - integer + - 'null' relationsChecksum: type: object ScreenGroup.ScreenGroup: @@ -8991,6 +9007,10 @@ components: type: - integer - 'null' + campaignsLength: + type: + - integer + - 'null' modifiedBy: type: string createdBy: @@ -9030,6 +9050,10 @@ components: type: - integer - 'null' + campaignsLength: + type: + - integer + - 'null' relationsChecksum: type: object ScreenGroup.ScreenGroup.jsonld: @@ -9072,6 +9096,10 @@ components: type: - integer - 'null' + campaignsLength: + type: + - integer + - 'null' modifiedBy: type: string createdBy: @@ -9123,6 +9151,10 @@ components: type: - integer - 'null' + campaignsLength: + type: + - integer + - 'null' relationsChecksum: type: object ScreenGroup.ScreenGroupInput: @@ -9183,6 +9215,10 @@ components: type: - integer - 'null' + campaignsLength: + type: + - integer + - 'null' modifiedBy: type: string createdBy: @@ -9238,6 +9274,10 @@ components: type: - integer - 'null' + campaignsLength: + type: + - integer + - 'null' relationsChecksum: type: object ScreenGroup.jsonld-screen-groups.campaigns.read: @@ -9280,6 +9320,10 @@ components: type: - integer - 'null' + campaignsLength: + type: + - integer + - 'null' relationsChecksum: type: object ScreenGroup.jsonld-screen-groups.screens.read: @@ -9318,6 +9362,10 @@ components: type: - integer - 'null' + campaignsLength: + type: + - integer + - 'null' relationsChecksum: type: object ScreenGroupCampaign: From 2d598a7b537f7a7d9f06997d6b1617f840e91bc5 Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Thu, 2 Apr 2026 07:35:28 +0200 Subject: [PATCH 18/31] 6871: Changed release values to be variables set in GitHub action --- .github/workflows/github_build_release.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/github_build_release.yml b/.github/workflows/github_build_release.yml index 191dcc70a..975b32f49 100644 --- a/.github/workflows/github_build_release.yml +++ b/.github/workflows/github_build_release.yml @@ -35,9 +35,14 @@ 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\": $(date +%s),\n \"releaseTime\": \"$(date)\",\n \"releaseVersion\": \"${{ github.ref_name }}\"\n}" > public/release.json + 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 From 7c860de9c8cd6989382f26e14dbefc5650a75b0f Mon Sep 17 00:00:00 2001 From: turegjorup Date: Thu, 9 Apr 2026 12:41:05 +0200 Subject: [PATCH 19/31] 6871: Add "testCampaignsLengthViaScreenGroupAndDeduplication" to ScreenTest --- tests/Api/ScreensTest.php | 101 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/tests/Api/ScreensTest.php b/tests/Api/ScreensTest.php index 59d9715fe..21408631e 100644 --- a/tests/Api/ScreensTest.php +++ b/tests/Api/ScreensTest.php @@ -396,6 +396,107 @@ public function testCampaignsLengthAndActiveCampaignsLength(): void $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'); From 0f031aecc439730d2cf7a99da831c862e9310b85 Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Thu, 9 Apr 2026 13:01:27 +0200 Subject: [PATCH 20/31] 6871: Fixed bugs in releaseLoader --- assets/shared/release-loader.js | 71 ++++++++++++++++++--------------- 1 file changed, 39 insertions(+), 32 deletions(-) diff --git a/assets/shared/release-loader.js b/assets/shared/release-loader.js index 51bac7470..c4142b661 100644 --- a/assets/shared/release-loader.js +++ b/assets/shared/release-loader.js @@ -11,41 +11,48 @@ let activePromise = null; const ReleaseLoader = { async loadRelease() { - if (activePromise) { + if (activePromise !== null) { 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; - }); - } - }); + const nowTimestamp = new Date().getTime(); + + // Return early without going through activePromise so the caller always + // receives a real promise, not null. + if (latestFetchTimestamp + configFetchInterval >= nowTimestamp) { + return Promise.resolve(releaseData); + } + + activePromise = fetch(`/release.json?t=${nowTimestamp}`) + .then((response) => response.json()) + .then((data) => { + latestFetchTimestamp = nowTimestamp; + releaseData = data; + return releaseData; + }) + .catch(() => { + if (releaseData !== null) { + // Bug 3 fix: advance the timestamp so the next call uses the + // cache instead of immediately retrying after a failed fetch. + latestFetchTimestamp = nowTimestamp; + return releaseData; + } + + /* eslint-disable-next-line no-console */ + console.warn("Could not find release.json. Returning defaults."); + + // Return defaults. + return { + releaseTime: null, + releaseTimestamp: null, + releaseVersion: null, + }; + }) + .finally(() => { + // Always clear activePromise via finally so concurrent callers share a + // single in-flight fetch and it is cleared on both success and failure. + activePromise = null; + }); return activePromise; }, From fe42298af624c904578c880f430efbc33af21872 Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Thu, 9 Apr 2026 16:18:52 +0200 Subject: [PATCH 21/31] 6871: Added extra lazy to all collections doctrine mappings --- src/Entity/ScreenLayout.php | 4 ++-- src/Entity/ScreenLayoutRegions.php | 4 ++-- src/Entity/Template.php | 2 +- src/Entity/Tenant.php | 2 +- src/Entity/Tenant/AbstractTenantScopedEntity.php | 2 +- src/Entity/Tenant/Feed.php | 2 +- src/Entity/Tenant/FeedSource.php | 2 +- src/Entity/Tenant/Media.php | 2 +- src/Entity/Tenant/Playlist.php | 10 +++++----- src/Entity/Tenant/PlaylistScreenRegion.php | 6 +++--- src/Entity/Tenant/PlaylistSlide.php | 4 ++-- src/Entity/Tenant/Schedule.php | 2 +- src/Entity/Tenant/Screen.php | 8 ++++---- src/Entity/Tenant/ScreenCampaign.php | 4 ++-- src/Entity/Tenant/ScreenGroup.php | 4 ++-- src/Entity/Tenant/ScreenGroupCampaign.php | 4 ++-- src/Entity/Tenant/Slide.php | 6 +++--- src/Entity/Tenant/Theme.php | 2 +- src/Entity/Traits/MultiTenantTrait.php | 2 +- src/Entity/User.php | 2 +- src/Entity/UserRoleTenant.php | 4 ++-- 21 files changed, 39 insertions(+), 39 deletions(-) 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..6c4bb3cff 100644 --- a/src/Entity/ScreenLayoutRegions.php +++ b/src/Entity/ScreenLayoutRegions.php @@ -35,13 +35,13 @@ class ScreenLayoutRegions extends AbstractBaseEntity implements MultiTenantInter #[Groups(['read'])] private ?string $type = null; - #[ORM\ManyToOne(targetEntity: ScreenLayout::class, inversedBy: 'regions')] + #[ORM\ManyToOne(targetEntity: ScreenLayout::class, fetch: 'EXTRA_LAZY', inversedBy: 'regions')] private ?ScreenLayout $screenLayout = null; /** * @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/AbstractTenantScopedEntity.php b/src/Entity/Tenant/AbstractTenantScopedEntity.php index 03edc1f14..f0b5a9a6f 100644 --- a/src/Entity/Tenant/AbstractTenantScopedEntity.php +++ b/src/Entity/Tenant/AbstractTenantScopedEntity.php @@ -13,7 +13,7 @@ #[ORM\HasLifecycleCallbacks] abstract class AbstractTenantScopedEntity extends AbstractBaseEntity implements TenantScopedEntityInterface { - #[ORM\ManyToOne(targetEntity: Tenant::class)] + #[ORM\ManyToOne(targetEntity: Tenant::class, fetch: 'EXTRA_LAZY')] #[ORM\JoinColumn(nullable: false)] private Tenant $tenant; diff --git a/src/Entity/Tenant/Feed.php b/src/Entity/Tenant/Feed.php index 4c7ed2cd6..3ca9fce07 100644 --- a/src/Entity/Tenant/Feed.php +++ b/src/Entity/Tenant/Feed.php @@ -16,7 +16,7 @@ class Feed extends AbstractTenantScopedEntity implements RelationsChecksumInterf { use RelationsChecksumTrait; - #[ORM\ManyToOne(targetEntity: FeedSource::class, inversedBy: 'feeds')] + #[ORM\ManyToOne(targetEntity: FeedSource::class, fetch: 'EXTRA_LAZY', inversedBy: 'feeds')] #[ORM\JoinColumn(nullable: false)] private ?FeedSource $feedSource = null; 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/PlaylistScreenRegion.php b/src/Entity/Tenant/PlaylistScreenRegion.php index 97a7a92b8..c801d7859 100644 --- a/src/Entity/Tenant/PlaylistScreenRegion.php +++ b/src/Entity/Tenant/PlaylistScreenRegion.php @@ -17,15 +17,15 @@ class PlaylistScreenRegion extends AbstractTenantScopedEntity implements Relatio { use RelationsChecksumTrait; - #[ORM\ManyToOne(targetEntity: Playlist::class, inversedBy: 'playlistScreenRegions')] + #[ORM\ManyToOne(targetEntity: Playlist::class, fetch: 'EXTRA_LAZY', inversedBy: 'playlistScreenRegions')] #[ORM\JoinColumn(nullable: false)] private ?Playlist $playlist = null; - #[ORM\ManyToOne(targetEntity: Screen::class, inversedBy: 'playlistScreenRegions')] + #[ORM\ManyToOne(targetEntity: Screen::class, fetch: 'EXTRA_LAZY', inversedBy: 'playlistScreenRegions')] #[ORM\JoinColumn(nullable: false)] private ?Screen $screen = null; - #[ORM\ManyToOne(targetEntity: ScreenLayoutRegions::class, inversedBy: 'playlistScreenRegions')] + #[ORM\ManyToOne(targetEntity: ScreenLayoutRegions::class, fetch: 'EXTRA_LAZY', inversedBy: 'playlistScreenRegions')] #[ORM\JoinColumn(nullable: false)] private ?ScreenLayoutRegions $region = null; diff --git a/src/Entity/Tenant/PlaylistSlide.php b/src/Entity/Tenant/PlaylistSlide.php index ae8f0a1da..a167643b1 100644 --- a/src/Entity/Tenant/PlaylistSlide.php +++ b/src/Entity/Tenant/PlaylistSlide.php @@ -15,11 +15,11 @@ class PlaylistSlide extends AbstractTenantScopedEntity implements RelationsCheck { use RelationsChecksumTrait; - #[ORM\ManyToOne(targetEntity: Playlist::class, inversedBy: 'playlistSlides')] + #[ORM\ManyToOne(targetEntity: Playlist::class, fetch: 'EXTRA_LAZY', inversedBy: 'playlistSlides')] #[ORM\JoinColumn(nullable: false)] private Playlist $playlist; - #[ORM\ManyToOne(targetEntity: Slide::class, inversedBy: 'playlistSlides')] + #[ORM\ManyToOne(targetEntity: Slide::class, fetch: 'EXTRA_LAZY', inversedBy: 'playlistSlides')] #[ORM\JoinColumn(nullable: false)] private Slide $slide; diff --git a/src/Entity/Tenant/Schedule.php b/src/Entity/Tenant/Schedule.php index c0789a771..20d9b1707 100644 --- a/src/Entity/Tenant/Schedule.php +++ b/src/Entity/Tenant/Schedule.php @@ -17,7 +17,7 @@ class Schedule extends AbstractTenantScopedEntity #[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER)] private int $duration = 0; - #[ORM\ManyToOne(targetEntity: Playlist::class, inversedBy: 'schedules')] + #[ORM\ManyToOne(targetEntity: Playlist::class, fetch: 'EXTRA_LAZY', inversedBy: 'schedules')] #[ORM\JoinColumn(nullable: false)] private ?Playlist $playlist = null; diff --git a/src/Entity/Tenant/Screen.php b/src/Entity/Tenant/Screen.php index 9fde5553e..7786f1d1d 100644 --- a/src/Entity/Tenant/Screen.php +++ b/src/Entity/Tenant/Screen.php @@ -36,7 +36,7 @@ class Screen extends AbstractTenantScopedEntity implements RelationsChecksumInte #[ORM\Column(type: \Doctrine\DBAL\Types\Types::BOOLEAN, nullable: true)] private ?bool $enableColorSchemeChange = null; - #[ORM\ManyToOne(targetEntity: ScreenLayout::class, inversedBy: 'screens')] + #[ORM\ManyToOne(targetEntity: ScreenLayout::class, fetch: 'EXTRA_LAZY', inversedBy: 'screens')] #[ORM\JoinColumn(nullable: false)] private ScreenLayout $screenLayout; @@ -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/ScreenCampaign.php b/src/Entity/Tenant/ScreenCampaign.php index 1101cbf31..eb70e78f3 100644 --- a/src/Entity/Tenant/ScreenCampaign.php +++ b/src/Entity/Tenant/ScreenCampaign.php @@ -15,11 +15,11 @@ class ScreenCampaign extends AbstractTenantScopedEntity implements RelationsChec { use RelationsChecksumTrait; - #[ORM\ManyToOne(targetEntity: Playlist::class, inversedBy: 'screenCampaigns')] + #[ORM\ManyToOne(targetEntity: Playlist::class, fetch: 'EXTRA_LAZY', inversedBy: 'screenCampaigns')] #[ORM\JoinColumn(nullable: false)] private Playlist $campaign; - #[ORM\ManyToOne(targetEntity: Screen::class, inversedBy: 'screenCampaigns')] + #[ORM\ManyToOne(targetEntity: Screen::class, fetch: 'EXTRA_LAZY', inversedBy: 'screenCampaigns')] #[ORM\JoinColumn(nullable: false)] private Screen $screen; 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/ScreenGroupCampaign.php b/src/Entity/Tenant/ScreenGroupCampaign.php index 8337d3795..680bafefc 100644 --- a/src/Entity/Tenant/ScreenGroupCampaign.php +++ b/src/Entity/Tenant/ScreenGroupCampaign.php @@ -15,11 +15,11 @@ class ScreenGroupCampaign extends AbstractTenantScopedEntity implements Relation { use RelationsChecksumTrait; - #[ORM\ManyToOne(targetEntity: Playlist::class, inversedBy: 'screenGroupCampaigns')] + #[ORM\ManyToOne(targetEntity: Playlist::class, fetch: 'EXTRA_LAZY', inversedBy: 'screenGroupCampaigns')] #[ORM\JoinColumn(nullable: false)] private Playlist $campaign; - #[ORM\ManyToOne(targetEntity: ScreenGroup::class, inversedBy: 'screenGroupCampaigns')] + #[ORM\ManyToOne(targetEntity: ScreenGroup::class, fetch: 'EXTRA_LAZY', inversedBy: 'screenGroupCampaigns')] #[ORM\JoinColumn(nullable: false)] private ScreenGroup $screenGroup; diff --git a/src/Entity/Tenant/Slide.php b/src/Entity/Tenant/Slide.php index 82e1fbd7f..f11cd6d33 100644 --- a/src/Entity/Tenant/Slide.php +++ b/src/Entity/Tenant/Slide.php @@ -31,18 +31,18 @@ class Slide extends AbstractTenantScopedEntity implements RelationsChecksumInter #[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, nullable: true)] private array $templateOptions = []; - #[ORM\ManyToOne(targetEntity: Template::class, inversedBy: 'slides')] + #[ORM\ManyToOne(targetEntity: Template::class, fetch: 'EXTRA_LAZY', inversedBy: 'slides')] #[ORM\JoinColumn(nullable: false)] private ?Template $template = null; - #[ORM\ManyToOne(targetEntity: Theme::class, inversedBy: 'slides')] + #[ORM\ManyToOne(targetEntity: Theme::class, fetch: 'EXTRA_LAZY', inversedBy: 'slides')] #[ORM\JoinColumn(onDelete: 'SET NULL')] private ?Theme $theme = null; /** * @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/Entity/UserRoleTenant.php b/src/Entity/UserRoleTenant.php index b94bc1005..ef6533b9a 100644 --- a/src/Entity/UserRoleTenant.php +++ b/src/Entity/UserRoleTenant.php @@ -12,11 +12,11 @@ #[ORM\Entity(repositoryClass: UserRoleTenantRepository::class)] class UserRoleTenant extends AbstractBaseEntity implements \JsonSerializable { - #[ORM\ManyToOne(targetEntity: User::class, inversedBy: 'userRoleTenants')] + #[ORM\ManyToOne(targetEntity: User::class, fetch: 'EXTRA_LAZY', inversedBy: 'userRoleTenants')] #[ORM\JoinColumn(nullable: false)] private ?User $user = null; - #[ORM\ManyToOne(targetEntity: Tenant::class, inversedBy: 'userRoleTenants')] + #[ORM\ManyToOne(targetEntity: Tenant::class, fetch: 'EXTRA_LAZY', inversedBy: 'userRoleTenants')] #[ORM\JoinColumn(nullable: false)] private ?Tenant $tenant = null; From 820d7498f71870fa03a7188cc4c0c19e5ff951b2 Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Fri, 10 Apr 2026 17:47:34 +0200 Subject: [PATCH 22/31] 6871: Changed to a testable class-based release loader and added tests --- .gitignore | 2 + assets/shared/release-loader.js | 90 +++++++++------ assets/tests/shared/release-loader.spec.js | 125 +++++++++++++++++++++ 3 files changed, 182 insertions(+), 35 deletions(-) create mode 100644 assets/tests/shared/release-loader.spec.js diff --git a/.gitignore b/.gitignore index 6a75d6466..ecee3e2fe 100644 --- a/.gitignore +++ b/.gitignore @@ -66,3 +66,5 @@ phpstan.neon ###> vincentlanglet/twig-cs-fixer ### /.twig-cs-fixer.cache ###< vincentlanglet/twig-cs-fixer ### + +.claude/ diff --git a/assets/shared/release-loader.js b/assets/shared/release-loader.js index c4142b661..c93298d8c 100644 --- a/assets/shared/release-loader.js +++ b/assets/shared/release-loader.js @@ -1,63 +1,83 @@ -// Only fetch new release.json if more than 5 minutes have passed. -const configFetchInterval = 5 * 60 * 1000; +const DEFAULT_FETCH_INTERVAL = 5 * 60 * 1000; -// Fetched release data. -let releaseData = null; +const DEFAULT_RELEASE = { + releaseTime: null, + releaseTimestamp: null, + releaseVersion: null, +}; -// Last time the release was fetched. -let latestFetchTimestamp = 0; +class ReleaseLoader { + #releaseData = null; + #latestFetchTimestamp = null; + #activePromise = null; + #fetchFn; + #nowFn; + #fetchInterval; -let activePromise = null; + /** + * @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; + } -const ReleaseLoader = { async loadRelease() { - if (activePromise !== null) { - return activePromise; + if (this.#activePromise !== null) { + return this.#activePromise; } - const nowTimestamp = new Date().getTime(); + const nowTimestamp = this.#nowFn(); // Return early without going through activePromise so the caller always // receives a real promise, not null. - if (latestFetchTimestamp + configFetchInterval >= nowTimestamp) { - return Promise.resolve(releaseData); + if ( + this.#latestFetchTimestamp !== null && + this.#latestFetchTimestamp + this.#fetchInterval >= nowTimestamp + ) { + return Promise.resolve(this.#releaseData); } - activePromise = fetch(`/release.json?t=${nowTimestamp}`) + this.#activePromise = this.#fetchFn(`/release.json?t=${nowTimestamp}`) .then((response) => response.json()) .then((data) => { - latestFetchTimestamp = nowTimestamp; - releaseData = data; - return releaseData; + this.#latestFetchTimestamp = nowTimestamp; + this.#releaseData = data; + return this.#releaseData; }) .catch(() => { - if (releaseData !== null) { - // Bug 3 fix: advance the timestamp so the next call uses the - // cache instead of immediately retrying after a failed fetch. - latestFetchTimestamp = nowTimestamp; - return releaseData; + 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 defaults. - return { - releaseTime: null, - releaseTimestamp: null, - releaseVersion: null, - }; + return DEFAULT_RELEASE; }) .finally(() => { // Always clear activePromise via finally so concurrent callers share a - // single in-flight fetch and it is cleared on both success and failure. - activePromise = null; + // single in-flight fetch. It is cleared on both success and failure. + this.#activePromise = null; }); - return activePromise; - }, -}; + return this.#activePromise; + } +} -Object.freeze(ReleaseLoader); +// Default singleton for production use. +const releaseLoader = new ReleaseLoader(); +export default releaseLoader; -export default ReleaseLoader; +export { ReleaseLoader }; 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); + }); +}); From f48a6076a1b310229156fc80d7d5929766d39a8a Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Fri, 10 Apr 2026 17:59:56 +0200 Subject: [PATCH 23/31] 6871: Removed fetch EXTRA_LAZY from ManyToOne associations --- src/Entity/ScreenLayoutRegions.php | 2 +- src/Entity/Tenant/AbstractTenantScopedEntity.php | 2 +- src/Entity/Tenant/Feed.php | 2 +- src/Entity/Tenant/PlaylistScreenRegion.php | 6 +++--- src/Entity/Tenant/PlaylistSlide.php | 4 ++-- src/Entity/Tenant/Schedule.php | 2 +- src/Entity/Tenant/Screen.php | 2 +- src/Entity/Tenant/ScreenCampaign.php | 4 ++-- src/Entity/Tenant/ScreenGroupCampaign.php | 4 ++-- src/Entity/Tenant/Slide.php | 4 ++-- src/Entity/UserRoleTenant.php | 4 ++-- 11 files changed, 18 insertions(+), 18 deletions(-) diff --git a/src/Entity/ScreenLayoutRegions.php b/src/Entity/ScreenLayoutRegions.php index 6c4bb3cff..a95711a2b 100644 --- a/src/Entity/ScreenLayoutRegions.php +++ b/src/Entity/ScreenLayoutRegions.php @@ -35,7 +35,7 @@ class ScreenLayoutRegions extends AbstractBaseEntity implements MultiTenantInter #[Groups(['read'])] private ?string $type = null; - #[ORM\ManyToOne(targetEntity: ScreenLayout::class, fetch: 'EXTRA_LAZY', inversedBy: 'regions')] + #[ORM\ManyToOne(targetEntity: ScreenLayout::class, inversedBy: 'regions')] private ?ScreenLayout $screenLayout = null; /** diff --git a/src/Entity/Tenant/AbstractTenantScopedEntity.php b/src/Entity/Tenant/AbstractTenantScopedEntity.php index f0b5a9a6f..03edc1f14 100644 --- a/src/Entity/Tenant/AbstractTenantScopedEntity.php +++ b/src/Entity/Tenant/AbstractTenantScopedEntity.php @@ -13,7 +13,7 @@ #[ORM\HasLifecycleCallbacks] abstract class AbstractTenantScopedEntity extends AbstractBaseEntity implements TenantScopedEntityInterface { - #[ORM\ManyToOne(targetEntity: Tenant::class, fetch: 'EXTRA_LAZY')] + #[ORM\ManyToOne(targetEntity: Tenant::class)] #[ORM\JoinColumn(nullable: false)] private Tenant $tenant; diff --git a/src/Entity/Tenant/Feed.php b/src/Entity/Tenant/Feed.php index 3ca9fce07..4c7ed2cd6 100644 --- a/src/Entity/Tenant/Feed.php +++ b/src/Entity/Tenant/Feed.php @@ -16,7 +16,7 @@ class Feed extends AbstractTenantScopedEntity implements RelationsChecksumInterf { use RelationsChecksumTrait; - #[ORM\ManyToOne(targetEntity: FeedSource::class, fetch: 'EXTRA_LAZY', inversedBy: 'feeds')] + #[ORM\ManyToOne(targetEntity: FeedSource::class, inversedBy: 'feeds')] #[ORM\JoinColumn(nullable: false)] private ?FeedSource $feedSource = null; diff --git a/src/Entity/Tenant/PlaylistScreenRegion.php b/src/Entity/Tenant/PlaylistScreenRegion.php index c801d7859..97a7a92b8 100644 --- a/src/Entity/Tenant/PlaylistScreenRegion.php +++ b/src/Entity/Tenant/PlaylistScreenRegion.php @@ -17,15 +17,15 @@ class PlaylistScreenRegion extends AbstractTenantScopedEntity implements Relatio { use RelationsChecksumTrait; - #[ORM\ManyToOne(targetEntity: Playlist::class, fetch: 'EXTRA_LAZY', inversedBy: 'playlistScreenRegions')] + #[ORM\ManyToOne(targetEntity: Playlist::class, inversedBy: 'playlistScreenRegions')] #[ORM\JoinColumn(nullable: false)] private ?Playlist $playlist = null; - #[ORM\ManyToOne(targetEntity: Screen::class, fetch: 'EXTRA_LAZY', inversedBy: 'playlistScreenRegions')] + #[ORM\ManyToOne(targetEntity: Screen::class, inversedBy: 'playlistScreenRegions')] #[ORM\JoinColumn(nullable: false)] private ?Screen $screen = null; - #[ORM\ManyToOne(targetEntity: ScreenLayoutRegions::class, fetch: 'EXTRA_LAZY', inversedBy: 'playlistScreenRegions')] + #[ORM\ManyToOne(targetEntity: ScreenLayoutRegions::class, inversedBy: 'playlistScreenRegions')] #[ORM\JoinColumn(nullable: false)] private ?ScreenLayoutRegions $region = null; diff --git a/src/Entity/Tenant/PlaylistSlide.php b/src/Entity/Tenant/PlaylistSlide.php index a167643b1..ae8f0a1da 100644 --- a/src/Entity/Tenant/PlaylistSlide.php +++ b/src/Entity/Tenant/PlaylistSlide.php @@ -15,11 +15,11 @@ class PlaylistSlide extends AbstractTenantScopedEntity implements RelationsCheck { use RelationsChecksumTrait; - #[ORM\ManyToOne(targetEntity: Playlist::class, fetch: 'EXTRA_LAZY', inversedBy: 'playlistSlides')] + #[ORM\ManyToOne(targetEntity: Playlist::class, inversedBy: 'playlistSlides')] #[ORM\JoinColumn(nullable: false)] private Playlist $playlist; - #[ORM\ManyToOne(targetEntity: Slide::class, fetch: 'EXTRA_LAZY', inversedBy: 'playlistSlides')] + #[ORM\ManyToOne(targetEntity: Slide::class, inversedBy: 'playlistSlides')] #[ORM\JoinColumn(nullable: false)] private Slide $slide; diff --git a/src/Entity/Tenant/Schedule.php b/src/Entity/Tenant/Schedule.php index 20d9b1707..c0789a771 100644 --- a/src/Entity/Tenant/Schedule.php +++ b/src/Entity/Tenant/Schedule.php @@ -17,7 +17,7 @@ class Schedule extends AbstractTenantScopedEntity #[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER)] private int $duration = 0; - #[ORM\ManyToOne(targetEntity: Playlist::class, fetch: 'EXTRA_LAZY', inversedBy: 'schedules')] + #[ORM\ManyToOne(targetEntity: Playlist::class, inversedBy: 'schedules')] #[ORM\JoinColumn(nullable: false)] private ?Playlist $playlist = null; diff --git a/src/Entity/Tenant/Screen.php b/src/Entity/Tenant/Screen.php index 7786f1d1d..660f59bc8 100644 --- a/src/Entity/Tenant/Screen.php +++ b/src/Entity/Tenant/Screen.php @@ -36,7 +36,7 @@ class Screen extends AbstractTenantScopedEntity implements RelationsChecksumInte #[ORM\Column(type: \Doctrine\DBAL\Types\Types::BOOLEAN, nullable: true)] private ?bool $enableColorSchemeChange = null; - #[ORM\ManyToOne(targetEntity: ScreenLayout::class, fetch: 'EXTRA_LAZY', inversedBy: 'screens')] + #[ORM\ManyToOne(targetEntity: ScreenLayout::class, inversedBy: 'screens')] #[ORM\JoinColumn(nullable: false)] private ScreenLayout $screenLayout; diff --git a/src/Entity/Tenant/ScreenCampaign.php b/src/Entity/Tenant/ScreenCampaign.php index eb70e78f3..1101cbf31 100644 --- a/src/Entity/Tenant/ScreenCampaign.php +++ b/src/Entity/Tenant/ScreenCampaign.php @@ -15,11 +15,11 @@ class ScreenCampaign extends AbstractTenantScopedEntity implements RelationsChec { use RelationsChecksumTrait; - #[ORM\ManyToOne(targetEntity: Playlist::class, fetch: 'EXTRA_LAZY', inversedBy: 'screenCampaigns')] + #[ORM\ManyToOne(targetEntity: Playlist::class, inversedBy: 'screenCampaigns')] #[ORM\JoinColumn(nullable: false)] private Playlist $campaign; - #[ORM\ManyToOne(targetEntity: Screen::class, fetch: 'EXTRA_LAZY', inversedBy: 'screenCampaigns')] + #[ORM\ManyToOne(targetEntity: Screen::class, inversedBy: 'screenCampaigns')] #[ORM\JoinColumn(nullable: false)] private Screen $screen; diff --git a/src/Entity/Tenant/ScreenGroupCampaign.php b/src/Entity/Tenant/ScreenGroupCampaign.php index 680bafefc..8337d3795 100644 --- a/src/Entity/Tenant/ScreenGroupCampaign.php +++ b/src/Entity/Tenant/ScreenGroupCampaign.php @@ -15,11 +15,11 @@ class ScreenGroupCampaign extends AbstractTenantScopedEntity implements Relation { use RelationsChecksumTrait; - #[ORM\ManyToOne(targetEntity: Playlist::class, fetch: 'EXTRA_LAZY', inversedBy: 'screenGroupCampaigns')] + #[ORM\ManyToOne(targetEntity: Playlist::class, inversedBy: 'screenGroupCampaigns')] #[ORM\JoinColumn(nullable: false)] private Playlist $campaign; - #[ORM\ManyToOne(targetEntity: ScreenGroup::class, fetch: 'EXTRA_LAZY', inversedBy: 'screenGroupCampaigns')] + #[ORM\ManyToOne(targetEntity: ScreenGroup::class, inversedBy: 'screenGroupCampaigns')] #[ORM\JoinColumn(nullable: false)] private ScreenGroup $screenGroup; diff --git a/src/Entity/Tenant/Slide.php b/src/Entity/Tenant/Slide.php index f11cd6d33..335846915 100644 --- a/src/Entity/Tenant/Slide.php +++ b/src/Entity/Tenant/Slide.php @@ -31,11 +31,11 @@ class Slide extends AbstractTenantScopedEntity implements RelationsChecksumInter #[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, nullable: true)] private array $templateOptions = []; - #[ORM\ManyToOne(targetEntity: Template::class, fetch: 'EXTRA_LAZY', inversedBy: 'slides')] + #[ORM\ManyToOne(targetEntity: Template::class, inversedBy: 'slides')] #[ORM\JoinColumn(nullable: false)] private ?Template $template = null; - #[ORM\ManyToOne(targetEntity: Theme::class, fetch: 'EXTRA_LAZY', inversedBy: 'slides')] + #[ORM\ManyToOne(targetEntity: Theme::class, inversedBy: 'slides')] #[ORM\JoinColumn(onDelete: 'SET NULL')] private ?Theme $theme = null; diff --git a/src/Entity/UserRoleTenant.php b/src/Entity/UserRoleTenant.php index ef6533b9a..b94bc1005 100644 --- a/src/Entity/UserRoleTenant.php +++ b/src/Entity/UserRoleTenant.php @@ -12,11 +12,11 @@ #[ORM\Entity(repositoryClass: UserRoleTenantRepository::class)] class UserRoleTenant extends AbstractBaseEntity implements \JsonSerializable { - #[ORM\ManyToOne(targetEntity: User::class, fetch: 'EXTRA_LAZY', inversedBy: 'userRoleTenants')] + #[ORM\ManyToOne(targetEntity: User::class, inversedBy: 'userRoleTenants')] #[ORM\JoinColumn(nullable: false)] private ?User $user = null; - #[ORM\ManyToOne(targetEntity: Tenant::class, fetch: 'EXTRA_LAZY', inversedBy: 'userRoleTenants')] + #[ORM\ManyToOne(targetEntity: Tenant::class, inversedBy: 'userRoleTenants')] #[ORM\JoinColumn(nullable: false)] private ?Tenant $tenant = null; From 1ff2f2349eb7f62b6ef22ef54f1e6cd5fcff38ef Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Sat, 11 Apr 2026 09:18:57 +0200 Subject: [PATCH 24/31] 6871: Refactored to use recursive functions to get all pages --- .../components/groups/groups-columns.jsx | 11 +- .../components/playlist/playlists-columns.jsx | 11 +- .../screen/util/campaigns-button.jsx | 179 +++++------------- .../screen/util/screen-groups-button.jsx | 11 +- .../components/util/helpers/get-all-pages.js | 16 ++ 5 files changed, 76 insertions(+), 152 deletions(-) create mode 100644 assets/admin/components/util/helpers/get-all-pages.js diff --git a/assets/admin/components/groups/groups-columns.jsx b/assets/admin/components/groups/groups-columns.jsx index 1880c1f03..191df9414 100644 --- a/assets/admin/components/groups/groups-columns.jsx +++ b/assets/admin/components/groups/groups-columns.jsx @@ -5,6 +5,7 @@ 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"; @@ -14,14 +15,12 @@ function ScreensButton({ group }) { const dispatch = useDispatch(); const onClick = () => { - dispatch( - enhancedApi.endpoints.getV2ScreenGroupsByIdScreens.initiate({ - id: idFromUrl(group.id), - }), - ).then(({ data }) => { + getAllPages(dispatch, enhancedApi.endpoints.getV2ScreenGroupsByIdScreens, { + id: idFromUrl(group.id), + }).then((screens) => { const content = (
      - {data["hydra:member"].map((screen) => ( + {screens.map((screen) => (
    • { - dispatch( - enhancedApi.endpoints.getV2PlaylistsByIdSlides.initiate({ - id: idFromUrl(playlist.id), - }), - ).then(({ data }) => { + getAllPages(dispatch, enhancedApi.endpoints.getV2PlaylistsByIdSlides, { + id: idFromUrl(playlist.id), + }).then((playlistSlides) => { const content = (
        - {data["hydra:member"].map((playlistSlide) => ( + {playlistSlides.map((playlistSlide) => (
      • { - if (screenId === null) { - resolve(results); - } else { - dispatch( - enhancedApi.endpoints.getV2ScreensByIdScreenGroups.initiate({ - id: screenId, - page: page, - }), - ).then(({ data }) => { - const newResults = [...results, ...data["hydra:member"]]; - const hydraView = data["hydra:view"] ?? null; - - if (hydraView !== null && (hydraView["hydra:next"] ?? false)) { - resolve(getAllScreenGroups(dispatch, screenId, newResults, page + 1)); - } else { - resolve(newResults); - } - }); - } - }); -} - -function getAllScreenGroupCampaigns( - dispatch, - screenGroupId = null, - screenGroupIds = [], - results = [], - page = 1, -) { - return new Promise((resolve, reject) => { - if (screenGroupId === null) { - resolve(results); - } else { - dispatch( - enhancedApi.endpoints.getV2ScreenGroupsByIdCampaigns.initiate({ - id: screenGroupId, - page: page, - }), - ).then(({ data }) => { - const newResults = [...results, ...data["hydra:member"]]; - const hydraView = data["hydra:view"] ?? null; - - if (hydraView !== null && (hydraView["hydra:next"] ?? false)) { - resolve( - getAllScreenGroupCampaigns( - dispatch, - screenGroupId, - screenGroupIds, - newResults, - page + 1, - ), - ); - } else { - const newScreenGroupIds = screenGroupIds.filter( - (id) => id !== screenGroupId, - ); - if (newScreenGroupIds.length === 0) { - resolve(newResults); - } else { - resolve( - getAllScreenGroupCampaigns( - dispatch, - newScreenGroupIds[0], - newScreenGroupIds, - newResults, - 1, - ), - ); - } - } - }); - } - }); -} - -function getAllScreenCampaigns( - dispatch, - screenId = null, - results = [], - page = 1, -) { - return new Promise((resolve, reject) => { - if (screenId === null) { - resolve(results); - } else { - dispatch( - enhancedApi.endpoints.getV2ScreensByIdCampaigns.initiate({ - id: screenId, - page: page, - }), - ).then(({ data }) => { - const newResults = [...results, ...data["hydra:member"]]; - const hydraView = data["hydra:view"] ?? null; - - if (hydraView !== null && (hydraView["hydra:next"] ?? false)) { - resolve( - getAllScreenCampaigns(dispatch, screenId, newResults, page + 1), - ); - } else { - resolve(newResults); - } - }); - } - }); +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, reject) => { + return new Promise((resolve) => { if (campaignIds.length === 0) { resolve(results); } else { @@ -156,40 +63,44 @@ function CampaignsButton({ screen }) { // Fetch screen campaigns. // Merge campaign arrays. // Set campaigns to trigger useEffect. - getAllScreenGroups(dispatch, screen.id) + getAllPages(dispatch, enhancedApi.endpoints.getV2ScreensByIdScreenGroups, { + id: screen.id, + }) .then((screenGroups) => { const screenGroupIds = screenGroups .filter(({ campaignsLength }) => campaignsLength > 0) .map((group) => idFromUrl(group["@id"])); - getAllScreenGroupCampaigns( - dispatch, - screenGroupIds[0] ?? null, - screenGroupIds, - ).then((screenGroupCampaigns) => { - getAllScreenCampaigns(dispatch, 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"]), - ); - - getAllCampaigns( + getAllScreenGroupCampaigns(dispatch, screenGroupIds).then( + (screenGroupCampaigns) => { + getAllPages( dispatch, - uniqueCampaigns.map((campaign) => idFromUrl(campaign["@id"])), - ).then((campaigns) => { - setCampaigns(campaigns); - setLoading(false); + 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"]), + ); + + getAllCampaigns( + dispatch, + uniqueCampaigns.map((campaign) => idFromUrl(campaign["@id"])), + ).then((allCampaigns) => { + setCampaigns(allCampaigns); + setLoading(false); + }); }); - }); - }); + }, + ); }) .catch(() => setLoading(false)); }; diff --git a/assets/admin/components/screen/util/screen-groups-button.jsx b/assets/admin/components/screen/util/screen-groups-button.jsx index 4d4dc89a0..dbd8d01b5 100644 --- a/assets/admin/components/screen/util/screen-groups-button.jsx +++ b/assets/admin/components/screen/util/screen-groups-button.jsx @@ -1,5 +1,6 @@ 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"; @@ -12,14 +13,12 @@ function ScreenGroupsButton({ screen }) { const dispatch = useDispatch(); const onClick = () => { - dispatch( - enhancedApi.endpoints.getV2ScreensByIdScreenGroups.initiate({ - id: idFromUrl(screen.id), - }), - ).then(({ data }) => { + getAllPages(dispatch, enhancedApi.endpoints.getV2ScreensByIdScreenGroups, { + id: idFromUrl(screen.id), + }).then((groups) => { const content = (
          - {data["hydra:member"].map((group) => ( + {groups.map((group) => (
        • { + dispatch(endpoint.initiate({ ...params, page })).then(({ data }) => { + const newResults = [...results, ...data["hydra:member"]]; + const hydraView = data["hydra:view"] ?? null; + + if (hydraView !== null && (hydraView["hydra:next"] ?? false)) { + resolve(getAllPages(dispatch, endpoint, params, newResults, page + 1)); + } else { + resolve(newResults); + } + }); + }); +} + +export default getAllPages; From 8fd12a69db233f62a65e1338ee6578fd842832c1 Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Sat, 11 Apr 2026 10:03:53 +0200 Subject: [PATCH 25/31] 6871: Cleaned up getAllPages implementation and added tests --- .../components/util/helpers/get-all-pages.js | 35 +++-- assets/tests/admin/get-all-pages.spec.js | 129 ++++++++++++++++++ 2 files changed, 152 insertions(+), 12 deletions(-) create mode 100644 assets/tests/admin/get-all-pages.spec.js diff --git a/assets/admin/components/util/helpers/get-all-pages.js b/assets/admin/components/util/helpers/get-all-pages.js index aa94cecd7..34524ba5b 100644 --- a/assets/admin/components/util/helpers/get-all-pages.js +++ b/assets/admin/components/util/helpers/get-all-pages.js @@ -1,16 +1,27 @@ -function getAllPages(dispatch, endpoint, params, results = [], page = 1) { - return new Promise((resolve) => { - dispatch(endpoint.initiate({ ...params, page })).then(({ data }) => { - const newResults = [...results, ...data["hydra:member"]]; - const hydraView = data["hydra:view"] ?? null; +const MAX_PAGES = 100; - if (hydraView !== null && (hydraView["hydra:next"] ?? false)) { - resolve(getAllPages(dispatch, endpoint, params, newResults, page + 1)); - } else { - resolve(newResults); - } - }); - }); +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/tests/admin/get-all-pages.spec.js b/assets/tests/admin/get-all-pages.spec.js new file mode 100644 index 000000000..f2476c45c --- /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([]); + }); +}); From 0c2b11283ab155f50c5b8cb3ba3f85eb35e88f0f Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Sat, 11 Apr 2026 10:14:56 +0200 Subject: [PATCH 26/31] 6871: Applied coding standards --- assets/tests/admin/get-all-pages.spec.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/assets/tests/admin/get-all-pages.spec.js b/assets/tests/admin/get-all-pages.spec.js index f2476c45c..25b715887 100644 --- a/assets/tests/admin/get-all-pages.spec.js +++ b/assets/tests/admin/get-all-pages.spec.js @@ -101,7 +101,7 @@ test.describe("getAllPages", () => { test("It respects the max pages limit", async () => { const responses = Array.from({ length: 101 }, (_, i) => - createHydraResponse([{ id: i }], true) + createHydraResponse([{ id: i }], true), ); const dispatch = createMockDispatch(responses); @@ -115,7 +115,7 @@ test.describe("getAllPages", () => { const dispatch = () => Promise.reject(new Error("Network error")); await expect( - getAllPages(dispatch, createMockEndpoint(), {}) + getAllPages(dispatch, createMockEndpoint(), {}), ).rejects.toThrow("Network error"); }); From d76773552e6169a0800d9c8eabb19ff24ec612d4 Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Sat, 11 Apr 2026 10:56:01 +0200 Subject: [PATCH 27/31] 6871: Fixed relations checksum test --- CHANGELOG.md | 1 + fixtures/playlist.yaml | 1 - fixtures/playlist_slide.yaml | 2 +- tests/EventListener/RelationsChecksumListenerTest.php | 2 +- 4 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 561ad259e..6da9b7df9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,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 relations checksum test. ### NB! Prior to 3.x the project was split into separate repositories 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/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); From bfd69e76b4e59b10e6ec0473046b622f3892a38f Mon Sep 17 00:00:00 2001 From: turegjorup Date: Mon, 13 Apr 2026 09:45:44 +0200 Subject: [PATCH 28/31] 6871: Added test for campaignsLength on ScreenGroup Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/Api/ScreenGroupsTest.php | 66 ++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/tests/Api/ScreenGroupsTest.php b/tests/Api/ScreenGroupsTest.php index 6231c6cb8..37502e675 100644 --- a/tests/Api/ScreenGroupsTest.php +++ b/tests/Api/ScreenGroupsTest.php @@ -177,6 +177,72 @@ public function testScreensLength(): void $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'); From 679147a9fe58193642f158db4a6360ace9b394a2 Mon Sep 17 00:00:00 2001 From: turegjorup Date: Mon, 13 Apr 2026 09:47:07 +0200 Subject: [PATCH 29/31] 6871: Added tests for length fields on collection endpoints Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/Api/PlaylistsTest.php | 14 ++++++++++++++ tests/Api/ScreenGroupsTest.php | 16 ++++++++++++++++ tests/Api/ScreensTest.php | 18 ++++++++++++++++++ 3 files changed, 48 insertions(+) diff --git a/tests/Api/PlaylistsTest.php b/tests/Api/PlaylistsTest.php index 1fdec7152..4559a6cdd 100644 --- a/tests/Api/PlaylistsTest.php +++ b/tests/Api/PlaylistsTest.php @@ -34,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']]); diff --git a/tests/Api/ScreenGroupsTest.php b/tests/Api/ScreenGroupsTest.php index 37502e675..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'); diff --git a/tests/Api/ScreensTest.php b/tests/Api/ScreensTest.php index 21408631e..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'); From 757daabe12664cf4eb8d1f9f51d669842f512a9e Mon Sep 17 00:00:00 2001 From: turegjorup Date: Mon, 13 Apr 2026 10:14:24 +0200 Subject: [PATCH 30/31] 6871: Fixed unhandled promise rejections in CampaignsButton onClick MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inner promises in the onClick chain were not returned, so rejections from getAllScreenGroupCampaigns, getAllPages (screen campaigns), or getAllCampaigns never reached the outer .catch() handler — leaving the button stuck in a loading state permanently on network errors. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../screen/util/campaigns-button.jsx | 6 +- .../campaigns-button-promise-chain.spec.js | 141 ++++++++++++++++++ 2 files changed, 144 insertions(+), 3 deletions(-) create mode 100644 assets/tests/admin/campaigns-button-promise-chain.spec.js diff --git a/assets/admin/components/screen/util/campaigns-button.jsx b/assets/admin/components/screen/util/campaigns-button.jsx index 6eb065962..edb5657c3 100644 --- a/assets/admin/components/screen/util/campaigns-button.jsx +++ b/assets/admin/components/screen/util/campaigns-button.jsx @@ -71,9 +71,9 @@ function CampaignsButton({ screen }) { .filter(({ campaignsLength }) => campaignsLength > 0) .map((group) => idFromUrl(group["@id"])); - getAllScreenGroupCampaigns(dispatch, screenGroupIds).then( + return getAllScreenGroupCampaigns(dispatch, screenGroupIds).then( (screenGroupCampaigns) => { - getAllPages( + return getAllPages( dispatch, enhancedApi.endpoints.getV2ScreensByIdCampaigns, { id: screen.id }, @@ -91,7 +91,7 @@ function CampaignsButton({ screen }) { !ids.has(campaign["@id"]) && ids.add(campaign["@id"]), ); - getAllCampaigns( + return getAllCampaigns( dispatch, uniqueCampaigns.map((campaign) => idFromUrl(campaign["@id"])), ).then((allCampaigns) => { 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..5b1a7d466 --- /dev/null +++ b/assets/tests/admin/campaigns-button-promise-chain.spec.js @@ -0,0 +1,141 @@ +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); + }); +}); From a9706726cebe6f8a31efa60d3216fcdd043afbc8 Mon Sep 17 00:00:00 2001 From: turegjorup Date: Mon, 13 Apr 2026 10:41:10 +0200 Subject: [PATCH 31/31] 6871: Applied coding standards Co-Authored-By: Claude Opus 4.6 (1M context) --- assets/tests/admin/campaigns-button-promise-chain.spec.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/assets/tests/admin/campaigns-button-promise-chain.spec.js b/assets/tests/admin/campaigns-button-promise-chain.spec.js index 5b1a7d466..1ec17d343 100644 --- a/assets/tests/admin/campaigns-button-promise-chain.spec.js +++ b/assets/tests/admin/campaigns-button-promise-chain.spec.js @@ -87,8 +87,7 @@ function createMocks({ failAt } = {}) { getAllCampaigns: failAt === "allCampaigns" ? () => Promise.reject(new Error("allCampaigns failed")) - : (ids) => - Promise.resolve(ids.map((id) => ({ "@id": id, title: id }))), + : (ids) => Promise.resolve(ids.map((id) => ({ "@id": id, title: id }))), }; }