diff --git a/Taskfile.yml b/Taskfile.yml index 0dbc4dc90..97639dd7c 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -202,7 +202,7 @@ tasks: test:frontend-local: desc: "Runs frontend tests from the local machine." cmds: - - BASE_URL="https://display.local.itkdev.dk" npx playwright test + - BASE_URL="https://display.local.itkdev.dk" npx playwright test {{.CLI_ARGS}} test:frontend-local-ui: desc: "Runs frontend tests from the local machine in UI mode." @@ -215,9 +215,10 @@ tasks: - task compose -- exec phpfpm composer update-api-spec generate:redux-toolkit-api: - desc: "Generate the RTK Query api slices (in assets/shared/redux/)." + desc: "Generate the RTK Query api slices." cmds: - - task compose -- exec node npx @rtk-query/codegen-openapi /app/assets/shared/redux/openapi-config.js + - task compose -- exec node npx @rtk-query/codegen-openapi /app/assets/admin/redux/openapi-config.js + - task compose -- exec node npx @rtk-query/codegen-openapi /app/assets/client/redux/openapi-config.js app:update: desc: "Migrate to latest database schema and update installed templates" diff --git a/assets/admin/components/activation-code/activation-code-activate.jsx b/assets/admin/components/activation-code/activation-code-activate.jsx index 711aabbbc..f6589b533 100644 --- a/assets/admin/components/activation-code/activation-code-activate.jsx +++ b/assets/admin/components/activation-code/activation-code-activate.jsx @@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; import Form from "react-bootstrap/Form"; import { Button } from "react-bootstrap"; -import { usePostV2UserActivationCodesActivateMutation } from "../../../shared/redux/enhanced-api.ts"; +import { usePostV2UserActivationCodesActivateMutation } from "../../redux/enhanced-api.ts"; import { displaySuccess, displayError, diff --git a/assets/admin/components/activation-code/activation-code-create.jsx b/assets/admin/components/activation-code/activation-code-create.jsx index 5a8faf35b..adb822cfe 100644 --- a/assets/admin/components/activation-code/activation-code-create.jsx +++ b/assets/admin/components/activation-code/activation-code-create.jsx @@ -1,7 +1,7 @@ import { useState, useEffect } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; -import { usePostV2UserActivationCodesMutation } from "../../../shared/redux/enhanced-api.ts"; +import { usePostV2UserActivationCodesMutation } from "../../redux/enhanced-api.ts"; import ActivationCodeForm from "./activation-code-form"; import { displaySuccess, diff --git a/assets/admin/components/activation-code/activation-code-list.jsx b/assets/admin/components/activation-code/activation-code-list.jsx index 8925119d1..5e9fd833f 100644 --- a/assets/admin/components/activation-code/activation-code-list.jsx +++ b/assets/admin/components/activation-code/activation-code-list.jsx @@ -18,7 +18,7 @@ import { enhancedApi, useDeleteV2UserActivationCodesByIdMutation, useGetV2UserActivationCodesQuery, -} from "../../../shared/redux/enhanced-api.ts"; +} from "../../redux/enhanced-api.ts"; /** * The Activation Code list component. diff --git a/assets/admin/components/feed-sources/feed-source-edit.jsx b/assets/admin/components/feed-sources/feed-source-edit.jsx index 602af8bcd..b57d74696 100644 --- a/assets/admin/components/feed-sources/feed-source-edit.jsx +++ b/assets/admin/components/feed-sources/feed-source-edit.jsx @@ -1,5 +1,5 @@ import { useParams } from "react-router-dom"; -import { useGetV2FeedSourcesByIdQuery } from "../../../shared/redux/enhanced-api.ts"; +import { useGetV2FeedSourcesByIdQuery } from "../../redux/enhanced-api.ts"; import FeedSourceManager from "./feed-source-manager"; /** diff --git a/assets/admin/components/feed-sources/feed-source-manager.jsx b/assets/admin/components/feed-sources/feed-source-manager.jsx index 6dcfe0e7b..e42eafcc9 100644 --- a/assets/admin/components/feed-sources/feed-source-manager.jsx +++ b/assets/admin/components/feed-sources/feed-source-manager.jsx @@ -5,7 +5,7 @@ import FeedSourceForm from "./feed-source-form"; import { usePostV2FeedSourcesMutation, usePutV2FeedSourcesByIdMutation, -} from "../../../shared/redux/enhanced-api.ts"; +} from "../../redux/enhanced-api.ts"; import { displayError, displaySuccess, diff --git a/assets/admin/components/feed-sources/feed-sources-list.jsx b/assets/admin/components/feed-sources/feed-sources-list.jsx index 90d4146de..054dd0fcb 100644 --- a/assets/admin/components/feed-sources/feed-sources-list.jsx +++ b/assets/admin/components/feed-sources/feed-sources-list.jsx @@ -5,7 +5,7 @@ import { useGetV2FeedSourcesQuery, useDeleteV2FeedSourcesByIdMutation, useGetV2FeedSourcesByIdSlidesQuery, -} from "../../../shared/redux/enhanced-api.ts"; +} from "../../redux/enhanced-api.ts"; import ListContext from "../../context/list-context"; import ContentBody from "../util/content-body/content-body"; import List from "../util/list/list"; diff --git a/assets/admin/components/groups/group-create.jsx b/assets/admin/components/groups/group-create.jsx index bc64a70bc..9830608b6 100644 --- a/assets/admin/components/groups/group-create.jsx +++ b/assets/admin/components/groups/group-create.jsx @@ -1,7 +1,7 @@ import { useState, useEffect } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; -import { usePostV2ScreenGroupsMutation } from "../../../shared/redux/enhanced-api.ts"; +import { usePostV2ScreenGroupsMutation } from "../../redux/enhanced-api.ts"; import GroupForm from "./group-form"; import { displaySuccess, diff --git a/assets/admin/components/groups/group-edit.jsx b/assets/admin/components/groups/group-edit.jsx index fca125590..a5d6c3466 100644 --- a/assets/admin/components/groups/group-edit.jsx +++ b/assets/admin/components/groups/group-edit.jsx @@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next"; import { useGetV2ScreenGroupsByIdQuery, usePutV2ScreenGroupsByIdMutation, -} from "../../../shared/redux/enhanced-api.ts"; +} from "../../redux/enhanced-api.ts"; import { displaySuccess, displayError, diff --git a/assets/admin/components/groups/groups-columns.jsx b/assets/admin/components/groups/groups-columns.jsx index 191df9414..0b38faa61 100644 --- a/assets/admin/components/groups/groups-columns.jsx +++ b/assets/admin/components/groups/groups-columns.jsx @@ -3,11 +3,11 @@ import ColumnHoc from "../util/column-hoc"; import SelectColumnHoc from "../util/select-column-hoc"; import useModal from "../../context/modal-context/modal-context-hook.jsx"; import { useDispatch } from "react-redux"; -import { enhancedApi } from "../../../shared/redux/enhanced-api.ts"; import idFromUrl from "../util/helpers/id-from-url.jsx"; import getAllPages from "../util/helpers/get-all-pages.js"; import { Link } from "react-router-dom"; import { Button } from "react-bootstrap"; +import { enhancedApi } from "../../redux/enhanced-api.ts"; function ScreensButton({ group }) { const { t } = useTranslation("common", { keyPrefix: "groups-columns" }); diff --git a/assets/admin/components/groups/groups-list.jsx b/assets/admin/components/groups/groups-list.jsx index 2b75c358a..a92d9cdfc 100644 --- a/assets/admin/components/groups/groups-list.jsx +++ b/assets/admin/components/groups/groups-list.jsx @@ -16,7 +16,7 @@ import { useGetV2ScreenGroupsQuery, useGetV2ScreenGroupsByIdScreensQuery, useDeleteV2ScreenGroupsByIdMutation, -} from "../../../shared/redux/enhanced-api.ts"; +} from "../../redux/enhanced-api.ts"; /** * The groups list component. diff --git a/assets/admin/components/media/media-create.jsx b/assets/admin/components/media/media-create.jsx index 051274acf..0db61a579 100644 --- a/assets/admin/components/media/media-create.jsx +++ b/assets/admin/components/media/media-create.jsx @@ -1,6 +1,6 @@ import { useState, useEffect } from "react"; import { useTranslation } from "react-i18next"; -import { usePostMediaCollectionMutation } from "../../../shared/redux/enhanced-api.ts"; +import { usePostMediaCollectionMutation } from "../../redux/enhanced-api.ts"; import MediaForm from "./media-form"; import { displayError, diff --git a/assets/admin/components/media/media-list.jsx b/assets/admin/components/media/media-list.jsx index 5cd8ed320..3897a71c1 100644 --- a/assets/admin/components/media/media-list.jsx +++ b/assets/admin/components/media/media-list.jsx @@ -16,7 +16,7 @@ import { import { useGetV2MediaQuery, useDeleteV2MediaByIdMutation, -} from "../../../shared/redux/enhanced-api.ts"; +} from "../../redux/enhanced-api.ts"; import FormCheckbox from "../util/forms/form-checkbox"; import "./media-list.scss"; diff --git a/assets/admin/components/playlist-drag-and-drop/playlist-drag-and-drop.jsx b/assets/admin/components/playlist-drag-and-drop/playlist-drag-and-drop.jsx index 1735e1661..5dd5d7849 100644 --- a/assets/admin/components/playlist-drag-and-drop/playlist-drag-and-drop.jsx +++ b/assets/admin/components/playlist-drag-and-drop/playlist-drag-and-drop.jsx @@ -7,7 +7,7 @@ import FormCheckbox from "../util/forms/form-checkbox"; import { useGetV2PlaylistsByIdSlidesQuery, useGetV2PlaylistsQuery, -} from "../../../shared/redux/enhanced-api.ts"; +} from "../../redux/enhanced-api.ts"; import ScreenGanttChart from "../screen/util/screen-gantt-chart"; /** diff --git a/assets/admin/components/playlist/campaign-form.jsx b/assets/admin/components/playlist/campaign-form.jsx index 4e2fe5fdc..ee993c694 100644 --- a/assets/admin/components/playlist/campaign-form.jsx +++ b/assets/admin/components/playlist/campaign-form.jsx @@ -1,6 +1,6 @@ import { useTranslation } from "react-i18next"; import idFromUrl from "../util/helpers/id-from-url"; -import { enhancedApi } from "../../../shared/redux/enhanced-api.ts"; +import { enhancedApi } from "../../redux/enhanced-api.ts"; import ContentBody from "../util/content-body/content-body"; import SelectScreensTable from "../util/multi-and-table/select-screens-table"; import SelectGroupsTable from "../util/multi-and-table/select-groups-table"; diff --git a/assets/admin/components/playlist/playlist-campaign-edit.jsx b/assets/admin/components/playlist/playlist-campaign-edit.jsx index 8a7af904e..7ffd955f9 100644 --- a/assets/admin/components/playlist/playlist-campaign-edit.jsx +++ b/assets/admin/components/playlist/playlist-campaign-edit.jsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react"; import { useParams } from "react-router-dom"; import PlaylistCampaignManager from "./playlist-campaign-manager"; import idFromUrl from "../util/helpers/id-from-url"; -import { useGetV2PlaylistsByIdQuery } from "../../../shared/redux/enhanced-api.ts"; +import { useGetV2PlaylistsByIdQuery } from "../../redux/enhanced-api.ts"; /** * The playlist/campaign edit component. diff --git a/assets/admin/components/playlist/playlist-campaign-list.jsx b/assets/admin/components/playlist/playlist-campaign-list.jsx index ddbcfddc0..59b593111 100644 --- a/assets/admin/components/playlist/playlist-campaign-list.jsx +++ b/assets/admin/components/playlist/playlist-campaign-list.jsx @@ -16,7 +16,7 @@ import { useDeleteV2PlaylistsByIdMutation, useGetV2PlaylistsByIdSlidesQuery, useGetV2PlaylistsQuery, -} from "../../../shared/redux/enhanced-api.ts"; +} from "../../redux/enhanced-api.ts"; /** * The shared list component. diff --git a/assets/admin/components/playlist/playlist-campaign-manager.jsx b/assets/admin/components/playlist/playlist-campaign-manager.jsx index b8cb387a3..2f65182ef 100644 --- a/assets/admin/components/playlist/playlist-campaign-manager.jsx +++ b/assets/admin/components/playlist/playlist-campaign-manager.jsx @@ -15,7 +15,7 @@ import { enhancedApi, usePutV2PlaylistsByIdMutation, usePostV2PlaylistsMutation, -} from "../../../shared/redux/enhanced-api.ts"; +} from "../../redux/enhanced-api.ts"; import { set } from "lodash/object"; /** diff --git a/assets/admin/components/playlist/playlist-form.jsx b/assets/admin/components/playlist/playlist-form.jsx index fd894738b..b58c663d2 100644 --- a/assets/admin/components/playlist/playlist-form.jsx +++ b/assets/admin/components/playlist/playlist-form.jsx @@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next"; import { Alert } from "react-bootstrap"; import UserContext from "../../context/user-context"; import Schedule from "../util/schedule/schedule"; -import { useGetV2TenantsQuery } from "../../../shared/redux/enhanced-api.ts"; +import { useGetV2TenantsQuery } from "../../redux/enhanced-api.ts"; import ContentBody from "../util/content-body/content-body"; import TenantsDropdown from "../util/forms/multiselect-dropdown/tenants/tenants-dropdown"; diff --git a/assets/admin/components/playlist/playlists-columns.jsx b/assets/admin/components/playlist/playlists-columns.jsx index d7c93be26..814838c80 100644 --- a/assets/admin/components/playlist/playlists-columns.jsx +++ b/assets/admin/components/playlist/playlists-columns.jsx @@ -5,11 +5,11 @@ import DateValue from "../util/date-value"; import PublishingStatus from "../util/publishingStatus"; import useModal from "../../context/modal-context/modal-context-hook.jsx"; import { useDispatch } from "react-redux"; -import { enhancedApi } from "../../../shared/redux/enhanced-api.ts"; import idFromUrl from "../util/helpers/id-from-url.jsx"; import getAllPages from "../util/helpers/get-all-pages.js"; import { Link } from "react-router-dom"; import { Button } from "react-bootstrap"; +import { enhancedApi } from "../../redux/enhanced-api.ts"; function SlidesButton({ playlist }) { const { t } = useTranslation("common", { keyPrefix: "playlists-columns" }); diff --git a/assets/admin/components/playlist/shared-playlists.jsx b/assets/admin/components/playlist/shared-playlists.jsx index 7b89e0fec..8523ca840 100644 --- a/assets/admin/components/playlist/shared-playlists.jsx +++ b/assets/admin/components/playlist/shared-playlists.jsx @@ -7,7 +7,7 @@ import ListContext from "../../context/list-context"; import ContentBody from "../util/content-body/content-body"; import { displayError } from "../util/list/toast-component/display-toast"; import getSharedPlaylistColumns from "./shared-playlists-column"; -import { useGetV2PlaylistsQuery } from "../../../shared/redux/enhanced-api.ts"; +import { useGetV2PlaylistsQuery } from "../../redux/enhanced-api.ts"; /** * The list component for shared playlists. diff --git a/assets/admin/components/screen/screen-edit.jsx b/assets/admin/components/screen/screen-edit.jsx index 7fd26e5a5..fd533fd62 100644 --- a/assets/admin/components/screen/screen-edit.jsx +++ b/assets/admin/components/screen/screen-edit.jsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react"; import { useParams } from "react-router-dom"; import idFromUrl from "../util/helpers/id-from-url"; import ScreenManager from "./screen-manager"; -import { useGetV2ScreensByIdQuery } from "../../../shared/redux/enhanced-api.ts"; +import { useGetV2ScreensByIdQuery } from "../../redux/enhanced-api.ts"; /** * The screen edit component. diff --git a/assets/admin/components/screen/screen-form.jsx b/assets/admin/components/screen/screen-form.jsx index 0f141cf33..4d2aa5457 100644 --- a/assets/admin/components/screen/screen-form.jsx +++ b/assets/admin/components/screen/screen-form.jsx @@ -14,7 +14,7 @@ import idFromUrl from "../util/helpers/id-from-url"; import { useGetV2LayoutsQuery, enhancedApi, -} from "../../../shared/redux/enhanced-api.ts"; +} from "../../redux/enhanced-api.ts"; import FormCheckbox from "../util/forms/form-checkbox"; import Preview from "../preview/preview"; import StickyFooter from "../util/sticky-footer"; diff --git a/assets/admin/components/screen/screen-list.jsx b/assets/admin/components/screen/screen-list.jsx index 9c5c33dd2..5f6a4dc41 100644 --- a/assets/admin/components/screen/screen-list.jsx +++ b/assets/admin/components/screen/screen-list.jsx @@ -11,7 +11,7 @@ import useModal from "../../context/modal-context/modal-context-hook"; import { useGetV2ScreensQuery, useDeleteV2ScreensByIdMutation, -} from "../../../shared/redux/enhanced-api.ts"; +} from "../../redux/enhanced-api.ts"; import { displaySuccess, displayError, diff --git a/assets/admin/components/screen/screen-manager.jsx b/assets/admin/components/screen/screen-manager.jsx index 0c141b493..930b8ad7f 100644 --- a/assets/admin/components/screen/screen-manager.jsx +++ b/assets/admin/components/screen/screen-manager.jsx @@ -4,7 +4,7 @@ import { useNavigate } from "react-router-dom"; import { usePostV2ScreensMutation, usePutV2ScreensByIdMutation, -} from "../../../shared/redux/enhanced-api.ts"; +} from "../../redux/enhanced-api.ts"; import ScreenForm from "./screen-form"; import { displaySuccess, diff --git a/assets/admin/components/screen/screen-status.jsx b/assets/admin/components/screen/screen-status.jsx index b6ee26645..543d28b1e 100644 --- a/assets/admin/components/screen/screen-status.jsx +++ b/assets/admin/components/screen/screen-status.jsx @@ -14,7 +14,7 @@ import { import { useNavigate } from "react-router-dom"; import { useDispatch } from "react-redux"; import idFromUrl from "../util/helpers/id-from-url"; -import { enhancedApi } from "../../../shared/redux/enhanced-api.ts"; +import { enhancedApi } from "../../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"; diff --git a/assets/admin/components/screen/util/campaign-icon.jsx b/assets/admin/components/screen/util/campaign-icon.jsx new file mode 100644 index 000000000..8003d2a26 --- /dev/null +++ b/assets/admin/components/screen/util/campaign-icon.jsx @@ -0,0 +1,105 @@ +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 "../../../redux/enhanced-api.ts"; + +/** + * An icon to show if the screen has an active campaign. + * + * @param {object} props - The props. + * @param {string} props.id The id of the screen. + * @param {number} props.delay Delay the fetch. + * @returns {object} The campaign icon. + */ +function CampaignIcon({ id, delay = 1000 }) { + const { t } = useTranslation("common", { keyPrefix: "campaign-icon" }); + const dispatch = useDispatch(); + const [isOverriddenByCampaign, setIsOverriddenByCampaign] = useState(null); + const [screenCampaignsChecked, setScreenCampaignsChecked] = useState(false); + const [allCampaigns, setAllCampaigns] = useState([]); + const [getData, setGetData] = useState(false); + + const { data: campaigns, isLoading } = useGetV2ScreensByIdCampaignsQuery( + { id }, + { skip: !getData || !id }, + ); + const { data: groups, isLoading: isLoadingScreenGroups } = + useGetV2ScreensByIdScreenGroupsQuery({ id }, { skip: !getData || !id }); + + useEffect(() => { + if (campaigns) { + setAllCampaigns( + campaigns["hydra:member"].map(({ campaign }) => campaign), + ); + setScreenCampaignsChecked(true); + } + }, [campaigns]); + + useEffect(() => { + if (groups && !isOverriddenByCampaign && screenCampaignsChecked) { + groups["hydra:member"].forEach((group) => { + dispatch( + enhancedApi.endpoints.getV2ScreenGroupsByIdCampaigns.initiate({ + id: idFromUrl(group["@id"]), + }), + ).then((result) => { + let allCampaignsCopy = [...allCampaigns]; + if (allCampaignsCopy.length > 0 && result.data) { + allCampaignsCopy = allCampaignsCopy.concat( + result.data["hydra:member"].map(({ campaign }) => campaign), + ); + } + setAllCampaigns(allCampaignsCopy); + }); + }); + } + }, [groups, screenCampaignsChecked]); + + useEffect(() => { + if (allCampaigns.length > 0 && !isOverriddenByCampaign) { + allCampaigns.forEach(({ published }) => { + if (calculateIsPublished(published)) { + setIsOverriddenByCampaign(true); + } + }); + } + }, [allCampaigns]); + + useEffect(() => { + const timeout = setTimeout(() => { + setGetData(true); + }, delay); + + return () => { + clearTimeout(timeout); + }; + }, []); + + if (!getData || isLoading || isLoadingScreenGroups) { + return ( +
+
+ ); + } + + return isOverriddenByCampaign + ? t("overridden-by-campaign") + : t("not-overridden-by-campaign"); +} + +export default CampaignIcon; diff --git a/assets/admin/components/screen/util/campaigns-button.jsx b/assets/admin/components/screen/util/campaigns-button.jsx index edb5657c3..c1b5e3dd3 100644 --- a/assets/admin/components/screen/util/campaigns-button.jsx +++ b/assets/admin/components/screen/util/campaigns-button.jsx @@ -6,8 +6,8 @@ import idFromUrl from "../../util/helpers/id-from-url.jsx"; import getAllPages from "../../util/helpers/get-all-pages.js"; import { useDispatch } from "react-redux"; import useModal from "../../../context/modal-context/modal-context-hook.jsx"; -import { enhancedApi } from "../../../../shared/redux/enhanced-api.ts"; import { Button } from "react-bootstrap"; +import { enhancedApi } from "../../../redux/enhanced-api.ts"; function getAllScreenGroupCampaigns(dispatch, screenGroupIds = []) { return screenGroupIds.reduce( diff --git a/assets/admin/components/screen/util/grid-generation-and-select.jsx b/assets/admin/components/screen/util/grid-generation-and-select.jsx index f9e9b6140..1faa64c40 100644 --- a/assets/admin/components/screen/util/grid-generation-and-select.jsx +++ b/assets/admin/components/screen/util/grid-generation-and-select.jsx @@ -4,7 +4,7 @@ import Grid from "./grid"; import { useTranslation } from "react-i18next"; import idFromUrl from "../../util/helpers/id-from-url"; import PlaylistDragAndDrop from "../../playlist-drag-and-drop/playlist-drag-and-drop"; -import { enhancedApi } from "../../../../shared/redux/enhanced-api.ts"; +import { enhancedApi } from "../../../redux/enhanced-api.ts"; import useFetchDataHook from "../../util/fetch-data-hook.js"; import mapToIds from "../../util/helpers/map-to-ids.js"; import "./grid.scss"; diff --git a/assets/admin/components/screen/util/screen-groups-button.jsx b/assets/admin/components/screen/util/screen-groups-button.jsx index dbd8d01b5..1fc696401 100644 --- a/assets/admin/components/screen/util/screen-groups-button.jsx +++ b/assets/admin/components/screen/util/screen-groups-button.jsx @@ -3,9 +3,9 @@ import idFromUrl from "../../util/helpers/id-from-url"; import getAllPages from "../../util/helpers/get-all-pages.js"; import { Button } from "react-bootstrap"; import useModal from "../../../context/modal-context/modal-context-hook.jsx"; -import { enhancedApi } from "../../../../shared/redux/enhanced-api.ts"; import { useDispatch } from "react-redux"; import { Link } from "react-router-dom"; +import { enhancedApi } from "../../../redux/enhanced-api.ts"; function ScreenGroupsButton({ screen }) { const { t } = useTranslation("common", { keyPrefix: "screen-columns" }); diff --git a/assets/admin/components/slide/content/feed-selector.jsx b/assets/admin/components/slide/content/feed-selector.jsx index 15ea1bfdc..2a05f419a 100644 --- a/assets/admin/components/slide/content/feed-selector.jsx +++ b/assets/admin/components/slide/content/feed-selector.jsx @@ -5,7 +5,7 @@ import { useDispatch } from "react-redux"; import { enhancedApi, useGetV2FeedSourcesQuery, -} from "../../../../shared/redux/enhanced-api.ts"; +} from "../../../redux/enhanced-api.ts"; import MultiSelectComponent from "../../util/forms/multiselect-dropdown/multi-dropdown"; import idFromUrl from "../../util/helpers/id-from-url"; import ContentForm from "./content-form"; diff --git a/assets/admin/components/slide/content/media-selector-list.jsx b/assets/admin/components/slide/content/media-selector-list.jsx index e247ab231..8e0c4d8ac 100644 --- a/assets/admin/components/slide/content/media-selector-list.jsx +++ b/assets/admin/components/slide/content/media-selector-list.jsx @@ -3,7 +3,7 @@ import { Col, Form, Row, Spinner } from "react-bootstrap"; import { useTranslation } from "react-i18next"; import SearchBox from "../../util/search-box/search-box"; import ContentBody from "../../util/content-body/content-body"; -import { useGetV2MediaQuery } from "../../../../shared/redux/enhanced-api.ts"; +import { useGetV2MediaQuery } from "../../../redux/enhanced-api.ts"; import "../../media/media-list.scss"; import Pagination from "../../util/paginate/pagination"; import FilePreview from "./file-preview"; diff --git a/assets/admin/components/slide/slide-edit.jsx b/assets/admin/components/slide/slide-edit.jsx index bea68346c..0e20dfa0a 100644 --- a/assets/admin/components/slide/slide-edit.jsx +++ b/assets/admin/components/slide/slide-edit.jsx @@ -1,5 +1,5 @@ import { useParams } from "react-router-dom"; -import { useGetV2SlidesByIdQuery } from "../../../shared/redux/enhanced-api.ts"; +import { useGetV2SlidesByIdQuery } from "../../redux/enhanced-api.ts"; import SlideManager from "./slide-manager"; /** diff --git a/assets/admin/components/slide/slide-form.jsx b/assets/admin/components/slide/slide-form.jsx index 902d86c51..5be7068ba 100644 --- a/assets/admin/components/slide/slide-form.jsx +++ b/assets/admin/components/slide/slide-form.jsx @@ -10,7 +10,7 @@ import MultiSelectComponent from "../util/forms/multiselect-dropdown/multi-dropd import { useGetV2TemplatesQuery, useGetV2ThemesQuery, -} from "../../../shared/redux/enhanced-api.ts"; +} from "../../redux/enhanced-api.ts"; import idFromUrl from "../util/helpers/id-from-url"; import FormInput from "../util/forms/form-input"; import ContentForm from "./content/content-form"; diff --git a/assets/admin/components/slide/slide-manager.jsx b/assets/admin/components/slide/slide-manager.jsx index eeee9320b..e1bb046a8 100644 --- a/assets/admin/components/slide/slide-manager.jsx +++ b/assets/admin/components/slide/slide-manager.jsx @@ -12,7 +12,7 @@ import { usePostV2SlidesMutation, usePutV2SlidesByIdPlaylistsMutation, usePutV2SlidesByIdMutation, -} from "../../../shared/redux/enhanced-api.ts"; +} from "../../redux/enhanced-api.ts"; import SlideForm from "./slide-form"; import { displaySuccess, @@ -292,7 +292,7 @@ function SlideManager({ localFormStateObject.media.forEach((media) => { promises.push( dispatch( - enhancedApi.endpoints.getv2MediaById.initiate({ + enhancedApi.endpoints.getV2MediaById.initiate({ id: idFromUrl(media), }), ), diff --git a/assets/admin/components/slide/slides-list.jsx b/assets/admin/components/slide/slides-list.jsx index 56d385c52..f2908fb8e 100644 --- a/assets/admin/components/slide/slides-list.jsx +++ b/assets/admin/components/slide/slides-list.jsx @@ -16,7 +16,7 @@ import { useGetV2SlidesQuery, useDeleteV2SlidesByIdMutation, useGetV2PlaylistsByIdQuery, -} from "../../../shared/redux/enhanced-api.ts"; +} from "../../redux/enhanced-api.ts"; /** * The slides list component. diff --git a/assets/admin/components/themes/theme-edit.jsx b/assets/admin/components/themes/theme-edit.jsx index 15870a906..ce533156d 100644 --- a/assets/admin/components/themes/theme-edit.jsx +++ b/assets/admin/components/themes/theme-edit.jsx @@ -1,5 +1,5 @@ import { useParams } from "react-router-dom"; -import { useGetV2ThemesByIdQuery } from "../../../shared/redux/enhanced-api.ts"; +import { useGetV2ThemesByIdQuery } from "../../redux/enhanced-api.ts"; import ThemeManager from "./theme-manager"; /** diff --git a/assets/admin/components/themes/theme-manager.jsx b/assets/admin/components/themes/theme-manager.jsx index 63ea6bef1..c2f19ea42 100644 --- a/assets/admin/components/themes/theme-manager.jsx +++ b/assets/admin/components/themes/theme-manager.jsx @@ -6,7 +6,7 @@ import { usePostV2ThemesMutation, usePutV2ThemesByIdMutation, usePostMediaCollectionMutation, -} from "../../../shared/redux/enhanced-api.ts"; +} from "../../redux/enhanced-api.ts"; import { displaySuccess, displayError, diff --git a/assets/admin/components/themes/themes-list.jsx b/assets/admin/components/themes/themes-list.jsx index b5a78e5d3..886cd7cd9 100644 --- a/assets/admin/components/themes/themes-list.jsx +++ b/assets/admin/components/themes/themes-list.jsx @@ -15,7 +15,7 @@ import { import { useGetV2ThemesQuery, useDeleteV2ThemesByIdMutation, -} from "../../../shared/redux/enhanced-api.ts"; +} from "../../redux/enhanced-api.ts"; /** * The themes list component. diff --git a/assets/admin/components/user/login.jsx b/assets/admin/components/user/login.jsx index 26b802c78..25d04c87c 100644 --- a/assets/admin/components/user/login.jsx +++ b/assets/admin/components/user/login.jsx @@ -8,7 +8,7 @@ import Col from "react-bootstrap/Col"; import { MultiSelect } from "react-multi-select-component"; import UserContext from "../../context/user-context"; import FormInput from "../util/forms/form-input"; -import { enhancedApi } from "../../../shared/redux/enhanced-api.ts"; +import { enhancedApi } from "../../redux/enhanced-api.ts"; import AdminConfigLoader from "../util/admin-config-loader.js"; import { displayError } from "../util/list/toast-component/display-toast"; import localStorageKeys from "../util/local-storage-keys"; diff --git a/assets/admin/components/users/users-list.jsx b/assets/admin/components/users/users-list.jsx index 95ad37d99..40311dac2 100644 --- a/assets/admin/components/users/users-list.jsx +++ b/assets/admin/components/users/users-list.jsx @@ -16,7 +16,7 @@ import { import { useDeleteV2UsersByIdRemoveFromTenantMutation, useGetV2UsersQuery, -} from "../../../shared/redux/enhanced-api.ts"; +} from "../../redux/enhanced-api.ts"; /** * The users list component. diff --git a/assets/admin/components/util/multi-and-table/select-groups-table.jsx b/assets/admin/components/util/multi-and-table/select-groups-table.jsx index 316c591a7..472ea1d3f 100644 --- a/assets/admin/components/util/multi-and-table/select-groups-table.jsx +++ b/assets/admin/components/util/multi-and-table/select-groups-table.jsx @@ -5,7 +5,7 @@ import { SelectGroupColumns } from "../../groups/groups-columns"; import { useGetV2ScreenGroupsQuery, useGetV2ScreenGroupsByIdScreensQuery, -} from "../../../../shared/redux/enhanced-api.ts"; +} from "../../../redux/enhanced-api.ts"; import GroupsDropdown from "../forms/multiselect-dropdown/groups/groups-dropdown"; import useFetchDataHook from "../fetch-data-hook.js"; diff --git a/assets/admin/components/util/multi-and-table/select-playlists-table.jsx b/assets/admin/components/util/multi-and-table/select-playlists-table.jsx index 89e9235fb..853a1e738 100644 --- a/assets/admin/components/util/multi-and-table/select-playlists-table.jsx +++ b/assets/admin/components/util/multi-and-table/select-playlists-table.jsx @@ -5,7 +5,7 @@ import { enhancedApi, useGetV2PlaylistsQuery, useGetV2PlaylistsByIdSlidesQuery, -} from "../../../../shared/redux/enhanced-api.ts"; +} from "../../../redux/enhanced-api.ts"; import PlaylistsDropdown from "../forms/multiselect-dropdown/playlists/playlists-dropdown"; import { SelectPlaylistColumns } from "../../playlist/playlists-columns"; import useFetchDataHook from "../fetch-data-hook.js"; diff --git a/assets/admin/components/util/multi-and-table/select-screens-table.jsx b/assets/admin/components/util/multi-and-table/select-screens-table.jsx index 1b743c588..9503ebcfd 100644 --- a/assets/admin/components/util/multi-and-table/select-screens-table.jsx +++ b/assets/admin/components/util/multi-and-table/select-screens-table.jsx @@ -7,7 +7,7 @@ import { enhancedApi, useGetV2ScreensQuery, useGetV2ScreensByIdScreenGroupsQuery, -} from "../../../../shared/redux/enhanced-api.ts"; +} from "../../../redux/enhanced-api.ts"; import useFetchDataHook from "../fetch-data-hook.js"; /** diff --git a/assets/admin/components/util/multi-and-table/select-slides-table.jsx b/assets/admin/components/util/multi-and-table/select-slides-table.jsx index 43e1f894b..5e711a529 100644 --- a/assets/admin/components/util/multi-and-table/select-slides-table.jsx +++ b/assets/admin/components/util/multi-and-table/select-slides-table.jsx @@ -8,7 +8,7 @@ import { useGetV2SlidesQuery, useGetV2PlaylistsByIdQuery, enhancedApi, -} from "../../../../shared/redux/enhanced-api.ts"; +} from "../../../redux/enhanced-api.ts"; import PlaylistGanttChart from "../../playlist/playlist-gantt-chart"; import { displayWarning } from "../list/toast-component/display-toast"; import useFetchDataHook from "../fetch-data-hook.js"; diff --git a/assets/admin/components/util/template-label-in-list.jsx b/assets/admin/components/util/template-label-in-list.jsx index a2975eb04..00602e91d 100644 --- a/assets/admin/components/util/template-label-in-list.jsx +++ b/assets/admin/components/util/template-label-in-list.jsx @@ -1,5 +1,5 @@ import Spinner from "react-bootstrap/Spinner"; -import { useGetV2TemplatesByIdQuery } from "../../../shared/redux/enhanced-api.ts"; +import { useGetV2TemplatesByIdQuery } from "../../redux/enhanced-api.ts"; import idFromUrl from "./helpers/id-from-url"; /** * @param {object} props The props. diff --git a/assets/admin/index.jsx b/assets/admin/index.jsx index 824959ecf..397b42ead 100644 --- a/assets/admin/index.jsx +++ b/assets/admin/index.jsx @@ -1,7 +1,7 @@ import { BrowserRouter } from "react-router-dom"; import { createRoot } from "react-dom/client"; import { Provider } from "react-redux"; -import { store } from "../shared/redux/store.js"; +import { store } from "./redux/store.js"; import App from "./app"; const container = document.getElementById("root"); diff --git a/assets/shared/redux/extended-base-query.js b/assets/admin/redux/base-query.js similarity index 97% rename from assets/shared/redux/extended-base-query.js rename to assets/admin/redux/base-query.js index 999d3e3e8..f1fa1a77c 100644 --- a/assets/shared/redux/extended-base-query.js +++ b/assets/admin/redux/base-query.js @@ -1,5 +1,5 @@ import { fetchBaseQuery } from "@reduxjs/toolkit/query/react"; -import localStorageKeys from "../../admin/components/util/local-storage-keys.jsx"; +import localStorageKeys from "../components/util/local-storage-keys.jsx"; const extendedBaseQuery = async (args, api, extraOptions) => { const baseUrl = "/"; diff --git a/assets/shared/redux/empty-api.ts b/assets/admin/redux/empty-api.ts similarity index 74% rename from assets/shared/redux/empty-api.ts rename to assets/admin/redux/empty-api.ts index 7e72803f7..f6076162f 100644 --- a/assets/shared/redux/empty-api.ts +++ b/assets/admin/redux/empty-api.ts @@ -1,5 +1,5 @@ import { createApi } from '@reduxjs/toolkit/query/react'; -import extendedBaseQuery from "./extended-base-query"; +import extendedBaseQuery from "./base-query"; export const emptySplitApi = createApi({ baseQuery: extendedBaseQuery, diff --git a/assets/shared/redux/enhanced-api.ts b/assets/admin/redux/enhanced-api.ts similarity index 100% rename from assets/shared/redux/enhanced-api.ts rename to assets/admin/redux/enhanced-api.ts diff --git a/assets/shared/redux/generated-api.ts b/assets/admin/redux/generated-api.ts similarity index 99% rename from assets/shared/redux/generated-api.ts rename to assets/admin/redux/generated-api.ts index 63959ac3e..005a2151f 100644 --- a/assets/shared/redux/generated-api.ts +++ b/assets/admin/redux/generated-api.ts @@ -237,9 +237,9 @@ const injectedRtkApi = api }), invalidatesTags: ["Media"], }), - getv2MediaById: build.query< - Getv2MediaByIdApiResponse, - Getv2MediaByIdApiArg + getV2MediaById: build.query< + GetV2MediaByIdApiResponse, + GetV2MediaByIdApiArg >({ query: (queryArg) => ({ url: `/v2/media/${queryArg.id}` }), providesTags: ["Media"], @@ -1144,8 +1144,8 @@ export type PostMediaCollectionApiArg = { file: Blob; }; }; -export type Getv2MediaByIdApiResponse = /** status 200 OK */ Blob; -export type Getv2MediaByIdApiArg = { +export type GetV2MediaByIdApiResponse = /** status 200 OK */ Blob; +export type GetV2MediaByIdApiArg = { id: string; }; export type DeleteV2MediaByIdApiResponse = unknown; @@ -2634,7 +2634,7 @@ export const { useLoginCheckPostMutation, useGetV2MediaQuery, usePostMediaCollectionMutation, - useGetv2MediaByIdQuery, + useGetV2MediaByIdQuery, useDeleteV2MediaByIdMutation, useGetV2CampaignsByIdScreenGroupsQuery, useGetV2CampaignsByIdScreensQuery, diff --git a/assets/shared/redux/openapi-config.js b/assets/admin/redux/openapi-config.js similarity index 100% rename from assets/shared/redux/openapi-config.js rename to assets/admin/redux/openapi-config.js diff --git a/assets/shared/redux/store.js b/assets/admin/redux/store.js similarity index 100% rename from assets/shared/redux/store.js rename to assets/admin/redux/store.js diff --git a/assets/client/app.css b/assets/client/app.css deleted file mode 100644 index 837191701..000000000 --- a/assets/client/app.css +++ /dev/null @@ -1,3 +0,0 @@ -body { - background-color: blue; -} diff --git a/assets/client/app.js b/assets/client/app.js deleted file mode 100644 index 620d7555d..000000000 --- a/assets/client/app.js +++ /dev/null @@ -1,3 +0,0 @@ -import "./app.css"; - -console.log("Hello client"); diff --git a/assets/client/app.jsx b/assets/client/app.jsx index 779f62bd3..4e02efa72 100644 --- a/assets/client/app.jsx +++ b/assets/client/app.jsx @@ -1,16 +1,18 @@ import { useEffect, useRef, useState } from "react"; import Screen from "./components/screen.jsx"; import ContentService from "./service/content-service"; -import ClientConfigLoader from "./util/client-config-loader.js"; -import logger from "./logger/logger"; +import ClientConfigLoader from "./core/client-config-loader.js"; +import logger from "./core/logger.js"; import fallback from "./assets/fallback.png"; -import appStorage from "./util/app-storage"; +import appStorage from "./core/app-storage"; import defaults from "./util/defaults"; import tokenService from "./service/token-service"; import releaseService from "./service/release-service"; import tenantService from "./service/tenant-service"; import statusService from "./service/status-service"; import constants from "./util/constants"; +import reauthenticateRef from "./redux/reauthenticate-ref"; +import { useClientState } from "./client-state-context.jsx"; import "./app.scss"; /** @@ -24,19 +26,23 @@ import "./app.scss"; */ function App({ preview, previewId }) { const [running, setRunning] = useState(false); - const [screen, setScreen] = useState(""); const [bindKey, setBindKey] = useState(null); - const [displayFallback, setDisplayFallback] = useState(true); const [debug, setDebug] = useState(false); + const { screen, isContentEmpty, callbacks } = useClientState(); + const checkLoginTimeoutRef = useRef(null); + const loginTimeoutGenRef = useRef(0); const contentServiceRef = useRef(null); + const runningRef = useRef(false); + const reauthenticatingRef = useRef(false); const fallbackImageUrl = appStorage.getFallbackImageUrl(); + const safeFallbackUrl = ( + fallbackImageUrl !== null ? fallbackImageUrl : fallback + ).replace(/"/g, ""); const fallbackStyle = { - backgroundImage: `url("${ - fallbackImageUrl !== null ? fallbackImageUrl : fallback - }")`, + backgroundImage: `url("${safeFallbackUrl}")`, }; const appStyle = {}; @@ -45,20 +51,6 @@ function App({ preview, previewId }) { appStyle.cursor = "none"; } - /** - * Handles "screen" events. - * - * @param {CustomEvent} event - * The event. - */ - function screenHandler(event) { - const screenData = event.detail?.screen; - - if (screenData !== null) { - setScreen(screenData); - } - } - const startContent = (localScreenId) => { logger.info("Starting content."); @@ -70,22 +62,17 @@ function App({ preview, previewId }) { } setBindKey(null); + runningRef.current = true; setRunning(true); - contentServiceRef.current = new ContentService(); + contentServiceRef.current = new ContentService(callbacks); // Start the content service. contentServiceRef.current.start(); const entrypoint = `/v2/screens/${localScreenId}`; - - document.dispatchEvent( - new CustomEvent("startDataSync", { - detail: { - screenPath: entrypoint, - }, - }), - ); + contentServiceRef.current.stopSync(); + contentServiceRef.current.startSyncing(entrypoint); tokenService.startRefreshing(); }; @@ -94,9 +81,14 @@ function App({ preview, previewId }) { const restartLoginTimeout = () => { if (checkLoginTimeoutRef.current !== null) { clearTimeout(checkLoginTimeoutRef.current); + checkLoginTimeoutRef.current = null; } + loginTimeoutGenRef.current += 1; + const gen = loginTimeoutGenRef.current; + ClientConfigLoader.loadConfig().then((config) => { + if (gen !== loginTimeoutGenRef.current) return; checkLoginTimeoutRef.current = setTimeout( checkLogin, config.loginCheckTimeout ?? defaults.loginCheckTimeoutDefault, @@ -111,7 +103,7 @@ function App({ preview, previewId }) { const localStorageToken = appStorage.getToken(); const localScreenId = appStorage.getScreenId(); - if (!running && localStorageToken && localScreenId) { + if (!runningRef.current && localStorageToken && localScreenId) { startContent(localScreenId); } else { statusService.setStatus(constants.STATUS_LOGIN); @@ -130,12 +122,16 @@ function App({ preview, previewId }) { } }) .catch(() => { + logger.warn("Failed to check login."); restartLoginTimeout(); }); } }; const reauthenticateHandler = () => { + if (reauthenticatingRef.current) return; + reauthenticatingRef.current = true; + logger.info("Reauthenticate handler invoked. Trying to use refresh token."); tokenService @@ -148,7 +144,11 @@ function App({ preview, previewId }) { statusService.setError(constants.ERROR_TOKEN_REFRESH_FAILED); - document.dispatchEvent(new Event("stopDataSync")); + if (contentServiceRef.current !== null) { + contentServiceRef.current.stopSync(); + contentServiceRef.current.stop(); + contentServiceRef.current = null; + } appStorage.clearToken(); appStorage.clearRefreshToken(); @@ -156,30 +156,19 @@ function App({ preview, previewId }) { appStorage.clearTenant(); appStorage.clearFallbackImageUrl(); - if (contentServiceRef?.current !== null) { - contentServiceRef.current.stop(); - contentServiceRef.current = null; - } - - setScreen(null); + callbacks.current.setScreen(null); + runningRef.current = false; setRunning(false); tokenService.stopRefreshing(); checkLogin(); + }) + .finally(() => { + reauthenticatingRef.current = false; }); }; - const contentEmpty = () => { - logger.info("Content empty. Displaying fallback."); - setDisplayFallback(true); - }; - - const contentNotEmpty = () => { - logger.info("Content not empty. Displaying content."); - setDisplayFallback(false); - }; - // ctrl/cmd i will log screen out and refresh const handleKeyboard = ({ repeat, metaKey, ctrlKey, code }) => { if (!repeat && (metaKey || ctrlKey) && code === "KeyI") { @@ -191,40 +180,32 @@ function App({ preview, previewId }) { useEffect(() => { logger.info("Mounting App."); if (preview !== null) { - document.addEventListener("screen", screenHandler); - document.addEventListener("contentEmpty", contentEmpty); - document.addEventListener("contentNotEmpty", contentNotEmpty); - if (preview === "screen") { startContent(previewId); - return; + } else { + setRunning(true); + contentServiceRef.current = new ContentService(callbacks); + contentServiceRef.current.start(); + contentServiceRef.current.startPreview(preview, previewId); } - setRunning(true); - contentServiceRef.current = new ContentService(); - contentServiceRef.current.start(); - document.dispatchEvent( - new CustomEvent("startPreview", { - detail: { - mode: preview, - id: previewId, - }, - }), - ); } else { - document.addEventListener("keypress", handleKeyboard); - document.addEventListener("screen", screenHandler); - document.addEventListener("reauthenticate", reauthenticateHandler); - document.addEventListener("contentEmpty", contentEmpty); - document.addEventListener("contentNotEmpty", contentNotEmpty); + document.addEventListener("keydown", handleKeyboard); + + // Wire up reauthenticate callback for base-query (outside React tree). + reauthenticateRef.current = reauthenticateHandler; tokenService.checkToken(); - ClientConfigLoader.loadConfig().then((config) => { - setDebug(config.debug ?? false); + ClientConfigLoader.loadConfig() + .then((config) => { + setDebug(config.debug ?? false); - const relationChecksumEnabled = config.relationsChecksumEnabled; - logger.info(`Relation checksum enabled: ${relationChecksumEnabled}`); - }); + const relationChecksumEnabled = config.relationsChecksumEnabled; + logger.info(`Relation checksum enabled: ${relationChecksumEnabled}`); + }) + .catch((err) => { + logger.error(`Failed to load config: ${err}`); + }); releaseService.checkForNewRelease().finally(() => { releaseService.setPreviousBootInUrl(); @@ -238,23 +219,26 @@ function App({ preview, previewId }) { statusService.setStatusInUrl(); } - /* eslint-disable-next-line consistent-return */ return function cleanup() { logger.info("Unmounting App."); - document.removeEventListener("keypress", handleKeyboard); - document.removeEventListener("screen", screenHandler); - document.removeEventListener("reauthenticate", reauthenticateHandler); - document.removeEventListener("contentEmpty", contentEmpty); - document.removeEventListener("contentNotEmpty", contentNotEmpty); - - if (checkLoginTimeoutRef?.current) { - clearTimeout(checkLoginTimeoutRef.current); + if (contentServiceRef.current !== null) { + contentServiceRef.current.stopSync(); + contentServiceRef.current.stop(); + contentServiceRef.current = null; } - tokenService.stopRefreshing(); + if (preview === null) { + document.removeEventListener("keydown", handleKeyboard); + reauthenticateRef.current = () => {}; - releaseService.stopReleaseCheck(); + if (checkLoginTimeoutRef.current) { + clearTimeout(checkLoginTimeoutRef.current); + } + + tokenService.stopRefreshing(); + releaseService.stopReleaseCheck(); + } }; }, []); @@ -282,7 +266,7 @@ function App({ preview, previewId }) { )} - {displayFallback && !bindKey && ( + {isContentEmpty && !bindKey && (
)}
diff --git a/assets/client/app.scss b/assets/client/app.scss index 2f68c5a8e..162b7a6a3 100644 --- a/assets/client/app.scss +++ b/assets/client/app.scss @@ -52,10 +52,6 @@ body { } } -.ql-editor { - min-height: 200px; -} - .fallback { height: 100%; width: 100%; diff --git a/assets/client/client-state-context.jsx b/assets/client/client-state-context.jsx new file mode 100644 index 000000000..b29e7e517 --- /dev/null +++ b/assets/client/client-state-context.jsx @@ -0,0 +1,47 @@ +import { createContext, useState, useRef, useContext, useCallback } from "react"; + +const ClientStateContext = createContext(); + +/** + * Provider that holds client state previously communicated via document events. + * Services receive the callbacks ref at construction and call them directly. + */ +function ClientStateProvider({ children }) { + const [screen, setScreen] = useState(null); + const [isContentEmpty, setIsContentEmpty] = useState(false); + const [regionSlides, setRegionSlides] = useState({}); + + const updateRegionSlides = useCallback((regionId, slides) => { + setRegionSlides((prev) => ({ ...prev, [regionId]: slides })); + }, []); + + // Stable callbacks ref — services hold a reference to this object + // and call its methods instead of dispatching document events. + const callbacks = useRef({ + setScreen, + setIsContentEmpty, + updateRegionSlides, + onRegionReady: () => {}, + onRegionRemoved: () => {}, + onReauthenticate: () => {}, + }); + + const value = { + screen, + isContentEmpty, + regionSlides, + callbacks, + }; + + return ( + + {children} + + ); +} + +function useClientState() { + return useContext(ClientStateContext); +} + +export { ClientStateProvider, useClientState }; diff --git a/assets/client/components/error-boundary.jsx b/assets/client/components/error-boundary.jsx index 98aeb0ac0..54259f111 100644 --- a/assets/client/components/error-boundary.jsx +++ b/assets/client/components/error-boundary.jsx @@ -1,5 +1,5 @@ import { Component } from "react"; -import logger from "../logger/logger"; +import logger from "../core/logger.js"; import fallback from "../assets/fallback.png"; import "./error-boundary.scss"; @@ -14,6 +14,12 @@ class ErrorBoundary extends Component { return { hasError: true }; } + componentDidUpdate(prevProps) { + if (this.props.resetKey !== prevProps.resetKey && this.state.hasError) { + this.setState({ hasError: false, errorMessage: null, errorStackTrace: null }); + } + } + componentDidCatch(error, errorInfo) { logger.error(`ErrorBoundary caught error: ${error}`, errorInfo); diff --git a/assets/client/components/region.jsx b/assets/client/components/region.jsx index 878350ba7..2cb0605da 100644 --- a/assets/client/components/region.jsx +++ b/assets/client/components/region.jsx @@ -1,10 +1,12 @@ -import { useEffect, useState, createRef } from "react"; +import { useEffect, useState, createRef, useRef } from "react"; import { createGridArea } from "../../shared/grid-generator/grid-generator"; import { TransitionGroup, CSSTransition } from "react-transition-group"; import ErrorBoundary from "./error-boundary.jsx"; import idFromPath from "../util/id-from-path"; -import logger from "../logger/logger"; +import logger from "../core/logger.js"; import Slide from "./slide.jsx"; +import constants from "../util/constants"; +import { useClientState } from "../client-state-context.jsx"; import "./region.scss"; /** @@ -24,8 +26,17 @@ function Region({ region }) { const [nodeRefs, setNodeRefs] = useState({}); const [runId, setRunId] = useState(null); + // Refs to avoid stale closures in slideDone — templates capture slideDone + // in a useEffect([run]) that won't re-run when slides/newSlides change. + const slidesRef = useRef(null); + const newSlidesRef = useRef(null); + slidesRef.current = slides; + newSlidesRef.current = newSlides; + + const { regionSlides, callbacks } = useClientState(); const rootStyle = {}; const regionId = idFromPath(region["@id"]); + const incomingSlides = regionSlides[regionId]; rootStyle.gridArea = createGridArea(region.gridArea); @@ -38,14 +49,20 @@ function Region({ region }) { * The slide. */ function findNextSlide(fromId) { - const slideIndex = slides.findIndex( + const currentSlides = slidesRef.current; + + if (!currentSlides || currentSlides.length === 0) { + return { nextSlide: null, nextIndex: 0 }; + } + + const slideIndex = currentSlides.findIndex( (slideElement) => slideElement.executionId === fromId, ); - const nextIndex = (slideIndex + 1) % slides.length; + const nextIndex = (slideIndex + 1) % currentSlides.length; return { - nextSlide: slides[nextIndex], + nextSlide: currentSlides[nextIndex], nextIndex, }; } @@ -57,12 +74,13 @@ function Region({ region }) { */ const slideDone = (slide) => { const nextSlideAndIndex = findNextSlide(slide.executionId); + const latestNewSlides = newSlidesRef.current; - if (nextSlideAndIndex.nextIndex === 0 && Array.isArray(newSlides)) { - const nextSlides = [...newSlides]; + if (nextSlideAndIndex.nextIndex === 0 && Array.isArray(latestNewSlides)) { + const nextSlides = [...latestNewSlides]; setSlides(nextSlides); setNewSlides(null); - setCurrentSlide(nextSlides[0]); + setCurrentSlide(nextSlides.length > 0 ? nextSlides[0] : null); } else { setCurrentSlide(nextSlideAndIndex.nextSlide); } @@ -70,15 +88,6 @@ function Region({ region }) { setRunId(new Date().toISOString()); logger.info(`Slide done with executionId: ${slide?.executionId}`); - - // Emit slideDone event. - const slideDoneEvent = new CustomEvent("slideDone", { - detail: { - regionId, - executionId: slide.executionId, - }, - }); - document.dispatchEvent(slideDoneEvent); }; /** @@ -88,70 +97,42 @@ function Region({ region }) { */ const slideError = (slideWithError) => { // Set error timestamp to force reload. - const slide = slides.find( - (slideElement) => slideElement.executionId === slideWithError.executionId, + setSlides((prev) => + prev.map((s) => + s.executionId === slideWithError.executionId + ? { ...s, errorTimestamp: new Date().getTime() } + : s, + ), ); - slide.errorTimestamp = new Date().getTime(); slideDone(slideWithError); }; - /** - * Handle region content event. - * - * @param {CustomEvent} event - * The event. The data is contained in detail. - */ - function regionContentListener(event) { - const receivedSlides = [...event.detail.slides]; + // Receive region slides from context. + useEffect(() => { + if (!incomingSlides) return; - // Filter out invalid slides. + const receivedSlides = [...incomingSlides]; setNewSlides(receivedSlides.filter((slide) => !slide.invalid)); - } + }, [incomingSlides]); - // Setup event listener for region content. + // Notify lifecycle on mount/unmount. useEffect(() => { logger.info(`Mounting region ${regionId}`); - - document.addEventListener( - `regionContent-${regionId}`, - regionContentListener, - ); + callbacks.current.onRegionReady(regionId); return function cleanup() { logger.info(`Unmounting region ${regionId}`); - - // Emit event that region has been removed. - const event = new CustomEvent("regionRemoved", { - detail: { - id: regionId, - }, - }); - document.dispatchEvent(event); - - // Cleanup event listener. - document.removeEventListener( - `regionContent-${regionId}`, - regionContentListener, - ); + callbacks.current.onRegionRemoved(regionId); }; - }, []); - - // Notify that region is ready. - useEffect(() => { - const event = new CustomEvent("regionReady", { - detail: { - id: regionId, - }, - }); - document.dispatchEvent(event); - }, [region]); + }, [regionId]); // Start the progress if no slide is currently playing. useEffect(() => { if (newSlides !== null && !currentSlide) { setSlides(newSlides); + setNewSlides(null); } - }, [newSlides]); + }, [newSlides, currentSlide]); // Make sure current slide is set. useEffect(() => { @@ -172,17 +153,17 @@ function Region({ region }) { return res; }, {}), ); - }, [slides]); + }, [slides, currentSlide]); return (
- + <> {currentSlide && ( diff --git a/assets/client/components/screen.jsx b/assets/client/components/screen.jsx index 2560f6d17..de3378a2e 100644 --- a/assets/client/components/screen.jsx +++ b/assets/client/components/screen.jsx @@ -1,10 +1,11 @@ -import { Fragment, useEffect, useRef } from "react"; +import { useEffect, useRef } from "react"; import SunCalc from "suncalc"; import { createGrid } from "../../shared/grid-generator/grid-generator"; import Region from "./region.jsx"; -import logger from "../logger/logger"; +import logger from "../core/logger.js"; import TouchRegion from "./touch-region.jsx"; -import ClientConfigLoader from "../util/client-config-loader.js"; +import ClientConfigLoader from "../core/client-config-loader.js"; +import constants from "../util/constants"; import "./screen.scss"; /** @@ -26,8 +27,8 @@ function Screen({ screen }) { const rootStyle = { gridTemplateAreas: createGrid(configColumns, configRows), - gridTemplateColumns: gridTemplateRows, - gridTemplateRows: gridTemplateColumns, + gridTemplateColumns, + gridTemplateRows, }; const refreshColorScheme = () => { @@ -72,16 +73,16 @@ function Screen({ screen }) { if (screen?.enableColorSchemeChange) { logger.info("Enabling color scheme change."); refreshColorScheme(); - // Refresh color scheme every 5 minutes. colorSchemeIntervalRef.current = setInterval( refreshColorScheme, - 5 * 60 * 1000, + constants.COLOR_SCHEME_REFRESH_INTERVAL, ); } return () => { if (colorSchemeIntervalRef.current !== null) { clearInterval(colorSchemeIntervalRef.current); + colorSchemeIntervalRef.current = null; } // Cleanup html root classes. @@ -90,22 +91,16 @@ function Screen({ screen }) { "color-scheme-dark", ); }; - }, [screen]); + }, [screen?.enableColorSchemeChange]); return (
- {screen?.layoutData?.regions?.map((region) => ( - - {/* Default region type */} - {(!region.type || region.type === "default") && ( - - )} - {/* Special region type: touch-buttons */} - {region?.type === "touch-buttons" && ( - - )} - - ))} + {screen?.layoutData?.regions?.map((region) => { + if (region?.type === "touch-buttons") { + return ; + } + return ; + })}
); } diff --git a/assets/client/components/slide.jsx b/assets/client/components/slide.jsx index bf5b76d59..ecd7b3db6 100644 --- a/assets/client/components/slide.jsx +++ b/assets/client/components/slide.jsx @@ -1,6 +1,8 @@ +import { useEffect, useRef } from "react"; import ErrorBoundary from "./error-boundary.jsx"; -import logger from "../logger/logger"; +import logger from "../core/logger.js"; import { renderSlide } from "../../shared/slide-utils/templates.js"; +import constants from "../util/constants"; import "./slide.scss"; /** @@ -14,7 +16,17 @@ import "./slide.scss"; * @param {Function} props.slideError - Callback when slide encountered an error. * @returns {object} - The component. */ -function Slide({ slide, id, run, slideDone, slideError }) { +function Slide({ slide, id, run, slideDone, slideError, forwardRef }) { + const errorTimeoutRef = useRef(null); + + useEffect(() => { + return () => { + if (errorTimeoutRef.current !== null) { + clearTimeout(errorTimeoutRef.current); + } + }; + }, []); + /** * Handle errors in ErrorBoundary. * @@ -23,13 +35,14 @@ function Slide({ slide, id, run, slideDone, slideError }) { const handleError = () => { logger.warn("Slide error boundary triggered."); - setTimeout(() => { + errorTimeoutRef.current = setTimeout(() => { slideError(slide); - }, 5000); + }, constants.SLIDE_ERROR_RECOVERY_TIMEOUT); }; return (
{ // Set error timestamp to force reload. - const slide = slides.find( - (slideElement) => slideElement.executionId === slideWithError.executionId, + setSlides((prev) => + prev.map((s) => + s.executionId === slideWithError.executionId + ? { ...s, errorTimestamp: new Date().getTime() } + : s, + ), ); - slide.errorTimestamp = new Date().getTime(); slideDone(slideWithError); }; - /** - * Handle region content event. - * - * @param {CustomEvent} event - * The event. The data is contained in detail. - */ - function regionContentListener(event) { - setSlides([...event.detail.slides]); - } + // Receive region slides from context. + useEffect(() => { + if (!incomingSlides) return; + + setSlides([...incomingSlides].filter((slide) => !slide.invalid)); + }, [incomingSlides]); - // Setup event listener for region content. + // Notify lifecycle on mount/unmount. useEffect(() => { - document.addEventListener( - `regionContent-${regionId}`, - regionContentListener, - ); + callbacks.current.onRegionReady(regionId); return function cleanup() { - // Emit event that region has been removed. - const event = new CustomEvent("regionRemoved", { - detail: { - id: regionId, - }, - }); - document.dispatchEvent(event); - - // Cleanup event listener. - document.removeEventListener( - `regionContent-${regionId}`, - regionContentListener, - ); + callbacks.current.onRegionRemoved(regionId); }; - }, []); - - // Notify that region is ready. - useEffect(() => { - const event = new CustomEvent("regionReady", { - detail: { - id: regionId, - }, - }); - document.dispatchEvent(event); - }, [region]); + }, [regionId]); // Make sure current slide is set. useEffect(() => { @@ -128,7 +98,7 @@ function TouchRegion({ region }) { return (
- + <> {currentSlide !== null && (
@@ -149,8 +119,8 @@ function TouchRegion({ region }) { {displayClose && (
slideDone(currentSlide)} + onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") slideDone(currentSlide); }} role="button" tabIndex={0} > @@ -171,7 +141,7 @@ function TouchRegion({ region }) { className="touch-button" key={`button-${slide.executionId}`} onClick={() => startSlide(slide)} - onKeyDown={() => startSlide(slide)} + onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") startSlide(slide); }} role="button" tabIndex={0} > diff --git a/assets/client/core/api-query.js b/assets/client/core/api-query.js new file mode 100644 index 000000000..e26feaa2a --- /dev/null +++ b/assets/client/core/api-query.js @@ -0,0 +1,89 @@ +import logger from "./logger.js"; +import { clientStore } from "../redux/store.js"; +import { clientApi } from "../redux/enhanced-api.ts"; +import defaults from "../util/defaults.js"; + +/** + * Dispatch an RTK Query endpoint and return the unwrapped result. + * + * @param {string} endpoint The endpoint name. + * @param {object} args The endpoint args. + * @param {boolean} forceRefetch Whether to bypass RTK Query cache. + * @returns {Promise} The result data. + */ +export function query(endpoint, args, forceRefetch = false) { + const request = clientStore.dispatch( + clientApi.endpoints[endpoint].initiate(args, { forceRefetch }), + ); + + let timeoutId; + const timeout = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + request.abort(); + reject(new Error(`Request timeout: ${endpoint}`)); + }, defaults.queryTimeoutDefault); + }); + + return Promise.race([request.unwrap(), timeout]) + .catch((err) => { + const cached = clientApi.endpoints[endpoint].select(args)( + clientStore.getState(), + ); + if (cached?.data) { + logger.warn(`Using cached data for ${endpoint} after fetch failure.`); + return cached.data; + } + throw err; + }) + .finally(() => { + clearTimeout(timeoutId); + request.unsubscribe(); + }); +} + +/** + * Fetch all pages from a paginated endpoint. + * + * @param {string} endpoint The endpoint name. + * @param {object} args The endpoint args (page will be added). + * @param {boolean} forceRefetch Whether to bypass RTK Query cache. + * @returns {Promise} All hydra:member results concatenated. + */ +// Upper bound on pagination — intentionally capped. Content types served to +// screens should never exceed this number of pages. +const MAX_PAGES = 50; + +export async function queryAllPages(endpoint, args, forceRefetch = false) { + let results = []; + let page = 1; + + do { + try { + const responseData = await query(endpoint, { ...args, page }, forceRefetch); + + if (responseData === null || responseData === undefined) { + logger.error(`Failed to fetch page ${page} for ${endpoint}`); + return results; + } + + results = results.concat(responseData["hydra:member"] ?? []); + + if (responseData["hydra:view"]?.["hydra:next"]) { + page += 1; + } else { + break; + } + } catch (err) { + logger.error( + `Failed to fetch all pages for ${endpoint}: ${err.message}`, + ); + return results; + } + } while (page <= MAX_PAGES); + + if (page > MAX_PAGES) { + logger.warn(`Reached max page limit (${MAX_PAGES}) for ${endpoint}`); + } + + return results; +} diff --git a/assets/client/util/app-storage.js b/assets/client/core/app-storage.js similarity index 100% rename from assets/client/util/app-storage.js rename to assets/client/core/app-storage.js diff --git a/assets/client/core/client-config-loader.js b/assets/client/core/client-config-loader.js new file mode 100644 index 000000000..d5a2df3b7 --- /dev/null +++ b/assets/client/core/client-config-loader.js @@ -0,0 +1,84 @@ +import appStorage from "./app-storage.js"; +import logger from "./logger.js"; +import defaults from "../util/defaults.js"; + +// Defaults. +let configData = null; + +// Last time the config was fetched. +let latestFetchTimestamp = 0; + +let activePromise = null; + +const ClientConfigLoader = { + async loadConfig() { + if (activePromise) { + return activePromise; + } + + const nowTimestamp = new Date().getTime(); + + // Return cached data directly — no promise, so activePromise stays null + // and a future call after the cache interval will trigger a real fetch. + if ( + configData !== null && + latestFetchTimestamp + + (configData?.configFetchInterval ?? defaults.configFetchIntervalDefault) >= + nowTimestamp + ) { + return configData; + } + + activePromise = new Promise((resolve) => { + fetch(`/config/client`) + .then((response) => response.json()) + .then((data) => { + latestFetchTimestamp = new Date().getTime(); + configData = data; + + // Make api endpoint available through localstorage. + appStorage.setApiUrl(configData.apiEndpoint); + + resolve(configData); + }) + .catch(() => { + if (configData !== null) { + resolve(configData); + } else { + logger.error("Could not load config. Will use default config."); + + // Default config. + resolve({ + apiEndpoint: "/api", + dataStrategy: { + type: "pull", + config: { + interval: 30000, + }, + }, + loginCheckTimeout: 20000, + configFetchInterval: 900000, + refreshTokenTimeout: 15000, + releaseTimestampIntervalTimeout: 600000, + colorScheme: { + type: "library", + lat: 56.0, + lng: 10.0, + }, + schedulingInterval: 60000, + debug: false, + }); + } + }) + .finally(() => { + activePromise = null; + }); + }); + + return activePromise; + }, +}; + +Object.freeze(ClientConfigLoader); + +export default ClientConfigLoader; diff --git a/assets/client/util/local-storage-keys.js b/assets/client/core/local-storage-keys.js similarity index 100% rename from assets/client/util/local-storage-keys.js rename to assets/client/core/local-storage-keys.js diff --git a/assets/client/logger/logger.js b/assets/client/core/logger.js similarity index 100% rename from assets/client/logger/logger.js rename to assets/client/core/logger.js diff --git a/assets/client/data-sync/api-helper.js b/assets/client/data-sync/api-helper.js deleted file mode 100644 index eb94e5aef..000000000 --- a/assets/client/data-sync/api-helper.js +++ /dev/null @@ -1,109 +0,0 @@ -import logger from "../logger/logger"; -import appStorage from "../util/app-storage"; - -class ApiHelper { - endpoint = ""; - - /** - * Constructor. - * - * @param {string} endpoint The API endpoint. - */ - constructor(endpoint) { - this.endpoint = endpoint; - } - - /** - * Get result from path. - * - * @param {string} path Path to the resource. - * @returns {Promise} Promise with data. - */ - async getPath(path) { - if (!path) { - throw new Error("No path"); - } - - let response; - - try { - const url = new URL(window.location.href); - const previewToken = url.searchParams.get("preview-token"); - const previewTenant = url.searchParams.get("preview-tenant"); - - logger.log("info", `Fetching: ${this.endpoint + path}`); - - const token = appStorage.getToken(); - const tenantKey = appStorage.getTenantKey(); - - if ((!token || !tenantKey) && (!previewToken || !previewTenant)) { - logger.error("Token or tenantKey not set."); - - return null; - } - - response = await fetch(this.endpoint + path, { - headers: { - authorization: `Bearer ${previewToken ?? token}`, - "Authorization-Tenant-Key": previewTenant ?? tenantKey, - }, - }); - - if (response.ok === false) { - // TODO: Change to a better strategy for triggering reauthenticate. - if (response.status === 401) { - document.dispatchEvent(new Event("reauthenticate")); - } - - logger.error( - `Failed to fetch (status: ${response.status}): ${ - this.endpoint + path - }`, - ); - - return null; - } - - return response.json(); - } catch (err) { - logger.error(`Failed to fetch: ${this.endpoint + path}`); - - return null; - } - } - - /** - * Gets all resources from the given path. - * - * @param {string} path Path to the resources. - * @param {object} keys Keys that should be passed along with the result. - * @returns {Promise<*>} Promise with all resources from a path. - */ - async getAllResultsFromPath(path, keys = {}) { - let results = []; - let nextPath = `${path}`; - let continueLoop = false; - let page = 1; - - do { - try { - // eslint-disable-next-line no-await-in-loop - const responseData = await this.getPath(nextPath); - results = results.concat(responseData["hydra:member"]); - if (results.length < responseData["hydra:totalItems"]) { - page += 1; - continueLoop = true; - nextPath = `${path}?page=${page}`; - } else { - continueLoop = false; - } - } catch (err) { - return {}; - } - } while (continueLoop); - - return { path, results, keys }; - } -} - -export default ApiHelper; diff --git a/assets/client/data-sync/pull-strategy.js b/assets/client/data-sync/pull-strategy.js deleted file mode 100644 index 5b4ea7eed..000000000 --- a/assets/client/data-sync/pull-strategy.js +++ /dev/null @@ -1,487 +0,0 @@ -import isPublished from "../util/isPublished"; -import logger from "../logger/logger"; -import ApiHelper from "./api-helper"; -import { cloneDeep } from "lodash"; -import ClientConfigLoader from "../util/client-config-loader.js"; - -/** - * PullStrategy. - * - * Handles pull strategy. - */ -class PullStrategy { - lastestScreenData; - - // Helper for all api calls. - apiHelper; - - // Fetch-interval in ms. - interval; - - // Path to screen that should be loaded data for. - entryPoint = ""; - - /** - * Constructor. - * - * @param {object} config - * The config object. - */ - constructor(config) { - this.start = this.start.bind(this); - this.stop = this.stop.bind(this); - this.getScreen = this.getScreen.bind(this); - - this.interval = config?.interval ?? 60000 * 5; - this.entryPoint = config.entryPoint; - - this.apiHelper = new ApiHelper(config.endpoint ?? ""); - } - - /** - * Gets all campaigns, both from screen and groups. - * - * @param {object} screen The screen object to extract campaigns from. - * @returns {Promise} Array of campaigns (playlists). - */ - async getCampaignsData(screen) { - const screenGroupCampaigns = []; - - try { - const response = await this.apiHelper.getPath(screen.inScreenGroups); - - if (Object.prototype.hasOwnProperty.call(response, "hydra:member")) { - const promises = []; - - response["hydra:member"].forEach((group) => { - promises.push(this.apiHelper.getAllResultsFromPath(group.campaigns)); - }); - - await Promise.allSettled(promises).then((results) => { - results.forEach((result) => { - if (result.status === "fulfilled") { - result.value.results.forEach(({ campaign }) => { - screenGroupCampaigns.push(campaign); - }); - } - }); - }); - } - } catch (err) { - logger.error(err); - } - - let screenCampaigns = []; - - try { - const screenCampaignsResponse = await this.apiHelper.getPath( - screen.campaigns, - ); - - screenCampaigns = screenCampaignsResponse["hydra:member"].map( - ({ campaign }) => campaign, - ); - } catch (err) { - logger.error(err); - } - - return new Promise((resolve) => { - resolve([...screenCampaigns, ...screenGroupCampaigns]); - }); - } - - /** - * Get slides for regions. - * - * @param {Array} regions Paths to regions. - * @returns {Promise} Regions data. - */ - async getRegions(regions) { - const reg = /\/v2\/screens\/.*\/regions\/(?.*)\/playlists/; - - return new Promise((resolve, reject) => { - const promises = []; - const regionData = {}; - - regions.forEach((regionPath) => { - promises.push(this.apiHelper.getAllResultsFromPath(regionPath)); - }); - - Promise.allSettled(promises) - .then((results) => { - results.forEach((result) => { - if (result.status === "fulfilled") { - const members = result?.value?.results ?? []; - const matches = result?.value?.path?.match(reg) ?? []; - - if (matches?.groups?.regionId) { - regionData[matches.groups.regionId] = members.map( - ({ playlist }) => playlist, - ); - } - } - }); - - resolve(regionData); - }) - .catch((err) => reject(err)); - }); - } - - /** - * Get slides for the given regions. - * - * @param {object} regions Regions to fetch slides for. - * @returns {Promise} Promise with slides for the given regions. - */ - async getSlidesForRegions(regions) { - return new Promise((resolve, reject) => { - const promises = []; - const regionData = cloneDeep(regions); - - // @TODO: Fix eslint-raised issues. - // eslint-disable-next-line guard-for-in,no-restricted-syntax - for (const regionKey in regionData) { - const playlists = regionData[regionKey]; - // eslint-disable-next-line guard-for-in,no-restricted-syntax - for (const playlistKey in playlists) { - promises.push( - this.apiHelper.getAllResultsFromPath( - regionData[regionKey][playlistKey].slides, - { - regionKey, - playlistKey, - }, - ), - ); - } - } - - Promise.allSettled(promises) - .then((results) => { - results.forEach((result) => { - if ( - result.status === "fulfilled" && - Object.prototype.hasOwnProperty.call(result.value, "keys") - ) { - regionData[result.value.keys.regionKey][ - result.value.keys.playlistKey - ].slidesData = result.value.results.map( - (playlistSlide) => playlistSlide.slide, - ); - } - }); - resolve(regionData); - }) - .catch((err) => reject(err)); - }); - } - - /** - * Fetch screen. - * - * @param {string} screenPath Path to the screen. - */ - async getScreen(screenPath) { - let screen; - - // Fetch screen - try { - screen = await this.apiHelper.getPath(screenPath); - } catch (err) { - logger.warn( - `Screen (${screenPath}) not loaded. Aborting content update.`, - ); - - return; - } - - const config = await ClientConfigLoader.loadConfig(); - const relationChecksumEnabled = config.relationsChecksumEnabled; - - if (screen === null) { - logger.warn(`Screen (${screenPath}) not loaded`); - return; - } - - const newScreen = cloneDeep(screen); - - newScreen.hasActiveCampaign = false; - - const newScreenChecksums = newScreen?.relationsChecksum ?? []; - const oldScreenChecksums = - this.lastestScreenData?.relationsChecksum ?? null; - - if ( - relationChecksumEnabled === false || - oldScreenChecksums === null || - oldScreenChecksums?.campaigns !== newScreenChecksums?.campaigns || - oldScreenChecksums?.inScreenGroups !== newScreenChecksums?.inScreenGroups - ) { - logger.info(`Fetching campaigns.`); - newScreen.campaignsData = await this.getCampaignsData(newScreen); - } else { - logger.info(`Campaigns data loaded from cache.`); - newScreen.campaignsData = this.lastestScreenData.campaignsData; - } - - if (newScreen.campaignsData.length > 0) { - newScreen.campaignsData.forEach(({ published }) => { - if (isPublished(published)) { - newScreen.hasActiveCampaign = true; - } - }); - } - - // With active campaigns, we override region/layout values. - if (newScreen.hasActiveCampaign) { - logger.info(`Has active campaign.`); - - // Create ulid to connect the campaign with the regions/playlists. - const campaignRegionId = "01G112XBWFPY029RYFB8X2H4KD"; - - // Campaigns are always in full screen layout, for simplicity. - newScreen.layoutData = { - grid: { - rows: 1, - columns: 1, - }, - regions: [ - { - "@id": `/v2/layouts/regions/${campaignRegionId}`, - gridArea: ["a"], - }, - ], - }; - - newScreen.regionData = {}; - newScreen.regionData[campaignRegionId] = newScreen.campaignsData; - newScreen.regions = [ - `/v2/screens/01FV9K4K0Y0X0K1J88SQ6B64VT/regions/${campaignRegionId}/playlists`, - ]; - newScreen.regionData = await this.getSlidesForRegions( - newScreen.regionData, - ); - } else { - logger.info(`Has no active campaign.`); - - // Get layout: Defines layout and regions. - if ( - relationChecksumEnabled === false || - this.lastestScreenData?.hasActiveCampaign || - oldScreenChecksums === null || - oldScreenChecksums?.layout !== newScreenChecksums?.layout - ) { - logger.info(`Fetching layout.`); - newScreen.layoutData = await this.apiHelper.getPath(newScreen.layout); - } else { - // Get layout: Defines layout and regions. - logger.info(`Layout loaded from cache.`); - newScreen.layoutData = this.lastestScreenData.layoutData; - } - - // Fetch regions playlists: Yields playlists of slides for the regions - if ( - relationChecksumEnabled === false || - this.lastestScreenData?.hasActiveCampaign || - oldScreenChecksums === null || - oldScreenChecksums?.regions !== newScreenChecksums?.regions - ) { - logger.info(`Fetching regions and slides for regions.`); - const regions = await this.getRegions(newScreen.regions); - newScreen.regionData = await this.getSlidesForRegions(regions); - } else { - logger.info(`Regions and slides for regions loaded from cache.`); - newScreen.regionData = this.lastestScreenData.regionData; - } - } - - // Cached data. - const fetchedTemplates = {}; - const fetchedMedia = {}; - - // Iterate all slides and load required relations. - const { regionData } = newScreen; - /* eslint-disable no-restricted-syntax,no-await-in-loop */ - for (const regionKey of Object.keys(regionData)) { - const regionDataEntry = regionData[regionKey]; - - for (const playlistKey of Object.keys(regionDataEntry)) { - const dataEntryPlaylist = regionDataEntry[playlistKey]; - const dataEntrySlidesData = dataEntryPlaylist.slidesData; - - for (const slideKey of Object.keys(dataEntrySlidesData)) { - const slide = cloneDeep(dataEntrySlidesData[slideKey]); - - let previousSlide = null; - - // Find the slide in previous data for comparing relationsChecksum values. - if ( - this.lastestScreenData?.regionData[regionKey] && - this.lastestScreenData.regionData[regionKey][playlistKey] && - this.lastestScreenData.regionData[regionKey][playlistKey] - .slidesData[slideKey] - ) { - previousSlide = cloneDeep( - this.lastestScreenData.regionData[regionKey][playlistKey] - .slidesData[slideKey], - ); - } else { - previousSlide = {}; - } - - const newSlideChecksums = slide.relationsChecksum ?? []; - const oldSlideChecksums = previousSlide?.relationsChecksum ?? null; - - // Fetch template if it has changed. - if ( - relationChecksumEnabled === false || - oldSlideChecksums === null || - newSlideChecksums.templateInfo !== oldSlideChecksums.templateInfo - ) { - const templatePath = slide.templateInfo["@id"]; - - // Load template into slide.templateData. - if ( - Object.prototype.hasOwnProperty.call( - fetchedTemplates, - templatePath, - ) - ) { - slide.templateData = fetchedTemplates[templatePath]; - } else { - logger.info(`Fetching template data.`); - const templateData = await this.apiHelper.getPath(templatePath); - slide.templateData = templateData; - - if (templateData !== null) { - fetchedTemplates[templatePath] = templateData; - } - } - } else { - logger.info(`Template data loaded from cache.`); - slide.templateData = previousSlide.templateData; - } - - // A slide cannot work without templateData. Mark as invalid. - if (slide.templateData === null) { - logger.warn( - `Template (${slide.templateInfo["@id"]}) not loaded, slideId: ${slide["@id"]}`, - ); - slide.invalid = true; - } - - // Fetch media if it has changed. - if ( - relationChecksumEnabled === false || - oldSlideChecksums === null || - newSlideChecksums.media !== oldSlideChecksums.media - ) { - const nextMediaData = {}; - - for (const mediaId of slide.media) { - if (Object.prototype.hasOwnProperty.call(fetchedMedia, mediaId)) { - nextMediaData[mediaId] = fetchedMedia[mediaId]; - } else { - logger.info(`Fetching media data.`); - const mediaData = await this.apiHelper.getPath(mediaId); - nextMediaData[mediaId] = mediaData; - - if (mediaData !== null) { - fetchedMedia[mediaId] = mediaData; - } - } - } - - slide.mediaData = nextMediaData; - } else { - logger.info(`Media data loaded from cache.`); - slide.mediaData = previousSlide.mediaData; - } - - // Fetch feed. - if (slide?.feed?.feedUrl !== undefined) { - logger.info(`Fetching feed data.`); - slide.feedData = await this.apiHelper.getPath(slide.feed.feedUrl); - } - - dataEntrySlidesData[slideKey] = slide; - } - } - } - /* eslint-enable no-restricted-syntax,no-await-in-loop */ - - this.lastestScreenData = newScreen; - - // Deliver result to rendering - const event = new CustomEvent("content", { - detail: { - screen: newScreen, - }, - }); - document.dispatchEvent(event); - } - - getPath(id) { - return this.apiHelper.getPath(id); - } - - async getTemplateData(slide) { - return new Promise((resolve) => { - const templatePath = slide.templateInfo["@id"]; - - this.apiHelper.getPath(templatePath).then((data) => { - resolve(data); - }); - }); - } - - async getFeedData(slide) { - return new Promise((resolve) => { - if (!slide?.feed?.feedUrl) { - resolve([]); - } else { - this.apiHelper.getPath(slide.feed.feedUrl).then((data) => { - resolve(data); - }); - } - }); - } - - async getMediaData(media) { - return new Promise((resolve) => { - this.apiHelper.getPath(media).then((data) => { - resolve(data); - }); - }); - } - - /** - * Start the data synchronization. - */ - start() { - // Pull now. - this.getScreen(this.entryPoint).then(() => { - // Make sure nothing is running. - this.stop(); - - // Start interval for pull periodically. - this.activeInterval = setInterval( - () => this.getScreen(this.entryPoint), - this.interval, - ); - }); - } - - /** - * Stop the data synchronization. - */ - stop() { - if (this.activeInterval !== undefined) { - clearInterval(this.activeInterval); - delete this.activeInterval; - } - } -} - -export default PullStrategy; diff --git a/assets/client/index.js b/assets/client/index.js deleted file mode 100644 index 121022c65..000000000 --- a/assets/client/index.js +++ /dev/null @@ -1,12 +0,0 @@ -import React from "react"; -import { createRoot } from "react-dom/client"; -import App from "./app"; - -const url = new URL(window.location.href); -const preview = url.searchParams.get("preview"); -const previewId = url.searchParams.get("preview-id"); - -const container = document.getElementById("root"); -const root = createRoot(container); - -root.render(); diff --git a/assets/client/index.jsx b/assets/client/index.jsx index dc7e35ef9..67a8a27a6 100644 --- a/assets/client/index.jsx +++ b/assets/client/index.jsx @@ -1,4 +1,7 @@ import { createRoot } from "react-dom/client"; +import { Provider } from "react-redux"; +import { clientStore } from "./redux/store.js"; +import { ClientStateProvider } from "./client-state-context.jsx"; import App from "./app.jsx"; const url = new URL(window.location.href); @@ -8,4 +11,10 @@ const previewId = url.searchParams.get("preview-id"); const container = document.getElementById("root"); const root = createRoot(container); -root.render(); +root.render( + + + + + , +); diff --git a/assets/client/redux/base-query.js b/assets/client/redux/base-query.js new file mode 100644 index 000000000..a8fc59db3 --- /dev/null +++ b/assets/client/redux/base-query.js @@ -0,0 +1,52 @@ +import { fetchBaseQuery } from "@reduxjs/toolkit/query/react"; +import localStorageKeys from "../core/local-storage-keys"; +import reauthenticateRef from "./reauthenticate-ref"; + +const rawBaseQuery = fetchBaseQuery({ baseUrl: "/", credentials: "include" }); + +const clientBaseQuery = async (args, api, extraOptions) => { + + const newArgs = { ...args }; + + if (!Object.prototype.hasOwnProperty.call(newArgs, "headers")) { + newArgs.headers = {}; + } + + if (!Object.prototype.hasOwnProperty.call(newArgs.headers, "accept")) { + newArgs.headers.accept = "application/ld+json"; + } + + // Support preview mode via URL params. + const url = new URL(window.location.href); + const previewToken = url.searchParams.get("preview-token"); + const previewTenant = url.searchParams.get("preview-tenant"); + + // Attach api token. + const apiToken = localStorage.getItem(localStorageKeys.API_TOKEN); + if (previewToken) { + newArgs.headers.authorization = `Bearer ${previewToken}`; + } else if (apiToken) { + newArgs.headers.authorization = `Bearer ${apiToken}`; + } + + // Attach tenant key. + const tenantKey = localStorage.getItem(localStorageKeys.TENANT_KEY); + if (previewTenant) { + newArgs.headers["Authorization-Tenant-Key"] = previewTenant; + } else if (tenantKey) { + newArgs.headers["Authorization-Tenant-Key"] = tenantKey; + } + + const baseResult = await rawBaseQuery(newArgs, api, extraOptions); + + // Handle authentication errors. + if (baseResult?.error?.status === 401) { + reauthenticateRef.current(); + } + + return { + ...baseResult, + }; +}; + +export default clientBaseQuery; diff --git a/assets/client/redux/empty-api.ts b/assets/client/redux/empty-api.ts new file mode 100644 index 000000000..95625c590 --- /dev/null +++ b/assets/client/redux/empty-api.ts @@ -0,0 +1,9 @@ +import { createApi } from "@reduxjs/toolkit/query/react"; +import clientBaseQuery from "./base-query"; + +export const clientEmptySplitApi = createApi({ + reducerPath: "clientApi", + baseQuery: clientBaseQuery, + keepUnusedDataFor: 2592000, // 30 days + endpoints: () => ({}), +}); diff --git a/assets/client/redux/enhanced-api.ts b/assets/client/redux/enhanced-api.ts new file mode 100644 index 000000000..e5654a543 --- /dev/null +++ b/assets/client/redux/enhanced-api.ts @@ -0,0 +1,28 @@ +import { clientApi as generatedApi } from "./generated-api"; + +// Invalidate the following tags for the given endpoints. +// The client uses very few mutations, so this is minimal. +const invalidatesTagsForEndpoints = { + postLoginInfoScreen: ["Authentication"], + postRefreshTokenItem: ["Authentication"], +}; + +const enhancedApi = generatedApi.enhanceEndpoints({ + // @ts-ignore + endpoints: Object.fromEntries( + // @ts-ignore + Object.entries(generatedApi.endpoints).map(([key, endpoint]) => { + const enhancedEndpoint = { + ...endpoint, + }; + + if (Object.prototype.hasOwnProperty.call(invalidatesTagsForEndpoints, key)) { + enhancedEndpoint.invalidatesTags = invalidatesTagsForEndpoints[key]; + } + + return [key, enhancedEndpoint]; + }) + ), +}); + +export { enhancedApi as clientApi }; diff --git a/assets/client/redux/generated-api.ts b/assets/client/redux/generated-api.ts new file mode 100644 index 000000000..5f80ee8f5 --- /dev/null +++ b/assets/client/redux/generated-api.ts @@ -0,0 +1,2704 @@ +import { clientEmptySplitApi as api } from "./empty-api"; +export const addTagTypes = [ + "Authentication", + "FeedSources", + "Feeds", + "Layouts", + "Login Check", + "Media", + "Playlists", + "ScreenGroupCampaign", + "ScreenGroups", + "Screens", + "Slides", + "Templates", + "Tenants", + "Themes", + "User", + "UserActivationCode", +] as const; +const injectedRtkApi = api + .enhanceEndpoints({ + addTagTypes, + }) + .injectEndpoints({ + endpoints: (build) => ({ + getOidcAuthTokenItem: build.query< + GetOidcAuthTokenItemApiResponse, + GetOidcAuthTokenItemApiArg + >({ + query: (queryArg) => ({ + url: `/v2/authentication/oidc/token`, + params: { + state: queryArg.state, + code: queryArg.code, + }, + }), + providesTags: ["Authentication"], + }), + getOidcAuthUrlsItem: build.query< + GetOidcAuthUrlsItemApiResponse, + GetOidcAuthUrlsItemApiArg + >({ + query: (queryArg) => ({ + url: `/v2/authentication/oidc/urls`, + params: { + providerKey: queryArg.providerKey, + }, + }), + providesTags: ["Authentication"], + }), + postLoginInfoScreen: build.mutation< + PostLoginInfoScreenApiResponse, + PostLoginInfoScreenApiArg + >({ + query: (queryArg) => ({ + url: `/v2/authentication/screen`, + method: "POST", + body: queryArg.screenLoginInput, + }), + invalidatesTags: ["Authentication"], + }), + postRefreshTokenItem: build.mutation< + PostRefreshTokenItemApiResponse, + PostRefreshTokenItemApiArg + >({ + query: (queryArg) => ({ + url: `/v2/authentication/token/refresh`, + method: "POST", + body: queryArg.refreshTokenRequest, + }), + invalidatesTags: ["Authentication"], + }), + getV2FeedSources: build.query< + GetV2FeedSourcesApiResponse, + GetV2FeedSourcesApiArg + >({ + query: (queryArg) => ({ + url: `/v2/feed-sources`, + params: { + page: queryArg.page, + itemsPerPage: queryArg.itemsPerPage, + supportedFeedOutputType: queryArg.supportedFeedOutputType, + title: queryArg.title, + description: queryArg.description, + createdBy: queryArg.createdBy, + modifiedBy: queryArg.modifiedBy, + order: queryArg.order, + }, + }), + providesTags: ["FeedSources"], + }), + postV2FeedSources: build.mutation< + PostV2FeedSourcesApiResponse, + PostV2FeedSourcesApiArg + >({ + query: (queryArg) => ({ + url: `/v2/feed-sources`, + method: "POST", + body: queryArg.feedSourceFeedSourceInputJsonld, + }), + invalidatesTags: ["FeedSources"], + }), + getV2FeedSourcesById: build.query< + GetV2FeedSourcesByIdApiResponse, + GetV2FeedSourcesByIdApiArg + >({ + query: (queryArg) => ({ url: `/v2/feed-sources/${queryArg.id}` }), + providesTags: ["FeedSources"], + }), + putV2FeedSourcesById: build.mutation< + PutV2FeedSourcesByIdApiResponse, + PutV2FeedSourcesByIdApiArg + >({ + query: (queryArg) => ({ + url: `/v2/feed-sources/${queryArg.id}`, + method: "PUT", + body: queryArg.feedSourceFeedSourceInputJsonld, + }), + invalidatesTags: ["FeedSources"], + }), + deleteV2FeedSourcesById: build.mutation< + DeleteV2FeedSourcesByIdApiResponse, + DeleteV2FeedSourcesByIdApiArg + >({ + query: (queryArg) => ({ + url: `/v2/feed-sources/${queryArg.id}`, + method: "DELETE", + }), + invalidatesTags: ["FeedSources"], + }), + getV2FeedSourcesByIdConfigAndName: build.query< + GetV2FeedSourcesByIdConfigAndNameApiResponse, + GetV2FeedSourcesByIdConfigAndNameApiArg + >({ + query: (queryArg) => ({ + url: `/v2/feed-sources/${queryArg.id}/config/${queryArg.name}`, + }), + providesTags: ["FeedSources"], + }), + getV2FeedSourcesByIdSlides: build.query< + GetV2FeedSourcesByIdSlidesApiResponse, + GetV2FeedSourcesByIdSlidesApiArg + >({ + query: (queryArg) => ({ + url: `/v2/feed-sources/${queryArg.id}/slides`, + params: { + page: queryArg.page, + itemsPerPage: queryArg.itemsPerPage, + title: queryArg.title, + description: queryArg.description, + createdBy: queryArg.createdBy, + modifiedBy: queryArg.modifiedBy, + published: queryArg.published, + order: queryArg.order, + }, + }), + providesTags: ["FeedSources"], + }), + getV2Feeds: build.query({ + query: (queryArg) => ({ + url: `/v2/feeds`, + params: { + page: queryArg.page, + itemsPerPage: queryArg.itemsPerPage, + createdBy: queryArg.createdBy, + modifiedBy: queryArg.modifiedBy, + order: queryArg.order, + }, + }), + providesTags: ["Feeds"], + }), + getV2FeedsById: build.query< + GetV2FeedsByIdApiResponse, + GetV2FeedsByIdApiArg + >({ + query: (queryArg) => ({ url: `/v2/feeds/${queryArg.id}` }), + providesTags: ["Feeds"], + }), + getV2FeedsByIdData: build.query< + GetV2FeedsByIdDataApiResponse, + GetV2FeedsByIdDataApiArg + >({ + query: (queryArg) => ({ url: `/v2/feeds/${queryArg.id}/data` }), + providesTags: ["Feeds"], + }), + getV2Layouts: build.query({ + query: (queryArg) => ({ + url: `/v2/layouts`, + params: { + page: queryArg.page, + itemsPerPage: queryArg.itemsPerPage, + }, + }), + providesTags: ["Layouts"], + }), + getV2LayoutsById: build.query< + GetV2LayoutsByIdApiResponse, + GetV2LayoutsByIdApiArg + >({ + query: (queryArg) => ({ url: `/v2/layouts/${queryArg.id}` }), + providesTags: ["Layouts"], + }), + loginCheckPost: build.mutation< + LoginCheckPostApiResponse, + LoginCheckPostApiArg + >({ + query: (queryArg) => ({ + url: `/v2/authentication/token`, + method: "POST", + body: queryArg.body, + }), + invalidatesTags: ["Login Check"], + }), + getV2Media: build.query({ + query: (queryArg) => ({ + url: `/v2/media`, + params: { + page: queryArg.page, + itemsPerPage: queryArg.itemsPerPage, + title: queryArg.title, + description: queryArg.description, + createdBy: queryArg.createdBy, + modifiedBy: queryArg.modifiedBy, + order: queryArg.order, + }, + }), + providesTags: ["Media"], + }), + postMediaCollection: build.mutation< + PostMediaCollectionApiResponse, + PostMediaCollectionApiArg + >({ + query: (queryArg) => ({ + url: `/v2/media`, + method: "POST", + body: queryArg.body, + }), + invalidatesTags: ["Media"], + }), + getV2MediaById: build.query< + GetV2MediaByIdApiResponse, + GetV2MediaByIdApiArg + >({ + query: (queryArg) => ({ url: `/v2/media/${queryArg.id}` }), + providesTags: ["Media"], + }), + deleteV2MediaById: build.mutation< + DeleteV2MediaByIdApiResponse, + DeleteV2MediaByIdApiArg + >({ + query: (queryArg) => ({ + url: `/v2/media/${queryArg.id}`, + method: "DELETE", + }), + invalidatesTags: ["Media"], + }), + getV2CampaignsByIdScreenGroups: build.query< + GetV2CampaignsByIdScreenGroupsApiResponse, + GetV2CampaignsByIdScreenGroupsApiArg + >({ + query: (queryArg) => ({ + url: `/v2/campaigns/${queryArg.id}/screen-groups`, + params: { + page: queryArg.page, + itemsPerPage: queryArg.itemsPerPage, + }, + }), + providesTags: ["Playlists"], + }), + getV2CampaignsByIdScreens: build.query< + GetV2CampaignsByIdScreensApiResponse, + GetV2CampaignsByIdScreensApiArg + >({ + query: (queryArg) => ({ + url: `/v2/campaigns/${queryArg.id}/screens`, + params: { + page: queryArg.page, + itemsPerPage: queryArg.itemsPerPage, + }, + }), + providesTags: ["Playlists"], + }), + getV2Playlists: build.query< + GetV2PlaylistsApiResponse, + GetV2PlaylistsApiArg + >({ + query: (queryArg) => ({ + url: `/v2/playlists`, + params: { + page: queryArg.page, + itemsPerPage: queryArg.itemsPerPage, + title: queryArg.title, + description: queryArg.description, + createdBy: queryArg.createdBy, + modifiedBy: queryArg.modifiedBy, + published: queryArg.published, + isCampaign: queryArg.isCampaign, + order: queryArg.order, + sharedWithMe: queryArg.sharedWithMe, + }, + }), + providesTags: ["Playlists"], + }), + postV2Playlists: build.mutation< + PostV2PlaylistsApiResponse, + PostV2PlaylistsApiArg + >({ + query: (queryArg) => ({ + url: `/v2/playlists`, + method: "POST", + body: queryArg.playlistPlaylistInputJsonld, + }), + invalidatesTags: ["Playlists"], + }), + getV2PlaylistsById: build.query< + GetV2PlaylistsByIdApiResponse, + GetV2PlaylistsByIdApiArg + >({ + query: (queryArg) => ({ url: `/v2/playlists/${queryArg.id}` }), + providesTags: ["Playlists"], + }), + putV2PlaylistsById: build.mutation< + PutV2PlaylistsByIdApiResponse, + PutV2PlaylistsByIdApiArg + >({ + query: (queryArg) => ({ + url: `/v2/playlists/${queryArg.id}`, + method: "PUT", + body: queryArg.playlistPlaylistInputJsonld, + }), + invalidatesTags: ["Playlists"], + }), + deleteV2PlaylistsById: build.mutation< + DeleteV2PlaylistsByIdApiResponse, + DeleteV2PlaylistsByIdApiArg + >({ + query: (queryArg) => ({ + url: `/v2/playlists/${queryArg.id}`, + method: "DELETE", + }), + invalidatesTags: ["Playlists"], + }), + getV2PlaylistsByIdSlides: build.query< + GetV2PlaylistsByIdSlidesApiResponse, + GetV2PlaylistsByIdSlidesApiArg + >({ + query: (queryArg) => ({ + url: `/v2/playlists/${queryArg.id}/slides`, + params: { + page: queryArg.page, + itemsPerPage: queryArg.itemsPerPage, + published: queryArg.published, + }, + }), + providesTags: ["Playlists"], + }), + putV2PlaylistsByIdSlides: build.mutation< + PutV2PlaylistsByIdSlidesApiResponse, + PutV2PlaylistsByIdSlidesApiArg + >({ + query: (queryArg) => ({ + url: `/v2/playlists/${queryArg.id}/slides`, + method: "PUT", + body: queryArg.body, + }), + invalidatesTags: ["Playlists"], + }), + deleteV2PlaylistsByIdSlidesAndSlideId: build.mutation< + DeleteV2PlaylistsByIdSlidesAndSlideIdApiResponse, + DeleteV2PlaylistsByIdSlidesAndSlideIdApiArg + >({ + query: (queryArg) => ({ + url: `/v2/playlists/${queryArg.id}/slides/${queryArg.slideId}`, + method: "DELETE", + }), + invalidatesTags: ["Playlists"], + }), + getV2SlidesByIdPlaylists: build.query< + GetV2SlidesByIdPlaylistsApiResponse, + GetV2SlidesByIdPlaylistsApiArg + >({ + query: (queryArg) => ({ + url: `/v2/slides/${queryArg.id}/playlists`, + params: { + page: queryArg.page, + itemsPerPage: queryArg.itemsPerPage, + published: queryArg.published, + }, + }), + providesTags: ["Playlists"], + }), + putV2SlidesByIdPlaylists: build.mutation< + PutV2SlidesByIdPlaylistsApiResponse, + PutV2SlidesByIdPlaylistsApiArg + >({ + query: (queryArg) => ({ + url: `/v2/slides/${queryArg.id}/playlists`, + method: "PUT", + body: queryArg.body, + }), + invalidatesTags: ["Playlists"], + }), + getScreenGroupCampaignItem: build.query< + GetScreenGroupCampaignItemApiResponse, + GetScreenGroupCampaignItemApiArg + >({ + query: (queryArg) => ({ + url: `/v2/screen-groups-campaigns/${queryArg.id}`, + }), + providesTags: ["ScreenGroupCampaign"], + }), + getV2ScreenGroups: build.query< + GetV2ScreenGroupsApiResponse, + GetV2ScreenGroupsApiArg + >({ + query: (queryArg) => ({ + url: `/v2/screen-groups`, + params: { + page: queryArg.page, + itemsPerPage: queryArg.itemsPerPage, + title: queryArg.title, + description: queryArg.description, + createdBy: queryArg.createdBy, + modifiedBy: queryArg.modifiedBy, + order: queryArg.order, + }, + }), + providesTags: ["ScreenGroups"], + }), + postV2ScreenGroups: build.mutation< + PostV2ScreenGroupsApiResponse, + PostV2ScreenGroupsApiArg + >({ + query: (queryArg) => ({ + url: `/v2/screen-groups`, + method: "POST", + body: queryArg.screenGroupScreenGroupInputJsonld, + }), + invalidatesTags: ["ScreenGroups"], + }), + getV2ScreenGroupsById: build.query< + GetV2ScreenGroupsByIdApiResponse, + GetV2ScreenGroupsByIdApiArg + >({ + query: (queryArg) => ({ url: `/v2/screen-groups/${queryArg.id}` }), + providesTags: ["ScreenGroups"], + }), + putV2ScreenGroupsById: build.mutation< + PutV2ScreenGroupsByIdApiResponse, + PutV2ScreenGroupsByIdApiArg + >({ + query: (queryArg) => ({ + url: `/v2/screen-groups/${queryArg.id}`, + method: "PUT", + body: queryArg.screenGroupScreenGroupInputJsonld, + }), + invalidatesTags: ["ScreenGroups"], + }), + deleteV2ScreenGroupsById: build.mutation< + DeleteV2ScreenGroupsByIdApiResponse, + DeleteV2ScreenGroupsByIdApiArg + >({ + query: (queryArg) => ({ + url: `/v2/screen-groups/${queryArg.id}`, + method: "DELETE", + }), + invalidatesTags: ["ScreenGroups"], + }), + getV2ScreenGroupsByIdCampaigns: build.query< + GetV2ScreenGroupsByIdCampaignsApiResponse, + GetV2ScreenGroupsByIdCampaignsApiArg + >({ + query: (queryArg) => ({ + url: `/v2/screen-groups/${queryArg.id}/campaigns`, + params: { + page: queryArg.page, + itemsPerPage: queryArg.itemsPerPage, + published: queryArg.published, + }, + }), + providesTags: ["ScreenGroups"], + }), + putV2ScreenGroupsByIdCampaigns: build.mutation< + PutV2ScreenGroupsByIdCampaignsApiResponse, + PutV2ScreenGroupsByIdCampaignsApiArg + >({ + query: (queryArg) => ({ + url: `/v2/screen-groups/${queryArg.id}/campaigns`, + method: "PUT", + body: queryArg.body, + }), + invalidatesTags: ["ScreenGroups"], + }), + deleteV2ScreenGroupsByIdCampaignsAndCampaignId: build.mutation< + DeleteV2ScreenGroupsByIdCampaignsAndCampaignIdApiResponse, + DeleteV2ScreenGroupsByIdCampaignsAndCampaignIdApiArg + >({ + query: (queryArg) => ({ + url: `/v2/screen-groups/${queryArg.id}/campaigns/${queryArg.campaignId}`, + method: "DELETE", + }), + invalidatesTags: ["ScreenGroups"], + }), + getV2ScreenGroupsByIdScreens: build.query< + GetV2ScreenGroupsByIdScreensApiResponse, + GetV2ScreenGroupsByIdScreensApiArg + >({ + query: (queryArg) => ({ + url: `/v2/screen-groups/${queryArg.id}/screens`, + params: { + page: queryArg.page, + itemsPerPage: queryArg.itemsPerPage, + }, + }), + providesTags: ["ScreenGroups"], + }), + getV2Screens: build.query({ + query: (queryArg) => ({ + url: `/v2/screens`, + params: { + page: queryArg.page, + itemsPerPage: queryArg.itemsPerPage, + search: queryArg.search, + exists: queryArg.exists, + "screenUser.latestRequest": queryArg["screenUser.latestRequest"], + createdBy: queryArg.createdBy, + modifiedBy: queryArg.modifiedBy, + order: queryArg.order, + }, + }), + providesTags: ["Screens"], + }), + postV2Screens: build.mutation< + PostV2ScreensApiResponse, + PostV2ScreensApiArg + >({ + query: (queryArg) => ({ + url: `/v2/screens`, + method: "POST", + body: queryArg.screenScreenInputJsonld, + }), + invalidatesTags: ["Screens"], + }), + getV2ScreensById: build.query< + GetV2ScreensByIdApiResponse, + GetV2ScreensByIdApiArg + >({ + query: (queryArg) => ({ url: `/v2/screens/${queryArg.id}` }), + providesTags: ["Screens"], + }), + putV2ScreensById: build.mutation< + PutV2ScreensByIdApiResponse, + PutV2ScreensByIdApiArg + >({ + query: (queryArg) => ({ + url: `/v2/screens/${queryArg.id}`, + method: "PUT", + body: queryArg.screenScreenInputJsonld, + }), + invalidatesTags: ["Screens"], + }), + deleteV2ScreensById: build.mutation< + DeleteV2ScreensByIdApiResponse, + DeleteV2ScreensByIdApiArg + >({ + query: (queryArg) => ({ + url: `/v2/screens/${queryArg.id}`, + method: "DELETE", + }), + invalidatesTags: ["Screens"], + }), + postScreenBindKey: build.mutation< + PostScreenBindKeyApiResponse, + PostScreenBindKeyApiArg + >({ + query: (queryArg) => ({ + url: `/v2/screens/${queryArg.id}/bind`, + method: "POST", + body: queryArg.screenBindObject, + }), + invalidatesTags: ["Screens"], + }), + getV2ScreensByIdCampaigns: build.query< + GetV2ScreensByIdCampaignsApiResponse, + GetV2ScreensByIdCampaignsApiArg + >({ + query: (queryArg) => ({ + url: `/v2/screens/${queryArg.id}/campaigns`, + params: { + page: queryArg.page, + itemsPerPage: queryArg.itemsPerPage, + published: queryArg.published, + }, + }), + providesTags: ["Screens"], + }), + putV2ScreensByIdCampaigns: build.mutation< + PutV2ScreensByIdCampaignsApiResponse, + PutV2ScreensByIdCampaignsApiArg + >({ + query: (queryArg) => ({ + url: `/v2/screens/${queryArg.id}/campaigns`, + method: "PUT", + body: queryArg.body, + }), + invalidatesTags: ["Screens"], + }), + deleteV2ScreensByIdCampaignsAndCampaignId: build.mutation< + DeleteV2ScreensByIdCampaignsAndCampaignIdApiResponse, + DeleteV2ScreensByIdCampaignsAndCampaignIdApiArg + >({ + query: (queryArg) => ({ + url: `/v2/screens/${queryArg.id}/campaigns/${queryArg.campaignId}`, + method: "DELETE", + }), + invalidatesTags: ["Screens"], + }), + getV2ScreensByIdRegionsAndRegionIdPlaylists: build.query< + GetV2ScreensByIdRegionsAndRegionIdPlaylistsApiResponse, + GetV2ScreensByIdRegionsAndRegionIdPlaylistsApiArg + >({ + query: (queryArg) => ({ + url: `/v2/screens/${queryArg.id}/regions/${queryArg.regionId}/playlists`, + params: { + page: queryArg.page, + itemsPerPage: queryArg.itemsPerPage, + sharedWithMe: queryArg.sharedWithMe, + }, + }), + providesTags: ["Screens"], + }), + putPlaylistScreenRegionItem: build.mutation< + PutPlaylistScreenRegionItemApiResponse, + PutPlaylistScreenRegionItemApiArg + >({ + query: (queryArg) => ({ + url: `/v2/screens/${queryArg.id}/regions/${queryArg.regionId}/playlists`, + method: "PUT", + body: queryArg.body, + }), + invalidatesTags: ["Screens"], + }), + deletePlaylistScreenRegionItem: build.mutation< + DeletePlaylistScreenRegionItemApiResponse, + DeletePlaylistScreenRegionItemApiArg + >({ + query: (queryArg) => ({ + url: `/v2/screens/${queryArg.id}/regions/${queryArg.regionId}/playlists/${queryArg.playlistId}`, + method: "DELETE", + }), + invalidatesTags: ["Screens"], + }), + getV2ScreensByIdScreenGroups: build.query< + GetV2ScreensByIdScreenGroupsApiResponse, + GetV2ScreensByIdScreenGroupsApiArg + >({ + query: (queryArg) => ({ + url: `/v2/screens/${queryArg.id}/screen-groups`, + params: { + page: queryArg.page, + itemsPerPage: queryArg.itemsPerPage, + order: queryArg.order, + }, + }), + providesTags: ["Screens"], + }), + putV2ScreensByIdScreenGroups: build.mutation< + PutV2ScreensByIdScreenGroupsApiResponse, + PutV2ScreensByIdScreenGroupsApiArg + >({ + query: (queryArg) => ({ + url: `/v2/screens/${queryArg.id}/screen-groups`, + method: "PUT", + body: queryArg.body, + }), + invalidatesTags: ["Screens"], + }), + deleteV2ScreensByIdScreenGroupsAndScreenGroupId: build.mutation< + DeleteV2ScreensByIdScreenGroupsAndScreenGroupIdApiResponse, + DeleteV2ScreensByIdScreenGroupsAndScreenGroupIdApiArg + >({ + query: (queryArg) => ({ + url: `/v2/screens/${queryArg.id}/screen-groups/${queryArg.screenGroupId}`, + method: "DELETE", + }), + invalidatesTags: ["Screens"], + }), + postScreenUnbind: build.mutation< + PostScreenUnbindApiResponse, + PostScreenUnbindApiArg + >({ + query: (queryArg) => ({ + url: `/v2/screens/${queryArg.id}/unbind`, + method: "POST", + body: queryArg.body, + }), + invalidatesTags: ["Screens"], + }), + getV2Slides: build.query({ + query: (queryArg) => ({ + url: `/v2/slides`, + params: { + page: queryArg.page, + itemsPerPage: queryArg.itemsPerPage, + title: queryArg.title, + description: queryArg.description, + createdBy: queryArg.createdBy, + modifiedBy: queryArg.modifiedBy, + published: queryArg.published, + order: queryArg.order, + }, + }), + providesTags: ["Slides"], + }), + postV2Slides: build.mutation( + { + query: (queryArg) => ({ + url: `/v2/slides`, + method: "POST", + body: queryArg.slideSlideInputJsonld, + }), + invalidatesTags: ["Slides"], + }, + ), + getV2SlidesById: build.query< + GetV2SlidesByIdApiResponse, + GetV2SlidesByIdApiArg + >({ + query: (queryArg) => ({ url: `/v2/slides/${queryArg.id}` }), + providesTags: ["Slides"], + }), + putV2SlidesById: build.mutation< + PutV2SlidesByIdApiResponse, + PutV2SlidesByIdApiArg + >({ + query: (queryArg) => ({ + url: `/v2/slides/${queryArg.id}`, + method: "PUT", + body: queryArg.slideSlideInputJsonld, + }), + invalidatesTags: ["Slides"], + }), + deleteV2SlidesById: build.mutation< + DeleteV2SlidesByIdApiResponse, + DeleteV2SlidesByIdApiArg + >({ + query: (queryArg) => ({ + url: `/v2/slides/${queryArg.id}`, + method: "DELETE", + }), + invalidatesTags: ["Slides"], + }), + apiSlidePerformAction: build.mutation< + ApiSlidePerformActionApiResponse, + ApiSlidePerformActionApiArg + >({ + query: (queryArg) => ({ + url: `/v2/slides/${queryArg.id}/action`, + method: "POST", + body: queryArg.slideInteractiveSlideActionInputJsonld, + }), + invalidatesTags: ["Slides"], + }), + getV2Templates: build.query< + GetV2TemplatesApiResponse, + GetV2TemplatesApiArg + >({ + query: (queryArg) => ({ + url: `/v2/templates`, + params: { + page: queryArg.page, + itemsPerPage: queryArg.itemsPerPage, + title: queryArg.title, + createdBy: queryArg.createdBy, + modifiedBy: queryArg.modifiedBy, + order: queryArg.order, + }, + }), + providesTags: ["Templates"], + }), + getV2TemplatesById: build.query< + GetV2TemplatesByIdApiResponse, + GetV2TemplatesByIdApiArg + >({ + query: (queryArg) => ({ url: `/v2/templates/${queryArg.id}` }), + providesTags: ["Templates"], + }), + getV2Tenants: build.query({ + query: (queryArg) => ({ + url: `/v2/tenants`, + params: { + page: queryArg.page, + itemsPerPage: queryArg.itemsPerPage, + title: queryArg.title, + description: queryArg.description, + createdBy: queryArg.createdBy, + modifiedBy: queryArg.modifiedBy, + }, + }), + providesTags: ["Tenants"], + }), + getV2TenantsById: build.query< + GetV2TenantsByIdApiResponse, + GetV2TenantsByIdApiArg + >({ + query: (queryArg) => ({ url: `/v2/tenants/${queryArg.id}` }), + providesTags: ["Tenants"], + }), + getV2Themes: build.query({ + query: (queryArg) => ({ + url: `/v2/themes`, + params: { + page: queryArg.page, + itemsPerPage: queryArg.itemsPerPage, + title: queryArg.title, + description: queryArg.description, + createdBy: queryArg.createdBy, + modifiedBy: queryArg.modifiedBy, + order: queryArg.order, + }, + }), + providesTags: ["Themes"], + }), + postV2Themes: build.mutation( + { + query: (queryArg) => ({ + url: `/v2/themes`, + method: "POST", + body: queryArg.themeThemeInputJsonld, + }), + invalidatesTags: ["Themes"], + }, + ), + getV2ThemesById: build.query< + GetV2ThemesByIdApiResponse, + GetV2ThemesByIdApiArg + >({ + query: (queryArg) => ({ url: `/v2/themes/${queryArg.id}` }), + providesTags: ["Themes"], + }), + putV2ThemesById: build.mutation< + PutV2ThemesByIdApiResponse, + PutV2ThemesByIdApiArg + >({ + query: (queryArg) => ({ + url: `/v2/themes/${queryArg.id}`, + method: "PUT", + body: queryArg.themeThemeInputJsonld, + }), + invalidatesTags: ["Themes"], + }), + deleteV2ThemesById: build.mutation< + DeleteV2ThemesByIdApiResponse, + DeleteV2ThemesByIdApiArg + >({ + query: (queryArg) => ({ + url: `/v2/themes/${queryArg.id}`, + method: "DELETE", + }), + invalidatesTags: ["Themes"], + }), + getV2Users: build.query({ + query: (queryArg) => ({ + url: `/v2/users`, + params: { + page: queryArg.page, + itemsPerPage: queryArg.itemsPerPage, + fullName: queryArg.fullName, + email: queryArg.email, + createdBy: queryArg.createdBy, + modifiedBy: queryArg.modifiedBy, + order: queryArg.order, + }, + }), + providesTags: ["User"], + }), + postV2Users: build.mutation({ + query: (queryArg) => ({ + url: `/v2/users`, + method: "POST", + body: queryArg.userUserInputJsonld, + }), + invalidatesTags: ["User"], + }), + getV2UsersById: build.query< + GetV2UsersByIdApiResponse, + GetV2UsersByIdApiArg + >({ + query: (queryArg) => ({ url: `/v2/users/${queryArg.id}` }), + providesTags: ["User"], + }), + putV2UsersById: build.mutation< + PutV2UsersByIdApiResponse, + PutV2UsersByIdApiArg + >({ + query: (queryArg) => ({ + url: `/v2/users/${queryArg.id}`, + method: "PUT", + body: queryArg.userUserInputJsonld, + }), + invalidatesTags: ["User"], + }), + deleteV2UsersById: build.mutation< + DeleteV2UsersByIdApiResponse, + DeleteV2UsersByIdApiArg + >({ + query: (queryArg) => ({ + url: `/v2/users/${queryArg.id}`, + method: "DELETE", + }), + invalidatesTags: ["User"], + }), + deleteV2UsersByIdRemoveFromTenant: build.mutation< + DeleteV2UsersByIdRemoveFromTenantApiResponse, + DeleteV2UsersByIdRemoveFromTenantApiArg + >({ + query: (queryArg) => ({ + url: `/v2/users/${queryArg.id}/remove-from-tenant`, + method: "DELETE", + }), + invalidatesTags: ["User"], + }), + getV2UserActivationCodes: build.query< + GetV2UserActivationCodesApiResponse, + GetV2UserActivationCodesApiArg + >({ + query: (queryArg) => ({ + url: `/v2/user-activation-codes`, + params: { + page: queryArg.page, + itemsPerPage: queryArg.itemsPerPage, + }, + }), + providesTags: ["UserActivationCode"], + }), + postV2UserActivationCodes: build.mutation< + PostV2UserActivationCodesApiResponse, + PostV2UserActivationCodesApiArg + >({ + query: (queryArg) => ({ + url: `/v2/user-activation-codes`, + method: "POST", + body: queryArg.userActivationCodeUserActivationCodeInputJsonld, + }), + invalidatesTags: ["UserActivationCode"], + }), + postV2UserActivationCodesActivate: build.mutation< + PostV2UserActivationCodesActivateApiResponse, + PostV2UserActivationCodesActivateApiArg + >({ + query: (queryArg) => ({ + url: `/v2/user-activation-codes/activate`, + method: "POST", + body: queryArg.userActivationCodeActivationCodeJsonld, + }), + invalidatesTags: ["UserActivationCode"], + }), + postV2UserActivationCodesRefresh: build.mutation< + PostV2UserActivationCodesRefreshApiResponse, + PostV2UserActivationCodesRefreshApiArg + >({ + query: (queryArg) => ({ + url: `/v2/user-activation-codes/refresh`, + method: "POST", + body: queryArg.userActivationCodeActivationCodeJsonld, + }), + invalidatesTags: ["UserActivationCode"], + }), + getV2UserActivationCodesById: build.query< + GetV2UserActivationCodesByIdApiResponse, + GetV2UserActivationCodesByIdApiArg + >({ + query: (queryArg) => ({ + url: `/v2/user-activation-codes/${queryArg.id}`, + }), + providesTags: ["UserActivationCode"], + }), + deleteV2UserActivationCodesById: build.mutation< + DeleteV2UserActivationCodesByIdApiResponse, + DeleteV2UserActivationCodesByIdApiArg + >({ + query: (queryArg) => ({ + url: `/v2/user-activation-codes/${queryArg.id}`, + method: "DELETE", + }), + invalidatesTags: ["UserActivationCode"], + }), + }), + overrideExisting: false, + }); +export { injectedRtkApi as clientApi }; +export type GetOidcAuthTokenItemApiResponse = + /** status 200 Get JWT token from OIDC code */ TokenRead; +export type GetOidcAuthTokenItemApiArg = { + /** OIDC state */ + state?: string; + /** OIDC code */ + code?: string; +}; +export type GetOidcAuthUrlsItemApiResponse = + /** status 200 Get authentication and end session endpoints */ OidcEndpoints; +export type GetOidcAuthUrlsItemApiArg = { + /** The key for the provider to use. Leave out to use the default provider */ + providerKey?: string; +}; +export type PostLoginInfoScreenApiResponse = + /** status 200 Login with bindKey to get JWT token for screen */ ScreenLoginOutputRead; +export type PostLoginInfoScreenApiArg = { + /** Get login info with JWT token for given nonce */ + screenLoginInput: ScreenLoginInput; +}; +export type PostRefreshTokenItemApiResponse = + /** status 200 Refresh JWT token */ RefreshTokenResponseRead; +export type PostRefreshTokenItemApiArg = { + /** Refresh JWT Token */ + refreshTokenRequest: RefreshTokenRequest; +}; +export type GetV2FeedSourcesApiResponse = /** status 200 OK */ Blob; +export type GetV2FeedSourcesApiArg = { + page?: number; + /** The number of items per page */ + itemsPerPage?: string; + supportedFeedOutputType?: string; + title?: string; + description?: string; + createdBy?: string; + modifiedBy?: string; + order?: { + title?: "asc" | "desc"; + description?: "asc" | "desc"; + createdAt?: "asc" | "desc"; + modifiedAt?: "asc" | "desc"; + }; +}; +export type PostV2FeedSourcesApiResponse = + /** status 201 FeedSource resource created */ FeedSourceFeedSourceJsonldRead; +export type PostV2FeedSourcesApiArg = { + /** The new FeedSource resource */ + feedSourceFeedSourceInputJsonld: FeedSourceFeedSourceInputJsonld; +}; +export type GetV2FeedSourcesByIdApiResponse = /** status 200 OK */ Blob; +export type GetV2FeedSourcesByIdApiArg = { + id: string; +}; +export type PutV2FeedSourcesByIdApiResponse = + /** status 200 FeedSource resource updated */ FeedSourceFeedSourceJsonldRead; +export type PutV2FeedSourcesByIdApiArg = { + id: string; + /** The updated FeedSource resource */ + feedSourceFeedSourceInputJsonld: FeedSourceFeedSourceInputJsonld; +}; +export type DeleteV2FeedSourcesByIdApiResponse = unknown; +export type DeleteV2FeedSourcesByIdApiArg = { + id: string; +}; +export type GetV2FeedSourcesByIdConfigAndNameApiResponse = + /** status 200 undefined */ Blob; +export type GetV2FeedSourcesByIdConfigAndNameApiArg = { + id: string; + name: string; +}; +export type GetV2FeedSourcesByIdSlidesApiResponse = /** status 200 OK */ Blob; +export type GetV2FeedSourcesByIdSlidesApiArg = { + id: string; + page: number; + /** The number of items per page */ + itemsPerPage?: string; + title?: string; + description?: string; + createdBy?: string; + modifiedBy?: string; + /** If true only published content will be shown */ + published?: boolean; + order?: { + title?: "asc" | "desc"; + description?: "asc" | "desc"; + createdAt?: "asc" | "desc"; + modifiedAt?: "asc" | "desc"; + }; +}; +export type GetV2FeedsApiResponse = /** status 200 OK */ Blob; +export type GetV2FeedsApiArg = { + page?: number; + /** The number of items per page */ + itemsPerPage?: string; + createdBy?: string; + modifiedBy?: string; + order?: { + createdAt?: "asc" | "desc"; + modifiedAt?: "asc" | "desc"; + }; +}; +export type GetV2FeedsByIdApiResponse = /** status 200 OK */ Blob; +export type GetV2FeedsByIdApiArg = { + id: string; +}; +export type GetV2FeedsByIdDataApiResponse = /** status 200 undefined */ Blob; +export type GetV2FeedsByIdDataApiArg = { + id: string; +}; +export type GetV2LayoutsApiResponse = /** status 200 OK */ Blob; +export type GetV2LayoutsApiArg = { + page?: number; + /** The number of items per page */ + itemsPerPage?: number; +}; +export type GetV2LayoutsByIdApiResponse = /** status 200 OK */ Blob; +export type GetV2LayoutsByIdApiArg = { + id: string; +}; +export type LoginCheckPostApiResponse = /** status 200 User token created */ { + token: string; +}; +export type LoginCheckPostApiArg = { + /** The login data */ + body: { + providerId: string; + password: string; + }; +}; +export type GetV2MediaApiResponse = /** status 200 OK */ Blob; +export type GetV2MediaApiArg = { + page?: number; + /** The number of items per page */ + itemsPerPage?: string; + title?: string; + description?: string; + createdBy?: string; + modifiedBy?: string; + order?: { + title?: "asc" | "desc"; + description?: "asc" | "desc"; + createdAt?: "asc" | "desc"; + modifiedAt?: "asc" | "desc"; + }; +}; +export type PostMediaCollectionApiResponse = + /** status 201 Media resource created */ MediaMediaJsonldRead; +export type PostMediaCollectionApiArg = { + body: { + title: string; + description: string; + license: string; + file: Blob; + }; +}; +export type GetV2MediaByIdApiResponse = /** status 200 OK */ Blob; +export type GetV2MediaByIdApiArg = { + id: string; +}; +export type DeleteV2MediaByIdApiResponse = unknown; +export type DeleteV2MediaByIdApiArg = { + id: string; +}; +export type GetV2CampaignsByIdScreenGroupsApiResponse = + /** status 200 ScreenGroupCampaign collection */ { + "hydra:member": ScreenGroupCampaignJsonldCampaignsScreenGroupsReadRead[]; + "hydra:totalItems"?: number; + "hydra:view"?: { + "@id"?: string; + "@type"?: string; + "hydra:first"?: string; + "hydra:last"?: string; + "hydra:previous"?: string; + "hydra:next"?: string; + }; + "hydra:search"?: { + "@type"?: string; + "hydra:template"?: string; + "hydra:variableRepresentation"?: string; + "hydra:mapping"?: { + "@type"?: string; + variable?: string; + property?: string | null; + required?: boolean; + }[]; + }; + }; +export type GetV2CampaignsByIdScreenGroupsApiArg = { + id: string; + page?: number; + /** The number of items per page */ + itemsPerPage?: string; +}; +export type GetV2CampaignsByIdScreensApiResponse = + /** status 200 ScreenCampaign collection */ { + "hydra:member": ScreenCampaignJsonldCampaignsScreensReadRead[]; + "hydra:totalItems"?: number; + "hydra:view"?: { + "@id"?: string; + "@type"?: string; + "hydra:first"?: string; + "hydra:last"?: string; + "hydra:previous"?: string; + "hydra:next"?: string; + }; + "hydra:search"?: { + "@type"?: string; + "hydra:template"?: string; + "hydra:variableRepresentation"?: string; + "hydra:mapping"?: { + "@type"?: string; + variable?: string; + property?: string | null; + required?: boolean; + }[]; + }; + }; +export type GetV2CampaignsByIdScreensApiArg = { + id: string; + page?: number; + /** The number of items per page */ + itemsPerPage?: string; +}; +export type GetV2PlaylistsApiResponse = /** status 200 OK */ Blob; +export type GetV2PlaylistsApiArg = { + page: number; + /** The number of items per page */ + itemsPerPage?: number; + title?: string; + description?: string; + createdBy?: string; + modifiedBy?: string; + /** If true only published content will be shown */ + published?: boolean; + /** If true only campaigns will be shown */ + isCampaign?: boolean; + order?: { + title?: "asc" | "desc"; + description?: "asc" | "desc"; + createdAt?: "asc" | "desc"; + modifiedAt?: "asc" | "desc"; + }; + /** If true only entities that are shared with me will be shown */ + sharedWithMe?: boolean; +}; +export type PostV2PlaylistsApiResponse = + /** status 201 Playlist resource created */ PlaylistPlaylistJsonldRead; +export type PostV2PlaylistsApiArg = { + /** The new Playlist resource */ + playlistPlaylistInputJsonld: PlaylistPlaylistInputJsonld; +}; +export type GetV2PlaylistsByIdApiResponse = /** status 200 OK */ Blob; +export type GetV2PlaylistsByIdApiArg = { + id: string; +}; +export type PutV2PlaylistsByIdApiResponse = + /** status 200 Playlist resource updated */ PlaylistPlaylistJsonldRead; +export type PutV2PlaylistsByIdApiArg = { + id: string; + /** The updated Playlist resource */ + playlistPlaylistInputJsonld: PlaylistPlaylistInputJsonld; +}; +export type DeleteV2PlaylistsByIdApiResponse = unknown; +export type DeleteV2PlaylistsByIdApiArg = { + id: string; +}; +export type GetV2PlaylistsByIdSlidesApiResponse = /** status 200 OK */ Blob; +export type GetV2PlaylistsByIdSlidesApiArg = { + id: string; + page: number; + /** The number of items per page */ + itemsPerPage?: string; + /** If true only published content will be shown */ + published?: boolean; +}; +export type PutV2PlaylistsByIdSlidesApiResponse = /** status 201 Created */ { + slide?: string; + playlist?: string; + weight?: number; +}[]; +export type PutV2PlaylistsByIdSlidesApiArg = { + /** PlaylistSlide identifier */ + id: string; + body: { + /** Slide ULID */ + slide?: string; + weight?: number; + }[]; +}; +export type DeleteV2PlaylistsByIdSlidesAndSlideIdApiResponse = unknown; +export type DeleteV2PlaylistsByIdSlidesAndSlideIdApiArg = { + id: string; + slideId: string; +}; +export type GetV2SlidesByIdPlaylistsApiResponse = /** status 200 OK */ Blob; +export type GetV2SlidesByIdPlaylistsApiArg = { + id: string; + page?: number; + /** The number of items per page */ + itemsPerPage?: string; + /** If true only published content will be shown */ + published?: boolean; +}; +export type PutV2SlidesByIdPlaylistsApiResponse = + /** status 200 PlaylistSlide resource updated */ PlaylistSlidePlaylistSlideJsonldRead; +export type PutV2SlidesByIdPlaylistsApiArg = { + id: string; + body: { + /** Playlist ULID */ + playlist?: string; + }[]; +}; +export type GetScreenGroupCampaignItemApiResponse = + /** status 200 ScreenGroupCampaign resource */ ScreenGroupCampaignJsonldRead; +export type GetScreenGroupCampaignItemApiArg = { + /** ScreenGroupCampaign identifier */ + id: string; +}; +export type GetV2ScreenGroupsApiResponse = /** status 200 OK */ Blob; +export type GetV2ScreenGroupsApiArg = { + page?: number; + /** The number of items per page */ + itemsPerPage?: string; + title?: string; + description?: string; + createdBy?: string; + modifiedBy?: string; + order?: { + title?: "asc" | "desc"; + description?: "asc" | "desc"; + createdAt?: "asc" | "desc"; + }; +}; +export type PostV2ScreenGroupsApiResponse = + /** status 201 ScreenGroup resource created */ ScreenGroupScreenGroupJsonldRead; +export type PostV2ScreenGroupsApiArg = { + /** The new ScreenGroup resource */ + screenGroupScreenGroupInputJsonld: ScreenGroupScreenGroupInputJsonld; +}; +export type GetV2ScreenGroupsByIdApiResponse = /** status 200 OK */ Blob; +export type GetV2ScreenGroupsByIdApiArg = { + id: string; +}; +export type PutV2ScreenGroupsByIdApiResponse = + /** status 200 ScreenGroup resource updated */ ScreenGroupScreenGroupJsonldRead; +export type PutV2ScreenGroupsByIdApiArg = { + id: string; + /** The updated ScreenGroup resource */ + screenGroupScreenGroupInputJsonld: ScreenGroupScreenGroupInputJsonld; +}; +export type DeleteV2ScreenGroupsByIdApiResponse = unknown; +export type DeleteV2ScreenGroupsByIdApiArg = { + id: string; +}; +export type GetV2ScreenGroupsByIdCampaignsApiResponse = + /** status 200 OK */ Blob; +export type GetV2ScreenGroupsByIdCampaignsApiArg = { + id: string; + page: number; + /** The number of items per page */ + itemsPerPage?: string; + /** If true only published content will be shown */ + published?: boolean; +}; +export type PutV2ScreenGroupsByIdCampaignsApiResponse = + /** status 201 Created */ { + playlist?: string; + "screen-group"?: string; + }[]; +export type PutV2ScreenGroupsByIdCampaignsApiArg = { + /** ScreenGroupCampaign identifier */ + id: string; + body: { + /** Screen group ULID */ + screenGroup?: string; + }[]; +}; +export type DeleteV2ScreenGroupsByIdCampaignsAndCampaignIdApiResponse = unknown; +export type DeleteV2ScreenGroupsByIdCampaignsAndCampaignIdApiArg = { + id: string; + campaignId: string; +}; +export type GetV2ScreenGroupsByIdScreensApiResponse = + /** status 200 ScreenGroup collection */ { + "hydra:member": ScreenGroupJsonldScreenGroupsScreensReadRead[]; + "hydra:totalItems"?: number; + "hydra:view"?: { + "@id"?: string; + "@type"?: string; + "hydra:first"?: string; + "hydra:last"?: string; + "hydra:previous"?: string; + "hydra:next"?: string; + }; + "hydra:search"?: { + "@type"?: string; + "hydra:template"?: string; + "hydra:variableRepresentation"?: string; + "hydra:mapping"?: { + "@type"?: string; + variable?: string; + property?: string | null; + required?: boolean; + }[]; + }; + }; +export type GetV2ScreenGroupsByIdScreensApiArg = { + id: string; + page?: number; + /** The number of items per page */ + itemsPerPage?: string; +}; +export type GetV2ScreensApiResponse = /** status 200 OK */ Blob; +export type GetV2ScreensApiArg = { + page?: number; + /** The number of items per page */ + itemsPerPage?: string; + /** Search on both location and title */ + search?: string; + exists?: { + screenUser?: boolean; + }; + "screenUser.latestRequest"?: { + before?: string; + strictly_before?: string; + after?: string; + strictly_after?: string; + }; + createdBy?: string; + modifiedBy?: string; + order?: { + title?: "asc" | "desc"; + description?: "asc" | "desc"; + createdAt?: "asc" | "desc"; + modifiedAt?: "asc" | "desc"; + }; +}; +export type PostV2ScreensApiResponse = + /** status 201 Screen resource created */ ScreenScreenJsonldRead; +export type PostV2ScreensApiArg = { + /** The new Screen resource */ + screenScreenInputJsonld: ScreenScreenInputJsonld; +}; +export type GetV2ScreensByIdApiResponse = /** status 200 OK */ Blob; +export type GetV2ScreensByIdApiArg = { + id: string; +}; +export type PutV2ScreensByIdApiResponse = + /** status 200 Screen resource updated */ ScreenScreenJsonldRead; +export type PutV2ScreensByIdApiArg = { + id: string; + /** The updated Screen resource */ + screenScreenInputJsonld: ScreenScreenInputJsonld; +}; +export type DeleteV2ScreensByIdApiResponse = unknown; +export type DeleteV2ScreensByIdApiArg = { + id: string; +}; +export type PostScreenBindKeyApiResponse = unknown; +export type PostScreenBindKeyApiArg = { + /** The screen id */ + id: string; + /** Bind the screen with the bind key */ + screenBindObject: ScreenBindObject; +}; +export type GetV2ScreensByIdCampaignsApiResponse = /** status 200 OK */ Blob; +export type GetV2ScreensByIdCampaignsApiArg = { + id: string; + page: number; + /** The number of items per page */ + itemsPerPage?: string; + /** If true only published content will be shown */ + published?: boolean; +}; +export type PutV2ScreensByIdCampaignsApiResponse = /** status 201 Created */ { + playlist?: string; + screen?: string; +}[]; +export type PutV2ScreensByIdCampaignsApiArg = { + /** ScreenCampaign identifier */ + id: string; + body: { + /** Screen ULID */ + screen?: string; + }[]; +}; +export type DeleteV2ScreensByIdCampaignsAndCampaignIdApiResponse = unknown; +export type DeleteV2ScreensByIdCampaignsAndCampaignIdApiArg = { + id: string; + campaignId: string; +}; +export type GetV2ScreensByIdRegionsAndRegionIdPlaylistsApiResponse = + /** status 200 PlaylistScreenRegion collection */ { + "hydra:member": PlaylistScreenRegionJsonldPlaylistScreenRegionReadRead[]; + "hydra:totalItems"?: number; + "hydra:view"?: { + "@id"?: string; + "@type"?: string; + "hydra:first"?: string; + "hydra:last"?: string; + "hydra:previous"?: string; + "hydra:next"?: string; + }; + "hydra:search"?: { + "@type"?: string; + "hydra:template"?: string; + "hydra:variableRepresentation"?: string; + "hydra:mapping"?: { + "@type"?: string; + variable?: string; + property?: string | null; + required?: boolean; + }[]; + }; + }; +export type GetV2ScreensByIdRegionsAndRegionIdPlaylistsApiArg = { + id: string; + regionId: string; + page: number; + /** The number of items per page */ + itemsPerPage?: string; + /** If true only entities that are shared with me will be shown */ + sharedWithMe?: boolean; +}; +export type PutPlaylistScreenRegionItemApiResponse = unknown; +export type PutPlaylistScreenRegionItemApiArg = { + id: string; + regionId: string; + body: { + /** Playlist ULID */ + playlist?: string; + weight?: number; + }[]; +}; +export type DeletePlaylistScreenRegionItemApiResponse = unknown; +export type DeletePlaylistScreenRegionItemApiArg = { + id: string; + regionId: string; + playlistId: string; +}; +export type GetV2ScreensByIdScreenGroupsApiResponse = + /** status 200 ScreenGroup collection */ { + "hydra:member": ScreenGroupScreenGroupJsonldScreensScreenGroupsReadRead[]; + "hydra:totalItems"?: number; + "hydra:view"?: { + "@id"?: string; + "@type"?: string; + "hydra:first"?: string; + "hydra:last"?: string; + "hydra:previous"?: string; + "hydra:next"?: string; + }; + "hydra:search"?: { + "@type"?: string; + "hydra:template"?: string; + "hydra:variableRepresentation"?: string; + "hydra:mapping"?: { + "@type"?: string; + variable?: string; + property?: string | null; + required?: boolean; + }[]; + }; + }; +export type GetV2ScreensByIdScreenGroupsApiArg = { + id: string; + page: number; + /** The number of items per page */ + itemsPerPage?: string; + order?: { + title?: "asc" | "desc"; + description?: "asc" | "desc"; + }; +}; +export type PutV2ScreensByIdScreenGroupsApiResponse = /** status 200 OK */ Blob; +export type PutV2ScreensByIdScreenGroupsApiArg = { + id: string; + body: string[]; +}; +export type DeleteV2ScreensByIdScreenGroupsAndScreenGroupIdApiResponse = + unknown; +export type DeleteV2ScreensByIdScreenGroupsAndScreenGroupIdApiArg = { + id: string; + screenGroupId: string; +}; +export type PostScreenUnbindApiResponse = unknown; +export type PostScreenUnbindApiArg = { + /** The screen id */ + id: string; + /** Unbind from machine */ + body: string; +}; +export type GetV2SlidesApiResponse = /** status 200 OK */ Blob; +export type GetV2SlidesApiArg = { + page?: number; + /** The number of items per page */ + itemsPerPage?: string; + title?: string; + description?: string; + createdBy?: string; + modifiedBy?: string; + /** If true only published content will be shown */ + published?: boolean; + order?: { + title?: "asc" | "desc"; + description?: "asc" | "desc"; + createdAt?: "asc" | "desc"; + modifiedAt?: "asc" | "desc"; + }; +}; +export type PostV2SlidesApiResponse = + /** status 201 Slide resource created */ SlideSlideJsonldRead; +export type PostV2SlidesApiArg = { + /** The new Slide resource */ + slideSlideInputJsonld: SlideSlideInputJsonld; +}; +export type GetV2SlidesByIdApiResponse = /** status 200 OK */ Blob; +export type GetV2SlidesByIdApiArg = { + id: string; +}; +export type PutV2SlidesByIdApiResponse = + /** status 200 Slide resource updated */ SlideSlideJsonldRead; +export type PutV2SlidesByIdApiArg = { + id: string; + /** The updated Slide resource */ + slideSlideInputJsonld: SlideSlideInputJsonld; +}; +export type DeleteV2SlidesByIdApiResponse = unknown; +export type DeleteV2SlidesByIdApiArg = { + id: string; +}; +export type ApiSlidePerformActionApiResponse = + /** status 201 Slide resource created */ SlideSlideJsonldRead; +export type ApiSlidePerformActionApiArg = { + id: string; + /** The new Slide resource */ + slideInteractiveSlideActionInputJsonld: SlideInteractiveSlideActionInputJsonld; +}; +export type GetV2TemplatesApiResponse = /** status 200 OK */ Blob; +export type GetV2TemplatesApiArg = { + page?: number; + /** The number of items per page */ + itemsPerPage?: string; + title?: string; + createdBy?: string; + modifiedBy?: string; + order?: { + createdAt?: "asc" | "desc"; + modifiedAt?: "asc" | "desc"; + }; +}; +export type GetV2TemplatesByIdApiResponse = /** status 200 OK */ Blob; +export type GetV2TemplatesByIdApiArg = { + id: string; +}; +export type GetV2TenantsApiResponse = /** status 200 OK */ Blob; +export type GetV2TenantsApiArg = { + page?: number; + /** The number of items per page */ + itemsPerPage?: string; + title?: string; + description?: string; + createdBy?: string; + modifiedBy?: string; +}; +export type GetV2TenantsByIdApiResponse = /** status 200 OK */ Blob; +export type GetV2TenantsByIdApiArg = { + id: string; +}; +export type GetV2ThemesApiResponse = /** status 200 OK */ Blob; +export type GetV2ThemesApiArg = { + page?: number; + /** The number of items per page */ + itemsPerPage?: string; + title?: string; + description?: string; + createdBy?: string; + modifiedBy?: string; + order?: { + title?: "asc" | "desc"; + description?: "asc" | "desc"; + createdAt?: "asc" | "desc"; + modifiedAt?: "asc" | "desc"; + }; +}; +export type PostV2ThemesApiResponse = + /** status 201 Theme resource created */ ThemeThemeJsonldRead; +export type PostV2ThemesApiArg = { + /** The new Theme resource */ + themeThemeInputJsonld: ThemeThemeInputJsonld; +}; +export type GetV2ThemesByIdApiResponse = /** status 200 OK */ Blob; +export type GetV2ThemesByIdApiArg = { + id: string; +}; +export type PutV2ThemesByIdApiResponse = + /** status 200 Theme resource updated */ ThemeThemeJsonldRead; +export type PutV2ThemesByIdApiArg = { + id: string; + /** The updated Theme resource */ + themeThemeInputJsonld: ThemeThemeInputJsonld; +}; +export type DeleteV2ThemesByIdApiResponse = unknown; +export type DeleteV2ThemesByIdApiArg = { + id: string; +}; +export type GetV2UsersApiResponse = /** status 200 OK */ Blob; +export type GetV2UsersApiArg = { + page?: number; + /** The number of items per page */ + itemsPerPage?: string; + fullName?: string; + email?: string; + createdBy?: string; + modifiedBy?: string; + order?: { + createdAt?: "asc" | "desc"; + }; +}; +export type PostV2UsersApiResponse = + /** status 201 User resource created */ UserUserJsonldRead; +export type PostV2UsersApiArg = { + id: string; + /** The new User resource */ + userUserInputJsonld: UserUserInputJsonld; +}; +export type GetV2UsersByIdApiResponse = /** status 200 OK */ Blob; +export type GetV2UsersByIdApiArg = { + id: string; +}; +export type PutV2UsersByIdApiResponse = + /** status 200 User resource updated */ UserUserJsonldRead; +export type PutV2UsersByIdApiArg = { + id: string; + /** The updated User resource */ + userUserInputJsonld: UserUserInputJsonld; +}; +export type DeleteV2UsersByIdApiResponse = unknown; +export type DeleteV2UsersByIdApiArg = { + id: string; +}; +export type DeleteV2UsersByIdRemoveFromTenantApiResponse = unknown; +export type DeleteV2UsersByIdRemoveFromTenantApiArg = { + id: string; +}; +export type GetV2UserActivationCodesApiResponse = + /** status 200 UserActivationCode collection */ { + "hydra:member": UserActivationCodeUserActivationCodeJsonldRead[]; + "hydra:totalItems"?: number; + "hydra:view"?: { + "@id"?: string; + "@type"?: string; + "hydra:first"?: string; + "hydra:last"?: string; + "hydra:previous"?: string; + "hydra:next"?: string; + }; + "hydra:search"?: { + "@type"?: string; + "hydra:template"?: string; + "hydra:variableRepresentation"?: string; + "hydra:mapping"?: { + "@type"?: string; + variable?: string; + property?: string | null; + required?: boolean; + }[]; + }; + }; +export type GetV2UserActivationCodesApiArg = { + /** The collection page number */ + page?: number; + /** The number of items per page */ + itemsPerPage?: number; +}; +export type PostV2UserActivationCodesApiResponse = + /** status 201 UserActivationCode resource created */ UserActivationCodeUserActivationCodeJsonldRead; +export type PostV2UserActivationCodesApiArg = { + /** The new UserActivationCode resource */ + userActivationCodeUserActivationCodeInputJsonld: UserActivationCodeUserActivationCodeInputJsonld; +}; +export type PostV2UserActivationCodesActivateApiResponse = + /** status 201 UserActivationCode resource created */ UserActivationCodeUserActivationCodeJsonldRead; +export type PostV2UserActivationCodesActivateApiArg = { + /** The new UserActivationCode resource */ + userActivationCodeActivationCodeJsonld: UserActivationCodeActivationCodeJsonld; +}; +export type PostV2UserActivationCodesRefreshApiResponse = + /** status 201 UserActivationCode resource created */ UserActivationCodeUserActivationCodeJsonldRead; +export type PostV2UserActivationCodesRefreshApiArg = { + /** The new UserActivationCode resource */ + userActivationCodeActivationCodeJsonld: UserActivationCodeActivationCodeJsonld; +}; +export type GetV2UserActivationCodesByIdApiResponse = + /** status 200 UserActivationCode resource */ UserActivationCodeJsonldRead; +export type GetV2UserActivationCodesByIdApiArg = { + /** UserActivationCode identifier */ + id: string; +}; +export type DeleteV2UserActivationCodesByIdApiResponse = unknown; +export type DeleteV2UserActivationCodesByIdApiArg = { + /** UserActivationCode identifier */ + id: string; +}; +export type Token = {}; +export type TokenRead = { + token?: string; + refresh_token?: string; + refresh_token_expiration?: any; + tenants?: { + tenantKey?: string; + title?: string; + description?: string; + roles?: string[]; + }[]; + user?: { + fullname?: string; + email?: string; + }; +}; +export type OidcEndpoints = { + authorizationUrl?: string; + endSessionUrl?: string; +}; +export type ScreenLoginOutput = {}; +export type ScreenLoginOutputRead = { + bindKey?: string; + token?: string; +}; +export type ScreenLoginInput = object; +export type RefreshTokenResponse = {}; +export type RefreshTokenResponseRead = { + token?: string; + refresh_token?: string; +}; +export type RefreshTokenRequest = { + refresh_token?: string; +}; +export type FeedSourceFeedSourceJsonld = { + title?: string; + description?: string; + outputType?: string; + feedType?: string; + secrets?: string[]; + feeds?: string[]; + admin?: string[]; + supportedFeedOutputType?: string; + modifiedBy?: string; + createdBy?: string; + id?: string; + created?: string; + modified?: string; +}; +export type FeedSourceFeedSourceJsonldRead = { + "@context"?: + | string + | { + "@vocab": string; + hydra: "http://www.w3.org/ns/hydra/core#"; + [key: string]: any; + }; + "@id"?: string; + "@type"?: string; + title?: string; + description?: string; + outputType?: string; + feedType?: string; + secrets?: string[]; + feeds?: string[]; + admin?: string[]; + supportedFeedOutputType?: string; + modifiedBy?: string; + createdBy?: string; + id?: string; + created?: string; + modified?: string; +}; +export type FeedSourceFeedSourceInputJsonld = { + title?: string; + description?: string; + outputType?: string; + feedType?: string; + secrets?: string[]; + feeds?: string[]; + supportedFeedOutputType?: string; +}; +export type CollectionJsonld = {}; +export type CollectionJsonldRead = { + "@context"?: + | string + | { + "@vocab": string; + hydra: "http://www.w3.org/ns/hydra/core#"; + [key: string]: any; + }; + "@id"?: string; + "@type"?: string; + /** Checks whether the collection is empty (contains no elements). */ + empty?: boolean; + /** Gets all keys/indices of the collection. */ + keys?: number[] | string[]; + /** Gets all values of the collection. */ + values?: string[]; + iterator?: any; +}; +export type MediaMediaJsonld = { + title?: string; + description?: string; + license?: string; + media?: CollectionJsonld; + assets?: string[]; + thumbnail?: string | null; + modifiedBy?: string; + createdBy?: string; + id?: string; + created?: string; + modified?: string; +}; +export type MediaMediaJsonldRead = { + "@context"?: + | string + | { + "@vocab": string; + hydra: "http://www.w3.org/ns/hydra/core#"; + [key: string]: any; + }; + "@id"?: string; + "@type"?: string; + title?: string; + description?: string; + license?: string; + media?: CollectionJsonldRead; + assets?: string[]; + thumbnail?: string | null; + modifiedBy?: string; + createdBy?: string; + id?: string; + created?: string; + modified?: string; +}; +export type CollectionJsonldCampaignsScreenGroupsRead = {}; +export type CollectionJsonldCampaignsScreenGroupsReadRead = { + "@context"?: + | string + | { + "@vocab": string; + hydra: "http://www.w3.org/ns/hydra/core#"; + [key: string]: any; + }; + "@id"?: string; + "@type"?: string; +}; +export type PlaylistJsonldCampaignsScreenGroupsRead = { + title?: string; + description?: string; + schedules?: string[] | null; + slides?: string; + campaignScreens?: CollectionJsonldCampaignsScreenGroupsRead | null; + campaignScreenGroups?: CollectionJsonldCampaignsScreenGroupsRead | null; + tenants?: CollectionJsonldCampaignsScreenGroupsRead | null; + isCampaign?: boolean; + slidesLength?: number | null; + published?: string[]; + relationsChecksum?: object; +}; +export type PlaylistJsonldCampaignsScreenGroupsReadRead = { + "@context"?: + | string + | { + "@vocab": string; + hydra: "http://www.w3.org/ns/hydra/core#"; + [key: string]: any; + }; + "@id"?: string; + "@type"?: string; + title?: string; + description?: string; + schedules?: string[] | null; + slides?: string; + campaignScreens?: CollectionJsonldCampaignsScreenGroupsReadRead | null; + campaignScreenGroups?: CollectionJsonldCampaignsScreenGroupsReadRead | null; + tenants?: CollectionJsonldCampaignsScreenGroupsReadRead | null; + isCampaign?: boolean; + slidesLength?: number | null; + published?: string[]; + relationsChecksum?: object; +}; +export type ScreenGroupJsonldCampaignsScreenGroupsRead = { + title?: string; + description?: string; + campaigns?: string; + screens?: string; + screensLength?: number | null; + campaignsLength?: number | null; + relationsChecksum?: object; +}; +export type ScreenGroupJsonldCampaignsScreenGroupsReadRead = { + "@context"?: + | string + | { + "@vocab": string; + hydra: "http://www.w3.org/ns/hydra/core#"; + [key: string]: any; + }; + "@id"?: string; + "@type"?: string; + title?: string; + description?: string; + campaigns?: string; + screens?: string; + screensLength?: number | null; + campaignsLength?: number | null; + relationsChecksum?: object; +}; +export type ScreenGroupCampaignJsonldCampaignsScreenGroupsRead = { + campaign?: PlaylistJsonldCampaignsScreenGroupsRead; + screenGroup?: ScreenGroupJsonldCampaignsScreenGroupsRead; + relationsChecksum?: object; +}; +export type ScreenGroupCampaignJsonldCampaignsScreenGroupsReadRead = { + "@id"?: string; + "@type"?: string; + campaign?: PlaylistJsonldCampaignsScreenGroupsReadRead; + screenGroup?: ScreenGroupJsonldCampaignsScreenGroupsReadRead; + relationsChecksum?: object; +}; +export type CollectionJsonldCampaignsScreensRead = {}; +export type CollectionJsonldCampaignsScreensReadRead = { + "@context"?: + | string + | { + "@vocab": string; + hydra: "http://www.w3.org/ns/hydra/core#"; + [key: string]: any; + }; + "@id"?: string; + "@type"?: string; +}; +export type PlaylistJsonldCampaignsScreensRead = { + title?: string; + description?: string; + schedules?: string[] | null; + slides?: string; + campaignScreens?: CollectionJsonldCampaignsScreensRead | null; + campaignScreenGroups?: CollectionJsonldCampaignsScreensRead | null; + tenants?: CollectionJsonldCampaignsScreensRead | null; + isCampaign?: boolean; + slidesLength?: number | null; + published?: string[]; + relationsChecksum?: object; +}; +export type PlaylistJsonldCampaignsScreensReadRead = { + "@context"?: + | string + | { + "@vocab": string; + hydra: "http://www.w3.org/ns/hydra/core#"; + [key: string]: any; + }; + "@id"?: string; + "@type"?: string; + title?: string; + description?: string; + schedules?: string[] | null; + slides?: string; + campaignScreens?: CollectionJsonldCampaignsScreensReadRead | null; + campaignScreenGroups?: CollectionJsonldCampaignsScreensReadRead | null; + tenants?: CollectionJsonldCampaignsScreensReadRead | null; + isCampaign?: boolean; + slidesLength?: number | null; + published?: string[]; + relationsChecksum?: object; +}; +export type ScreenJsonldCampaignsScreensRead = { + title?: string; + description?: string; + size?: string; + campaigns?: string; + layout?: string; + orientation?: string; + resolution?: string; + location?: string; + regions?: string[]; + inScreenGroups?: string; + screenUser?: string | null; + enableColorSchemeChange?: boolean | null; + status?: string[] | null; + activeCampaignsLength?: number | null; + campaignsLength?: number | null; + inScreenGroupsLength?: number | null; + relationsChecksum?: object; +}; +export type ScreenJsonldCampaignsScreensReadRead = { + "@context"?: + | string + | { + "@vocab": string; + hydra: "http://www.w3.org/ns/hydra/core#"; + [key: string]: any; + }; + "@id"?: string; + "@type"?: string; + title?: string; + description?: string; + size?: string; + campaigns?: string; + layout?: string; + orientation?: string; + resolution?: string; + location?: string; + regions?: string[]; + inScreenGroups?: string; + screenUser?: string | null; + enableColorSchemeChange?: boolean | null; + status?: string[] | null; + activeCampaignsLength?: number | null; + campaignsLength?: number | null; + inScreenGroupsLength?: number | null; + relationsChecksum?: object; +}; +export type ScreenCampaignJsonldCampaignsScreensRead = { + campaign?: PlaylistJsonldCampaignsScreensRead; + screen?: ScreenJsonldCampaignsScreensRead; + relationsChecksum?: object; +}; +export type ScreenCampaignJsonldCampaignsScreensReadRead = { + "@id"?: string; + "@type"?: string; + campaign?: PlaylistJsonldCampaignsScreensReadRead; + screen?: ScreenJsonldCampaignsScreensReadRead; + relationsChecksum?: object; +}; +export type PlaylistPlaylistJsonld = { + title?: string; + description?: string; + schedules?: string[] | null; + slides?: string; + campaignScreens?: CollectionJsonld | null; + campaignScreenGroups?: CollectionJsonld | null; + tenants?: CollectionJsonld | null; + isCampaign?: boolean; + slidesLength?: number | null; + published?: string[]; + modifiedBy?: string; + createdBy?: string; + id?: string; + created?: string; + modified?: string; + relationsChecksum?: object; +}; +export type PlaylistPlaylistJsonldRead = { + "@context"?: + | string + | { + "@vocab": string; + hydra: "http://www.w3.org/ns/hydra/core#"; + [key: string]: any; + }; + "@id"?: string; + "@type"?: string; + title?: string; + description?: string; + schedules?: string[] | null; + slides?: string; + campaignScreens?: CollectionJsonldRead | null; + campaignScreenGroups?: CollectionJsonldRead | null; + tenants?: CollectionJsonldRead | null; + isCampaign?: boolean; + slidesLength?: number | null; + published?: string[]; + modifiedBy?: string; + createdBy?: string; + id?: string; + created?: string; + modified?: string; + relationsChecksum?: object; +}; +export type PlaylistPlaylistInputJsonld = { + title?: string; + description?: string; + schedules?: string[]; + tenants?: string[]; + isCampaign?: boolean; + published?: string[]; +}; +export type PlaylistSlidePlaylistSlideJsonld = { + slide?: string; + playlist?: string; + weight?: number; + id?: string; + relationsChecksum?: object; +}; +export type PlaylistSlidePlaylistSlideJsonldRead = { + "@context"?: + | string + | { + "@vocab": string; + hydra: "http://www.w3.org/ns/hydra/core#"; + [key: string]: any; + }; + "@id"?: string; + "@type"?: string; + slide?: string; + playlist?: string; + weight?: number; + id?: string; + relationsChecksum?: object; +}; +export type ScreenGroupCampaignJsonld = { + campaign?: string; + screenGroup?: string; + id?: string; + relationsChecksum?: object; +}; +export type ScreenGroupCampaignJsonldRead = { + "@context"?: + | string + | { + "@vocab": string; + hydra: "http://www.w3.org/ns/hydra/core#"; + [key: string]: any; + }; + "@id"?: string; + "@type"?: string; + campaign?: string; + screenGroup?: string; + id?: string; + relationsChecksum?: object; +}; +export type ScreenGroupScreenGroupJsonld = { + title?: string; + description?: string; + campaigns?: string; + screens?: string; + screensLength?: number | null; + campaignsLength?: number | null; + modifiedBy?: string; + createdBy?: string; + id?: string; + created?: string; + modified?: string; + relationsChecksum?: object; +}; +export type ScreenGroupScreenGroupJsonldRead = { + "@context"?: + | string + | { + "@vocab": string; + hydra: "http://www.w3.org/ns/hydra/core#"; + [key: string]: any; + }; + "@id"?: string; + "@type"?: string; + title?: string; + description?: string; + campaigns?: string; + screens?: string; + screensLength?: number | null; + campaignsLength?: number | null; + modifiedBy?: string; + createdBy?: string; + id?: string; + created?: string; + modified?: string; + relationsChecksum?: object; +}; +export type ScreenGroupScreenGroupInputJsonld = { + title?: string; + description?: string; +}; +export type ScreenGroupJsonldScreenGroupsScreensRead = { + relationsChecksum?: object; +}; +export type ScreenGroupJsonldScreenGroupsScreensReadRead = { + "@id"?: string; + "@type"?: string; + relationsChecksum?: object; +}; +export type ScreenScreenJsonld = { + title?: string; + description?: string; + size?: string; + campaigns?: string; + layout?: string; + orientation?: string; + resolution?: string; + location?: string; + regions?: string[]; + inScreenGroups?: string; + screenUser?: string | null; + enableColorSchemeChange?: boolean | null; + status?: string[] | null; + activeCampaignsLength?: number | null; + campaignsLength?: number | null; + inScreenGroupsLength?: number | null; + modifiedBy?: string; + createdBy?: string; + id?: string; + created?: string; + modified?: string; + relationsChecksum?: object; +}; +export type ScreenScreenJsonldRead = { + "@context"?: + | string + | { + "@vocab": string; + hydra: "http://www.w3.org/ns/hydra/core#"; + [key: string]: any; + }; + "@id"?: string; + "@type"?: string; + title?: string; + description?: string; + size?: string; + campaigns?: string; + layout?: string; + orientation?: string; + resolution?: string; + location?: string; + regions?: string[]; + inScreenGroups?: string; + screenUser?: string | null; + enableColorSchemeChange?: boolean | null; + status?: string[] | null; + activeCampaignsLength?: number | null; + campaignsLength?: number | null; + inScreenGroupsLength?: number | null; + modifiedBy?: string; + createdBy?: string; + id?: string; + created?: string; + modified?: string; + relationsChecksum?: object; +}; +export type ScreenScreenInputJsonld = { + title?: string; + description?: string; + size?: string; + layout?: string; + location?: string; + resolution?: string; + orientation?: string; + enableColorSchemeChange?: boolean | null; + regions?: string[] | null; + groups?: string[] | null; +}; +export type ScreenBindObject = { + bindKey?: string; +}; +export type CollectionJsonldPlaylistScreenRegionRead = {}; +export type CollectionJsonldPlaylistScreenRegionReadRead = { + "@context"?: + | string + | { + "@vocab": string; + hydra: "http://www.w3.org/ns/hydra/core#"; + [key: string]: any; + }; + "@id"?: string; + "@type"?: string; +}; +export type PlaylistJsonldPlaylistScreenRegionRead = { + title?: string; + description?: string; + schedules?: string[] | null; + slides?: string; + campaignScreens?: CollectionJsonldPlaylistScreenRegionRead | null; + campaignScreenGroups?: CollectionJsonldPlaylistScreenRegionRead | null; + tenants?: CollectionJsonldPlaylistScreenRegionRead | null; + isCampaign?: boolean; + slidesLength?: number | null; + published?: string[]; + relationsChecksum?: object; +}; +export type PlaylistJsonldPlaylistScreenRegionReadRead = { + "@context"?: + | string + | { + "@vocab": string; + hydra: "http://www.w3.org/ns/hydra/core#"; + [key: string]: any; + }; + "@id"?: string; + "@type"?: string; + title?: string; + description?: string; + schedules?: string[] | null; + slides?: string; + campaignScreens?: CollectionJsonldPlaylistScreenRegionReadRead | null; + campaignScreenGroups?: CollectionJsonldPlaylistScreenRegionReadRead | null; + tenants?: CollectionJsonldPlaylistScreenRegionReadRead | null; + isCampaign?: boolean; + slidesLength?: number | null; + published?: string[]; + relationsChecksum?: object; +}; +export type PlaylistScreenRegionJsonldPlaylistScreenRegionRead = { + playlist?: PlaylistJsonldPlaylistScreenRegionRead; + weight?: number; + relationsChecksum?: object; +}; +export type PlaylistScreenRegionJsonldPlaylistScreenRegionReadRead = { + "@id"?: string; + "@type"?: string; + playlist?: PlaylistJsonldPlaylistScreenRegionReadRead; + weight?: number; + relationsChecksum?: object; +}; +export type ScreenGroupScreenGroupJsonldScreensScreenGroupsRead = { + title?: string; + description?: string; + campaigns?: string; + screens?: string; + screensLength?: number | null; + campaignsLength?: number | null; + relationsChecksum?: object; +}; +export type ScreenGroupScreenGroupJsonldScreensScreenGroupsReadRead = { + "@id"?: string; + "@type"?: string; + title?: string; + description?: string; + campaigns?: string; + screens?: string; + screensLength?: number | null; + campaignsLength?: number | null; + relationsChecksum?: object; +}; +export type SlideSlideJsonld = { + title?: string; + description?: string; + templateInfo?: string[]; + theme?: string; + onPlaylists?: CollectionJsonld; + duration?: number | null; + published?: string[]; + media?: CollectionJsonld; + content?: string[]; + feed?: string[] | null; + modifiedBy?: string; + createdBy?: string; + id?: string; + created?: string; + modified?: string; + relationsChecksum?: object; +}; +export type SlideSlideJsonldRead = { + "@context"?: + | string + | { + "@vocab": string; + hydra: "http://www.w3.org/ns/hydra/core#"; + [key: string]: any; + }; + "@id"?: string; + "@type"?: string; + title?: string; + description?: string; + templateInfo?: string[]; + theme?: string; + onPlaylists?: CollectionJsonldRead; + duration?: number | null; + published?: string[]; + media?: CollectionJsonldRead; + content?: string[]; + feed?: string[] | null; + modifiedBy?: string; + createdBy?: string; + id?: string; + created?: string; + modified?: string; + relationsChecksum?: object; +}; +export type SlideSlideInputJsonld = { + title?: string; + description?: string; + templateInfo?: string[]; + theme?: string; + duration?: number | null; + published?: string[]; + feed?: string[] | null; + media?: string[]; + content?: string[]; +}; +export type SlideInteractiveSlideActionInputJsonld = { + action?: string | null; + data?: string[]; +}; +export type ThemeThemeJsonld = { + title?: string; + description?: string; + logo?: string | null; + cssStyles?: string; + modifiedBy?: string; + createdBy?: string; + id?: string; + created?: string; + modified?: string; +}; +export type ThemeThemeJsonldRead = { + "@context"?: + | string + | { + "@vocab": string; + hydra: "http://www.w3.org/ns/hydra/core#"; + [key: string]: any; + }; + "@id"?: string; + "@type"?: string; + title?: string; + description?: string; + logo?: string | null; + cssStyles?: string; + modifiedBy?: string; + createdBy?: string; + id?: string; + created?: string; + modified?: string; +}; +export type ThemeThemeInputJsonld = { + title?: string; + description?: string; + logo?: string; + css?: string; +}; +export type UserUserJsonld = { + fullName?: string | null; + userType?: + | ("OIDC_EXTERNAL" | "OIDC_INTERNAL" | "USERNAME_PASSWORD" | null) + | ("OIDC_EXTERNAL" | "OIDC_INTERNAL" | "USERNAME_PASSWORD" | null); + roles?: string[]; + createdAt?: string; + id?: string; +}; +export type UserUserJsonldRead = { + "@context"?: + | string + | { + "@vocab": string; + hydra: "http://www.w3.org/ns/hydra/core#"; + [key: string]: any; + }; + "@id"?: string; + "@type"?: string; + fullName?: string | null; + userType?: + | ("OIDC_EXTERNAL" | "OIDC_INTERNAL" | "USERNAME_PASSWORD" | null) + | ("OIDC_EXTERNAL" | "OIDC_INTERNAL" | "USERNAME_PASSWORD" | null); + roles?: string[]; + createdAt?: string; + id?: string; +}; +export type UserUserInputJsonld = { + fullName?: string | null; +}; +export type UserActivationCodeUserActivationCodeJsonld = { + code?: string | null; + codeExpire?: string | null; + username?: string | null; + roles?: string[] | null; + modifiedBy?: string; + createdBy?: string; + id?: string; + created?: string; + modified?: string; +}; +export type UserActivationCodeUserActivationCodeJsonldRead = { + "@context"?: + | string + | { + "@vocab": string; + hydra: "http://www.w3.org/ns/hydra/core#"; + [key: string]: any; + }; + "@id"?: string; + "@type"?: string; + code?: string | null; + codeExpire?: string | null; + username?: string | null; + roles?: string[] | null; + modifiedBy?: string; + createdBy?: string; + id?: string; + created?: string; + modified?: string; +}; +export type UserActivationCodeUserActivationCodeInputJsonld = { + displayName?: string; + roles?: string[]; +}; +export type UserActivationCodeActivationCodeJsonld = { + activationCode?: string; +}; +export type UserActivationCodeJsonld = { + code?: string | null; + codeExpire?: string | null; + username?: string | null; + roles?: string[] | null; + modifiedBy?: string; + createdBy?: string; + id?: string; + created?: string; + modified?: string; +}; +export type UserActivationCodeJsonldRead = { + "@context"?: + | string + | { + "@vocab": string; + hydra: "http://www.w3.org/ns/hydra/core#"; + [key: string]: any; + }; + "@id"?: string; + "@type"?: string; + code?: string | null; + codeExpire?: string | null; + username?: string | null; + roles?: string[] | null; + modifiedBy?: string; + createdBy?: string; + id?: string; + created?: string; + modified?: string; +}; +export const { + useGetOidcAuthTokenItemQuery, + useGetOidcAuthUrlsItemQuery, + usePostLoginInfoScreenMutation, + usePostRefreshTokenItemMutation, + useGetV2FeedSourcesQuery, + usePostV2FeedSourcesMutation, + useGetV2FeedSourcesByIdQuery, + usePutV2FeedSourcesByIdMutation, + useDeleteV2FeedSourcesByIdMutation, + useGetV2FeedSourcesByIdConfigAndNameQuery, + useGetV2FeedSourcesByIdSlidesQuery, + useGetV2FeedsQuery, + useGetV2FeedsByIdQuery, + useGetV2FeedsByIdDataQuery, + useGetV2LayoutsQuery, + useGetV2LayoutsByIdQuery, + useLoginCheckPostMutation, + useGetV2MediaQuery, + usePostMediaCollectionMutation, + useGetV2MediaByIdQuery, + useDeleteV2MediaByIdMutation, + useGetV2CampaignsByIdScreenGroupsQuery, + useGetV2CampaignsByIdScreensQuery, + useGetV2PlaylistsQuery, + usePostV2PlaylistsMutation, + useGetV2PlaylistsByIdQuery, + usePutV2PlaylistsByIdMutation, + useDeleteV2PlaylistsByIdMutation, + useGetV2PlaylistsByIdSlidesQuery, + usePutV2PlaylistsByIdSlidesMutation, + useDeleteV2PlaylistsByIdSlidesAndSlideIdMutation, + useGetV2SlidesByIdPlaylistsQuery, + usePutV2SlidesByIdPlaylistsMutation, + useGetScreenGroupCampaignItemQuery, + useGetV2ScreenGroupsQuery, + usePostV2ScreenGroupsMutation, + useGetV2ScreenGroupsByIdQuery, + usePutV2ScreenGroupsByIdMutation, + useDeleteV2ScreenGroupsByIdMutation, + useGetV2ScreenGroupsByIdCampaignsQuery, + usePutV2ScreenGroupsByIdCampaignsMutation, + useDeleteV2ScreenGroupsByIdCampaignsAndCampaignIdMutation, + useGetV2ScreenGroupsByIdScreensQuery, + useGetV2ScreensQuery, + usePostV2ScreensMutation, + useGetV2ScreensByIdQuery, + usePutV2ScreensByIdMutation, + useDeleteV2ScreensByIdMutation, + usePostScreenBindKeyMutation, + useGetV2ScreensByIdCampaignsQuery, + usePutV2ScreensByIdCampaignsMutation, + useDeleteV2ScreensByIdCampaignsAndCampaignIdMutation, + useGetV2ScreensByIdRegionsAndRegionIdPlaylistsQuery, + usePutPlaylistScreenRegionItemMutation, + useDeletePlaylistScreenRegionItemMutation, + useGetV2ScreensByIdScreenGroupsQuery, + usePutV2ScreensByIdScreenGroupsMutation, + useDeleteV2ScreensByIdScreenGroupsAndScreenGroupIdMutation, + usePostScreenUnbindMutation, + useGetV2SlidesQuery, + usePostV2SlidesMutation, + useGetV2SlidesByIdQuery, + usePutV2SlidesByIdMutation, + useDeleteV2SlidesByIdMutation, + useApiSlidePerformActionMutation, + useGetV2TemplatesQuery, + useGetV2TemplatesByIdQuery, + useGetV2TenantsQuery, + useGetV2TenantsByIdQuery, + useGetV2ThemesQuery, + usePostV2ThemesMutation, + useGetV2ThemesByIdQuery, + usePutV2ThemesByIdMutation, + useDeleteV2ThemesByIdMutation, + useGetV2UsersQuery, + usePostV2UsersMutation, + useGetV2UsersByIdQuery, + usePutV2UsersByIdMutation, + useDeleteV2UsersByIdMutation, + useDeleteV2UsersByIdRemoveFromTenantMutation, + useGetV2UserActivationCodesQuery, + usePostV2UserActivationCodesMutation, + usePostV2UserActivationCodesActivateMutation, + usePostV2UserActivationCodesRefreshMutation, + useGetV2UserActivationCodesByIdQuery, + useDeleteV2UserActivationCodesByIdMutation, +} = injectedRtkApi; diff --git a/assets/client/redux/openapi-config.js b/assets/client/redux/openapi-config.js new file mode 100644 index 000000000..000b16335 --- /dev/null +++ b/assets/client/redux/openapi-config.js @@ -0,0 +1,25 @@ +const config = { + schemaFile: "../../../public/api-spec-v2.json", + apiFile: "./empty-api.ts", + apiImport: "clientEmptySplitApi", + outputFile: "./generated-api.ts", + exportName: "clientApi", + hooks: true, + tag: true, + endpointOverrides: [ + { + pattern: /.*/, + parameterFilter: (_name, parameter) => { + // Filter out parameters from OpenAPI specification that results in + // invalid javascript with duplicate query parameters. + return !( + ["createdBy", "modifiedBy", "supportedFeedOutputType"].includes( + _name, + ) && parameter.style === "deepObject" + ); + }, + }, + ], +}; + +export default config; diff --git a/assets/client/redux/reauthenticate-ref.js b/assets/client/redux/reauthenticate-ref.js new file mode 100644 index 000000000..5873130f9 --- /dev/null +++ b/assets/client/redux/reauthenticate-ref.js @@ -0,0 +1,8 @@ +/** + * Mutable ref for the reauthenticate callback. + * Set by App component, called by base-query when a 401 is received. + * This bridges the Redux middleware layer (outside React) with the React tree. + */ +const reauthenticateRef = { current: () => {} }; + +export default reauthenticateRef; diff --git a/assets/client/redux/store.js b/assets/client/redux/store.js new file mode 100644 index 000000000..738c1b5a6 --- /dev/null +++ b/assets/client/redux/store.js @@ -0,0 +1,11 @@ +import { configureStore } from "@reduxjs/toolkit"; +import { clientApi } from "./enhanced-api.ts"; + +/* eslint-disable-next-line import/prefer-default-export */ +export const clientStore = configureStore({ + reducer: { + [clientApi.reducerPath]: clientApi.reducer, + }, + middleware: (getDefaultMiddleware) => + getDefaultMiddleware().concat(clientApi.middleware), +}); diff --git a/assets/client/service/content-service.js b/assets/client/service/content-service.js index 512f46e6a..765810266 100644 --- a/assets/client/service/content-service.js +++ b/assets/client/service/content-service.js @@ -1,14 +1,15 @@ import sha256 from "crypto-js/sha256"; import Base64 from "crypto-js/enc-base64"; -import PullStrategy from "../data-sync/pull-strategy"; import { screenForPlaylistPreview, screenForSlidePreview, } from "../util/preview"; -import logger from "../logger/logger"; -import DataSync from "../data-sync/data-sync"; +import logger from "../core/logger.js"; +import idFromPath from "../util/id-from-path"; +import DataSync from "./data-sync"; import ScheduleService from "./schedule-service"; -import ClientConfigLoader from "../util/client-config-loader.js"; +import ClientConfigLoader from "../core/client-config-loader.js"; +import { query } from "../core/api-query.js"; /** * ContentService. @@ -26,18 +27,23 @@ class ContentService { /** * Constructor. + * + * @param {object} callbacks - Ref object whose .current holds callback functions + * (setScreen, setIsContentEmpty, updateRegionSlides, onRegionReady, onRegionRemoved). */ - constructor() { + constructor(callbacks) { + this.callbacks = callbacks; + // Setup schedule service. - this.scheduleService = new ScheduleService(); + this.scheduleService = new ScheduleService(callbacks); this.startSyncing = this.startSyncing.bind(this); - this.stopSyncHandler = this.stopSyncHandler.bind(this); - this.startDataSyncHandler = this.startDataSyncHandler.bind(this); - this.regionReadyHandler = this.regionReadyHandler.bind(this); - this.regionRemovedHandler = this.regionRemovedHandler.bind(this); + this.stopSync = this.stopSync.bind(this); this.contentHandler = this.contentHandler.bind(this); + this.startPreview = this.startPreview.bind(this); this.start = this.start.bind(this); + this.regionReady = this.regionReady.bind(this); + this.regionRemoved = this.regionRemoved.bind(this); } /** @@ -47,11 +53,15 @@ class ContentService { */ startSyncing(screenPath) { logger.info("Starting data synchronization"); + this.syncingStopped = false; ClientConfigLoader.loadConfig().then((config) => { + if (this.syncingStopped) return; + const dataStrategyConfig = { interval: config.pullStrategyInterval, endpoint: "", + onContent: this.contentHandler, }; if (screenPath) { @@ -64,92 +74,57 @@ class ContentService { } /** - * Stop sync event handler. + * Stop data synchronization. */ - stopSyncHandler() { - logger.info("Event received: Stop data synchronization"); + stopSync() { + logger.info("Stopping data synchronization"); + this.syncingStopped = true; if (this.dataSync) { - logger.info("Stopping data synchronization"); this.dataSync.stop(); this.dataSync = null; } } /** - * Start data event handler. + * New content handler. * - * @param {CustomEvent} event - * The event. + * @param {object} screen - The screen data. */ - startDataSyncHandler(event) { - const data = event.detail; - - this.stopSyncHandler(); - - logger.log( - "info", - `Event received: Start data synchronization from ${data?.screenPath}`, - ); - if (data?.screenPath) { - logger.info( - `Event received: Start data synchronization from ${data.screenPath}`, - ); - this.startSyncing(data.screenPath); - } else { - logger.log("error", "Error: screenPath not set."); - } - } + contentHandler(screen) { + logger.info("Content received"); - /** - * New content event handler. - * - * @param {CustomEvent} event - * The event. - */ - contentHandler(event) { - logger.info("Event received: content"); - - const data = event.detail; - this.currentScreen = data.screen; + this.currentScreen = screen; const screenData = { ...this.currentScreen }; // Remove regionData to only emit screen when it has changed. - for (let i = 0; i < screenData.regions.length; i += 1) { - delete screenData.regionData; - } + delete screenData.regionData; const newHash = Base64.stringify(sha256(JSON.stringify(screenData))); - // TODO: Handle issue where region data is not present for a given region. Remove given region content. - if (newHash !== this.screenHash) { - logger.info("Screen has changed. Emitting screen."); + logger.info("Screen has changed. Updating screen."); this.screenHash = newHash; - ContentService.emitScreen(screenData); + this.callbacks.current.setScreen(screenData); } else { - logger.info("Screen has not changed. Not emitting screen."); + logger.info("Screen has not changed. Not updating screen."); + } - // eslint-disable-next-line guard-for-in,no-restricted-syntax - for (const regionKey in data.screen.regionData) { - const region = this.currentScreen.regionData[regionKey]; - this.scheduleService.updateRegion(regionKey, region); - } + // Always push region data so both new and existing regions get content. + // eslint-disable-next-line guard-for-in,no-restricted-syntax + for (const regionKey in screen.regionData) { + this.scheduleService.updateRegion(regionKey, screen.regionData[regionKey]); } } /** * Region ready handler. * - * @param {CustomEvent} event - * The event. + * @param {string} regionId - The region id. */ - regionReadyHandler(event) { - const data = event.detail; - const regionId = data.id; - - logger.info(`Event received: regionReady for ${regionId}`); + regionReady(regionId) { + logger.info(`Region ready: ${regionId}`); if (this.currentScreen) { this.scheduleService.updateRegion( @@ -162,14 +137,10 @@ class ContentService { /** * Region removed handler. * - * @param {CustomEvent} event - * The event. + * @param {string} regionId - The region id. */ - regionRemovedHandler(event) { - const data = event.detail; - const regionId = data.id; - - logger.info(`Event received: regionRemoved for ${regionId}`); + regionRemoved(regionId) { + logger.info(`Region removed: ${regionId}`); this.scheduleService.regionRemoved(regionId); } @@ -178,130 +149,133 @@ class ContentService { * Start the engine. */ start() { + if (this.started) { + logger.warn("Content service already started."); + return; + } + this.started = true; + logger.info("Content service started."); - document.addEventListener("stopDataSync", this.stopSyncHandler); - document.addEventListener("startDataSync", this.startDataSyncHandler); - document.addEventListener("content", this.contentHandler); - document.addEventListener("regionReady", this.regionReadyHandler); - document.addEventListener("regionRemoved", this.regionRemovedHandler); - document.addEventListener("startPreview", this.startPreview); + // Wire up region lifecycle callbacks so components can notify us directly. + this.callbacks.current.onRegionReady = this.regionReady; + this.callbacks.current.onRegionRemoved = this.regionRemoved; } /** * Stop the engine. */ stop() { + if (!this.started) return; + this.started = false; + logger.info("Content service stopped."); - document.removeEventListener("stopDataSync", this.stopSyncHandler); - document.removeEventListener("startDataSync", this.startDataSyncHandler); - document.removeEventListener("content", this.contentHandler); - document.removeEventListener("regionReady", this.regionReadyHandler); - document.removeEventListener("regionRemoved", this.regionRemovedHandler); - document.removeEventListener("startPreview", this.startPreview); + this.callbacks.current.onRegionReady = () => {}; + this.callbacks.current.onRegionRemoved = () => {}; } /** * Start preview. * - * @param {CustomEvent} event The event. + * @param {string} mode - Preview mode (screen, playlist, slide). + * @param {string} id - Entity ID to preview. */ - async startPreview(event) { - const data = event.detail; - const { mode, id } = data; - logger.log("info", `Starting preview. Mode: ${mode}, ID: ${id}`); - - if (mode === "screen") { - this.startSyncing(`/v2/screen/${id}`); - } else if (mode === "playlist") { - const pullStrategy = new PullStrategy({ - endpoint: "", - }); - - const playlist = await pullStrategy.getPath(`/v2/playlists/${id}`); - - const playlistSlidesResponse = await pullStrategy.getPath( - playlist.slides, - ); - - playlist.slidesData = playlistSlidesResponse["hydra:member"].map( - (playlistSlide) => playlistSlide.slide, - ); + async startPreview(mode, id) { + logger.info(`Starting preview. Mode: ${mode}, ID: ${id}`); + + try { + if (mode === "screen") { + this.startSyncing(`/v2/screens/${id}`); + } else if (mode === "playlist") { + const playlist = await query("getV2PlaylistsById", { id }, true); + + const playlistSlidesResponse = await query( + "getV2PlaylistsByIdSlides", + { id: idFromPath(playlist.slides) }, + true, + ); + + playlist.slidesData = playlistSlidesResponse["hydra:member"].map( + (playlistSlide) => playlistSlide.slide, + ); + + // eslint-disable-next-line no-restricted-syntax + for (const slide of playlist.slidesData) { + // eslint-disable-next-line no-await-in-loop + await ContentService.attachReferencesToSlide(slide); + } + + const screen = screenForPlaylistPreview(playlist); + this.contentHandler(screen); + } else if (mode === "slide") { + const slide = await query("getV2SlidesById", { id }, true); - // eslint-disable-next-line no-restricted-syntax - for (const slide of playlist.slidesData) { // eslint-disable-next-line no-await-in-loop - await ContentService.attachReferencesToSlide(pullStrategy, slide); - } - - const screen = screenForPlaylistPreview(playlist); - - document.dispatchEvent( - new CustomEvent("content", { - detail: { - screen, - }, - }), - ); - } else if (mode === "slide") { - const pullStrategy = new PullStrategy({ - endpoint: "", - }); - - const slide = await pullStrategy.getPath(`/v2/slides/${id}`); - - // eslint-disable-next-line no-await-in-loop - await ContentService.attachReferencesToSlide(pullStrategy, slide); + await ContentService.attachReferencesToSlide(slide); - const screen = screenForSlidePreview(slide); - - document.dispatchEvent( - new CustomEvent("content", { - detail: { - screen, - }, - }), + const screen = screenForSlidePreview(slide); + this.contentHandler(screen); + } else { + logger.error(`Unsupported preview mode: ${mode}.`); + } + } catch (err) { + logger.error( + `Preview failed (mode: ${mode}, id: ${id}): ${err.message}`, ); - } else { - logger.error(`Unsupported preview mode: ${mode}.`); } } - static async attachReferencesToSlide(strategy, slide) { + static async attachReferencesToSlide(slide) { /* eslint-disable no-param-reassign */ - slide.templateData = await strategy.getTemplateData(slide); - slide.feedData = await strategy.getFeedData(slide); + try { + slide.templateData = await query("getV2TemplatesById", { + id: idFromPath(slide.templateInfo["@id"]), + }, true); + } catch (err) { + slide.templateData = null; + slide.invalid = true; + slide.mediaData = {}; + slide.feedData = null; + return; + } + + if (slide?.feed?.feedUrl !== undefined) { + try { + slide.feedData = await query("getV2FeedsByIdData", { + id: idFromPath(slide.feed.feedUrl), + }, true); + } catch (err) { + slide.feedData = null; + } + } else { + slide.feedData = []; + } slide.mediaData = {}; // eslint-disable-next-line no-restricted-syntax for (const media of slide.media) { - // eslint-disable-next-line no-await-in-loop - slide.mediaData[media] = await strategy.getMediaData(media); + try { + // eslint-disable-next-line no-await-in-loop + slide.mediaData[media] = await query("getV2MediaById", { + id: idFromPath(media), + }, true); + } catch (err) { + slide.mediaData[media] = null; + } } if (typeof slide.theme === "string" || slide.theme instanceof String) { - slide.theme = await strategy.getPath(slide.theme); + try { + slide.theme = await query("getV2ThemesById", { + id: idFromPath(slide.theme), + }, true); + } catch (err) { + // Keep theme as the original string path. + } } /* eslint-enable no-param-reassign */ } - - /** - * Emit screen. - * - * @param {object} screen - * Screen data. - */ - static emitScreen(screen) { - logger.info("Emitting screen"); - - const event = new CustomEvent("screen", { - detail: { - screen, - }, - }); - document.dispatchEvent(event); - } } export default ContentService; diff --git a/assets/client/data-sync/data-sync.js b/assets/client/service/data-sync.js similarity index 82% rename from assets/client/data-sync/data-sync.js rename to assets/client/service/data-sync.js index b8d6ea153..b40b5ad95 100644 --- a/assets/client/data-sync/data-sync.js +++ b/assets/client/service/data-sync.js @@ -14,9 +14,10 @@ class DataSync { */ constructor(config) { this.start = this.start.bind(this); + this.stop = this.stop.bind(this); this.config = config; - this.strategy = new PullStrategy(this.config); + this.strategy = new PullStrategy(this.config, this.config.onContent); } /** diff --git a/assets/client/service/pull-strategy.js b/assets/client/service/pull-strategy.js new file mode 100644 index 000000000..c72946188 --- /dev/null +++ b/assets/client/service/pull-strategy.js @@ -0,0 +1,629 @@ +import isPublished from "../util/is-published"; +import logger from "../core/logger.js"; +import idFromPath from "../util/id-from-path"; +import { cloneDeep } from "lodash"; +import ClientConfigLoader from "../core/client-config-loader.js"; +import { query, queryAllPages } from "../core/api-query.js"; +import constants from "../util/constants.js"; +import defaults from "../util/defaults.js"; + +// Regex to extract regionId from region playlist paths. +const REGION_PATH_REGEX = + /\/v2\/screens\/([^/]+)\/regions\/([^/]+)\/playlists/; + +/** + * Check if any of the given checksum fields have changed. + * + * @param {boolean} enabled Whether checksum comparison is enabled. + * @param {object|null} oldChecksums Previous checksums (null on first run). + * @param {object} newChecksums Current checksums. + * @param {Array} fields Checksum field names to compare. + * @returns {boolean} True if data should be refetched. + */ +function checksumChanged(enabled, oldChecksums, newChecksums, fields) { + if (!enabled || !oldChecksums) return true; + return fields.some((field) => oldChecksums[field] !== newChecksums[field]); +} + +/** + * PullStrategy. + * + * Handles pull strategy. + */ +class PullStrategy { + previousScreenChecksums = null; + + previousSlideChecksums = {}; + + previousHadActiveCampaign = false; + + // Fetch-interval in ms. + interval; + + // Path to screen that should be loaded data for. + entryPoint = ""; + + /** + * Constructor. + * + * @param {object} config + * The config object. + */ + /** + * @param {object} config The config object. + * @param {Function} onContent Callback invoked with the screen object when content is ready. + */ + constructor(config, onContent) { + this.start = this.start.bind(this); + this.stop = this.stop.bind(this); + this.getScreen = this.getScreen.bind(this); + this.buildCampaignLayout = this.buildCampaignLayout.bind(this); + this.fetchLayoutAndRegions = this.fetchLayoutAndRegions.bind(this); + this.enrichSlides = this.enrichSlides.bind(this); + this.enrichSlide = this.enrichSlide.bind(this); + + this.interval = config?.interval ?? defaults.pullStrategyIntervalDefault; + this.entryPoint = config.entryPoint; + this.onContent = onContent; + } + + /** + * Gets all campaigns, both from screen and groups. + * + * @param {object} screen The screen object to extract campaigns from. + * @param {boolean} forceRefetch Whether to bypass RTK Query cache. + * @returns {Promise} Array of campaigns (playlists). + */ + async getCampaignsData(screen, forceRefetch) { + const screenGroupCampaigns = []; + const screenId = idFromPath(screen["@id"]); + + if (!screenId) { + logger.warn(`Could not extract screen ID from ${screen["@id"]}`); + return []; + } + + try { + const response = await queryAllPages("getV2ScreensByIdScreenGroups", { + id: screenId, + }, forceRefetch); + + const promises = []; + + response.forEach((group) => { + const groupId = idFromPath(group["@id"]); + if (!groupId) return; + promises.push( + queryAllPages("getV2ScreenGroupsByIdCampaigns", { id: groupId }, forceRefetch), + ); + }); + + const settledResults = await Promise.allSettled(promises); + settledResults.forEach((result) => { + if (result.status === "fulfilled") { + result.value.forEach(({ campaign }) => { + screenGroupCampaigns.push(campaign); + }); + } else { + logger.warn(`Failed to fetch screen group campaigns: ${result.reason}`); + } + }); + } catch (err) { + logger.error(err); + } + + let screenCampaigns = []; + + try { + const screenCampaignsResults = await queryAllPages( + "getV2ScreensByIdCampaigns", + { id: screenId }, + forceRefetch, + ); + + screenCampaigns = screenCampaignsResults.map( + ({ campaign }) => campaign, + ); + } catch (err) { + logger.error(err); + } + + const allCampaigns = [...screenCampaigns, ...screenGroupCampaigns]; + const seen = new Set(); + return allCampaigns.filter((campaign) => { + const id = campaign["@id"]; + if (seen.has(id)) return false; + seen.add(id); + return true; + }); + } + + /** + * Get slides for regions. + * + * @param {Array} regions Paths to regions. + * @param {boolean} forceRefetch Whether to bypass RTK Query cache. + * @returns {Promise} Regions data. + */ + async getRegions(regions, forceRefetch) { + const promises = []; + const regionData = {}; + + regions.forEach((regionPath) => { + const matches = regionPath.match(REGION_PATH_REGEX); + if (matches) { + promises.push( + queryAllPages("getV2ScreensByIdRegionsAndRegionIdPlaylists", { + id: matches[1], + regionId: matches[2], + }, forceRefetch).then((results) => ({ + regionId: matches[2], + results, + })), + ); + } + }); + + const results = await Promise.allSettled(promises); + results.forEach((result) => { + if (result.status === "fulfilled") { + regionData[result.value.regionId] = result.value.results.map( + ({ playlist }) => playlist, + ); + } else { + logger.warn(`Failed to fetch region playlists: ${result.reason}`); + } + }); + + return regionData; + } + + /** + * Get slides for the given regions. + * + * @param {object} regions Regions to fetch slides for. + * @param {boolean} forceRefetch Whether to bypass RTK Query cache. + * @returns {Promise} Promise with slides for the given regions. + */ + async getSlidesForRegions(regions, forceRefetch) { + const promises = []; + const regionData = cloneDeep(regions); + + // eslint-disable-next-line guard-for-in,no-restricted-syntax + for (const regionKey in regionData) { + const playlists = regionData[regionKey]; + // eslint-disable-next-line guard-for-in,no-restricted-syntax + for (const playlistKey in playlists) { + const playlistId = idFromPath( + regionData[regionKey][playlistKey]["@id"], + ); + if (!playlistId) continue; + promises.push( + queryAllPages("getV2PlaylistsByIdSlides", { + id: playlistId, + }, forceRefetch).then((results) => ({ + regionKey, + playlistKey, + results, + })), + ); + } + } + + const results = await Promise.allSettled(promises); + results.forEach((result) => { + if (result.status === "fulfilled") { + regionData[result.value.regionKey][ + result.value.playlistKey + ].slidesData = result.value.results.map( + (playlistSlide) => playlistSlide.slide, + ); + } else { + logger.warn(`Failed to fetch playlist slides: ${result.reason}`); + } + }); + + return regionData; + } + + /** + * Fetch screen. + * + * @param {string} screenPath Path to the screen. + */ + async getScreen(screenPath) { + let screen; + + const screenId = idFromPath(screenPath); + if (!screenId) { + logger.warn(`Could not extract screen ID from ${screenPath}. Aborting content update.`); + return; + } + + // Always forceRefetch the screen to get fresh checksums. + try { + screen = await query("getV2ScreensById", { + id: screenId, + }, true); + } catch (err) { + logger.warn( + `Screen (${screenPath}) not loaded. Aborting content update.`, + ); + + return; + } + + if (!screen) { + logger.warn( + `Screen (${screenPath}) not loaded. Aborting content update.`, + ); + + return; + } + + const config = await ClientConfigLoader.loadConfig(); + if (this.stopped) return; + + const relationChecksumEnabled = config.relationsChecksumEnabled; + + const newScreen = cloneDeep(screen); + + newScreen.hasActiveCampaign = false; + + const newScreenChecksums = newScreen?.relationsChecksum ?? {}; + + // Determine which resources need fresh data based on checksum changes. + const campaignsChanged = checksumChanged( + relationChecksumEnabled, this.previousScreenChecksums, newScreenChecksums, + ["campaigns", "inScreenGroups"], + ); + + if (campaignsChanged) { + logger.info(`Fetching campaigns.`); + } + newScreen.campaignsData = await this.getCampaignsData(newScreen, campaignsChanged); + if (this.stopped) return; + + if (newScreen.campaignsData.length > 0) { + newScreen.campaignsData.forEach(({ published }) => { + if (isPublished(published)) { + newScreen.hasActiveCampaign = true; + } + }); + } + + // With active campaigns, we override region/layout values. + if (newScreen.hasActiveCampaign) { + const forceRefetch = !this.previousHadActiveCampaign || campaignsChanged; + await this.buildCampaignLayout(newScreen, forceRefetch); + } else { + const success = await this.fetchLayoutAndRegions( + newScreen, newScreenChecksums, relationChecksumEnabled, + ); + if (!success) return; + } + if (this.stopped) return; + + const nextSlideChecksums = await this.enrichSlides( + newScreen.regionData, relationChecksumEnabled, + ); + if (this.stopped) return; + + this.previousScreenChecksums = newScreen.relationsChecksum ?? {}; + this.previousSlideChecksums = nextSlideChecksums; + this.previousHadActiveCampaign = newScreen.hasActiveCampaign; + + // Deliver result to rendering. + this.onContent(newScreen); + } + + /** + * Build a synthetic full-screen layout for active campaigns. + * + * @param {object} screen The screen object to mutate. + * @param {boolean} forceRefetch Whether to bypass RTK Query cache. + */ + async buildCampaignLayout(screen, forceRefetch) { + logger.info(`Has active campaign.`); + + const campaignRegionId = constants.CAMPAIGN_REGION_ID; + + screen.layoutData = { + grid: { + rows: 1, + columns: 1, + }, + regions: [ + { + "@id": `/v2/layouts/regions/${campaignRegionId}`, + gridArea: ["a"], + }, + ], + }; + + screen.regionData = {}; + screen.regionData[campaignRegionId] = screen.campaignsData; + const campaignScreenId = idFromPath(screen["@id"]); + if (!campaignScreenId) { + logger.warn(`Could not extract screen ID from ${screen["@id"]} for campaign layout.`); + return; + } + screen.regions = [ + `/v2/screens/${campaignScreenId}/regions/${campaignRegionId}/playlists`, + ]; + screen.regionData = await this.getSlidesForRegions( + screen.regionData, + forceRefetch, + ); + } + + /** + * Fetch layout and regions for the normal (non-campaign) path. + * + * @param {object} screen The screen object to mutate. + * @param {object} newScreenChecksums Current screen checksums. + * @param {boolean} relationChecksumEnabled Whether checksum comparison is enabled. + * @returns {Promise} False if content update should be aborted. + */ + async fetchLayoutAndRegions(screen, newScreenChecksums, relationChecksumEnabled) { + logger.info(`Has no active campaign.`); + + const layoutChanged = + this.previousHadActiveCampaign || + checksumChanged( + relationChecksumEnabled, this.previousScreenChecksums, newScreenChecksums, + ["layout"], + ); + + if (layoutChanged) { + logger.info(`Fetching layout.`); + } + + const layoutId = idFromPath(screen.layout); + if (!layoutId) { + logger.warn(`Could not extract layout ID from ${screen.layout}. Aborting content update.`); + return false; + } + + try { + screen.layoutData = await query("getV2LayoutsById", { + id: layoutId, + }, layoutChanged); + } catch (err) { + logger.warn( + `Layout (${screen.layout}) not loaded. Aborting content update.`, + ); + return false; + } + + if (!screen.layoutData) { + logger.warn( + `Layout (${screen.layout}) not loaded. Aborting content update.`, + ); + return false; + } + + const regionsChanged = + this.previousHadActiveCampaign || + checksumChanged( + relationChecksumEnabled, this.previousScreenChecksums, newScreenChecksums, + ["regions"], + ); + + if (regionsChanged) { + logger.info(`Fetching regions and slides for regions.`); + } + + const regions = await this.getRegions(screen.regions ?? [], regionsChanged); + screen.regionData = await this.getSlidesForRegions(regions, regionsChanged); + + return true; + } + + /** + * Enrich all slides in regionData with template, media, and feed data. + * + * @param {object} regionData Regions containing playlists with slides. + * @param {boolean} relationChecksumEnabled Whether checksum comparison is enabled. + * @returns {Promise} Updated slide checksums. + */ + async enrichSlides(regionData, relationChecksumEnabled) { + const nextSlideChecksums = {}; + const promises = []; + + for (const regionKey of Object.keys(regionData)) { + const regionDataEntry = regionData[regionKey]; + + for (const playlistKey of Object.keys(regionDataEntry)) { + const dataEntryPlaylist = regionDataEntry[playlistKey]; + const dataEntrySlidesData = dataEntryPlaylist.slidesData ?? []; + + for (const slideKey of Object.keys(dataEntrySlidesData)) { + const slide = cloneDeep(dataEntrySlidesData[slideKey]); + + promises.push( + this.enrichSlide(slide, relationChecksumEnabled).then(() => { + nextSlideChecksums[slide["@id"]] = slide.relationsChecksum ?? {}; + dataEntrySlidesData[slideKey] = slide; + }), + ); + } + } + } + + const results = await Promise.allSettled(promises); + + const failed = results.filter((r) => r.status === "rejected"); + if (failed.length > 0) { + logger.warn(`Failed to enrich ${failed.length} slide(s).`); + } + + return nextSlideChecksums; + } + + /** + * Enrich a single slide with template, media, and feed data. + * + * @param {object} slide The slide object to mutate. + * @param {boolean} relationChecksumEnabled Whether checksum comparison is enabled. + */ + async enrichSlide(slide, relationChecksumEnabled) { + const slideId = slide["@id"]; + const newSlideChecksums = slide.relationsChecksum ?? {}; + const oldSlideChecksums = this.previousSlideChecksums[slideId] ?? null; + + // A slide cannot work without templateInfo. Mark as invalid and skip. + if (!slide.templateInfo?.["@id"]) { + logger.warn( + `Slide (${slide["@id"]}) has no templateInfo. Marking as invalid.`, + ); + slide.templateData = null; + slide.invalid = true; + slide.mediaData = {}; + slide.feedData = null; + return; + } + + // Fetch template if it has changed. + const templateChanged = checksumChanged( + relationChecksumEnabled, oldSlideChecksums, newSlideChecksums, + ["templateInfo"], + ); + + const templateId = idFromPath(slide.templateInfo["@id"]); + + if (!templateId) { + logger.warn(`Could not extract template ID from ${slide.templateInfo["@id"]}. Marking slide as invalid.`); + slide.templateData = null; + slide.invalid = true; + slide.mediaData = {}; + slide.feedData = null; + return; + } + + if (templateChanged) { + logger.info(`Fetching template data.`); + } + + try { + slide.templateData = await query("getV2TemplatesById", { + id: templateId, + }, templateChanged); + } catch (err) { + slide.templateData = null; + } + + if (!slide.templateData) { + slide.invalid = true; + slide.mediaData = {}; + slide.feedData = null; + return; + } + + // Fetch media if it has changed. + const mediaChanged = checksumChanged( + relationChecksumEnabled, oldSlideChecksums, newSlideChecksums, + ["media"], + ); + + if (mediaChanged) { + logger.info(`Fetching media data.`); + } + + const mediaEntries = (slide.media ?? []) + .map((mediaPath) => ({ mediaPath, mediaId: idFromPath(mediaPath) })) + .filter(({ mediaId }) => mediaId); + + const mediaResults = await Promise.allSettled( + mediaEntries.map(({ mediaPath, mediaId }) => + query("getV2MediaById", { id: mediaId }, mediaChanged) + .then((data) => ({ mediaPath, data })) + .catch(() => ({ mediaPath, data: null })), + ), + ); + + const nextMediaData = {}; + mediaResults.forEach((result) => { + if (result.status === "fulfilled") { + nextMediaData[result.value.mediaPath] = result.value.data; + } + }); + slide.mediaData = nextMediaData; + + // Fetch feed — always forceRefetch (no checksum, needs fresh data). + if (slide?.feed?.feedUrl !== undefined) { + const feedId = idFromPath(slide.feed.feedUrl); + if (!feedId) return; + logger.info(`Fetching feed data.`); + try { + slide.feedData = await query("getV2FeedsByIdData", { + id: feedId, + }, true); + } catch (err) { + slide.feedData = null; + } + } else { + slide.feedData = []; + } + } + + /** + * Start the data synchronization. + */ + start() { + // Make sure nothing is running. + this.stop(); + this.stopped = false; + this.pulling = false; + + // Pull now, then schedule the next pull after completion. + this.pull(); + } + + /** + * Run a single pull cycle, then schedule the next one. + */ + pull() { + if (this.pulling) { + return; + } + this.pulling = true; + + let timeoutId; + const guard = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + reject(new Error("getScreen exceeded max execution time")); + }, defaults.getScreenTimeoutDefault); + }); + + Promise.race([this.getScreen(this.entryPoint), guard]) + .catch((err) => { + logger.error(`Content update failed: ${err.message}`); + }) + .finally(() => { + clearTimeout(timeoutId); + this.pulling = false; + + if (this.stopped) { + return; + } + this.activeTimeout = setTimeout(() => { + this.activeTimeout = undefined; + this.pull(); + }, this.interval); + }); + } + + /** + * Stop the data synchronization. + */ + stop() { + this.stopped = true; + if (this.activeTimeout !== undefined) { + clearTimeout(this.activeTimeout); + this.activeTimeout = undefined; + } + } +} + +export default PullStrategy; diff --git a/assets/client/service/release-service.js b/assets/client/service/release-service.js index 0cc41a588..83d4e59ef 100644 --- a/assets/client/service/release-service.js +++ b/assets/client/service/release-service.js @@ -1,11 +1,11 @@ -import ClientConfigLoader from "../util/client-config-loader.js"; +import ClientConfigLoader from "../core/client-config-loader.js"; import defaults from "../util/defaults"; import idFromPath from "../util/id-from-path"; -import appStorage from "../util/app-storage"; -import logger from "../logger/logger"; +import appStorage from "../core/app-storage"; +import logger from "../core/logger.js"; import statusService from "./status-service"; import constants from "../util/constants"; -import ReleaseLoader from "../../shared/release-loader.js"; +import releaseLoader from "../../shared/release-loader.js"; class ReleaseService { releaseCheckInterval = null; @@ -17,40 +17,45 @@ class ReleaseService { const url = new URL(window.location.href); const currentTimestamp = url.searchParams.get("releaseTimestamp"); - ReleaseLoader.loadRelease().then((release) => { - if (release.releaseTimestamp === null) { - statusService.setError(constants.ERROR_RELEASE_FILE_NOT_LOADED); - } else if ( - statusService.error === constants.ERROR_RELEASE_FILE_NOT_LOADED - ) { - statusService.setError(null); - } - - if ( - release.releaseTimestamp !== null && - (!currentTimestamp || - currentTimestamp !== release.releaseTimestamp.toString()) - ) { - const redirectUrl = url; - - redirectUrl.searchParams.set( - "releaseTimestamp", - release.releaseTimestamp, - ); - - if (release.releaseVersion !== null) { + releaseLoader.loadRelease() + .then((release) => { + if (release.releaseTimestamp === null) { + statusService.setError(constants.ERROR_RELEASE_FILE_NOT_LOADED); + } else if ( + statusService.error === constants.ERROR_RELEASE_FILE_NOT_LOADED + ) { + statusService.setError(null); + } + + if ( + release.releaseTimestamp !== null && + (!currentTimestamp || + currentTimestamp !== release.releaseTimestamp.toString()) + ) { + const redirectUrl = url; + redirectUrl.searchParams.set( - "releaseVersion", - release.releaseVersion, + "releaseTimestamp", + release.releaseTimestamp, ); - } - window.location.replace(redirectUrl); - reject(); - } else { + if (release.releaseVersion !== null) { + redirectUrl.searchParams.set( + "releaseVersion", + release.releaseVersion, + ); + } + + window.location.replace(redirectUrl); + reject(); + } else { + resolve(); + } + }) + .catch((err) => { + logger.error(`Failed to load release: ${err}`); resolve(); - } - }); + }); }); }; @@ -70,7 +75,12 @@ class ReleaseService { }; startReleaseCheck = () => { + this.stopReleaseCheck(); + this.releaseCheckStopped = false; + ClientConfigLoader.loadConfig().then((config) => { + if (this.releaseCheckStopped) return; + this.releaseCheckInterval = setInterval( this.checkForNewRelease, config.releaseTimestampIntervalTimeout ?? @@ -80,8 +90,10 @@ class ReleaseService { }; stopReleaseCheck = () => { + this.releaseCheckStopped = true; if (this.releaseCheckInterval) { clearInterval(this.releaseCheckInterval); + this.releaseCheckInterval = null; } }; } diff --git a/assets/client/service/schedule-service.js b/assets/client/service/schedule-service.js index 23b56757e..173d7218a 100644 --- a/assets/client/service/schedule-service.js +++ b/assets/client/service/schedule-service.js @@ -1,11 +1,11 @@ import sha256 from "crypto-js/sha256"; import Md5 from "crypto-js/md5"; import Base64 from "crypto-js/enc-base64"; -import isPublished from "../util/isPublished"; -import logger from "../logger/logger"; -import ClientConfigLoader from "../util/client-config-loader.js"; +import isPublished from "../util/is-published"; +import logger from "../core/logger.js"; +import ClientConfigLoader from "../core/client-config-loader.js"; import ScheduleUtils from "../util/schedule"; -import { cloneDeep } from "lodash"; +import defaults from "../util/defaults"; /** * ScheduleService. @@ -20,7 +20,11 @@ class ScheduleService { contentEmpty = true; - constructor() { + /** + * @param {object} callbacks - Ref object with setIsContentEmpty and updateRegionSlides. + */ + constructor(callbacks) { + this.callbacks = callbacks; this.updateRegion = this.updateRegion.bind(this); this.checkForEmptyContent = this.checkForEmptyContent.bind(this); this.sendSlides = this.sendSlides.bind(this); @@ -37,12 +41,7 @@ class ScheduleService { if (contentEmpty !== this.contentEmpty) { this.contentEmpty = contentEmpty; - - // Deliver result to rendering - const event = new Event( - contentEmpty ? "contentEmpty" : "contentNotEmpty", - ); - document.dispatchEvent(event); + this.callbacks.current.setIsContentEmpty(contentEmpty); } } @@ -95,10 +94,13 @@ class ScheduleService { if (!Object.prototype.hasOwnProperty.call(intervals, regionId)) { ClientConfigLoader.loadConfig().then((config) => { - const schedulingInterval = config?.schedulingInterval ?? 60000; + const schedulingInterval = config?.schedulingInterval ?? defaults.schedulingIntervalDefault; - // Extra check because of async. - if (!Object.prototype.hasOwnProperty.call(intervals, regionId)) { + // Extra check because of async — region may have been removed. + if ( + !Object.prototype.hasOwnProperty.call(intervals, regionId) && + Object.prototype.hasOwnProperty.call(this.regions, regionId) + ) { logger.info( `registering scheduling interval for region: ${regionId}, with an update rate of ${schedulingInterval}`, ); @@ -127,6 +129,15 @@ class ScheduleService { const region = this.regions[regionId]; + if (!region) { + // Region was removed while the interval registration was in-flight. + if (Object.prototype.hasOwnProperty.call(this.intervals, regionId)) { + clearInterval(this.intervals[regionId]); + delete this.intervals[regionId]; + } + return; + } + // Extract slides from playlists. const slides = ScheduleService.findScheduledSlides(region.region, regionId); @@ -138,7 +149,7 @@ class ScheduleService { // Update region. this.regions[regionId].hash = hash; - this.regions[regionId].slide = slides; + this.regions[regionId].slides = slides; if (newContent) { // Send slides to region. @@ -156,13 +167,7 @@ class ScheduleService { */ sendSlides(regionId, slides) { logger.info(`sendSlides regionContent-${regionId}`); - const event = new CustomEvent(`regionContent-${regionId}`, { - detail: { - slides, - }, - }); - document.dispatchEvent(event); - + this.callbacks.current.updateRegionSlides(regionId, slides); this.checkForEmptyContent(); } @@ -214,15 +219,12 @@ class ScheduleService { return; } - const newSlide = cloneDeep(slide); - // Execution id is the product of region, playlist and slide id, to ensure uniqueness in the client. - const executionId = Md5(regionId + playlist["@id"] + slide["@id"]); - newSlide.executionId = `EXE-ID-${executionId}`; - slides.push(newSlide); + const executionId = Md5(regionId + playlist["@id"] + slide["@id"]).toString(); + slides.push({ ...slide, executionId: `EXE-ID-${executionId}` }); }); } else { - logger.log("info", `Playlist ${playlist["@id"]} not scheduled for now`); + logger.info(`Playlist ${playlist["@id"]} not scheduled for now`); } }); diff --git a/assets/client/service/tenant-service.js b/assets/client/service/tenant-service.js index 66512e769..f948ca181 100644 --- a/assets/client/service/tenant-service.js +++ b/assets/client/service/tenant-service.js @@ -1,4 +1,7 @@ -import appStorage from "../util/app-storage"; +import appStorage from "../core/app-storage"; +import logger from "../core/logger.js"; +import { clientStore } from "../redux/store.js"; +import { clientApi } from "../redux/enhanced-api.ts"; class TenantService { loadTenantConfig = () => { @@ -7,18 +10,21 @@ class TenantService { const tenantId = appStorage.getTenantId(); if (token && tenantKey && tenantId) { - // Get fallback image. - fetch(`/v2/tenants/${tenantId}`, { - headers: { - authorization: `Bearer ${token}`, - "Authorization-Tenant-Key": tenantKey, - }, - }) - .then((response) => response.json()) + const request = clientStore.dispatch( + clientApi.endpoints.getV2TenantsById.initiate({ id: tenantId }), + ); + request + .unwrap() .then((tenantData) => { if (tenantData?.fallbackImageUrl) { appStorage.setFallbackImageUrl(tenantData.fallbackImageUrl); } + }) + .catch((err) => { + logger.error(`Failed to load tenant config: ${err.message}`); + }) + .finally(() => { + request.unsubscribe(); }); } }; diff --git a/assets/client/service/token-service.js b/assets/client/service/token-service.js index 478758cdb..a3cbe32a2 100644 --- a/assets/client/service/token-service.js +++ b/assets/client/service/token-service.js @@ -1,9 +1,11 @@ -import logger from "../logger/logger"; -import appStorage from "../util/app-storage"; -import ClientConfigLoader from "../util/client-config-loader.js"; +import logger from "../core/logger.js"; +import appStorage from "../core/app-storage"; +import ClientConfigLoader from "../core/client-config-loader.js"; import defaults from "../util/defaults"; import statusService from "./status-service"; import constants from "../util/constants"; +import { clientStore } from "../redux/store.js"; +import { clientApi } from "../redux/enhanced-api.ts"; class TokenService { refreshingToken = false; @@ -81,47 +83,42 @@ class TokenService { logger.info("Refresh token invoked."); if (this.refreshPromise === null) { - this.refreshPromise = new Promise((resolve, reject) => { - const refreshToken = appStorage.getRefreshToken(); - this.refreshingToken = true; - - fetch(`/v2/authentication/token/refresh`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - refresh_token: refreshToken, - }), + this.refreshingToken = true; + const refreshToken = appStorage.getRefreshToken(); + + const request = clientStore.dispatch( + clientApi.endpoints.postRefreshTokenItem.initiate({ + refreshTokenRequest: { refresh_token: refreshToken }, + }), + ); + + this.refreshPromise = request + .unwrap() + .then((data) => { + logger.info("Token refreshed."); + + appStorage.setToken(data.token); + appStorage.setRefreshToken(data.refresh_token); + + // Remove token expired error codes. + if ( + [ + constants.ERROR_TOKEN_EXPIRED, + constants.ERROR_TOKEN_VALID_SHOULD_HAVE_BEEN_REFRESHED, + ].includes(statusService.error) + ) { + statusService.setError(null); + } }) - .then((response) => response.json()) - .then((data) => { - logger.info("Token refreshed."); - - appStorage.setToken(data.token); - appStorage.setRefreshToken(data.refresh_token); - - // Remove token expired error codes. - if ( - [ - constants.ERROR_TOKEN_EXPIRED, - constants.ERROR_TOKEN_VALID_SHOULD_HAVE_BEEN_REFRESHED, - ].includes(statusService.error) - ) { - statusService.setError(null); - } - - resolve(); - }) - .catch((err) => { - logger.error("Token refresh error."); - reject(err); - }) - .finally(() => { - this.refreshingToken = false; - this.refreshPromise = null; - }); - }); + .catch((err) => { + logger.error("Token refresh error."); + throw err; + }) + .finally(() => { + this.refreshingToken = false; + this.refreshPromise = null; + request.reset(); + }); } return this.refreshPromise; @@ -152,8 +149,8 @@ class TokenService { if ( err !== null && [ - constants.TOKEN_EXPIRED, - constants.TOKEN_VALID_SHOULD_HAVE_BEEN_REFRESHED, + constants.ERROR_TOKEN_EXPIRED, + constants.ERROR_TOKEN_VALID_SHOULD_HAVE_BEEN_REFRESHED, ].includes(err) ) { statusService.setError(null); @@ -162,64 +159,65 @@ class TokenService { }; checkLogin = () => { - return new Promise((resolve, reject) => { - fetch(`/v2/authentication/screen`, { - method: "POST", - mode: "cors", - credentials: "include", - }) - .then((response) => response.json()) - .then((data) => { + const request = clientStore.dispatch( + clientApi.endpoints.postLoginInfoScreen.initiate({ + screenLoginInput: {}, + }), + ); + return request + .unwrap() + .then((data) => { + if ( + data?.status === constants.LOGIN_STATUS_READY && + data?.token && + data?.screenId && + data?.tenantKey && + data?.refresh_token + ) { + appStorage.setToken(data.token); + appStorage.setRefreshToken(data.refresh_token); + appStorage.setScreenId(data.screenId); + appStorage.setTenant(data.tenantKey, data.tenantId); + + // Remove token expired error codes. if ( - data?.status === constants.LOGIN_STATUS_READY && - data?.token && - data?.screenId && - data?.tenantKey && - data?.refresh_token - ) { - appStorage.setToken(data.token); - appStorage.setRefreshToken(data.refresh_token); - appStorage.setScreenId(data.screenId); - appStorage.setTenant(data.tenantKey, data.tenantId); - - // Remove token expired error codes. - if ( - [ - constants.ERROR_TOKEN_REFRESH_FAILED, - constants.ERROR_TOKEN_REFRESH_LOOP_FAILED, - constants.ERROR_TOKEN_EXP_IAT_NOT_SET, - constants.ERROR_TOKEN_EXPIRED, - constants.ERROR_TOKEN_VALID_SHOULD_HAVE_BEEN_REFRESHED, - ].includes(statusService.error) - ) { - statusService.setError(null); - } - - resolve({ - status: constants.LOGIN_STATUS_READY, - screenId: data.screenId, - }); - } else if ( - data?.status === constants.LOGIN_STATUS_AWAITING_BIND_KEY + [ + constants.ERROR_TOKEN_REFRESH_FAILED, + constants.ERROR_TOKEN_REFRESH_LOOP_FAILED, + constants.ERROR_TOKEN_EXP_IAT_NOT_SET, + constants.ERROR_TOKEN_EXPIRED, + constants.ERROR_TOKEN_VALID_SHOULD_HAVE_BEEN_REFRESHED, + ].includes(statusService.error) ) { - resolve({ - status: constants.LOGIN_STATUS_AWAITING_BIND_KEY, - bindKey: data.bindKey, - }); - } else { - resolve({ - status: constants.LOGIN_STATUS_UNKNOWN, - }); + statusService.setError(null); } - }) - .catch((err) => { - reject(err); - }); - }); + + return { + status: constants.LOGIN_STATUS_READY, + screenId: data.screenId, + }; + } else if (data?.status === constants.LOGIN_STATUS_AWAITING_BIND_KEY) { + return { + status: constants.LOGIN_STATUS_AWAITING_BIND_KEY, + bindKey: data.bindKey, + }; + } + return { + status: constants.LOGIN_STATUS_UNKNOWN, + }; + }) + .finally(() => { + request.reset(); + }); }; startRefreshing = () => { + this.stopRefreshing(); + this.refreshingStopped = false; + ClientConfigLoader.loadConfig().then((config) => { + if (this.refreshingStopped) return; + // Start refresh token interval. this.refreshInterval = setInterval( this.ensureFreshToken, @@ -229,8 +227,10 @@ class TokenService { }; stopRefreshing = () => { + this.refreshingStopped = true; if (this.refreshInterval) { clearInterval(this.refreshInterval); + this.refreshInterval = null; } }; } diff --git a/assets/client/util/client-config-loader.js b/assets/client/util/client-config-loader.js deleted file mode 100644 index f940bb779..000000000 --- a/assets/client/util/client-config-loader.js +++ /dev/null @@ -1,83 +0,0 @@ -// Only fetch new config if more than 15 minutes have passed. -import appStorage from "./app-storage.js"; - -const configFetchIntervalDefault = 15 * 60 * 1000; - -// Defaults. -let configData = null; - -// Last time the config was fetched. -let latestFetchTimestamp = 0; - -let activePromise = null; - -const ClientConfigLoader = { - async loadConfig() { - if (activePromise) { - return activePromise; - } - - activePromise = new Promise((resolve) => { - const nowTimestamp = new Date().getTime(); - - if ( - latestFetchTimestamp + - (configData?.configFetchInterval ?? configFetchIntervalDefault) >= - nowTimestamp - ) { - resolve(configData); - } else { - fetch(`/config/client`) - .then((response) => response.json()) - .then((data) => { - latestFetchTimestamp = nowTimestamp; - configData = data; - - // Make api endpoint available through localstorage. - appStorage.setApiUrl(configData.apiEndpoint); - - resolve(configData); - }) - .catch(() => { - if (configData !== null) { - resolve(configData); - } else { - // eslint-disable-next-line no-console - console.error("Could not load config. Will use default config."); - - // Default config. - resolve({ - apiEndpoint: "/api", - dataStrategy: { - type: "pull", - config: { - interval: 30000, - }, - }, - loginCheckTimeout: 20000, - configFetchInterval: 900000, - refreshTokenTimeout: 15000, - releaseTimestampIntervalTimeout: 600000, - colorScheme: { - type: "library", - lat: 56.0, - lng: 10.0, - }, - schedulingInterval: 60000, - debug: false, - }); - } - }) - .finally(() => { - activePromise = null; - }); - } - }); - - return activePromise; - }, -}; - -Object.freeze(ClientConfigLoader); - -export default ClientConfigLoader; diff --git a/assets/client/util/constants.js b/assets/client/util/constants.js index 1c32963f2..837541b6d 100644 --- a/assets/client/util/constants.js +++ b/assets/client/util/constants.js @@ -17,6 +17,10 @@ const constants = { NO_TOKEN: "NO_TOKEN", NO_EXPIRE: "NO_EXPIRE", NO_ISSUED_AT: "NO_ISSUED_AT", + CAMPAIGN_REGION_ID: "01G112XBWFPY029RYFB8X2H4KD", + SLIDE_ERROR_RECOVERY_TIMEOUT: 5 * 1000, + SLIDE_TRANSITION_TIMEOUT: 1000, + COLOR_SCHEME_REFRESH_INTERVAL: 5 * 60 * 1000, }; export default constants; diff --git a/assets/client/util/defaults.js b/assets/client/util/defaults.js index 4c1e61d2c..0881ac450 100644 --- a/assets/client/util/defaults.js +++ b/assets/client/util/defaults.js @@ -5,6 +5,16 @@ const defaults = { refreshTokenTimeoutDefault: 15 * 60 * 1000, // Every 10 minutes. releaseTimestampIntervalTimeoutDefault: 10 * 60 * 1000, + // Every 60 seconds. Fallback for scheduling interval. + schedulingIntervalDefault: 60 * 1000, + // Every 5 minutes. Fallback for pull strategy interval. + pullStrategyIntervalDefault: 5 * 60 * 1000, + // Every 15 minutes. Fallback for config fetch interval. + configFetchIntervalDefault: 15 * 60 * 1000, + // 30 seconds. Timeout for individual API requests. + queryTimeoutDefault: 30 * 1000, + // 2 minutes. Max execution time for a full getScreen cycle. + getScreenTimeoutDefault: 2 * 60 * 1000, }; export default defaults; diff --git a/assets/client/util/id-from-path.js b/assets/client/util/id-from-path.js index 24ecdb053..a80a69db5 100644 --- a/assets/client/util/id-from-path.js +++ b/assets/client/util/id-from-path.js @@ -9,7 +9,7 @@ function idFromPath(string) { return matches.shift(); } } - return false; + return null; } export default idFromPath; diff --git a/assets/client/util/isPublished.js b/assets/client/util/is-published.js similarity index 100% rename from assets/client/util/isPublished.js rename to assets/client/util/is-published.js diff --git a/assets/client/util/schedule.js b/assets/client/util/schedule.js index 23f8b8661..745eca744 100644 --- a/assets/client/util/schedule.js +++ b/assets/client/util/schedule.js @@ -2,7 +2,7 @@ import { RRule } from "rrule"; class ScheduleUtils { static occursNow(rruleString, durationSeconds) { - const rrule = RRule.fromString(rruleString.replace("\\n", "\n")); + const rrule = RRule.fromString(rruleString.replaceAll("\\n", "\n")); const duration = durationSeconds * 1000; const now = new Date(); @@ -40,7 +40,7 @@ class ScheduleUtils { nowWithoutTimezone, true, function iterator(occurrenceDate) { - // The "ccurrenceDate" we are iterating over contains a "pretend UTC" datetime + // The "occurrenceDate" we are iterating over contains a "pretend UTC" datetime // object. As above, if the time for "occurrenceDate" is 09:00 UTC it should be // treated as 09:00 local time regardsless of the actual local timezone const end = new Date(occurrenceDate.getTime() + duration); diff --git a/assets/tests/client/api-query.test.js b/assets/tests/client/api-query.test.js new file mode 100644 index 000000000..a89e6cf1c --- /dev/null +++ b/assets/tests/client/api-query.test.js @@ -0,0 +1,319 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +// --- Hoisted mocks --- +// +// RTK Query's dispatch(endpoint.initiate(args)) returns a "request" object +// with { unwrap(), abort(), unsubscribe() }. We simulate that here so the +// source's Promise.race / cleanup logic can be tested in isolation. +// +// dispatchDefaults.unwrapResult controls what unwrap() resolves/rejects with. +// Set it in each test before calling query(). Tests that need per-call control +// (e.g. pagination) override mockDispatch.mockImplementation() directly. +// +// mockSelect simulates clientApi.endpoints[name].select(args) which returns +// a selector function (state => cacheEntry). The double-arrow mirrors the real +// RTK Query API: select(args) returns (state) => ({ data, ... }). +const { mockDispatch, endpoints, mockSelect, dispatchDefaults } = vi.hoisted(() => { + const mockSelect = vi.fn(() => () => undefined); + const mockAbort = vi.fn(); + const mockUnsubscribe = vi.fn(); + + const dispatchDefaults = { + unwrapResult: Promise.resolve("data"), + makeReturnValue() { + return { + unwrap: () => dispatchDefaults.unwrapResult, + abort: mockAbort, + unsubscribe: mockUnsubscribe, + }; + }, + }; + + const mockDispatch = vi.fn(() => dispatchDefaults.makeReturnValue()); + + mockDispatch._abort = mockAbort; + mockDispatch._unsubscribe = mockUnsubscribe; + + const endpoints = {}; + const initiate = vi.fn((args, opts) => ({ + _endpoint: "testEndpoint", + _args: args, + _opts: opts, + })); + endpoints.testEndpoint = { initiate, select: mockSelect }; + + return { mockDispatch, endpoints, mockSelect, dispatchDefaults }; +}); + +vi.mock("../../client/core/logger.js", () => ({ + default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), log: vi.fn() }, +})); + +vi.mock("../../client/redux/store.js", () => ({ + clientStore: { dispatch: mockDispatch, getState: () => ({}) }, +})); + +vi.mock("../../client/redux/enhanced-api.ts", () => ({ + clientApi: { + endpoints, + reducerPath: "clientApi", + reducer: (state = {}) => state, + middleware: () => (next) => (action) => next(action), + }, +})); + +vi.mock("../../client/util/defaults.js", () => ({ + default: { queryTimeoutDefault: 500 }, +})); + +import { query, queryAllPages } from "../../client/core/api-query.js"; +import logger from "../../client/core/logger.js"; + +describe("query", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + // Restore default implementation (mockImplementation from prior tests persists through clearAllMocks). + mockDispatch.mockImplementation(() => dispatchDefaults.makeReturnValue()); + dispatchDefaults.unwrapResult = Promise.resolve("data"); + mockSelect.mockReturnValue(() => undefined); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("should resolve with unwrapped data when fetch succeeds", async () => { + dispatchDefaults.unwrapResult = Promise.resolve({ id: 1 }); + + const result = await query("testEndpoint", { id: "abc" }); + + expect(result).toEqual({ id: 1 }); + }); + + it("should pass forceRefetch false by default", () => { + query("testEndpoint", { id: "abc" }); + + expect(endpoints.testEndpoint.initiate).toHaveBeenCalledWith( + { id: "abc" }, + { forceRefetch: false }, + ); + }); + + it("should pass forceRefetch true when requested", () => { + query("testEndpoint", { id: "abc" }, true); + + expect(endpoints.testEndpoint.initiate).toHaveBeenCalledWith( + { id: "abc" }, + { forceRefetch: true }, + ); + }); + + it("should reject with timeout error when fetch exceeds queryTimeoutDefault", async () => { + dispatchDefaults.unwrapResult = new Promise(() => {}); // never resolves + + const promise = query("testEndpoint", {}); + vi.advanceTimersByTime(500); + + await expect(promise).rejects.toThrow("Request timeout: testEndpoint"); + }); + + it("should call request.abort on timeout", async () => { + dispatchDefaults.unwrapResult = new Promise(() => {}); + + const promise = query("testEndpoint", {}); + vi.advanceTimersByTime(500); + + await promise.catch(() => {}); + expect(mockDispatch._abort).toHaveBeenCalled(); + }); + + it("should return cached data when fetch fails but cache exists", async () => { + dispatchDefaults.unwrapResult = Promise.reject(new Error("network")); + mockSelect.mockReturnValue(() => ({ data: { cached: true } })); + + const result = await query("testEndpoint", { id: "abc" }); + + expect(result).toEqual({ cached: true }); + }); + + it("should log warning when falling back to cached data", async () => { + dispatchDefaults.unwrapResult = Promise.reject(new Error("network")); + mockSelect.mockReturnValue(() => ({ data: { cached: true } })); + + await query("testEndpoint", { id: "abc" }); + + expect(logger.warn).toHaveBeenCalledWith( + "Using cached data for testEndpoint after fetch failure.", + ); + }); + + it("should re-throw when fetch fails and no cache exists", async () => { + dispatchDefaults.unwrapResult = Promise.reject(new Error("network")); + mockSelect.mockReturnValue(() => undefined); + + await expect(query("testEndpoint", {})).rejects.toThrow("network"); + }); + + it("should re-throw when cached data is undefined", async () => { + dispatchDefaults.unwrapResult = Promise.reject(new Error("network")); + mockSelect.mockReturnValue(() => ({ data: undefined })); + + await expect(query("testEndpoint", {})).rejects.toThrow("network"); + }); + + it("should call unsubscribe on success", async () => { + dispatchDefaults.unwrapResult = Promise.resolve("ok"); + + await query("testEndpoint", {}); + + expect(mockDispatch._unsubscribe).toHaveBeenCalled(); + }); + + it("should call unsubscribe on error", async () => { + dispatchDefaults.unwrapResult = Promise.reject(new Error("fail")); + mockSelect.mockReturnValue(() => undefined); + + await query("testEndpoint", {}).catch(() => {}); + + expect(mockDispatch._unsubscribe).toHaveBeenCalled(); + }); +}); + +describe("queryAllPages", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + mockDispatch.mockImplementation(() => dispatchDefaults.makeReturnValue()); + dispatchDefaults.unwrapResult = Promise.resolve("data"); + mockSelect.mockReturnValue(() => undefined); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("should return hydra:member from a single page with no hydra:next", async () => { + dispatchDefaults.unwrapResult = Promise.resolve({ + "hydra:member": [{ id: 1 }, { id: 2 }], + "hydra:view": {}, + }); + + const result = await queryAllPages("testEndpoint", {}); + + expect(result).toEqual([{ id: 1 }, { id: 2 }]); + }); + + it("should concatenate members across multiple pages", async () => { + let callCount = 0; + mockDispatch.mockImplementation(() => { + callCount += 1; + const page = callCount; + return { + unwrap: () => + Promise.resolve({ + "hydra:member": [{ id: page }], + "hydra:view": + page < 3 ? { "hydra:next": `/page/${page + 1}` } : {}, + }), + abort: vi.fn(), + unsubscribe: vi.fn(), + }; + }); + + const result = await queryAllPages("testEndpoint", {}); + + expect(result).toEqual([{ id: 1 }, { id: 2 }, { id: 3 }]); + }); + + it("should stop when hydra:view has no hydra:next", async () => { + dispatchDefaults.unwrapResult = Promise.resolve({ + "hydra:member": [{ id: 1 }], + "hydra:view": { "hydra:last": "/page/1" }, + }); + + const result = await queryAllPages("testEndpoint", {}); + + expect(result).toEqual([{ id: 1 }]); + expect(mockDispatch).toHaveBeenCalledTimes(1); + }); + + it("should return partial results when mid-pagination fetch throws", async () => { + let callCount = 0; + mockDispatch.mockImplementation(() => { + callCount += 1; + const page = callCount; + if (page === 2) { + return { + unwrap: () => Promise.reject(new Error("fail page 2")), + abort: vi.fn(), + unsubscribe: vi.fn(), + }; + } + return { + unwrap: () => + Promise.resolve({ + "hydra:member": [{ id: page }], + "hydra:view": { "hydra:next": `/page/${page + 1}` }, + }), + abort: vi.fn(), + unsubscribe: vi.fn(), + }; + }); + + const result = await queryAllPages("testEndpoint", {}); + + expect(result).toEqual([{ id: 1 }]); + }); + + it("should return empty array when page 1 returns null", async () => { + dispatchDefaults.unwrapResult = Promise.resolve(null); + + const result = await queryAllPages("testEndpoint", {}); + + expect(result).toEqual([]); + }); + + it("should log error on null response", async () => { + dispatchDefaults.unwrapResult = Promise.resolve(null); + + await queryAllPages("testEndpoint", {}); + + expect(logger.error).toHaveBeenCalledWith( + "Failed to fetch page 1 for testEndpoint", + ); + }); + + it("should pass forceRefetch through to query", async () => { + dispatchDefaults.unwrapResult = Promise.resolve({ + "hydra:member": [], + "hydra:view": {}, + }); + + await queryAllPages("testEndpoint", { filter: "x" }, true); + + expect(endpoints.testEndpoint.initiate).toHaveBeenCalledWith( + { filter: "x", page: 1 }, + { forceRefetch: true }, + ); + }); + + it("should stop at MAX_PAGES and log warning", async () => { + mockDispatch.mockImplementation(() => ({ + unwrap: () => + Promise.resolve({ + "hydra:member": [{ id: "item" }], + "hydra:view": { "hydra:next": "/next" }, + }), + abort: vi.fn(), + unsubscribe: vi.fn(), + })); + + const result = await queryAllPages("testEndpoint", {}); + + expect(result).toHaveLength(50); + expect(logger.warn).toHaveBeenCalledWith( + "Reached max page limit (50) for testEndpoint", + ); + }); +}); diff --git a/assets/tests/client/app-storage.test.js b/assets/tests/client/app-storage.test.js new file mode 100644 index 000000000..8efcbe584 --- /dev/null +++ b/assets/tests/client/app-storage.test.js @@ -0,0 +1,154 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("jwt-decode", () => ({ + default: () => ({ exp: 1700000000, iat: 1699990000 }), +})); + +import appStorage from "../../client/core/app-storage"; + +describe("AppStorage", () => { + beforeEach(() => { + localStorage.clear(); + }); + + describe("token", () => { + it("setToken stores token, exp and iat", () => { + appStorage.setToken("fake.jwt.token"); + expect(localStorage.getItem("apiToken")).toBe("fake.jwt.token"); + expect(localStorage.getItem("apiTokenExpire")).toBe("1700000000"); + expect(localStorage.getItem("apiTokenIssuedAt")).toBe("1699990000"); + }); + + it("getToken returns stored token", () => { + localStorage.setItem("apiToken", "my-token"); + expect(appStorage.getToken()).toBe("my-token"); + }); + + it("getToken returns null when nothing stored", () => { + expect(appStorage.getToken()).toBeNull(); + }); + + it("getTokenExpire returns parsed integer", () => { + localStorage.setItem("apiTokenExpire", "1700000000"); + expect(appStorage.getTokenExpire()).toBe(1700000000); + }); + + it("getTokenExpire returns null when nothing stored", () => { + expect(appStorage.getTokenExpire()).toBeNull(); + }); + + it("getTokenIssueAt returns parsed integer", () => { + localStorage.setItem("apiTokenIssuedAt", "1699990000"); + expect(appStorage.getTokenIssueAt()).toBe(1699990000); + }); + + it("getTokenIssueAt returns null when nothing stored", () => { + expect(appStorage.getTokenIssueAt()).toBeNull(); + }); + + it("clearToken removes all token keys", () => { + localStorage.setItem("apiToken", "t"); + localStorage.setItem("apiTokenExpire", "1"); + localStorage.setItem("apiTokenIssuedAt", "2"); + appStorage.clearToken(); + expect(localStorage.getItem("apiToken")).toBeNull(); + expect(localStorage.getItem("apiTokenExpire")).toBeNull(); + expect(localStorage.getItem("apiTokenIssuedAt")).toBeNull(); + }); + }); + + describe("refreshToken", () => { + it("round-trips set and get", () => { + appStorage.setRefreshToken("refresh-123"); + expect(appStorage.getRefreshToken()).toBe("refresh-123"); + }); + + it("clearRefreshToken removes the value", () => { + appStorage.setRefreshToken("refresh-123"); + appStorage.clearRefreshToken(); + expect(appStorage.getRefreshToken()).toBeNull(); + }); + }); + + describe("screenId", () => { + it("round-trips set and get", () => { + appStorage.setScreenId("screen-abc"); + expect(appStorage.getScreenId()).toBe("screen-abc"); + }); + + it("clearScreenId removes the value", () => { + appStorage.setScreenId("screen-abc"); + appStorage.clearScreenId(); + expect(appStorage.getScreenId()).toBeNull(); + }); + }); + + describe("tenant", () => { + it("setTenant stores both key and id", () => { + appStorage.setTenant("key-1", "id-1"); + expect(appStorage.getTenantKey()).toBe("key-1"); + expect(appStorage.getTenantId()).toBe("id-1"); + }); + + it("clearTenant removes both key and id", () => { + appStorage.setTenant("key-1", "id-1"); + appStorage.clearTenant(); + expect(appStorage.getTenantKey()).toBeNull(); + expect(appStorage.getTenantId()).toBeNull(); + }); + }); + + describe("fallbackImageUrl", () => { + it("round-trips set and get", () => { + appStorage.setFallbackImageUrl("https://example.com/img.png"); + expect(appStorage.getFallbackImageUrl()).toBe( + "https://example.com/img.png" + ); + }); + + it("clearFallbackImageUrl removes the value", () => { + appStorage.setFallbackImageUrl("https://example.com/img.png"); + appStorage.clearFallbackImageUrl(); + expect(appStorage.getFallbackImageUrl()).toBeNull(); + }); + }); + + describe("apiUrl", () => { + it("setApiUrl stores the value", () => { + appStorage.setApiUrl("https://api.example.com"); + expect(localStorage.getItem("apiUrl")).toBe("https://api.example.com"); + }); + }); + + describe("previousBoot", () => { + it("returns 0 when nothing stored", () => { + expect(appStorage.getPreviousBoot()).toBe(0); + }); + + it("round-trips set and get", () => { + appStorage.setPreviousBoot("1700000000"); + expect(appStorage.getPreviousBoot()).toBe("1700000000"); + }); + }); + + describe("clearAppStorage", () => { + it("clears token, refreshToken, screenId, tenant, and fallbackImage", () => { + appStorage.setToken("fake.jwt.token"); + appStorage.setRefreshToken("refresh"); + appStorage.setScreenId("screen"); + appStorage.setTenant("key", "id"); + appStorage.setFallbackImageUrl("img.png"); + appStorage.setApiUrl("https://api.example.com"); + + appStorage.clearAppStorage(); + + expect(appStorage.getToken()).toBeNull(); + expect(appStorage.getRefreshToken()).toBeNull(); + expect(appStorage.getScreenId()).toBeNull(); + expect(appStorage.getTenantKey()).toBeNull(); + expect(appStorage.getFallbackImageUrl()).toBeNull(); + // apiUrl is NOT cleared by clearAppStorage + expect(localStorage.getItem("apiUrl")).toBe("https://api.example.com"); + }); + }); +}); diff --git a/assets/tests/client/app.test.jsx b/assets/tests/client/app.test.jsx new file mode 100644 index 000000000..d98d295ec --- /dev/null +++ b/assets/tests/client/app.test.jsx @@ -0,0 +1,407 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, cleanup, act, fireEvent } from "@testing-library/react"; + +// --- Hoisted mocks --- +// +// App.jsx orchestrates 12+ services/modules. Each is mocked below so we can +// verify wiring without running real API calls or timers. +// +// mockScreen / mockIsContentEmpty use a { value } wrapper so tests can mutate +// the value returned by the useClientState mock (plain primitives can't be +// reassigned from outside the hoisted closure). +const { + mockContentService, + mockTokenService, + mockReleaseService, + mockTenantService, + mockStatusService, + mockAppStorage, + mockConfigLoader, + mockReauthRef, + mockCallbacks, + mockScreen, + mockIsContentEmpty, +} = vi.hoisted(() => { + // --- Service mocks --- + const mockContentService = { + start: vi.fn(), + stop: vi.fn(), + startSyncing: vi.fn(), + stopSync: vi.fn(), + startPreview: vi.fn(), + }; + const mockTokenService = { + checkLogin: vi.fn().mockResolvedValue({ status: "ready", screenId: "S1" }), + checkToken: vi.fn(), + refreshToken: vi.fn().mockResolvedValue(), + startRefreshing: vi.fn(), + stopRefreshing: vi.fn(), + }; + const mockReleaseService = { + checkForNewRelease: vi.fn().mockResolvedValue(), + setPreviousBootInUrl: vi.fn(), + startReleaseCheck: vi.fn(), + stopReleaseCheck: vi.fn(), + setScreenIdInUrl: vi.fn(), + }; + const mockTenantService = { loadTenantConfig: vi.fn() }; + const mockStatusService = { + setStatus: vi.fn(), + setError: vi.fn(), + setStatusInUrl: vi.fn(), + error: null, + }; + + // --- Storage / config mocks --- + const mockAppStorage = { + getToken: vi.fn().mockReturnValue(null), + getScreenId: vi.fn().mockReturnValue(null), + getFallbackImageUrl: vi.fn().mockReturnValue(null), + setPreviousBoot: vi.fn(), + clearToken: vi.fn(), + clearRefreshToken: vi.fn(), + clearScreenId: vi.fn(), + clearTenant: vi.fn(), + clearFallbackImageUrl: vi.fn(), + clearAppStorage: vi.fn(), + }; + const mockConfigLoader = { + loadConfig: vi.fn().mockResolvedValue({ debug: false }), + }; + + // --- React bridge mocks --- + const mockReauthRef = { current: vi.fn() }; + const mockCallbacks = { + current: { + setScreen: vi.fn(), + setIsContentEmpty: vi.fn(), + updateRegionSlides: vi.fn(), + onRegionReady: vi.fn(), + onRegionRemoved: vi.fn(), + onReauthenticate: vi.fn(), + }, + }; + + // Mutable wrappers so tests can change the value returned by useClientState. + const mockScreen = { value: null }; + const mockIsContentEmpty = { value: false }; + + return { + mockContentService, + mockTokenService, + mockReleaseService, + mockTenantService, + mockStatusService, + mockAppStorage, + mockConfigLoader, + mockReauthRef, + mockCallbacks, + mockScreen, + mockIsContentEmpty, + }; +}); + +vi.mock("../../client/core/logger.js", () => ({ + default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), log: vi.fn() }, +})); +vi.mock("../../client/app.scss", () => ({})); +vi.mock("../../client/assets/fallback.png", () => ({ + default: "fallback.png", +})); +vi.mock("../../client/components/screen.jsx", () => ({ + default: ({ screen }) => ( +
{screen["@id"]}
+ ), +})); +vi.mock("../../client/components/screen.scss", () => ({})); +vi.mock("../../client/service/content-service", () => ({ + default: vi.fn(function () { + return mockContentService; + }), +})); +vi.mock("../../client/core/client-config-loader.js", () => ({ + default: mockConfigLoader, +})); +vi.mock("../../client/core/app-storage", () => ({ + default: mockAppStorage, +})); +vi.mock("../../client/util/defaults", () => ({ + default: { + loginCheckTimeoutDefault: 100, + refreshTokenTimeoutDefault: 100, + releaseTimestampIntervalTimeoutDefault: 100, + }, +})); +vi.mock("../../client/service/token-service", () => ({ + default: mockTokenService, +})); +vi.mock("../../client/service/release-service", () => ({ + default: mockReleaseService, +})); +vi.mock("../../client/service/tenant-service", () => ({ + default: mockTenantService, +})); +vi.mock("../../client/service/status-service", () => ({ + default: mockStatusService, +})); +vi.mock("../../client/util/constants", () => { + const c = { + LOGIN_STATUS_READY: "ready", + LOGIN_STATUS_AWAITING_BIND_KEY: "awaitingBindKey", + STATUS_RUNNING: "running", + STATUS_LOGIN: "login", + ERROR_TOKEN_REFRESH_FAILED: "ER101", + SLIDE_ERROR_RECOVERY_TIMEOUT: 5000, + SLIDE_TRANSITION_TIMEOUT: 1000, + COLOR_SCHEME_REFRESH_INTERVAL: 300000, + }; + return { default: c }; +}); +vi.mock("../../client/redux/reauthenticate-ref", () => ({ + default: mockReauthRef, +})); +vi.mock("../../client/client-state-context.jsx", () => ({ + useClientState: () => ({ + screen: mockScreen.value, + isContentEmpty: mockIsContentEmpty.value, + callbacks: mockCallbacks, + }), +})); + +import App from "../../client/app.jsx"; +import ContentService from "../../client/service/content-service"; + +describe("App", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + + // Reset state + mockScreen.value = null; + mockIsContentEmpty.value = false; + mockStatusService.error = null; + + mockAppStorage.getToken.mockReturnValue(null); + mockAppStorage.getScreenId.mockReturnValue(null); + mockAppStorage.getFallbackImageUrl.mockReturnValue(null); + mockTokenService.checkLogin.mockResolvedValue({ status: "ready", screenId: "S1" }); + mockTokenService.refreshToken.mockResolvedValue(); + mockReleaseService.checkForNewRelease.mockResolvedValue(); + mockConfigLoader.loadConfig.mockResolvedValue({ debug: false }); + }); + + afterEach(() => { + cleanup(); + vi.useRealTimers(); + }); + + describe("preview mode", () => { + it("should start content with previewId for screen preview", async () => { + await act(async () => { + render(); + }); + + expect(ContentService).toHaveBeenCalled(); + expect(mockContentService.start).toHaveBeenCalled(); + expect(mockContentService.startSyncing).toHaveBeenCalledWith( + "/v2/screens/SCREEN_ABC", + ); + }); + + it("should call startPreview for non-screen preview", async () => { + await act(async () => { + render(); + }); + + expect(ContentService).toHaveBeenCalled(); + expect(mockContentService.start).toHaveBeenCalled(); + expect(mockContentService.startPreview).toHaveBeenCalledWith( + "playlist", + "PL_123", + ); + }); + + it("should not add keyboard listener in preview mode", async () => { + const addSpy = vi.spyOn(document, "addEventListener"); + + await act(async () => { + render(); + }); + + expect(addSpy).not.toHaveBeenCalledWith( + "keydown", + expect.any(Function), + ); + }); + }); + + describe("normal mode - login flow", () => { + it("should use fast path when token and screenId exist in storage", async () => { + mockAppStorage.getToken.mockReturnValue("jwt-token"); + mockAppStorage.getScreenId.mockReturnValue("SCREEN_FAST"); + + await act(async () => { + render(); + // Flush the promise chain: releaseService.checkForNewRelease().finally(checkLogin). + await vi.advanceTimersByTimeAsync(0); + }); + + expect(mockContentService.startSyncing).toHaveBeenCalledWith( + "/v2/screens/SCREEN_FAST", + ); + expect(mockTokenService.checkLogin).not.toHaveBeenCalled(); + }); + + it("should display bindKey when login status is awaitingBindKey", async () => { + mockTokenService.checkLogin.mockResolvedValue({ + status: "awaitingBindKey", + bindKey: "ABC-123", + }); + + let result; + await act(async () => { + result = render(); + await vi.advanceTimersByTimeAsync(0); + }); + + expect(result.container.querySelector(".bind-key").textContent).toBe( + "ABC-123", + ); + }); + + it("should retry login on checkLogin failure", async () => { + mockTokenService.checkLogin.mockRejectedValueOnce(new Error("fail")); + + await act(async () => { + render(); + await vi.advanceTimersByTimeAsync(0); + }); + + // restartLoginTimeout is called, which loads config then sets timeout + expect(mockConfigLoader.loadConfig).toHaveBeenCalled(); + }); + }); + + describe("reauthenticateHandler", () => { + // Mount the App in normal mode, which wires reauthenticateRef.current to + // the internal reauthenticateHandler. Returns that handler so tests can + // invoke it directly (simulating a 401 from base-query). + async function mountAndGetReauthHandler() { + await act(async () => { + render(); + await vi.advanceTimersByTimeAsync(0); + }); + return mockReauthRef.current; + } + + it("should attempt token refresh on reauthenticate", async () => { + const handler = await mountAndGetReauthHandler(); + + await act(async () => { + handler(); + await vi.advanceTimersByTimeAsync(0); + }); + + expect(mockTokenService.refreshToken).toHaveBeenCalled(); + }); + + it("should clean up and restart login on refresh failure", async () => { + mockTokenService.refreshToken.mockRejectedValue(new Error("expired")); + const handler = await mountAndGetReauthHandler(); + + await act(async () => { + handler(); + await vi.advanceTimersByTimeAsync(0); + }); + + expect(mockStatusService.setError).toHaveBeenCalledWith("ER101"); + expect(mockAppStorage.clearToken).toHaveBeenCalled(); + expect(mockAppStorage.clearRefreshToken).toHaveBeenCalled(); + expect(mockAppStorage.clearScreenId).toHaveBeenCalled(); + expect(mockAppStorage.clearTenant).toHaveBeenCalled(); + expect(mockAppStorage.clearFallbackImageUrl).toHaveBeenCalled(); + expect(mockCallbacks.current.setScreen).toHaveBeenCalledWith(null); + expect(mockTokenService.stopRefreshing).toHaveBeenCalled(); + }); + + it("should guard against concurrent reauthentication", async () => { + // Make refreshToken hang (never resolve) so the first call stays in-flight. + mockTokenService.refreshToken.mockReturnValue(new Promise(() => {})); + const handler = await mountAndGetReauthHandler(); + + await act(async () => { + handler(); // first call + handler(); // second call while first is in-flight + }); + + expect(mockTokenService.refreshToken).toHaveBeenCalledTimes(1); + }); + }); + + describe("keyboard handler", () => { + it("should clear storage and reload on Ctrl+I", async () => { + const reloadMock = vi.fn(); + vi.stubGlobal("location", { + ...window.location, + href: "http://localhost/", + reload: reloadMock, + }); + + await act(async () => { + render(); + await vi.advanceTimersByTimeAsync(0); + }); + + fireEvent.keyDown(document, { code: "KeyI", ctrlKey: true, repeat: false }); + + expect(mockAppStorage.clearAppStorage).toHaveBeenCalled(); + expect(reloadMock).toHaveBeenCalled(); + + vi.unstubAllGlobals(); + }); + + it("should not trigger on repeated keydown events", async () => { + const reloadMock = vi.fn(); + vi.stubGlobal("location", { + ...window.location, + href: "http://localhost/", + reload: reloadMock, + }); + + await act(async () => { + render(); + await vi.advanceTimersByTimeAsync(0); + }); + + fireEvent.keyDown(document, { code: "KeyI", ctrlKey: true, repeat: true }); + + expect(reloadMock).not.toHaveBeenCalled(); + + vi.unstubAllGlobals(); + }); + }); + + describe("cleanup on unmount", () => { + it("should stop content service and remove listeners on unmount", async () => { + // Start content first so contentServiceRef is populated. + mockAppStorage.getToken.mockReturnValue("jwt"); + mockAppStorage.getScreenId.mockReturnValue("SCR1"); + const removeSpy = vi.spyOn(document, "removeEventListener"); + + let unmountFn; + await act(async () => { + const { unmount } = render(); + unmountFn = unmount; + await vi.advanceTimersByTimeAsync(0); + }); + + unmountFn(); + + expect(mockContentService.stopSync).toHaveBeenCalled(); + expect(mockContentService.stop).toHaveBeenCalled(); + expect(removeSpy).toHaveBeenCalledWith("keydown", expect.any(Function)); + expect(mockTokenService.stopRefreshing).toHaveBeenCalled(); + expect(mockReleaseService.stopReleaseCheck).toHaveBeenCalled(); + }); + }); +}); diff --git a/assets/tests/client/base-query.test.js b/assets/tests/client/base-query.test.js new file mode 100644 index 000000000..5084c7002 --- /dev/null +++ b/assets/tests/client/base-query.test.js @@ -0,0 +1,149 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +// --- Hoisted mocks --- +const { mockRawBaseQuery, mockReauthRef } = vi.hoisted(() => { + const mockRawBaseQuery = vi.fn().mockResolvedValue({ data: "ok" }); + const mockReauthRef = { current: vi.fn() }; + return { mockRawBaseQuery, mockReauthRef }; +}); + +vi.mock("@reduxjs/toolkit/query/react", () => ({ + fetchBaseQuery: () => mockRawBaseQuery, +})); + +vi.mock("../../client/redux/reauthenticate-ref", () => ({ + default: mockReauthRef, +})); + +import clientBaseQuery from "../../client/redux/base-query"; + +describe("clientBaseQuery", () => { + const api = {}; + const extraOptions = {}; + + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + mockRawBaseQuery.mockResolvedValue({ data: "ok" }); + // Default: no preview params. + vi.stubGlobal("location", { href: "http://localhost/" }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + describe("headers", () => { + it("should set accept to application/ld+json when no accept header", async () => { + await clientBaseQuery({ url: "/test" }, api, extraOptions); + + const passedArgs = mockRawBaseQuery.mock.calls[0][0]; + expect(passedArgs.headers.accept).toBe("application/ld+json"); + }); + + it("should preserve existing accept header", async () => { + await clientBaseQuery( + { url: "/test", headers: { accept: "text/html" } }, + api, + extraOptions, + ); + + const passedArgs = mockRawBaseQuery.mock.calls[0][0]; + expect(passedArgs.headers.accept).toBe("text/html"); + }); + + it("should initialize headers object when args has none", async () => { + await clientBaseQuery({ url: "/test" }, api, extraOptions); + + const passedArgs = mockRawBaseQuery.mock.calls[0][0]; + expect(passedArgs.headers).toBeDefined(); + expect(passedArgs.headers.accept).toBe("application/ld+json"); + }); + }); + + describe("authorization", () => { + it("should use preview-token from URL over localStorage token", async () => { + vi.stubGlobal("location", { + href: "http://localhost/?preview-token=preview-jwt", + }); + localStorage.setItem("apiToken", "stored-jwt"); + + await clientBaseQuery({ url: "/test" }, api, extraOptions); + + const passedArgs = mockRawBaseQuery.mock.calls[0][0]; + expect(passedArgs.headers.authorization).toBe("Bearer preview-jwt"); + }); + + it("should use localStorage apiToken when no preview-token", async () => { + localStorage.setItem("apiToken", "stored-jwt"); + + await clientBaseQuery({ url: "/test" }, api, extraOptions); + + const passedArgs = mockRawBaseQuery.mock.calls[0][0]; + expect(passedArgs.headers.authorization).toBe("Bearer stored-jwt"); + }); + + it("should not set authorization when neither exists", async () => { + await clientBaseQuery({ url: "/test" }, api, extraOptions); + + const passedArgs = mockRawBaseQuery.mock.calls[0][0]; + expect(passedArgs.headers.authorization).toBeUndefined(); + }); + }); + + describe("tenant key", () => { + it("should use preview-tenant from URL over localStorage tenantKey", async () => { + vi.stubGlobal("location", { + href: "http://localhost/?preview-tenant=preview-tenant-key", + }); + localStorage.setItem("tenantKey", "stored-tenant"); + + await clientBaseQuery({ url: "/test" }, api, extraOptions); + + const passedArgs = mockRawBaseQuery.mock.calls[0][0]; + expect(passedArgs.headers["Authorization-Tenant-Key"]).toBe( + "preview-tenant-key", + ); + }); + + it("should use localStorage tenantKey when no preview-tenant", async () => { + localStorage.setItem("tenantKey", "stored-tenant"); + + await clientBaseQuery({ url: "/test" }, api, extraOptions); + + const passedArgs = mockRawBaseQuery.mock.calls[0][0]; + expect(passedArgs.headers["Authorization-Tenant-Key"]).toBe( + "stored-tenant", + ); + }); + + it("should not set tenant header when neither exists", async () => { + await clientBaseQuery({ url: "/test" }, api, extraOptions); + + const passedArgs = mockRawBaseQuery.mock.calls[0][0]; + expect( + passedArgs.headers["Authorization-Tenant-Key"], + ).toBeUndefined(); + }); + }); + + describe("401 handling", () => { + it("should call reauthenticateRef.current on 401 error", async () => { + mockRawBaseQuery.mockResolvedValue({ error: { status: 401 } }); + + await clientBaseQuery({ url: "/test" }, api, extraOptions); + + expect(mockReauthRef.current).toHaveBeenCalled(); + }); + + it("should not call reauthenticateRef on non-401 errors or success", async () => { + mockRawBaseQuery.mockResolvedValue({ error: { status: 500 } }); + await clientBaseQuery({ url: "/test" }, api, extraOptions); + + mockRawBaseQuery.mockResolvedValue({ data: "ok" }); + await clientBaseQuery({ url: "/test" }, api, extraOptions); + + expect(mockReauthRef.current).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/assets/tests/client/client-config-loader.test.js b/assets/tests/client/client-config-loader.test.js new file mode 100644 index 000000000..7dab7838b --- /dev/null +++ b/assets/tests/client/client-config-loader.test.js @@ -0,0 +1,132 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +vi.mock("../../client/core/app-storage.js", () => ({ + default: { setApiUrl: vi.fn() }, +})); +vi.mock("../../client/core/logger.js", () => ({ + default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), log: vi.fn() }, +})); + +const mockConfig = { + apiEndpoint: "https://api.test.com", + schedulingInterval: 60000, + debug: false, +}; + +describe("ClientConfigLoader", () => { + let ClientConfigLoader; + let appStorage; + + beforeEach(async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2025-06-15T12:00:00Z")); + vi.resetModules(); + + // Re-mock after resetModules + vi.doMock("../../client/core/app-storage.js", () => ({ + default: { setApiUrl: vi.fn() }, + })); + vi.doMock("../../client/core/logger.js", () => ({ + default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), log: vi.fn() }, + })); + + const module = await import("../../client/core/client-config-loader.js"); + ClientConfigLoader = module.default; + + const storageModule = await import("../../client/core/app-storage.js"); + appStorage = storageModule.default; + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it("fetches /config/client and returns the data", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: () => Promise.resolve(mockConfig), + }) + ); + + const config = await ClientConfigLoader.loadConfig(); + expect(fetch).toHaveBeenCalledWith("/config/client"); + expect(config).toEqual(mockConfig); + }); + + it("calls appStorage.setApiUrl with the apiEndpoint", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: () => Promise.resolve(mockConfig), + }) + ); + + await ClientConfigLoader.loadConfig(); + expect(appStorage.setApiUrl).toHaveBeenCalledWith("https://api.test.com"); + }); + + it("returns cached data on second call within cache window", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + json: () => Promise.resolve(mockConfig), + }); + vi.stubGlobal("fetch", fetchMock); + + await ClientConfigLoader.loadConfig(); + + // Advance 5 minutes (within 15 min default cache) + vi.setSystemTime(new Date("2025-06-15T12:05:00Z")); + + const config = await ClientConfigLoader.loadConfig(); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(config).toEqual(mockConfig); + }); + + it("re-fetches after cache expires", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + json: () => Promise.resolve(mockConfig), + }); + vi.stubGlobal("fetch", fetchMock); + + await ClientConfigLoader.loadConfig(); + + // Wait for the finally() callback to clear activePromise + await vi.advanceTimersByTimeAsync(0); + + // Advance 16 minutes (past 15 min default cache) + vi.setSystemTime(new Date("2025-06-15T12:16:00Z")); + + await ClientConfigLoader.loadConfig(); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("returns default config when fetch fails and no cached data", async () => { + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("network"))); + + const config = await ClientConfigLoader.loadConfig(); + expect(config.apiEndpoint).toBe("/api"); + expect(config.schedulingInterval).toBe(60000); + expect(config.debug).toBe(false); + }); + + it("returns cached data when fetch fails and cached data exists", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce({ + json: () => Promise.resolve(mockConfig), + }) + .mockRejectedValueOnce(new Error("network")); + vi.stubGlobal("fetch", fetchMock); + + // First call succeeds + await ClientConfigLoader.loadConfig(); + + // Advance past cache + vi.setSystemTime(new Date("2025-06-15T12:16:00Z")); + + // Second call fails — should return cached + const config = await ClientConfigLoader.loadConfig(); + expect(config).toEqual(mockConfig); + }); +}); diff --git a/assets/tests/client/client-e2e-fixtures.js b/assets/tests/client/client-e2e-fixtures.js new file mode 100644 index 000000000..b01c87e8d --- /dev/null +++ b/assets/tests/client/client-e2e-fixtures.js @@ -0,0 +1,176 @@ +import { clientConfigJson } from "../admin/data-fixtures.js"; + +// --- IDs (must be exactly 26 alphanumeric chars for idFromPath regex) --- +const SCREEN_ID = "SCREEN0001AAAAAAAAAAAAAAAA"; +const LAYOUT_ID = "LAYOUT0001AAAAAAAAAAAAAAAA"; +const REGION_ID = "REGION0001AAAAAAAAAAAAAAAA"; +const PLAYLIST_1_ID = "PLAYLS0001AAAAAAAAAAAAAAAA"; +const PLAYLIST_2_ID = "PLAYLS0002AAAAAAAAAAAAAAAA"; +const SLIDE_1_ID = "SLIDES0001AAAAAAAAAAAAAAAA"; +const SLIDE_2_ID = "SLIDES0002AAAAAAAAAAAAAAAA"; +const SLIDE_3_ID = "SLIDES0003AAAAAAAAAAAAAAAA"; +const TEMPLATE_ID = "01FP2SNGFN0BZQH03KCBXHKYHG"; // Must match image-text.json id +const MEDIA_1_ID = "MEDIAS0001AAAAAAAAAAAAAAAA"; +const MEDIA_2_ID = "MEDIAS0002AAAAAAAAAAAAAAAA"; +const TENANT_ID = "TENANT0001AAAAAAAAAAAAAAAA"; + +// Minimal JWT with exp in 2099. jwt-decode only decodes, no signature check. +// Header: {"alg":"none","typ":"JWT"} Payload: {"iat":1700000000,"exp":4102444800} +const TEST_JWT_TOKEN = + "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJpYXQiOjE3MDAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMH0."; + +const releaseJson = { + releaseTimestamp: null, + releaseVersion: null, + releaseTime: null, +}; + +const loginReadyJson = { + status: "ready", + token: TEST_JWT_TOKEN, + refresh_token: "test-refresh-token", + screenId: SCREEN_ID, + tenantKey: "TestTenantKey", + tenantId: TENANT_ID, +}; + +const loginBindKeyJson = { + status: "awaitingBindKey", + bindKey: "TEST-BIND-KEY", +}; + +const screenJson = { + "@id": `/v2/screens/${SCREEN_ID}`, + layout: `/v2/layouts/${LAYOUT_ID}`, + regions: [`/v2/screens/${SCREEN_ID}/regions/${REGION_ID}/playlists`], + relationsChecksum: { + campaigns: "aaa", + inScreenGroups: "bbb", + layout: "ccc", + regions: "ddd", + }, +}; + +const layoutJson = { + "@id": `/v2/layouts/${LAYOUT_ID}`, + grid: { rows: 1, columns: 1 }, + regions: [{ "@id": `/v2/layouts/regions/${REGION_ID}`, gridArea: ["a"] }], +}; + +const regionPlaylistsJson = { + "hydra:member": [ + { + playlist: { + "@id": `/v2/playlists/${PLAYLIST_1_ID}`, + title: "Playlist 1", + published: { from: "2020-01-01T00:00:00.000Z" }, + schedules: [], + slides: `/v2/playlists/${PLAYLIST_1_ID}/slides`, + }, + }, + { + playlist: { + "@id": `/v2/playlists/${PLAYLIST_2_ID}`, + title: "Playlist 2", + published: { from: "2020-01-01T00:00:00.000Z" }, + schedules: [], + slides: `/v2/playlists/${PLAYLIST_2_ID}/slides`, + }, + }, + ], + "hydra:totalItems": 2, +}; + +function makeSlide(id, mediaIds, title, text, checksumSuffix) { + return { + "@id": `/v2/slides/${id}`, + templateInfo: { "@id": `/v2/templates/${TEMPLATE_ID}` }, + media: mediaIds.map((mid) => `/v2/media/${mid}`), + content: { + duration: 2000, + title, + text, + boxAlign: "left", + fontSize: "font-size-m", + }, + published: { from: "2020-01-01T00:00:00.000Z" }, + relationsChecksum: { + templateInfo: `t${checksumSuffix}`, + media: `m${checksumSuffix}`, + }, + }; +} + +const playlist1SlidesJson = { + "hydra:member": [ + { slide: makeSlide(SLIDE_1_ID, [MEDIA_1_ID], "Slide 1 Title", "Slide 1 text", "1") }, + { slide: makeSlide(SLIDE_2_ID, [MEDIA_2_ID], "Slide 2 Title", "Slide 2 text", "2") }, + ], + "hydra:totalItems": 2, +}; + +const playlist2SlidesJson = { + "hydra:member": [ + { slide: makeSlide(SLIDE_3_ID, [], "Slide 3 Title", "Slide 3 text", "3") }, + ], + "hydra:totalItems": 1, +}; + +const templateJson = { + "@id": `/v2/templates/${TEMPLATE_ID}`, + id: TEMPLATE_ID, + resources: {}, +}; + +const media1Json = { + "@id": `/v2/media/${MEDIA_1_ID}`, + assets: { uri: "/fixtures/template/images/mountain1.jpeg" }, +}; + +const media2Json = { + "@id": `/v2/media/${MEDIA_2_ID}`, + assets: { uri: "/fixtures/template/images/mountain2.jpeg" }, +}; + +const tenantJson = { + "@id": `/v2/tenants/${TENANT_ID}`, + fallbackImageUrl: null, +}; + +const emptyHydraJson = { + "hydra:member": [], + "hydra:totalItems": 0, +}; + +// Short login check timeout for bind-key test (2 seconds). +const clientConfigShortLoginJson = { + ...clientConfigJson, + loginCheckTimeout: 2000, +}; + +export { + SCREEN_ID, + LAYOUT_ID, + REGION_ID, + PLAYLIST_1_ID, + PLAYLIST_2_ID, + TEMPLATE_ID, + MEDIA_1_ID, + MEDIA_2_ID, + TENANT_ID, + releaseJson, + clientConfigJson, + clientConfigShortLoginJson, + loginReadyJson, + loginBindKeyJson, + screenJson, + layoutJson, + regionPlaylistsJson, + playlist1SlidesJson, + playlist2SlidesJson, + templateJson, + media1Json, + media2Json, + tenantJson, + emptyHydraJson, +}; diff --git a/assets/tests/client/client-e2e.spec.js b/assets/tests/client/client-e2e.spec.js new file mode 100644 index 000000000..2e5e2be7a --- /dev/null +++ b/assets/tests/client/client-e2e.spec.js @@ -0,0 +1,244 @@ +import { test, expect } from "@playwright/test"; +import { + SCREEN_ID, + LAYOUT_ID, + REGION_ID, + PLAYLIST_1_ID, + PLAYLIST_2_ID, + TEMPLATE_ID, + MEDIA_1_ID, + MEDIA_2_ID, + TENANT_ID, + releaseJson, + clientConfigJson, + clientConfigShortLoginJson, + loginReadyJson, + loginBindKeyJson, + screenJson, + layoutJson, + regionPlaylistsJson, + playlist1SlidesJson, + playlist2SlidesJson, + templateJson, + media1Json, + media2Json, + tenantJson, + emptyHydraJson, +} from "./client-e2e-fixtures.js"; + +/** + * Register all route mocks for the client application. + * + * Catch-all abort is registered first; specific routes registered after take + * priority (Playwright matches in LIFO order). + */ +async function setupClientRoutes(page, configOverride = null) { + // Catch-all: abort unregistered fetch/XHR requests (API calls). + // Document, script, stylesheet, and image requests pass through to the server. + await page.route("**/*", async (route) => { + const type = route.request().resourceType(); + if (type === "fetch" || type === "xhr") { + await route.abort(); + } else { + await route.continue(); + } + }); + + // Release check. + await page.route("**/release.json*", async (route) => { + await route.fulfill({ json: releaseJson }); + }); + + // Client config. + await page.route("**/config/client", async (route) => { + await route.fulfill({ json: configOverride ?? clientConfigJson }); + }); + + // Screen authentication (POST only). + await page.route("**/v2/authentication/screen", async (route) => { + if (route.request().method() === "POST") { + await route.fulfill({ json: loginReadyJson }); + } else { + await route.abort(); + } + }); + + // Screen data. + await page.route(`**/v2/screens/${SCREEN_ID}`, async (route) => { + await route.fulfill({ json: screenJson }); + }); + + // Layout. + await page.route(`**/v2/layouts/${LAYOUT_ID}`, async (route) => { + await route.fulfill({ json: layoutJson }); + }); + + // Region playlists. + await page.route( + `**/v2/screens/${SCREEN_ID}/regions/${REGION_ID}/playlists*`, + async (route) => { + await route.fulfill({ json: regionPlaylistsJson }); + }, + ); + + // Playlist 1 slides. + await page.route(`**/v2/playlists/${PLAYLIST_1_ID}/slides*`, async (route) => { + await route.fulfill({ json: playlist1SlidesJson }); + }); + + // Playlist 2 slides. + await page.route(`**/v2/playlists/${PLAYLIST_2_ID}/slides*`, async (route) => { + await route.fulfill({ json: playlist2SlidesJson }); + }); + + // Template. + await page.route(`**/v2/templates/${TEMPLATE_ID}`, async (route) => { + await route.fulfill({ json: templateJson }); + }); + + // Media. + await page.route(`**/v2/media/${MEDIA_1_ID}`, async (route) => { + await route.fulfill({ json: media1Json }); + }); + await page.route(`**/v2/media/${MEDIA_2_ID}`, async (route) => { + await route.fulfill({ json: media2Json }); + }); + + // Campaigns (empty). + await page.route(`**/v2/screens/${SCREEN_ID}/campaigns*`, async (route) => { + await route.fulfill({ json: emptyHydraJson }); + }); + + // Screen groups (empty). + await page.route( + `**/v2/screens/${SCREEN_ID}/screen-groups*`, + async (route) => { + await route.fulfill({ json: emptyHydraJson }); + }, + ); + + // Tenant. + await page.route(`**/v2/tenants/${TENANT_ID}`, async (route) => { + await route.fulfill({ json: tenantJson }); + }); +} + +test.describe("Client E2E: login, playlists, slide progression", () => { + test("Login with bind key then authentication", async ({ page }) => { + // Use short login timeout so the bind-key -> ready transition is fast. + await setupClientRoutes(page, clientConfigShortLoginJson); + + // Override auth to first return bind key. + let loginCallCount = 0; + await page.unroute("**/v2/authentication/screen"); + await page.route("**/v2/authentication/screen", async (route) => { + if (route.request().method() !== "POST") { + await route.abort(); + return; + } + + loginCallCount += 1; + + if (loginCallCount <= 1) { + await route.fulfill({ json: loginBindKeyJson }); + } else { + await route.fulfill({ json: loginReadyJson }); + } + }); + + await page.goto("/client"); + + // Bind key should be displayed. + await expect(page.locator(".bind-key")).toHaveText("TEST-BIND-KEY"); + + // After the next login poll, screen should appear. + await expect(page.locator(".screen")).toBeVisible({ timeout: 10000 }); + }); + + test("Screen renders with region and slides from multiple playlists", async ({ + page, + }) => { + await setupClientRoutes(page); + await page.goto("/client"); + + await expect(page.locator(".screen")).toBeVisible({ timeout: 10000 }); + await expect(page.locator(".region")).toBeVisible(); + await expect(page.locator(".slide")).toBeVisible(); + await expect(page.locator(".template-image-text")).toBeVisible(); + await expect(page.locator(".template-image-text h1")).toHaveText( + "Slide 1 Title", + ); + }); + + test("slideDone is called - slides transition after duration", async ({ + page, + }) => { + await setupClientRoutes(page); + await page.goto("/client"); + + // Wait for first slide. + await expect(page.locator(".template-image-text h1")).toHaveText( + "Slide 1 Title", + { timeout: 10000 }, + ); + + // Capture initial data-run. + const initialRun = await page.locator(".slide").first().getAttribute("data-run"); + + // Wait for data-run to change (proves slideDone was called). + await page.waitForFunction( + (oldRun) => { + const slide = document.querySelector(".slide"); + return slide && slide.dataset.run !== oldRun; + }, + initialRun, + { timeout: 10000 }, + ); + + // Second slide should now be visible (use .first() because TransitionGroup + // briefly keeps both old and new slides in the DOM during the transition). + await expect(page.locator(".template-image-text h1").first()).toHaveText( + "Slide 2 Title", + ); + }); + + test("Progress never stops - slides cycle through all playlists and wrap", async ({ + page, + }) => { + await setupClientRoutes(page); + await page.goto("/client"); + + // Expected slide order: Playlist 1 (Slide 1, Slide 2), Playlist 2 (Slide 3), then wrap. + const expectedTitles = [ + "Slide 1 Title", + "Slide 2 Title", + "Slide 3 Title", + "Slide 1 Title", // Wrap — proves progress never stops. + ]; + + for (const expectedTitle of expectedTitles) { + // Use .first() because TransitionGroup briefly keeps both old and new + // slides in the DOM during the CSS transition. + await expect(page.locator(".template-image-text h1").first()).toHaveText( + expectedTitle, + { timeout: 10000 }, + ); + + // Wait for this slide to finish (data-run changes). + if (expectedTitle !== expectedTitles[expectedTitles.length - 1]) { + const currentRun = await page + .locator(".slide") + .first() + .getAttribute("data-run"); + await page.waitForFunction( + (oldRun) => { + const slide = document.querySelector(".slide"); + return slide && slide.dataset.run !== oldRun; + }, + currentRun, + { timeout: 10000 }, + ); + } + } + }); +}); diff --git a/assets/tests/client/constants.test.js b/assets/tests/client/constants.test.js new file mode 100644 index 000000000..87c20badb --- /dev/null +++ b/assets/tests/client/constants.test.js @@ -0,0 +1,51 @@ +import { describe, it, expect } from "vitest"; +import constants from "../../client/util/constants"; + +describe("constants", () => { + it("has all login status constants", () => { + expect(constants.LOGIN_STATUS_READY).toBe("ready"); + expect(constants.LOGIN_STATUS_AWAITING_BIND_KEY).toBe("awaitingBindKey"); + expect(constants.LOGIN_STATUS_UNKNOWN).toBe("unknown"); + }); + + it("has all token state constants", () => { + expect(constants.TOKEN_EXPIRED).toBe("Expired"); + expect(constants.TOKEN_VALID_SHOULD_HAVE_BEEN_REFRESHED).toBe( + "ValidShouldHaveBeenRefreshed" + ); + expect(constants.TOKEN_VALID).toBe("Valid"); + }); + + it("has all app status constants", () => { + expect(constants.STATUS_INIT).toBe("init"); + expect(constants.STATUS_LOGIN).toBe("login"); + expect(constants.STATUS_RUNNING).toBe("running"); + }); + + it("has all error code constants following ER1xx pattern", () => { + expect(constants.ERROR_TOKEN_REFRESH_FAILED).toBe("ER101"); + expect(constants.ERROR_TOKEN_REFRESH_LOOP_FAILED).toBe("ER102"); + expect(constants.ERROR_TOKEN_EXP_IAT_NOT_SET).toBe("ER103"); + expect(constants.ERROR_RELEASE_FILE_NOT_LOADED).toBe("ER104"); + expect(constants.ERROR_TOKEN_EXPIRED).toBe("ER105"); + expect(constants.ERROR_TOKEN_VALID_SHOULD_HAVE_BEEN_REFRESHED).toBe( + "ER106" + ); + }); + + it("has all sentinel constants", () => { + expect(constants.NO_TOKEN).toBe("NO_TOKEN"); + expect(constants.NO_EXPIRE).toBe("NO_EXPIRE"); + expect(constants.NO_ISSUED_AT).toBe("NO_ISSUED_AT"); + }); + + it("has campaign region ID", () => { + expect(constants.CAMPAIGN_REGION_ID).toBe("01G112XBWFPY029RYFB8X2H4KD"); + }); + + it("has timing constants", () => { + expect(constants.SLIDE_ERROR_RECOVERY_TIMEOUT).toBe(5000); + expect(constants.SLIDE_TRANSITION_TIMEOUT).toBe(1000); + expect(constants.COLOR_SCHEME_REFRESH_INTERVAL).toBe(300000); + }); +}); diff --git a/assets/tests/client/content-service.test.js b/assets/tests/client/content-service.test.js new file mode 100644 index 000000000..6fba0d6c9 --- /dev/null +++ b/assets/tests/client/content-service.test.js @@ -0,0 +1,443 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../../client/core/logger.js", () => ({ + default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), log: vi.fn() }, +})); + +vi.mock("../../client/core/client-config-loader.js", () => ({ + default: { + loadConfig: vi.fn().mockResolvedValue({ pullStrategyInterval: 30000 }), + }, +})); + +vi.mock("../../client/service/data-sync", () => { + const MockDataSync = vi.fn(function () { + this.start = vi.fn(); + this.stop = vi.fn(); + }); + return { default: MockDataSync }; +}); + +vi.mock("../../client/service/schedule-service", () => { + const MockScheduleService = vi.fn(function () { + this.updateRegion = vi.fn(); + this.regionRemoved = vi.fn(); + }); + return { default: MockScheduleService }; +}); + +const mockQuery = vi.fn(); +vi.mock("../../client/core/api-query.js", () => ({ + query: (...args) => mockQuery(...args), +})); + +vi.mock("../../client/util/id-from-path", () => ({ + default: (path) => { + if (!path) return null; + const match = path.match(/[A-Za-z0-9]{26}/); + return match ? match[0] : null; + }, +})); + +vi.mock("../../client/util/preview", () => ({ + screenForPlaylistPreview: vi.fn((playlist) => ({ + "@id": "/v2/screens/PREVIEW", + regionData: { REGION01: [playlist] }, + layoutData: { grid: { rows: 1, columns: 1 }, regions: [] }, + })), + screenForSlidePreview: vi.fn((slide) => ({ + "@id": "/v2/screens/PREVIEW", + regionData: { REGION01: [{ slidesData: [slide] }] }, + layoutData: { grid: { rows: 1, columns: 1 }, regions: [] }, + })), +})); + +import ContentService from "../../client/service/content-service"; +import DataSync from "../../client/service/data-sync"; +import ScheduleService from "../../client/service/schedule-service"; +import logger from "../../client/core/logger.js"; + +function makeCallbacks() { + return { + current: { + setScreen: vi.fn(), + setIsContentEmpty: vi.fn(), + updateRegionSlides: vi.fn(), + onRegionReady: vi.fn(), + onRegionRemoved: vi.fn(), + }, + }; +} + +describe("ContentService", () => { + let service; + let callbacks; + + beforeEach(() => { + vi.clearAllMocks(); + callbacks = makeCallbacks(); + service = new ContentService(callbacks); + }); + + describe("constructor", () => { + it("creates a ScheduleService with the callbacks", () => { + expect(ScheduleService).toHaveBeenCalledWith(callbacks); + expect(service.scheduleService).toBeDefined(); + }); + }); + + describe("start / stop", () => { + it("wires onRegionReady and onRegionRemoved callbacks", () => { + service.start(); + + expect(callbacks.current.onRegionReady).toBe(service.regionReady); + expect(callbacks.current.onRegionRemoved).toBe(service.regionRemoved); + }); + + it("does not start twice", () => { + service.start(); + service.start(); + + expect(logger.warn).toHaveBeenCalledWith( + "Content service already started." + ); + }); + + it("clears callbacks on stop", () => { + service.start(); + service.stop(); + + expect(callbacks.current.onRegionReady).not.toBe(service.regionReady); + expect(callbacks.current.onRegionRemoved).not.toBe(service.regionRemoved); + }); + + it("stop is a no-op if not started", () => { + service.stop(); + + // Should not throw or log. + expect(logger.info).not.toHaveBeenCalledWith( + "Content service stopped." + ); + }); + }); + + describe("startSyncing / stopSync", () => { + it("creates a DataSync and starts it after config loads", async () => { + service.startSyncing("/v2/screens/ABC"); + await vi.waitFor(() => expect(DataSync).toHaveBeenCalled()); + + const config = DataSync.mock.calls[0][0]; + expect(config.entryPoint).toBe("/v2/screens/ABC"); + expect(config.interval).toBe(30000); + + const instance = DataSync.mock.results[0].value; + expect(instance.start).toHaveBeenCalled(); + }); + + it("does not create DataSync if stopSync is called before config resolves", async () => { + service.startSyncing("/v2/screens/ABC"); + service.stopSync(); + + // Let the config promise resolve. + await new Promise((r) => setTimeout(r, 0)); + + expect(DataSync).not.toHaveBeenCalled(); + }); + + it("stops and nulls dataSync on stopSync", async () => { + service.startSyncing("/v2/screens/ABC"); + await vi.waitFor(() => expect(DataSync).toHaveBeenCalled()); + + const instance = DataSync.mock.results[0].value; + service.stopSync(); + + expect(instance.stop).toHaveBeenCalled(); + expect(service.dataSync).toBeNull(); + }); + }); + + describe("contentHandler", () => { + const makeScreen = (overrides = {}) => ({ + "@id": "/v2/screens/SCREEN01234567890123456789", + title: "Test Screen", + regionData: { + region1: [{ "@id": "/v2/playlists/P1" }], + }, + ...overrides, + }); + + it("calls setScreen when screen data changes", () => { + service.contentHandler(makeScreen()); + + expect(callbacks.current.setScreen).toHaveBeenCalledTimes(1); + const screenArg = callbacks.current.setScreen.mock.calls[0][0]; + expect(screenArg["@id"]).toBe( + "/v2/screens/SCREEN01234567890123456789" + ); + // regionData should be stripped from the screen passed to setScreen. + expect(screenArg.regionData).toBeUndefined(); + }); + + it("does not call setScreen when screen data has not changed", () => { + const screen = makeScreen(); + service.contentHandler(screen); + service.contentHandler(screen); + + expect(callbacks.current.setScreen).toHaveBeenCalledTimes(1); + }); + + it("calls setScreen again when screen data changes", () => { + service.contentHandler(makeScreen()); + service.contentHandler(makeScreen({ title: "Changed" })); + + expect(callbacks.current.setScreen).toHaveBeenCalledTimes(2); + }); + + it("always pushes region data to schedule service", () => { + const screen = makeScreen(); + service.contentHandler(screen); + + expect(service.scheduleService.updateRegion).toHaveBeenCalledWith( + "region1", + screen.regionData.region1 + ); + }); + + it("pushes region data even when screen hash has not changed", () => { + const screen = makeScreen(); + service.contentHandler(screen); + service.contentHandler(screen); + + expect(service.scheduleService.updateRegion).toHaveBeenCalledTimes(2); + }); + + it("pushes data for all regions", () => { + const screen = makeScreen({ + regionData: { + region1: [{ "@id": "/v2/playlists/P1" }], + region2: [{ "@id": "/v2/playlists/P2" }], + }, + }); + service.contentHandler(screen); + + expect(service.scheduleService.updateRegion).toHaveBeenCalledTimes(2); + }); + }); + + describe("regionReady", () => { + it("sends current region data to schedule service", () => { + service.currentScreen = { + regionData: { + region1: [{ "@id": "/v2/playlists/P1" }], + }, + }; + + service.regionReady("region1"); + + expect(service.scheduleService.updateRegion).toHaveBeenCalledWith( + "region1", + service.currentScreen.regionData.region1 + ); + }); + + it("does nothing when no current screen exists", () => { + service.regionReady("region1"); + + expect(service.scheduleService.updateRegion).not.toHaveBeenCalled(); + }); + }); + + describe("regionRemoved", () => { + it("delegates to schedule service", () => { + service.regionRemoved("region1"); + + expect(service.scheduleService.regionRemoved).toHaveBeenCalledWith( + "region1" + ); + }); + }); + + describe("startPreview", () => { + it("starts syncing for screen mode", async () => { + const spy = vi.spyOn(service, "startSyncing"); + + await service.startPreview("screen", "SCREEN01234567890123456789"); + + expect(spy).toHaveBeenCalledWith( + "/v2/screens/SCREEN01234567890123456789" + ); + }); + + it("fetches playlist and slides for playlist mode", async () => { + const playlist = { + "@id": "/v2/playlists/PLSTAAA0000000000000000001", + slides: "/v2/playlists/PLSTAAA0000000000000000001/slides", + }; + const slidesResponse = { + "hydra:member": [ + { + slide: { + "@id": "/v2/slides/SLIDEAAA000000000000000001", + templateInfo: { "@id": "/v2/templates/TMPLAAA0000000000000000001" }, + media: [], + }, + }, + ], + }; + const templateData = { "@id": "/v2/templates/TMPLAAA0000000000000000001" }; + + mockQuery + .mockResolvedValueOnce(playlist) + .mockResolvedValueOnce(slidesResponse) + .mockResolvedValueOnce(templateData); + + await service.startPreview("playlist", "PLSTAAA0000000000000000001"); + + expect(mockQuery).toHaveBeenCalledWith( + "getV2PlaylistsById", + { id: "PLSTAAA0000000000000000001" }, + true + ); + expect(callbacks.current.setScreen).toHaveBeenCalled(); + }); + + it("fetches slide and attaches references for slide mode", async () => { + const slide = { + "@id": "/v2/slides/SLIDEAAA000000000000000001", + templateInfo: { "@id": "/v2/templates/TMPLAAA0000000000000000001" }, + media: ["/v2/media/MDIAAA00000000000000000001"], + }; + const templateData = { "@id": "/v2/templates/TMPLAAA0000000000000000001" }; + const mediaData = { "@id": "/v2/media/MDIAAA00000000000000000001" }; + + mockQuery + .mockResolvedValueOnce(slide) + .mockResolvedValueOnce(templateData) + .mockResolvedValueOnce(mediaData); + + await service.startPreview("slide", "SLIDEAAA000000000000000001"); + + expect(mockQuery).toHaveBeenCalledWith( + "getV2SlidesById", + { id: "SLIDEAAA000000000000000001" }, + true + ); + expect(callbacks.current.setScreen).toHaveBeenCalled(); + }); + + it("logs error for unsupported mode", async () => { + await service.startPreview("unknown", "123"); + + expect(logger.error).toHaveBeenCalledWith( + "Unsupported preview mode: unknown." + ); + }); + + it("catches and logs errors", async () => { + mockQuery.mockRejectedValueOnce(new Error("Network error")); + + await service.startPreview("slide", "SLIDEAAA000000000000000001"); + + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining("Preview failed") + ); + }); + }); + + describe("attachReferencesToSlide", () => { + it("fetches template, media, and feed data", async () => { + const slide = { + "@id": "/v2/slides/SLIDEAAA000000000000000001", + templateInfo: { "@id": "/v2/templates/TMPLAAA0000000000000000001" }, + media: ["/v2/media/MDIAAA00000000000000000001"], + feed: { feedUrl: "/v2/feeds/FEEDAAA0000000000000000001" }, + theme: "/v2/themes/THMEAAA0000000000000000001", + }; + + const templateData = { "@id": "/v2/templates/TMPLAAA0000000000000000001" }; + const mediaData = { "@id": "/v2/media/MDIAAA00000000000000000001" }; + const feedData = [{ title: "Feed item" }]; + const themeData = { "@id": "/v2/themes/THMEAAA0000000000000000001" }; + + // Order: template, feed, then each media (loop), then theme. + mockQuery + .mockResolvedValueOnce(templateData) + .mockResolvedValueOnce(feedData) + .mockResolvedValueOnce(mediaData) + .mockResolvedValueOnce(themeData); + + await ContentService.attachReferencesToSlide(slide); + + expect(slide.templateData).toEqual(templateData); + expect(slide.mediaData["/v2/media/MDIAAA00000000000000000001"]).toEqual( + mediaData + ); + expect(slide.feedData).toEqual(feedData); + expect(slide.theme).toEqual(themeData); + }); + + it("marks slide invalid when template fetch fails", async () => { + const slide = { + "@id": "/v2/slides/SLIDEAAA000000000000000001", + templateInfo: { "@id": "/v2/templates/TMPLAAA0000000000000000001" }, + media: [], + }; + + mockQuery.mockRejectedValueOnce(new Error("Not found")); + + await ContentService.attachReferencesToSlide(slide); + + expect(slide.invalid).toBe(true); + expect(slide.templateData).toBeNull(); + expect(slide.mediaData).toEqual({}); + expect(slide.feedData).toBeNull(); + }); + + it("sets feedData to empty array when no feed configured", async () => { + const slide = { + "@id": "/v2/slides/SLIDEAAA000000000000000001", + templateInfo: { "@id": "/v2/templates/TMPLAAA0000000000000000001" }, + media: [], + }; + + mockQuery.mockResolvedValueOnce({ "@id": "/v2/templates/T" }); + + await ContentService.attachReferencesToSlide(slide); + + expect(slide.feedData).toEqual([]); + }); + + it("sets media to null on fetch failure", async () => { + const slide = { + "@id": "/v2/slides/SLIDEAAA000000000000000001", + templateInfo: { "@id": "/v2/templates/TMPLAAA0000000000000000001" }, + media: ["/v2/media/MDIAAA00000000000000000001"], + }; + + mockQuery + .mockResolvedValueOnce({ "@id": "/v2/templates/T" }) + .mockRejectedValueOnce(new Error("Media error")); + + await ContentService.attachReferencesToSlide(slide); + + expect(slide.mediaData["/v2/media/MDIAAA00000000000000000001"]).toBeNull(); + }); + + it("keeps theme as string when theme fetch fails", async () => { + const slide = { + "@id": "/v2/slides/SLIDEAAA000000000000000001", + templateInfo: { "@id": "/v2/templates/TMPLAAA0000000000000000001" }, + media: [], + theme: "/v2/themes/THMEAAA0000000000000000001", + }; + + mockQuery + .mockResolvedValueOnce({ "@id": "/v2/templates/T" }) + .mockRejectedValueOnce(new Error("Theme error")); + + await ContentService.attachReferencesToSlide(slide); + + expect(slide.theme).toBe("/v2/themes/THMEAAA0000000000000000001"); + }); + }); +}); diff --git a/assets/tests/client/data-sync.test.js b/assets/tests/client/data-sync.test.js new file mode 100644 index 000000000..b2cc003c8 --- /dev/null +++ b/assets/tests/client/data-sync.test.js @@ -0,0 +1,65 @@ +import { describe, it, expect, vi } from "vitest"; + +const mockStart = vi.fn(); +const mockStop = vi.fn(); + +vi.mock("../../client/service/pull-strategy", () => { + const MockPullStrategy = vi.fn(function (config, onContent) { + this.config = config; + this.onContent = onContent; + this.start = mockStart; + this.stop = mockStop; + }); + return { default: MockPullStrategy }; +}); + +import DataSync from "../../client/service/data-sync"; +import PullStrategy from "../../client/service/pull-strategy"; + +describe("DataSync", () => { + it("creates a PullStrategy with config and onContent callback", () => { + const onContent = vi.fn(); + const config = { entryPoint: "/v2/screens/ABC", interval: 5000, onContent }; + + new DataSync(config); + + expect(PullStrategy).toHaveBeenCalledWith(config, onContent); + }); + + it("delegates start to the strategy", () => { + const config = { entryPoint: "/v2/screens/ABC", onContent: vi.fn() }; + const sync = new DataSync(config); + + sync.start(); + + expect(mockStart).toHaveBeenCalled(); + }); + + it("delegates stop to the strategy", () => { + const config = { entryPoint: "/v2/screens/ABC", onContent: vi.fn() }; + const sync = new DataSync(config); + + sync.stop(); + + expect(mockStop).toHaveBeenCalled(); + }); + + it("stores config on the instance", () => { + const config = { entryPoint: "/v2/screens/ABC", onContent: vi.fn() }; + const sync = new DataSync(config); + + expect(sync.config).toBe(config); + }); + + it("binds start and stop so they work when destructured", () => { + const config = { entryPoint: "/v2/screens/ABC", onContent: vi.fn() }; + const sync = new DataSync(config); + const { start, stop } = sync; + + start(); + stop(); + + expect(mockStart).toHaveBeenCalled(); + expect(mockStop).toHaveBeenCalled(); + }); +}); diff --git a/assets/tests/client/defaults.test.js b/assets/tests/client/defaults.test.js new file mode 100644 index 000000000..1a50464db --- /dev/null +++ b/assets/tests/client/defaults.test.js @@ -0,0 +1,28 @@ +import { describe, it, expect } from "vitest"; +import defaults from "../../client/util/defaults"; + +describe("defaults", () => { + it("loginCheckTimeoutDefault is 20 seconds", () => { + expect(defaults.loginCheckTimeoutDefault).toBe(20000); + }); + + it("refreshTokenTimeoutDefault is 15 minutes", () => { + expect(defaults.refreshTokenTimeoutDefault).toBe(900000); + }); + + it("releaseTimestampIntervalTimeoutDefault is 10 minutes", () => { + expect(defaults.releaseTimestampIntervalTimeoutDefault).toBe(600000); + }); + + it("schedulingIntervalDefault is 60 seconds", () => { + expect(defaults.schedulingIntervalDefault).toBe(60000); + }); + + it("pullStrategyIntervalDefault is 5 minutes", () => { + expect(defaults.pullStrategyIntervalDefault).toBe(300000); + }); + + it("configFetchIntervalDefault is 15 minutes", () => { + expect(defaults.configFetchIntervalDefault).toBe(900000); + }); +}); diff --git a/assets/tests/client/error-boundary.test.jsx b/assets/tests/client/error-boundary.test.jsx new file mode 100644 index 000000000..f7804ab6f --- /dev/null +++ b/assets/tests/client/error-boundary.test.jsx @@ -0,0 +1,119 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, cleanup } from "@testing-library/react"; +import ErrorBoundary from "../../client/components/error-boundary.jsx"; + +vi.mock("../../client/core/logger.js", () => ({ + default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), log: vi.fn() }, +})); +vi.mock("../../client/assets/fallback.png", () => ({ + default: "fallback.png", +})); +vi.mock("../../client/components/error-boundary.scss", () => ({})); + +function ThrowingComponent({ message }) { + throw new Error(message); +} + +describe("ErrorBoundary", () => { + let consoleError; + + beforeEach(() => { + // Suppress React's console.error for caught errors + consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + cleanup(); + }); + + it("renders children when no error", () => { + render( + +
Hello
+
+ ); + expect(screen.getByTestId("child")).toBeInTheDocument(); + }); + + it("shows fallback UI when child throws", () => { + render( + + + + ); + expect(screen.getByText("Seneste log hændelser")).toBeInTheDocument(); + expect(screen.getByText(/Test error/)).toBeInTheDocument(); + }); + + it("calls errorHandler prop when child throws", () => { + const errorHandler = vi.fn(); + render( + + + + ); + expect(errorHandler).toHaveBeenCalledTimes(1); + expect(errorHandler.mock.calls[0][0].message).toBe("Test error"); + }); + + it("works without errorHandler prop", () => { + render( + + + + ); + expect(screen.getByText(/No handler/)).toBeInTheDocument(); + }); + + it("recovers from error state when resetKey changes", () => { + const { rerender } = render( + + + + ); + expect(screen.getByText(/boom/)).toBeInTheDocument(); + + rerender( + +
OK
+
+ ); + expect(screen.getByTestId("recovered")).toBeInTheDocument(); + }); + + it("does not recover when resetKey stays the same", () => { + const { rerender } = render( + + + + ); + expect(screen.getByText(/boom/)).toBeInTheDocument(); + + rerender( + +
OK
+
+ ); + expect(screen.queryByTestId("child")).not.toBeInTheDocument(); + expect(screen.getByText(/boom/)).toBeInTheDocument(); + }); + + it("calls errorHandler for each error in a sequence with different resetKeys", () => { + const errorHandler = vi.fn(); + const { rerender } = render( + + + + ); + expect(errorHandler).toHaveBeenCalledTimes(1); + expect(errorHandler.mock.calls[0][0].message).toBe("error 1"); + + rerender( + + + + ); + expect(errorHandler).toHaveBeenCalledTimes(2); + expect(errorHandler.mock.calls[1][0].message).toBe("error 2"); + }); +}); diff --git a/assets/tests/client/id-from-path.test.js b/assets/tests/client/id-from-path.test.js index d207d8b5b..bc1fd1a8b 100644 --- a/assets/tests/client/id-from-path.test.js +++ b/assets/tests/client/id-from-path.test.js @@ -16,23 +16,23 @@ describe("idFromPath", () => { ).toBe("01ARZ3NDEKTSV4RRFFQ69G5FAV"); }); - it("returns false for a string with no 26-char alphanumeric match", () => { - expect(idFromPath("/v2/screens/short")).toBe(false); + it("returns null for a string with no 26-char alphanumeric match", () => { + expect(idFromPath("/v2/screens/short")).toBe(null); }); - it("returns false for an empty string", () => { - expect(idFromPath("")).toBe(false); + it("returns null for an empty string", () => { + expect(idFromPath("")).toBe(null); }); - it("returns false for null", () => { - expect(idFromPath(null)).toBe(false); + it("returns null for null", () => { + expect(idFromPath(null)).toBe(null); }); - it("returns false for undefined", () => { - expect(idFromPath(undefined)).toBe(false); + it("returns null for undefined", () => { + expect(idFromPath(undefined)).toBe(null); }); - it("returns false for a number", () => { - expect(idFromPath(123)).toBe(false); + it("returns null for a number", () => { + expect(idFromPath(123)).toBe(null); }); }); diff --git a/assets/tests/client/is-published.test.js b/assets/tests/client/is-published.test.js new file mode 100644 index 000000000..2f8fbb6ea --- /dev/null +++ b/assets/tests/client/is-published.test.js @@ -0,0 +1,67 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import isPublished from "../../client/util/is-published"; + +describe("isPublished", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2025-06-15T12:00:00Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe("both from and to set", () => { + it("returns true when current time is within window", () => { + expect( + isPublished({ from: "2025-06-15T10:00:00Z", to: "2025-06-15T14:00:00Z" }) + ).toBe(true); + }); + + it("returns false when current time is before from", () => { + expect( + isPublished({ from: "2025-06-15T13:00:00Z", to: "2025-06-15T14:00:00Z" }) + ).toBe(false); + }); + + it("returns false when current time is after to", () => { + expect( + isPublished({ from: "2025-06-15T08:00:00Z", to: "2025-06-15T10:00:00Z" }) + ).toBe(false); + }); + }); + + describe("only from set", () => { + it("returns true when from is in the past", () => { + expect(isPublished({ from: "2025-06-15T10:00:00Z" })).toBe(true); + }); + + it("returns false when from is in the future", () => { + expect(isPublished({ from: "2025-06-15T14:00:00Z" })).toBe(false); + }); + }); + + describe("only to set", () => { + it("returns true when to is in the future", () => { + expect(isPublished({ to: "2025-06-15T14:00:00Z" })).toBe(true); + }); + + it("returns false when to is in the past", () => { + expect(isPublished({ to: "2025-06-15T10:00:00Z" })).toBe(false); + }); + }); + + describe("neither from nor to set", () => { + it("returns true for empty object", () => { + expect(isPublished({})).toBe(true); + }); + + it("returns true for null", () => { + expect(isPublished(null)).toBe(true); + }); + + it("returns true for undefined", () => { + expect(isPublished(undefined)).toBe(true); + }); + }); +}); diff --git a/assets/tests/client/preview.test.js b/assets/tests/client/preview.test.js new file mode 100644 index 000000000..bd1beda25 --- /dev/null +++ b/assets/tests/client/preview.test.js @@ -0,0 +1,69 @@ +import { describe, it, expect } from "vitest"; +import { + screenForPlaylistPreview, + screenForSlidePreview, +} from "../../client/util/preview"; + +describe("screenForPlaylistPreview", () => { + const playlist = { + "@id": "/v2/playlists/TEST01234567890123456789", + "@type": "Playlist", + title: "Test playlist", + }; + + it("returns a screen object with expected structure", () => { + const screen = screenForPlaylistPreview(playlist); + expect(screen["@id"]).toBe("/v2/screens/SCREEN01234567890123456789"); + expect(screen["@type"]).toBe("Screen"); + expect(screen.title).toBe("Preview"); + }); + + it("has a single region", () => { + const screen = screenForPlaylistPreview(playlist); + expect(screen.regions).toHaveLength(1); + }); + + it("includes the playlist in regionData", () => { + const screen = screenForPlaylistPreview(playlist); + expect(screen.regionData.REGION01234567890123456789).toEqual([playlist]); + }); + + it("has a 1x1 grid layout", () => { + const screen = screenForPlaylistPreview(playlist); + expect(screen.layoutData.grid).toEqual({ rows: 1, columns: 1 }); + }); + + it("has a single region in layout with gridArea ['a']", () => { + const screen = screenForPlaylistPreview(playlist); + expect(screen.layoutData.regions).toHaveLength(1); + expect(screen.layoutData.regions[0].gridArea).toEqual(["a"]); + }); +}); + +describe("screenForSlidePreview", () => { + const slide = { + "@id": "/v2/slides/SLIDE01234567890123456789", + "@type": "Slide", + title: "Test slide", + }; + + it("wraps the slide in a playlist", () => { + const screen = screenForSlidePreview(slide); + const playlists = screen.regionData.REGION01234567890123456789; + expect(playlists).toHaveLength(1); + expect(playlists[0]["@type"]).toBe("Playlist"); + expect(playlists[0].slidesData).toEqual([slide]); + }); + + it("the wrapper playlist has empty schedules", () => { + const screen = screenForSlidePreview(slide); + const playlists = screen.regionData.REGION01234567890123456789; + expect(playlists[0].schedules).toEqual([]); + }); + + it("produces a valid screen structure", () => { + const screen = screenForSlidePreview(slide); + expect(screen["@type"]).toBe("Screen"); + expect(screen.layoutData.grid).toEqual({ rows: 1, columns: 1 }); + }); +}); diff --git a/assets/tests/client/pull-strategy.test.js b/assets/tests/client/pull-strategy.test.js new file mode 100644 index 000000000..b4d253d4f --- /dev/null +++ b/assets/tests/client/pull-strategy.test.js @@ -0,0 +1,657 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +// --- Hoisted mocks --- +const { mockDispatch, endpoints } = vi.hoisted(() => { + const mockDispatch = vi.fn(); + const endpoints = {}; + [ + "getV2ScreensById", + "getV2ScreensByIdScreenGroups", + "getV2ScreenGroupsByIdCampaigns", + "getV2ScreensByIdCampaigns", + "getV2LayoutsById", + "getV2ScreensByIdRegionsAndRegionIdPlaylists", + "getV2PlaylistsByIdSlides", + "getV2TemplatesById", + "getV2MediaById", + "getV2FeedsByIdData", + ].forEach((name) => { + endpoints[name] = { + initiate: (args, opts) => ({ _endpoint: name, _args: args, _opts: opts }), + select: () => () => undefined, + }; + }); + return { mockDispatch, endpoints }; +}); + +vi.mock("../../client/core/logger.js", () => ({ + default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), log: vi.fn() }, +})); + +vi.mock("../../client/core/client-config-loader.js", () => ({ + default: { loadConfig: vi.fn() }, +})); + +vi.mock("../../client/redux/store.js", () => ({ + clientStore: { dispatch: mockDispatch, getState: () => ({}) }, +})); + +vi.mock("../../client/redux/enhanced-api.ts", () => ({ + clientApi: { + endpoints, + reducerPath: "clientApi", + reducer: (state = {}) => state, + middleware: () => (next) => (action) => next(action), + }, +})); + +import PullStrategy from "../../client/service/pull-strategy"; +import logger from "../../client/core/logger.js"; +import ClientConfigLoader from "../../client/core/client-config-loader.js"; + +// --- Test IDs (26 alphanumeric chars each, required by idFromPath) --- +const SCREEN_ID = "SCREEN0001AAAAAAAAAAAAAAAA"; +const LAYOUT_ID = "LAYOUT0001AAAAAAAAAAAAAAAA"; +const REGION_ID = "REGION0001AAAAAAAAAAAAAAAA"; +const PLAYLIST_ID = "PLAYLS0001AAAAAAAAAAAAAAAA"; +const SLIDE_ID = "SLIDES0001AAAAAAAAAAAAAAAA"; +const TEMPLATE_ID = "TEMPLT0001AAAAAAAAAAAAAAAA"; +const MEDIA_ID_1 = "MEDIAS0001AAAAAAAAAAAAAAAA"; +const MEDIA_ID_2 = "MEDIAS0002AAAAAAAAAAAAAAAA"; +const FEED_ID = "FEEDSS0001AAAAAAAAAAAAAAAA"; +const CAMPAIGN_REGION_ID = "01G112XBWFPY029RYFB8X2H4KD"; + +const SCREEN_PATH = `/v2/screens/${SCREEN_ID}`; + +// --- Factories --- +function makeScreen(overrides = {}) { + return { + "@id": SCREEN_PATH, + layout: `/v2/layouts/${LAYOUT_ID}`, + regions: [ + `/v2/screens/${SCREEN_ID}/regions/${REGION_ID}/playlists`, + ], + relationsChecksum: { + campaigns: "aaa", + inScreenGroups: "bbb", + layout: "ccc", + regions: "ddd", + }, + ...overrides, + }; +} + +function makeSlide(id = SLIDE_ID, overrides = {}) { + return { + "@id": `/v2/slides/${id}`, + templateInfo: { "@id": `/v2/templates/${TEMPLATE_ID}` }, + media: [], + relationsChecksum: { templateInfo: "t1", media: "m1" }, + ...overrides, + }; +} + +function makePlaylist(id = PLAYLIST_ID) { + return { "@id": `/v2/playlists/${id}` }; +} + +function makeLayout() { + return { + "@id": `/v2/layouts/${LAYOUT_ID}`, + grid: { rows: 1, columns: 1 }, + regions: [ + { "@id": `/v2/layouts/regions/${REGION_ID}`, gridArea: ["a"] }, + ], + }; +} + +function makeTemplateData() { + return { "@id": `/v2/templates/${TEMPLATE_ID}`, resources: {} }; +} + +function hydra(members) { + return { "hydra:member": members, "hydra:totalItems": members.length }; +} + +// --- Response helpers --- +function setupResponses(responseMap) { + mockDispatch.mockImplementation((action) => ({ + unwrap: () => { + const handler = responseMap[action._endpoint]; + if (handler === undefined) { + return Promise.reject( + new Error(`No mock response for ${action._endpoint}`), + ); + } + if (typeof handler === "function") { + return handler(action._args, action._opts); + } + return Promise.resolve(handler); + }, + unsubscribe: vi.fn(), + abort: vi.fn(), + })); +} + +function setupBasicResponses(overrides = {}) { + setupResponses({ + getV2ScreensById: makeScreen(), + getV2ScreensByIdScreenGroups: { "hydra:member": [] }, + getV2ScreensByIdCampaigns: { "hydra:member": [] }, + getV2LayoutsById: makeLayout(), + getV2ScreensByIdRegionsAndRegionIdPlaylists: hydra([ + { playlist: makePlaylist() }, + ]), + getV2PlaylistsByIdSlides: hydra([{ slide: makeSlide() }]), + getV2TemplatesById: makeTemplateData(), + ...overrides, + }); +} + +// --- Content callback capture --- +function captureContentCallback() { + const captured = { screen: null, callCount: 0 }; + const callback = (screen) => { + captured.screen = screen; + captured.callCount += 1; + }; + return { + callback, + get screen() { + return captured.screen; + }, + get callCount() { + return captured.callCount; + }, + }; +} + +// --- Dispatch call inspection --- +function getDispatchCallsFor(endpoint) { + return mockDispatch.mock.calls + .filter(([action]) => action._endpoint === endpoint) + .map(([action]) => ({ + args: action._args, + forceRefetch: action._opts?.forceRefetch, + })); +} + +// --- Tests --- +describe("PullStrategy.getScreen", () => { + let strategy; + let contentCapture; + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2025-06-15T12:00:00Z")); + vi.clearAllMocks(); + + ClientConfigLoader.loadConfig.mockResolvedValue({ + relationsChecksumEnabled: false, + }); + + contentCapture = captureContentCallback(); + strategy = new PullStrategy({ + entryPoint: SCREEN_PATH, + interval: 60000, + }, contentCapture.callback); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe("early returns", () => { + it("aborts when screen fetch throws", async () => { + setupResponses({ + getV2ScreensById: () => Promise.reject(new Error("Network error")), + }); + + await strategy.getScreen(SCREEN_PATH); + + expect(contentCapture.callCount).toBe(0); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining("not loaded. Aborting content update"), + ); + }); + + it("aborts when screen is null", async () => { + setupResponses({ + getV2ScreensById: null, + }); + + await strategy.getScreen(SCREEN_PATH); + + expect(contentCapture.callCount).toBe(0); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining("not loaded"), + ); + }); + }); + + describe("active campaign path", () => { + it("builds synthetic layout when campaign is active", async () => { + const activeCampaign = { + "@id": `/v2/playlists/${PLAYLIST_ID}`, + published: {}, + }; + + setupResponses({ + getV2ScreensById: makeScreen(), + getV2ScreensByIdScreenGroups: { "hydra:member": [] }, + getV2ScreensByIdCampaigns: { + "hydra:member": [{ campaign: activeCampaign }], + }, + getV2PlaylistsByIdSlides: hydra([{ slide: makeSlide() }]), + getV2TemplatesById: makeTemplateData(), + }); + + await strategy.getScreen(SCREEN_PATH); + + expect(contentCapture.callCount).toBe(1); + const { screen } = contentCapture; + expect(screen.hasActiveCampaign).toBe(true); + expect(screen.layoutData.grid).toEqual({ rows: 1, columns: 1 }); + expect(screen.layoutData.regions[0]["@id"]).toContain( + CAMPAIGN_REGION_ID, + ); + }); + + it("falls through to normal path when campaign is expired", async () => { + const expiredCampaign = { + "@id": `/v2/playlists/${PLAYLIST_ID}`, + published: { to: "2020-01-01T00:00:00Z" }, + }; + + setupBasicResponses({ + getV2ScreensByIdCampaigns: { + "hydra:member": [{ campaign: expiredCampaign }], + }, + }); + + await strategy.getScreen(SCREEN_PATH); + + expect(contentCapture.callCount).toBe(1); + expect(contentCapture.screen.hasActiveCampaign).toBe(false); + expect(contentCapture.screen.layoutData["@id"]).toContain(LAYOUT_ID); + }); + }); + + describe("normal path", () => { + it("fetches layout, regions, and slides", async () => { + setupBasicResponses(); + + await strategy.getScreen(SCREEN_PATH); + + expect(contentCapture.callCount).toBe(1); + const { screen } = contentCapture; + expect(screen.hasActiveCampaign).toBe(false); + expect(screen.layoutData["@id"]).toContain(LAYOUT_ID); + expect(screen.regionData[REGION_ID]).toBeDefined(); + + const slide = screen.regionData[REGION_ID][0].slidesData[0]; + expect(slide.templateData).toEqual(makeTemplateData()); + expect(slide.mediaData).toEqual({}); + }); + + it("aborts when layout fetch throws", async () => { + setupResponses({ + getV2ScreensById: makeScreen(), + getV2ScreensByIdScreenGroups: { "hydra:member": [] }, + getV2ScreensByIdCampaigns: { "hydra:member": [] }, + getV2LayoutsById: () => Promise.reject(new Error("Layout error")), + }); + + await strategy.getScreen(SCREEN_PATH); + + expect(contentCapture.callCount).toBe(0); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining("not loaded. Aborting content update"), + ); + }); + + it("aborts when layout is null", async () => { + setupResponses({ + getV2ScreensById: makeScreen(), + getV2ScreensByIdScreenGroups: { "hydra:member": [] }, + getV2ScreensByIdCampaigns: { "hydra:member": [] }, + getV2LayoutsById: null, + }); + + await strategy.getScreen(SCREEN_PATH); + + expect(contentCapture.callCount).toBe(0); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining("not loaded. Aborting content update"), + ); + }); + }); + + describe("slide enrichment", () => { + it("marks slide as invalid when templateInfo is missing", async () => { + const slideNoTemplate = makeSlide(SLIDE_ID, { templateInfo: {} }); + + setupBasicResponses({ + getV2PlaylistsByIdSlides: hydra([{ slide: slideNoTemplate }]), + }); + + await strategy.getScreen(SCREEN_PATH); + + expect(contentCapture.callCount).toBe(1); + const slide = + contentCapture.screen.regionData[REGION_ID][0].slidesData[0]; + expect(slide.invalid).toBe(true); + expect(slide.templateData).toBeNull(); + expect(slide.mediaData).toEqual({}); + }); + + it("marks slide as invalid when template fetch fails", async () => { + setupBasicResponses({ + getV2TemplatesById: () => + Promise.reject(new Error("Template error")), + }); + + await strategy.getScreen(SCREEN_PATH); + + expect(contentCapture.callCount).toBe(1); + const slide = + contentCapture.screen.regionData[REGION_ID][0].slidesData[0]; + expect(slide.invalid).toBe(true); + expect(slide.templateData).toBeNull(); + }); + + it("fetches media for each media path", async () => { + const slideWithMedia = makeSlide(SLIDE_ID, { + media: [`/v2/media/${MEDIA_ID_1}`, `/v2/media/${MEDIA_ID_2}`], + }); + const media1 = { "@id": `/v2/media/${MEDIA_ID_1}`, title: "img1" }; + const media2 = { "@id": `/v2/media/${MEDIA_ID_2}`, title: "img2" }; + + setupBasicResponses({ + getV2PlaylistsByIdSlides: hydra([{ slide: slideWithMedia }]), + getV2MediaById: (args) => { + if (args.id === MEDIA_ID_1) return Promise.resolve(media1); + if (args.id === MEDIA_ID_2) return Promise.resolve(media2); + return Promise.reject(new Error("Unknown media")); + }, + }); + + await strategy.getScreen(SCREEN_PATH); + + const slide = + contentCapture.screen.regionData[REGION_ID][0].slidesData[0]; + expect(slide.mediaData[`/v2/media/${MEDIA_ID_1}`]).toEqual(media1); + expect(slide.mediaData[`/v2/media/${MEDIA_ID_2}`]).toEqual(media2); + }); + + it("sets null for failed media fetch", async () => { + const slideWithMedia = makeSlide(SLIDE_ID, { + media: [`/v2/media/${MEDIA_ID_1}`, `/v2/media/${MEDIA_ID_2}`], + }); + const media1 = { "@id": `/v2/media/${MEDIA_ID_1}`, title: "img1" }; + + setupBasicResponses({ + getV2PlaylistsByIdSlides: hydra([{ slide: slideWithMedia }]), + getV2MediaById: (args) => { + if (args.id === MEDIA_ID_1) return Promise.resolve(media1); + return Promise.reject(new Error("Not found")); + }, + }); + + await strategy.getScreen(SCREEN_PATH); + + const slide = + contentCapture.screen.regionData[REGION_ID][0].slidesData[0]; + expect(slide.mediaData[`/v2/media/${MEDIA_ID_1}`]).toEqual(media1); + expect(slide.mediaData[`/v2/media/${MEDIA_ID_2}`]).toBeNull(); + }); + + it("fetches feed data when feedUrl is present", async () => { + const slideWithFeed = makeSlide(SLIDE_ID, { + feed: { feedUrl: `/v2/feeds/${FEED_ID}` }, + }); + const feedData = { data: [{ title: "Feed item" }] }; + + setupBasicResponses({ + getV2PlaylistsByIdSlides: hydra([{ slide: slideWithFeed }]), + getV2FeedsByIdData: feedData, + }); + + await strategy.getScreen(SCREEN_PATH); + + const slide = + contentCapture.screen.regionData[REGION_ID][0].slidesData[0]; + expect(slide.feedData).toEqual(feedData); + }); + + it("sets feedData to null when feed fetch fails", async () => { + const slideWithFeed = makeSlide(SLIDE_ID, { + feed: { feedUrl: `/v2/feeds/${FEED_ID}` }, + }); + + setupBasicResponses({ + getV2FeedsByIdData: () => Promise.reject(new Error("Feed error")), + getV2PlaylistsByIdSlides: hydra([{ slide: slideWithFeed }]), + }); + + await strategy.getScreen(SCREEN_PATH); + + const slide = + contentCapture.screen.regionData[REGION_ID][0].slidesData[0]; + expect(slide.feedData).toBeNull(); + }); + }); + + describe("checksum caching", () => { + beforeEach(() => { + ClientConfigLoader.loadConfig.mockResolvedValue({ + relationsChecksumEnabled: true, + }); + }); + + it("force-fetches everything on first call", async () => { + setupBasicResponses(); + + await strategy.getScreen(SCREEN_PATH); + + const layoutCalls = getDispatchCallsFor("getV2LayoutsById"); + expect(layoutCalls[0].forceRefetch).toBe(true); + }); + + it("skips refetch on second call with unchanged checksums", async () => { + setupBasicResponses(); + await strategy.getScreen(SCREEN_PATH); + + mockDispatch.mockClear(); + setupBasicResponses(); + await strategy.getScreen(SCREEN_PATH); + + const layoutCalls = getDispatchCallsFor("getV2LayoutsById"); + expect(layoutCalls[0].forceRefetch).toBe(false); + }); + + it("refetches when checksum changes", async () => { + setupBasicResponses(); + await strategy.getScreen(SCREEN_PATH); + + mockDispatch.mockClear(); + setupBasicResponses({ + getV2ScreensById: makeScreen({ + relationsChecksum: { + campaigns: "aaa", + inScreenGroups: "bbb", + layout: "CHANGED", + regions: "ddd", + }, + }), + }); + await strategy.getScreen(SCREEN_PATH); + + const layoutCalls = getDispatchCallsFor("getV2LayoutsById"); + expect(layoutCalls[0].forceRefetch).toBe(true); + }); + }); + + describe("campaign-to-normal transition", () => { + it("force-fetches layout after campaign ends", async () => { + ClientConfigLoader.loadConfig.mockResolvedValue({ + relationsChecksumEnabled: true, + }); + + // First call: active campaign + const activeCampaign = { + "@id": `/v2/playlists/${PLAYLIST_ID}`, + published: {}, + }; + + setupResponses({ + getV2ScreensById: makeScreen(), + getV2ScreensByIdScreenGroups: { "hydra:member": [] }, + getV2ScreensByIdCampaigns: { + "hydra:member": [{ campaign: activeCampaign }], + }, + getV2PlaylistsByIdSlides: hydra([{ slide: makeSlide() }]), + getV2TemplatesById: makeTemplateData(), + }); + + await strategy.getScreen(SCREEN_PATH); + expect(contentCapture.screen.hasActiveCampaign).toBe(true); + + // Second call: no campaign, same checksums + mockDispatch.mockClear(); + setupBasicResponses(); + + await strategy.getScreen(SCREEN_PATH); + + expect(contentCapture.screen.hasActiveCampaign).toBe(false); + const layoutCalls = getDispatchCallsFor("getV2LayoutsById"); + expect(layoutCalls[0].forceRefetch).toBe(true); + }); + }); +}); + +describe("PullStrategy.pull", () => { + let strategy; + let contentCapture; + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2025-06-15T12:00:00Z")); + vi.clearAllMocks(); + + ClientConfigLoader.loadConfig.mockResolvedValue({ + relationsChecksumEnabled: false, + }); + + contentCapture = captureContentCallback(); + strategy = new PullStrategy( + { entryPoint: SCREEN_PATH, interval: 60000 }, + contentCapture.callback, + ); + }); + + afterEach(() => { + strategy.stop(); + vi.useRealTimers(); + }); + + it("schedules next pull after completion", async () => { + setupBasicResponses(); + + strategy.pull(); + await vi.advanceTimersByTimeAsync(0); // let getScreen resolve + + expect(contentCapture.callCount).toBe(1); + + // Next pull should be scheduled after the interval. + setupBasicResponses(); + await vi.advanceTimersByTimeAsync(60000); + + expect(contentCapture.callCount).toBe(2); + }); + + it("does not run concurrent pulls", async () => { + // Make getScreen hang for a while. + let resolveScreen; + setupResponses({ + getV2ScreensById: () => + new Promise((resolve) => { + resolveScreen = () => resolve(makeScreen()); + }), + getV2ScreensByIdScreenGroups: { "hydra:member": [] }, + getV2ScreensByIdCampaigns: { "hydra:member": [] }, + getV2LayoutsById: makeLayout(), + getV2ScreensByIdRegionsAndRegionIdPlaylists: hydra([ + { playlist: makePlaylist() }, + ]), + getV2PlaylistsByIdSlides: hydra([{ slide: makeSlide() }]), + getV2TemplatesById: makeTemplateData(), + }); + + strategy.pull(); + strategy.pull(); // Should be a no-op. + + // Only one getScreen call should be in flight. + const screenCalls = getDispatchCallsFor("getV2ScreensById"); + expect(screenCalls).toHaveLength(1); + + resolveScreen(); + }); + + it("recovers and schedules next pull when screen fetch fails", async () => { + setupResponses({ + getV2ScreensById: () => Promise.reject(new Error("Boom")), + }); + + strategy.pull(); + // Let the query timeout fire (30s) and then the getScreen catch. + await vi.advanceTimersByTimeAsync(30000); + + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining("not loaded. Aborting content update"), + ); + + // Should still schedule next pull. + setupBasicResponses(); + await vi.advanceTimersByTimeAsync(60000); + + expect(contentCapture.callCount).toBe(1); + }); + + it("does not schedule next pull after stop", async () => { + setupBasicResponses(); + + strategy.pull(); + await vi.advanceTimersByTimeAsync(0); + + strategy.stop(); + + setupBasicResponses(); + await vi.advanceTimersByTimeAsync(60000); + + // Should not have run a second time. + expect(contentCapture.callCount).toBe(1); + }); + + it("aborts getScreen cycle when it exceeds max execution time", async () => { + // Spy on getScreen to return a never-resolving promise, bypassing + // the internal query timeouts that would otherwise resolve it. + vi.spyOn(strategy, "getScreen").mockReturnValue(new Promise(() => {})); + + strategy.pull(); + + // Advance past the 2 minute getScreen timeout. + await vi.advanceTimersByTimeAsync(2 * 60 * 1000); + + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining("getScreen exceeded max execution time"), + ); + + // Should still schedule next pull after the guard fires. + strategy.getScreen.mockRestore(); + setupBasicResponses(); + await vi.advanceTimersByTimeAsync(60000); + + expect(contentCapture.callCount).toBe(1); + }); +}); diff --git a/assets/tests/client/region.test.jsx b/assets/tests/client/region.test.jsx new file mode 100644 index 000000000..bef943f8d --- /dev/null +++ b/assets/tests/client/region.test.jsx @@ -0,0 +1,199 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, act, cleanup, within } from "@testing-library/react"; +import Region from "../../client/components/region.jsx"; + +vi.mock("../../client/core/logger.js", () => ({ + default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), log: vi.fn() }, +})); +vi.mock("../../client/components/region.scss", () => ({})); +vi.mock("../../client/components/slide.scss", () => ({})); +vi.mock("../../client/components/error-boundary.scss", () => ({})); +vi.mock("../../client/assets/fallback.png", () => ({ + default: "fallback.png", +})); +vi.mock("../../shared/grid-generator/grid-generator", () => ({ + createGridArea: (gridArea) => gridArea.join(" / "), +})); +vi.mock("../../client/util/id-from-path", () => ({ + default: () => "REGION01", +})); + +// Mock Slide to render a simple div and expose slideDone +let capturedSlideDone = null; +vi.mock("../../client/components/slide.jsx", () => ({ + default: ({ slide, slideDone }) => { + capturedSlideDone = () => slideDone(slide); + return
{slide.title}
; + }, +})); + +// Mock react-transition-group to be pass-through +vi.mock("react-transition-group", () => ({ + TransitionGroup: ({ children }) => <>{children}, + CSSTransition: ({ children }) => <>{children}, +})); + +// Mock context +const mockCallbacks = { + current: { + onRegionReady: vi.fn(), + onRegionRemoved: vi.fn(), + setScreen: vi.fn(), + setIsContentEmpty: vi.fn(), + updateRegionSlides: vi.fn(), + }, +}; +let mockRegionSlides = {}; + +vi.mock("../../client/client-state-context.jsx", () => ({ + useClientState: () => ({ + regionSlides: mockRegionSlides, + callbacks: mockCallbacks, + }), +})); + +describe("Region", () => { + const region = { + "@id": "/v2/layouts/regions/REGION01", + gridArea: ["a"], + }; + + beforeEach(() => { + capturedSlideDone = null; + mockRegionSlides = {}; + mockCallbacks.current.onRegionReady.mockClear(); + mockCallbacks.current.onRegionRemoved.mockClear(); + }); + + afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + }); + + it("calls onRegionReady on mount", () => { + render(); + + expect(mockCallbacks.current.onRegionReady).toHaveBeenCalledWith("REGION01"); + }); + + it("calls onRegionRemoved on unmount", () => { + const { unmount } = render(); + unmount(); + + expect(mockCallbacks.current.onRegionRemoved).toHaveBeenCalledWith("REGION01"); + }); + + it("displays the first slide when regionSlides updates", () => { + mockRegionSlides = { + REGION01: [ + { executionId: "EXE-1", title: "Slide 1" }, + { executionId: "EXE-2", title: "Slide 2" }, + ], + }; + + const { container } = render(); + + expect( + within(container).getByTestId("slide-EXE-1") + ).toBeInTheDocument(); + }); + + it("filters out invalid slides", () => { + mockRegionSlides = { + REGION01: [ + { executionId: "EXE-1", title: "Valid", invalid: false }, + { executionId: "EXE-2", title: "Invalid", invalid: true }, + ], + }; + + const { container } = render(); + + expect( + within(container).getByTestId("slide-EXE-1") + ).toBeInTheDocument(); + }); + + it("advances to next slide when slideDone is called", () => { + mockRegionSlides = { + REGION01: [ + { executionId: "EXE-1", title: "Slide 1" }, + { executionId: "EXE-2", title: "Slide 2" }, + ], + }; + + const { container } = render(); + + act(() => { + capturedSlideDone(); + }); + + expect( + within(container).getByTestId("slide-EXE-2") + ).toBeInTheDocument(); + }); + + it("wraps around to first slide after last", () => { + mockRegionSlides = { + REGION01: [ + { executionId: "EXE-1", title: "Slide 1" }, + { executionId: "EXE-2", title: "Slide 2" }, + ], + }; + + const { container } = render(); + + // Advance to EXE-2 + act(() => { + capturedSlideDone(); + }); + + // Advance past EXE-2 → wraps to EXE-1 + act(() => { + capturedSlideDone(); + }); + + expect( + within(container).getByTestId("slide-EXE-1") + ).toBeInTheDocument(); + }); + + it("recovers playback when new slides arrive after content was cleared mid-cycle", () => { + mockRegionSlides = { + REGION01: [ + { executionId: "EXE-1", title: "Slide 1" }, + { executionId: "EXE-2", title: "Slide 2" }, + ], + }; + + const { container, rerender } = render(); + expect(within(container).getByTestId("slide-EXE-1")).toBeInTheDocument(); + + // Advance to slide 2. + act(() => { capturedSlideDone(); }); + expect(within(container).getByTestId("slide-EXE-2")).toBeInTheDocument(); + + // Content is removed while slide 2 is still playing. + mockRegionSlides = { REGION01: [] }; + rerender(); + + // Slide 2 finishes — cycle wraps (nextIndex 0) and applies the empty + // newSlides, setting currentSlide to null and slides to []. + act(() => { capturedSlideDone(); }); + expect(container.querySelector("[data-testid^='slide-']")).toBeNull(); + + // New content arrives while currentSlide is null. + mockRegionSlides = { + REGION01: [{ executionId: "EXE-3", title: "Slide 3" }], + }; + rerender(); + + // The region should recover and display the new slide. + expect(within(container).getByTestId("slide-EXE-3")).toBeInTheDocument(); + }); + + it("renders with correct grid area style", () => { + const { container } = render(); + const el = container.querySelector(".region"); + expect(el.style.gridArea).toBe("a"); + }); +}); diff --git a/assets/tests/client/release-service.test.js b/assets/tests/client/release-service.test.js new file mode 100644 index 000000000..61e35df7c --- /dev/null +++ b/assets/tests/client/release-service.test.js @@ -0,0 +1,220 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +vi.mock("../../client/core/logger.js", () => ({ + default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), log: vi.fn() }, +})); + +vi.mock("../../client/core/client-config-loader.js", () => ({ + default: { + loadConfig: vi + .fn() + .mockResolvedValue({ releaseTimestampIntervalTimeout: 600000 }), + }, +})); + +vi.mock("../../client/core/app-storage.js", () => ({ + default: { + getPreviousBoot: vi.fn().mockReturnValue("1700000000000"), + }, +})); + +vi.mock("../../client/service/status-service.js", () => ({ + default: { + error: null, + setError: vi.fn(), + setStatus: vi.fn(), + }, +})); + +vi.mock("../../client/util/id-from-path", () => ({ + default: (path) => { + if (!path) return null; + const match = path.match(/[A-Za-z0-9]{26}/); + return match ? match[0] : null; + }, +})); + +const mockLoadRelease = vi.fn(); +vi.mock("../../shared/release-loader.js", () => ({ + default: { loadRelease: (...args) => mockLoadRelease(...args) }, +})); + +import statusService from "../../client/service/status-service.js"; +import constants from "../../client/util/constants"; +import logger from "../../client/core/logger.js"; + +describe("ReleaseService", () => { + let releaseService; + let mockReplace; + let mockReplaceState; + + beforeEach(async () => { + vi.useFakeTimers(); + vi.clearAllMocks(); + vi.resetModules(); + + mockReplace = vi.fn(); + mockReplaceState = vi.fn(); + + vi.stubGlobal("location", { + href: "http://localhost/?releaseTimestamp=100", + replace: mockReplace, + }); + vi.stubGlobal("history", { replaceState: mockReplaceState }); + + const module = await import("../../client/service/release-service.js"); + releaseService = module.default; + }); + + afterEach(() => { + releaseService.stopReleaseCheck(); + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + describe("checkForNewRelease", () => { + it("resolves when release timestamp matches current", async () => { + mockLoadRelease.mockResolvedValue({ + releaseTimestamp: 100, + releaseVersion: "1.0", + }); + + await expect(releaseService.checkForNewRelease()).resolves.toBeUndefined(); + expect(mockReplace).not.toHaveBeenCalled(); + }); + + it("redirects when release timestamp differs", async () => { + mockLoadRelease.mockResolvedValue({ + releaseTimestamp: 200, + releaseVersion: "2.0", + }); + + await expect( + releaseService.checkForNewRelease() + ).rejects.toBeUndefined(); + + expect(mockReplace).toHaveBeenCalledTimes(1); + const redirectUrl = mockReplace.mock.calls[0][0].toString(); + expect(redirectUrl).toContain("releaseTimestamp=200"); + expect(redirectUrl).toContain("releaseVersion=2.0"); + }); + + it("redirects when no current timestamp in URL", async () => { + vi.stubGlobal("location", { + href: "http://localhost/", + replace: mockReplace, + }); + + mockLoadRelease.mockResolvedValue({ + releaseTimestamp: 200, + releaseVersion: null, + }); + + // Re-import to pick up new location + vi.resetModules(); + const mod = await import("../../client/service/release-service.js"); + + await expect(mod.default.checkForNewRelease()).rejects.toBeUndefined(); + expect(mockReplace).toHaveBeenCalled(); + }); + + it("sets error when release timestamp is null", async () => { + mockLoadRelease.mockResolvedValue({ + releaseTimestamp: null, + releaseVersion: null, + }); + + await releaseService.checkForNewRelease(); + + expect(statusService.setError).toHaveBeenCalledWith( + constants.ERROR_RELEASE_FILE_NOT_LOADED + ); + }); + + it("clears error when release loads after previous failure", async () => { + statusService.error = constants.ERROR_RELEASE_FILE_NOT_LOADED; + + mockLoadRelease.mockResolvedValue({ + releaseTimestamp: 100, + releaseVersion: "1.0", + }); + + await releaseService.checkForNewRelease(); + + expect(statusService.setError).toHaveBeenCalledWith(null); + }); + + it("resolves when loadRelease fails", async () => { + mockLoadRelease.mockRejectedValue(new Error("Network")); + + await expect(releaseService.checkForNewRelease()).resolves.toBeUndefined(); + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining("Failed to load release") + ); + }); + }); + + describe("setScreenIdInUrl", () => { + it("sets screenId in URL search params", () => { + releaseService.setScreenIdInUrl( + "/v2/screens/SCREEN0001AAAAAAAAAAAAAAAA" + ); + + expect(mockReplaceState).toHaveBeenCalled(); + const url = mockReplaceState.mock.calls[0][2].toString(); + expect(url).toContain("screenId=SCREEN0001AAAAAAAAAAAAAAAA"); + }); + }); + + describe("setPreviousBootInUrl", () => { + it("sets pb param from appStorage", () => { + releaseService.setPreviousBootInUrl(); + + expect(mockReplaceState).toHaveBeenCalled(); + const url = mockReplaceState.mock.calls[0][2].toString(); + expect(url).toContain("pb=1700000000000"); + }); + }); + + describe("startReleaseCheck / stopReleaseCheck", () => { + it("sets up an interval that calls checkForNewRelease", async () => { + mockLoadRelease.mockResolvedValue({ + releaseTimestamp: 100, + releaseVersion: "1.0", + }); + + releaseService.startReleaseCheck(); + await vi.advanceTimersByTimeAsync(0); // let config resolve + + expect(releaseService.releaseCheckInterval).not.toBeNull(); + + // Advance past one interval tick. + await vi.advanceTimersByTimeAsync(600000); + + expect(mockLoadRelease).toHaveBeenCalled(); + }); + + it("clears interval on stop", async () => { + mockLoadRelease.mockResolvedValue({ + releaseTimestamp: 100, + releaseVersion: "1.0", + }); + + releaseService.startReleaseCheck(); + await vi.advanceTimersByTimeAsync(0); + + releaseService.stopReleaseCheck(); + + expect(releaseService.releaseCheckInterval).toBeNull(); + }); + + it("does not create interval if stopped before config resolves", async () => { + releaseService.startReleaseCheck(); + releaseService.stopReleaseCheck(); + + await vi.advanceTimersByTimeAsync(0); + + expect(releaseService.releaseCheckInterval).toBeNull(); + }); + }); +}); diff --git a/assets/tests/client/schedule-service.test.js b/assets/tests/client/schedule-service.test.js new file mode 100644 index 000000000..c5761ea99 --- /dev/null +++ b/assets/tests/client/schedule-service.test.js @@ -0,0 +1,237 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +vi.mock("../../client/core/logger.js", () => ({ + default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), log: vi.fn() }, +})); +vi.mock("../../client/core/client-config-loader.js", () => ({ + default: { + loadConfig: vi.fn().mockResolvedValue({ schedulingInterval: 60000 }), + }, +})); + +import ScheduleService from "../../client/service/schedule-service"; + +function makeCallbacks() { + return { + current: { + setIsContentEmpty: vi.fn(), + updateRegionSlides: vi.fn(), + }, + }; +} + +describe("ScheduleService", () => { + describe("findScheduledSlides (static)", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2025-06-15T12:00:00Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + const makeSlide = (id, published = {}) => ({ + "@id": `/v2/slides/${id}`, + "@type": "Slide", + title: `Slide ${id}`, + published, + }); + + const makePlaylist = (id, slides, options = {}) => ({ + "@id": `/v2/playlists/${id}`, + "@type": "Playlist", + title: `Playlist ${id}`, + schedules: [], + published: {}, + slidesData: slides, + ...options, + }); + + it("returns all slides from a published playlist with no schedules", () => { + const slides = [makeSlide("A"), makeSlide("B")]; + const playlists = [makePlaylist("P1", slides)]; + + const result = ScheduleService.findScheduledSlides(playlists, "region1"); + expect(result).toHaveLength(2); + expect(result[0].title).toBe("Slide A"); + expect(result[1].title).toBe("Slide B"); + }); + + it("excludes slides from unpublished playlists", () => { + const slides = [makeSlide("A")]; + const playlists = [ + makePlaylist("P1", slides, { + published: { from: "2025-06-16T00:00:00Z" }, + }), + ]; + + const result = ScheduleService.findScheduledSlides(playlists, "region1"); + expect(result).toHaveLength(0); + }); + + it("includes slides when playlist schedule is active", () => { + const slides = [makeSlide("A")]; + const playlists = [ + makePlaylist("P1", slides, { + schedules: [ + { + rrule: "DTSTART:20250601T120000Z\nRRULE:FREQ=DAILY", + duration: 3600, + }, + ], + }), + ]; + + const result = ScheduleService.findScheduledSlides(playlists, "region1"); + expect(result).toHaveLength(1); + }); + + it("excludes slides when playlist schedule is inactive", () => { + const slides = [makeSlide("A")]; + const playlists = [ + makePlaylist("P1", slides, { + schedules: [ + { + rrule: "DTSTART:20250601T080000Z\nRRULE:FREQ=DAILY", + duration: 3600, + }, + ], + }), + ]; + + // Schedule is 08:00-09:00, current time is 12:00 + const result = ScheduleService.findScheduledSlides(playlists, "region1"); + expect(result).toHaveLength(0); + }); + + it("filters unpublished slides within a published playlist", () => { + const slides = [ + makeSlide("A"), // published (no dates = always published) + makeSlide("B", { from: "2025-06-16T00:00:00Z" }), // not yet published + ]; + const playlists = [makePlaylist("P1", slides)]; + + const result = ScheduleService.findScheduledSlides(playlists, "region1"); + expect(result).toHaveLength(1); + expect(result[0].title).toBe("Slide A"); + }); + + it("sets executionId on each slide in EXE-ID-xxx format", () => { + const slides = [makeSlide("A")]; + const playlists = [makePlaylist("P1", slides)]; + + const result = ScheduleService.findScheduledSlides(playlists, "region1"); + expect(result[0].executionId).toMatch(/^EXE-ID-.+/); + }); + + it("produces deterministic executionIds for the same inputs", () => { + const slides = [makeSlide("A")]; + const playlists = [makePlaylist("P1", slides)]; + + const result1 = ScheduleService.findScheduledSlides(playlists, "region1"); + const result2 = ScheduleService.findScheduledSlides(playlists, "region1"); + expect(result1[0].executionId).toBe(result2[0].executionId); + }); + + it("produces different executionIds for different regions", () => { + const slides = [makeSlide("A")]; + const playlists = [makePlaylist("P1", slides)]; + + const result1 = ScheduleService.findScheduledSlides( + playlists, + "region1" + ); + const result2 = ScheduleService.findScheduledSlides( + playlists, + "region2" + ); + expect(result1[0].executionId).not.toBe(result2[0].executionId); + }); + + it("deep clones slides so modifications do not affect input", () => { + const originalSlide = makeSlide("A"); + const playlists = [makePlaylist("P1", [originalSlide])]; + + const result = ScheduleService.findScheduledSlides(playlists, "region1"); + result[0].title = "Modified"; + + expect(originalSlide.title).toBe("Slide A"); + }); + + it("handles multiple playlists with mixed published states", () => { + const playlists = [ + makePlaylist("P1", [makeSlide("A")]), // published + makePlaylist("P2", [makeSlide("B")], { + published: { to: "2025-06-14T00:00:00Z" }, + }), // expired + makePlaylist("P3", [makeSlide("C")]), // published + ]; + + const result = ScheduleService.findScheduledSlides(playlists, "region1"); + expect(result).toHaveLength(2); + expect(result[0].title).toBe("Slide A"); + expect(result[1].title).toBe("Slide C"); + }); + }); + + describe("instance methods", () => { + let service; + let callbacks; + + beforeEach(() => { + callbacks = makeCallbacks(); + service = new ScheduleService(callbacks); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("sendSlides calls updateRegionSlides callback", () => { + const slides = [{ "@id": "/v2/slides/A" }]; + service.sendSlides("region1", slides); + + expect(callbacks.current.updateRegionSlides).toHaveBeenCalledWith("region1", slides); + }); + + it("checkForEmptyContent calls setIsContentEmpty(true) when no regions have slides", () => { + service.regions = { r1: { slides: [] } }; + service.contentEmpty = false; // force change detection + service.checkForEmptyContent(); + + expect(callbacks.current.setIsContentEmpty).toHaveBeenCalledWith(true); + }); + + it("checkForEmptyContent calls setIsContentEmpty(false) when regions have slides", () => { + service.regions = { r1: { slides: [{ "@id": "s1" }] } }; + service.contentEmpty = true; // force change detection + service.checkForEmptyContent(); + + expect(callbacks.current.setIsContentEmpty).toHaveBeenCalledWith(false); + }); + + it("regionRemoved clears interval and cached data", () => { + const intervalId = setInterval(() => {}, 1000); + service.intervals.region1 = intervalId; + service.regions.region1 = { hash: "abc", slides: [] }; + + service.regionRemoved("region1"); + + expect(service.intervals.region1).toBeUndefined(); + expect(service.regions.region1).toBeUndefined(); + }); + + it("updateRegion is a no-op when regionId is falsy", () => { + service.updateRegion(null, []); + + expect(callbacks.current.updateRegionSlides).not.toHaveBeenCalled(); + }); + + it("updateRegion is a no-op when region is falsy", () => { + service.updateRegion("region1", null); + expect(service.regions.region1).toBeUndefined(); + }); + }); +}); diff --git a/assets/tests/client/schedule.test.js b/assets/tests/client/schedule.test.js new file mode 100644 index 000000000..07ab9b861 --- /dev/null +++ b/assets/tests/client/schedule.test.js @@ -0,0 +1,71 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import ScheduleUtils from "../../client/util/schedule"; + +describe("ScheduleUtils.occursNow", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("returns true when within a daily occurrence + duration", () => { + // Fake local time to 10:30 on a Sunday + vi.setSystemTime(new Date(2025, 5, 15, 10, 30, 0)); + + // Daily at 10:00 with 1 hour duration + const rrule = "DTSTART:20250601T100000Z\nRRULE:FREQ=DAILY"; + expect(ScheduleUtils.occursNow(rrule, 3600)).toBe(true); + }); + + it("returns false when after the duration has ended", () => { + // Fake local time to 11:30 + vi.setSystemTime(new Date(2025, 5, 15, 11, 30, 0)); + + const rrule = "DTSTART:20250601T100000Z\nRRULE:FREQ=DAILY"; + expect(ScheduleUtils.occursNow(rrule, 3600)).toBe(false); + }); + + it("returns false when before the occurrence", () => { + // Fake local time to 09:30 + vi.setSystemTime(new Date(2025, 5, 15, 9, 30, 0)); + + const rrule = "DTSTART:20250601T100000Z\nRRULE:FREQ=DAILY"; + expect(ScheduleUtils.occursNow(rrule, 3600)).toBe(false); + }); + + it("returns true for a weekly rule on the correct day", () => { + // 2025-06-16 is a Monday. Fake local time to Monday 14:30 + vi.setSystemTime(new Date(2025, 5, 16, 14, 30, 0)); + + // Weekly on Mondays at 14:00 with 1 hour duration + const rrule = "DTSTART:20250602T140000Z\nRRULE:FREQ=WEEKLY;BYDAY=MO"; + expect(ScheduleUtils.occursNow(rrule, 3600)).toBe(true); + }); + + it("returns false for a weekly rule on the wrong day", () => { + // 2025-06-15 is a Sunday. Fake local time to Sunday 14:30 + vi.setSystemTime(new Date(2025, 5, 15, 14, 30, 0)); + + // Weekly on Mondays at 14:00 + const rrule = "DTSTART:20250602T140000Z\nRRULE:FREQ=WEEKLY;BYDAY=MO"; + expect(ScheduleUtils.occursNow(rrule, 3600)).toBe(false); + }); + + it("handles the escaped newline replacement (\\\\n to \\n)", () => { + vi.setSystemTime(new Date(2025, 5, 15, 10, 30, 0)); + + // Use literal \\n as the code replaces it with \n + const rrule = "DTSTART:20250601T100000Z\\nRRULE:FREQ=DAILY"; + expect(ScheduleUtils.occursNow(rrule, 3600)).toBe(true); + }); + + it("returns false with zero duration when not exactly at occurrence time", () => { + // Fake local time to 10:01 + vi.setSystemTime(new Date(2025, 5, 15, 10, 1, 0)); + + const rrule = "DTSTART:20250601T100000Z\nRRULE:FREQ=DAILY"; + expect(ScheduleUtils.occursNow(rrule, 0)).toBe(false); + }); +}); diff --git a/assets/tests/client/screen.test.jsx b/assets/tests/client/screen.test.jsx new file mode 100644 index 000000000..bea2eefcd --- /dev/null +++ b/assets/tests/client/screen.test.jsx @@ -0,0 +1,165 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, cleanup, act } from "@testing-library/react"; +import Screen from "../../client/components/screen.jsx"; + +vi.mock("../../client/core/logger.js", () => ({ + default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), log: vi.fn() }, +})); +vi.mock("../../client/components/screen.scss", () => ({})); +vi.mock("../../client/components/region.scss", () => ({})); +vi.mock("../../client/components/touch-region.scss", () => ({})); +vi.mock("../../client/components/slide.scss", () => ({})); +vi.mock("../../client/components/error-boundary.scss", () => ({})); +vi.mock("../../client/assets/fallback.png", () => ({ + default: "fallback.png", +})); +vi.mock("../../client/assets/icon-close.svg", () => ({ + default: () => , +})); +vi.mock("../../client/assets/icon-pointer.svg", () => ({ + default: () => , +})); + +vi.mock("../../shared/grid-generator/grid-generator", () => ({ + createGrid: (cols, rows) => `"${"a ".repeat(cols).trim()}"${" ".repeat(rows)}`, + createGridArea: (gridArea) => gridArea.join(" / "), +})); + +vi.mock("../../client/core/client-config-loader.js", () => ({ + default: { + loadConfig: vi.fn().mockResolvedValue({ colorScheme: { type: "browser" } }), + }, +})); + +vi.mock("suncalc", () => ({ + default: { getTimes: vi.fn() }, +})); + +vi.mock("../../shared/slide-utils/templates.js", () => ({ + renderSlide: vi.fn().mockReturnValue(null), +})); + +vi.mock("../../client/util/id-from-path", () => ({ + default: (path) => path.split("/").pop(), +})); + +vi.mock("../../client/client-state-context.jsx", () => ({ + useClientState: () => ({ + regionSlides: {}, + callbacks: { + current: { + onRegionReady: vi.fn(), + onRegionRemoved: vi.fn(), + setScreen: vi.fn(), + setIsContentEmpty: vi.fn(), + updateRegionSlides: vi.fn(), + }, + }, + }), +})); + +describe("Screen", () => { + const makeScreen = (regions = [], grid = { rows: 1, columns: 1 }) => ({ + "@id": "/v2/screens/SCREEN01", + "@type": "Screen", + layoutData: { + grid, + regions, + }, + }); + + it("renders a div with class screen and the screen id", () => { + const screen = makeScreen(); + const { container } = render(); + const el = container.querySelector(".screen"); + expect(el).toBeInTheDocument(); + expect(el.id).toBe("/v2/screens/SCREEN01"); + }); + + it("renders Region components for default regions", () => { + const screen = makeScreen([ + { + "@id": "/v2/layouts/regions/R1", + title: "Region 1", + gridArea: ["a"], + }, + ]); + const { container } = render(); + // Region renders a div with class "region" + expect(container.querySelector(".region")).toBeInTheDocument(); + }); + + it("renders TouchRegion for touch-buttons regions", () => { + const screen = makeScreen([ + { + "@id": "/v2/layouts/regions/R2", + title: "Touch Region", + gridArea: ["a"], + type: "touch-buttons", + }, + ]); + const { container } = render(); + expect(container.querySelector(".touch-region")).toBeInTheDocument(); + }); + + it("renders both Region and TouchRegion when mixed", () => { + const screen = makeScreen([ + { + "@id": "/v2/layouts/regions/R1", + title: "Default", + gridArea: ["a"], + }, + { + "@id": "/v2/layouts/regions/R2", + title: "Touch", + gridArea: ["b"], + type: "touch-buttons", + }, + ]); + const { container } = render(); + expect(container.querySelector(".region")).toBeInTheDocument(); + expect(container.querySelector(".touch-region")).toBeInTheDocument(); + }); + + it("applies correct grid template values from layout data", () => { + const screen = makeScreen([], { rows: 2, columns: 3 }); + const { container } = render(); + const el = container.querySelector(".screen"); + // jsdom trims trailing whitespace from CSS values. + expect(el.style.gridTemplateColumns).toBe("1fr 1fr 1fr"); + expect(el.style.gridTemplateRows).toBe("1fr 1fr"); + }); + + it("renders screen div with no child regions when regions array is empty", () => { + const screen = makeScreen([], { rows: 1, columns: 1 }); + const { container } = render(); + expect(container.querySelector(".screen")).toBeInTheDocument(); + expect(container.querySelector(".region")).not.toBeInTheDocument(); + expect(container.querySelector(".touch-region")).not.toBeInTheDocument(); + }); + + it("removes color scheme classes from documentElement on unmount", async () => { + const screen = makeScreen(); + screen.enableColorSchemeChange = true; + + // Mock matchMedia for browser-based color scheme. + window.matchMedia = vi.fn().mockReturnValue({ matches: true }); + + let unmountFn; + await act(async () => { + const { unmount } = render(); + unmountFn = unmount; + }); + + // After config loads, color scheme class should be set. + expect( + document.documentElement.classList.contains("color-scheme-dark") || + document.documentElement.classList.contains("color-scheme-light"), + ).toBe(true); + + unmountFn(); + + expect(document.documentElement.classList.contains("color-scheme-dark")).toBe(false); + expect(document.documentElement.classList.contains("color-scheme-light")).toBe(false); + }); +}); diff --git a/assets/tests/client/slide.test.jsx b/assets/tests/client/slide.test.jsx new file mode 100644 index 000000000..aee7406e1 --- /dev/null +++ b/assets/tests/client/slide.test.jsx @@ -0,0 +1,169 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, cleanup } from "@testing-library/react"; +import Slide from "../../client/components/slide.jsx"; + +vi.mock("../../client/core/logger.js", () => ({ + default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), log: vi.fn() }, +})); +vi.mock("../../client/assets/fallback.png", () => ({ + default: "fallback.png", +})); +vi.mock("../../client/components/slide.scss", () => ({})); +vi.mock("../../client/components/error-boundary.scss", () => ({})); + +const mockRenderSlide = vi.fn(); +vi.mock("../../shared/slide-utils/templates.js", () => ({ + renderSlide: (...args) => mockRenderSlide(...args), +})); + +describe("Slide", () => { + const slide = { + "@id": "/v2/slides/TEST01234567890123456789", + executionId: "EXE-ID-abc123", + title: "Test Slide", + }; + + beforeEach(() => { + vi.useFakeTimers(); + mockRenderSlide.mockReturnValue(
Content
); + }); + + afterEach(() => { + cleanup(); + vi.useRealTimers(); + }); + + it("renders with correct id and data-execution-id", () => { + const { container } = render( + + ); + + const el = container.querySelector("#slide-1"); + expect(el).toBeInTheDocument(); + expect(el.getAttribute("data-execution-id")).toBe("EXE-ID-abc123"); + expect(el.getAttribute("data-run")).toBe("12345"); + }); + + it("calls renderSlide with slide, run, and slideDone", () => { + const slideDone = vi.fn(); + render( + + ); + + expect(mockRenderSlide).toHaveBeenCalledWith(slide, "12345", slideDone); + }); + + it("renders the output of renderSlide", () => { + render( + + ); + + expect(screen.getByTestId("rendered")).toBeInTheDocument(); + }); + + it("calls slideError after 5 seconds when rendered template throws", () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + + // Return a component that throws during its own render, + // so ErrorBoundary can catch it (unlike throwing in renderSlide itself). + function ThrowingTemplate() { + throw new Error("template crash"); + } + mockRenderSlide.mockReturnValue(); + + const slideError = vi.fn(); + render( + + ); + + expect(slideError).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(5000); + + expect(slideError).toHaveBeenCalledWith(slide); + }); + + it("renders without crashing when slide has no executionId", () => { + const slideNoExecId = { "@id": "/v2/slides/TEST01234567890123456789", title: "No exec" }; + const { container } = render( + + ); + + const el = container.querySelector("#slide-no-exec"); + expect(el).toBeInTheDocument(); + expect(el.getAttribute("data-execution-id")).toBeNull(); + }); + + it("does not call slideError if component unmounts before error timeout fires", () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + + function ThrowingTemplate() { + throw new Error("template crash"); + } + mockRenderSlide.mockReturnValue(); + + const slideError = vi.fn(); + const { unmount } = render( + + ); + + unmount(); + vi.advanceTimersByTime(5000); + + expect(slideError).not.toHaveBeenCalled(); + }); + + it("attaches forwardRef to the slide div", () => { + const ref = { current: null }; + render( + + ); + + expect(ref.current).not.toBeNull(); + expect(ref.current.id).toBe("slide-ref"); + expect(ref.current.classList.contains("slide")).toBe(true); + }); +}); diff --git a/assets/tests/client/status-service.test.js b/assets/tests/client/status-service.test.js new file mode 100644 index 000000000..06adbd7b6 --- /dev/null +++ b/assets/tests/client/status-service.test.js @@ -0,0 +1,62 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import statusService from "../../client/service/status-service"; +import constants from "../../client/util/constants"; + +describe("StatusService", () => { + beforeEach(() => { + statusService.status = constants.STATUS_INIT; + statusService.error = null; + window.history.replaceState(null, "", "/"); + }); + + it("has correct initial state", () => { + expect(statusService.status).toBe("init"); + expect(statusService.error).toBeNull(); + }); + + describe("setStatus", () => { + it("updates status property", () => { + statusService.setStatus("running"); + expect(statusService.status).toBe("running"); + }); + + it("adds status to URL query params", () => { + statusService.setStatus("running"); + expect(window.location.search).toContain("status=running"); + }); + + it("removes status param when set to falsy", () => { + statusService.setStatus("running"); + statusService.setStatus(null); + expect(window.location.search).not.toContain("status="); + }); + }); + + describe("setError", () => { + it("updates error property", () => { + statusService.setError("ER101"); + expect(statusService.error).toBe("ER101"); + }); + + it("adds error to URL query params", () => { + statusService.setError("ER101"); + expect(window.location.search).toContain("error=ER101"); + }); + + it("removes error param when set to null", () => { + statusService.setError("ER101"); + statusService.setError(null); + expect(window.location.search).not.toContain("error="); + }); + }); + + describe("setStatusInUrl", () => { + it("includes both status and error when both are set", () => { + statusService.setStatus("running"); + statusService.setError("ER101"); + const params = new URLSearchParams(window.location.search); + expect(params.get("status")).toBe("running"); + expect(params.get("error")).toBe("ER101"); + }); + }); +}); diff --git a/assets/tests/client/tenant-service.test.js b/assets/tests/client/tenant-service.test.js new file mode 100644 index 000000000..048d9a99d --- /dev/null +++ b/assets/tests/client/tenant-service.test.js @@ -0,0 +1,170 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const { mockDispatch } = vi.hoisted(() => ({ + mockDispatch: vi.fn(), +})); + +vi.mock("../../client/core/logger.js", () => ({ + default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), log: vi.fn() }, +})); + +vi.mock("../../client/core/app-storage.js", () => ({ + default: { + getToken: vi.fn(), + getTenantKey: vi.fn(), + getTenantId: vi.fn(), + setFallbackImageUrl: vi.fn(), + }, +})); + +vi.mock("../../client/redux/store.js", () => ({ + clientStore: { dispatch: mockDispatch }, +})); + +vi.mock("../../client/redux/enhanced-api.ts", () => ({ + clientApi: { + endpoints: { + getV2TenantsById: { + initiate: vi.fn().mockReturnValue("tenantAction"), + }, + }, + reducerPath: "clientApi", + reducer: (state = {}) => state, + middleware: () => (next) => (action) => next(action), + }, +})); + +vi.mock("../../client/redux/empty-api.ts", () => ({ + clientEmptySplitApi: { + injectEndpoints: vi.fn().mockReturnValue({ endpoints: {} }), + }, +})); + +import tenantService from "../../client/service/tenant-service"; +import appStorage from "../../client/core/app-storage.js"; +import logger from "../../client/core/logger.js"; + +describe("TenantService", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("dispatches getV2TenantsById when credentials are present", () => { + appStorage.getToken.mockReturnValue("jwt-token"); + appStorage.getTenantKey.mockReturnValue("tenant-key"); + appStorage.getTenantId.mockReturnValue("tenant-123"); + + mockDispatch.mockReturnValue({ + unwrap: () => Promise.resolve({}), + unsubscribe: vi.fn(), + }); + + tenantService.loadTenantConfig(); + + expect(mockDispatch).toHaveBeenCalled(); + }); + + it("sets fallback image URL from tenant data", async () => { + appStorage.getToken.mockReturnValue("jwt-token"); + appStorage.getTenantKey.mockReturnValue("tenant-key"); + appStorage.getTenantId.mockReturnValue("tenant-123"); + + mockDispatch.mockReturnValue({ + unwrap: () => + Promise.resolve({ fallbackImageUrl: "https://example.com/bg.png" }), + unsubscribe: vi.fn(), + }); + + tenantService.loadTenantConfig(); + + // Wait for the promise chain. + await new Promise((r) => setTimeout(r, 0)); + + expect(appStorage.setFallbackImageUrl).toHaveBeenCalledWith( + "https://example.com/bg.png" + ); + }); + + it("does not set fallback image when not present in tenant data", async () => { + appStorage.getToken.mockReturnValue("jwt-token"); + appStorage.getTenantKey.mockReturnValue("tenant-key"); + appStorage.getTenantId.mockReturnValue("tenant-123"); + + mockDispatch.mockReturnValue({ + unwrap: () => Promise.resolve({}), + unsubscribe: vi.fn(), + }); + + tenantService.loadTenantConfig(); + await new Promise((r) => setTimeout(r, 0)); + + expect(appStorage.setFallbackImageUrl).not.toHaveBeenCalled(); + }); + + it("does nothing when token is missing", () => { + appStorage.getToken.mockReturnValue(null); + appStorage.getTenantKey.mockReturnValue("tenant-key"); + appStorage.getTenantId.mockReturnValue("tenant-123"); + + tenantService.loadTenantConfig(); + + expect(mockDispatch).not.toHaveBeenCalled(); + }); + + it("does nothing when tenantKey is missing", () => { + appStorage.getToken.mockReturnValue("jwt-token"); + appStorage.getTenantKey.mockReturnValue(null); + appStorage.getTenantId.mockReturnValue("tenant-123"); + + tenantService.loadTenantConfig(); + + expect(mockDispatch).not.toHaveBeenCalled(); + }); + + it("does nothing when tenantId is missing", () => { + appStorage.getToken.mockReturnValue("jwt-token"); + appStorage.getTenantKey.mockReturnValue("tenant-key"); + appStorage.getTenantId.mockReturnValue(null); + + tenantService.loadTenantConfig(); + + expect(mockDispatch).not.toHaveBeenCalled(); + }); + + it("logs error and unsubscribes on fetch failure", async () => { + appStorage.getToken.mockReturnValue("jwt-token"); + appStorage.getTenantKey.mockReturnValue("tenant-key"); + appStorage.getTenantId.mockReturnValue("tenant-123"); + + const unsubscribe = vi.fn(); + mockDispatch.mockReturnValue({ + unwrap: () => Promise.reject(new Error("Network error")), + unsubscribe, + }); + + tenantService.loadTenantConfig(); + await new Promise((r) => setTimeout(r, 0)); + + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining("Failed to load tenant config") + ); + expect(unsubscribe).toHaveBeenCalled(); + }); + + it("unsubscribes after successful fetch", async () => { + appStorage.getToken.mockReturnValue("jwt-token"); + appStorage.getTenantKey.mockReturnValue("tenant-key"); + appStorage.getTenantId.mockReturnValue("tenant-123"); + + const unsubscribe = vi.fn(); + mockDispatch.mockReturnValue({ + unwrap: () => Promise.resolve({}), + unsubscribe, + }); + + tenantService.loadTenantConfig(); + await new Promise((r) => setTimeout(r, 0)); + + expect(unsubscribe).toHaveBeenCalled(); + }); +}); diff --git a/assets/tests/client/token-service.test.js b/assets/tests/client/token-service.test.js new file mode 100644 index 000000000..d386d1912 --- /dev/null +++ b/assets/tests/client/token-service.test.js @@ -0,0 +1,385 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +const { mockDispatch } = vi.hoisted(() => ({ + mockDispatch: vi.fn(), +})); + +vi.mock("../../client/core/logger.js", () => ({ + default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), log: vi.fn() }, +})); + +vi.mock("../../client/core/app-storage.js", () => ({ + default: { + getTokenExpire: vi.fn(), + getTokenIssueAt: vi.fn(), + getToken: vi.fn(), + getRefreshToken: vi.fn(), + setToken: vi.fn(), + setRefreshToken: vi.fn(), + setScreenId: vi.fn(), + setTenant: vi.fn(), + }, +})); + +vi.mock("../../client/service/status-service.js", () => ({ + default: { + error: null, + setError: vi.fn(), + setStatus: vi.fn(), + }, +})); + +vi.mock("../../client/core/client-config-loader.js", () => ({ + default: { + loadConfig: vi.fn().mockResolvedValue({ refreshTokenTimeout: 900000 }), + }, +})); + +vi.mock("../../client/redux/store.js", () => ({ + clientStore: { dispatch: mockDispatch }, +})); + +vi.mock("../../client/redux/enhanced-api.ts", () => ({ + clientApi: { + endpoints: { + postRefreshTokenItem: { + initiate: vi.fn().mockReturnValue("refreshAction"), + }, + postLoginInfoScreen: { + initiate: vi.fn().mockReturnValue("loginAction"), + }, + }, + reducerPath: "clientApi", + reducer: (state = {}) => state, + middleware: () => (next) => (action) => next(action), + }, +})); + +vi.mock("../../client/redux/empty-api.ts", () => ({ + clientEmptySplitApi: { + injectEndpoints: vi.fn().mockReturnValue({ + endpoints: {}, + }), + }, +})); + +import tokenService from "../../client/service/token-service"; +import constants from "../../client/util/constants"; +import appStorage from "../../client/core/app-storage.js"; +import statusService from "../../client/service/status-service.js"; + +describe("TokenService", () => { + beforeEach(() => { + vi.useFakeTimers(); + tokenService.refreshingToken = false; + tokenService.refreshInterval = null; + tokenService.refreshPromise = null; + statusService.error = null; + vi.clearAllMocks(); + }); + + afterEach(() => { + tokenService.stopRefreshing(); + vi.useRealTimers(); + }); + + describe("getExpireState", () => { + it("returns NO_EXPIRE when no expire in storage", () => { + appStorage.getTokenExpire.mockReturnValue(null); + appStorage.getTokenIssueAt.mockReturnValue(100); + appStorage.getToken.mockReturnValue("token"); + + expect(tokenService.getExpireState()).toBe(constants.NO_EXPIRE); + }); + + it("returns NO_ISSUED_AT when no issuedAt in storage", () => { + appStorage.getTokenExpire.mockReturnValue(200); + appStorage.getTokenIssueAt.mockReturnValue(null); + appStorage.getToken.mockReturnValue("token"); + + expect(tokenService.getExpireState()).toBe(constants.NO_ISSUED_AT); + }); + + it("returns NO_TOKEN when no token in storage", () => { + appStorage.getTokenExpire.mockReturnValue(200); + appStorage.getTokenIssueAt.mockReturnValue(100); + appStorage.getToken.mockReturnValue(null); + + expect(tokenService.getExpireState()).toBe(constants.NO_TOKEN); + }); + + it("returns TOKEN_EXPIRED when now > expire", () => { + vi.setSystemTime(new Date(1000 * 1000)); + appStorage.getTokenExpire.mockReturnValue(500); + appStorage.getTokenIssueAt.mockReturnValue(100); + appStorage.getToken.mockReturnValue("token"); + + expect(tokenService.getExpireState()).toBe(constants.TOKEN_EXPIRED); + }); + + it("returns TOKEN_VALID_SHOULD_HAVE_BEEN_REFRESHED when past 50% of lifetime", () => { + vi.setSystemTime(new Date(160 * 1000)); + appStorage.getTokenExpire.mockReturnValue(200); + appStorage.getTokenIssueAt.mockReturnValue(100); + appStorage.getToken.mockReturnValue("token"); + + expect(tokenService.getExpireState()).toBe( + constants.TOKEN_VALID_SHOULD_HAVE_BEEN_REFRESHED + ); + }); + + it("returns TOKEN_VALID when within first half of lifetime", () => { + vi.setSystemTime(new Date(120 * 1000)); + appStorage.getTokenExpire.mockReturnValue(200); + appStorage.getTokenIssueAt.mockReturnValue(100); + appStorage.getToken.mockReturnValue("token"); + + expect(tokenService.getExpireState()).toBe(constants.TOKEN_VALID); + }); + }); + + describe("ensureFreshToken", () => { + it("returns immediately when already refreshing", () => { + tokenService.refreshingToken = true; + appStorage.getRefreshToken.mockReturnValue("token"); + + tokenService.ensureFreshToken(); + + expect(mockDispatch).not.toHaveBeenCalled(); + }); + + it("sets error when refresh token is not set", () => { + appStorage.getRefreshToken.mockReturnValue(null); + appStorage.getTokenExpire.mockReturnValue(200); + appStorage.getTokenIssueAt.mockReturnValue(100); + + tokenService.ensureFreshToken(); + + expect(statusService.setError).toHaveBeenCalledWith( + constants.ERROR_TOKEN_EXP_IAT_NOT_SET + ); + }); + + it("sets error when expire is not set", () => { + appStorage.getRefreshToken.mockReturnValue("token"); + appStorage.getTokenExpire.mockReturnValue(null); + appStorage.getTokenIssueAt.mockReturnValue(100); + + tokenService.ensureFreshToken(); + + expect(statusService.setError).toHaveBeenCalledWith( + constants.ERROR_TOKEN_EXP_IAT_NOT_SET + ); + }); + + it("calls refreshToken when past 50% of token lifetime", () => { + vi.setSystemTime(new Date(160 * 1000)); + appStorage.getRefreshToken.mockReturnValue("refresh"); + appStorage.getTokenExpire.mockReturnValue(200); + appStorage.getTokenIssueAt.mockReturnValue(100); + + mockDispatch.mockReturnValue({ + unwrap: () => Promise.resolve({ token: "new", refresh_token: "new-r" }), + }); + + tokenService.ensureFreshToken(); + + expect(mockDispatch).toHaveBeenCalled(); + }); + + it("does not call refreshToken when before 50% of token lifetime", () => { + vi.setSystemTime(new Date(120 * 1000)); + appStorage.getRefreshToken.mockReturnValue("refresh"); + appStorage.getTokenExpire.mockReturnValue(200); + appStorage.getTokenIssueAt.mockReturnValue(100); + + tokenService.ensureFreshToken(); + + expect(mockDispatch).not.toHaveBeenCalled(); + }); + }); + + describe("checkToken", () => { + it("sets ERROR_TOKEN_EXPIRED when token is expired", () => { + vi.setSystemTime(new Date(1000 * 1000)); + appStorage.getTokenExpire.mockReturnValue(500); + appStorage.getTokenIssueAt.mockReturnValue(100); + appStorage.getToken.mockReturnValue("token"); + + tokenService.checkToken(); + + expect(statusService.setError).toHaveBeenCalledWith( + constants.ERROR_TOKEN_EXPIRED + ); + }); + + it("sets ERROR_TOKEN_VALID_SHOULD_HAVE_BEEN_REFRESHED when past 50%", () => { + vi.setSystemTime(new Date(160 * 1000)); + appStorage.getTokenExpire.mockReturnValue(200); + appStorage.getTokenIssueAt.mockReturnValue(100); + appStorage.getToken.mockReturnValue("token"); + + tokenService.checkToken(); + + expect(statusService.setError).toHaveBeenCalledWith( + constants.ERROR_TOKEN_VALID_SHOULD_HAVE_BEEN_REFRESHED + ); + }); + + it("clears token-related errors when token is valid", () => { + vi.setSystemTime(new Date(120 * 1000)); + appStorage.getTokenExpire.mockReturnValue(200); + appStorage.getTokenIssueAt.mockReturnValue(100); + appStorage.getToken.mockReturnValue("token"); + statusService.error = constants.ERROR_TOKEN_EXPIRED; + + tokenService.checkToken(); + + expect(statusService.setError).toHaveBeenCalledWith(null); + }); + + it("does nothing when no token info exists", () => { + appStorage.getTokenExpire.mockReturnValue(null); + + tokenService.checkToken(); + + expect(statusService.setError).not.toHaveBeenCalled(); + }); + }); + + describe("checkLogin", () => { + it("stores credentials and returns ready when login is ready", async () => { + const loginData = { + status: constants.LOGIN_STATUS_READY, + token: "jwt-token", + screenId: "screen-1", + tenantKey: "tenant-key", + tenantId: "tenant-id", + refresh_token: "refresh-token", + }; + mockDispatch.mockReturnValue({ + unwrap: () => Promise.resolve(loginData), + unsubscribe: vi.fn(), + reset: vi.fn(), + }); + + const result = await tokenService.checkLogin(); + + expect(appStorage.setToken).toHaveBeenCalledWith("jwt-token"); + expect(appStorage.setRefreshToken).toHaveBeenCalledWith("refresh-token"); + expect(appStorage.setScreenId).toHaveBeenCalledWith("screen-1"); + expect(appStorage.setTenant).toHaveBeenCalledWith( + "tenant-key", + "tenant-id" + ); + expect(result).toEqual({ + status: constants.LOGIN_STATUS_READY, + screenId: "screen-1", + }); + }); + + it("returns bindKey when awaiting bind key", async () => { + mockDispatch.mockReturnValue({ + unwrap: () => + Promise.resolve({ + status: constants.LOGIN_STATUS_AWAITING_BIND_KEY, + bindKey: "ABCD-1234", + }), + unsubscribe: vi.fn(), + reset: vi.fn(), + }); + + const result = await tokenService.checkLogin(); + + expect(result).toEqual({ + status: constants.LOGIN_STATUS_AWAITING_BIND_KEY, + bindKey: "ABCD-1234", + }); + }); + + it("returns unknown status for unexpected response", async () => { + mockDispatch.mockReturnValue({ + unwrap: () => Promise.resolve({ status: "something-else" }), + unsubscribe: vi.fn(), + reset: vi.fn(), + }); + + const result = await tokenService.checkLogin(); + + expect(result).toEqual({ status: constants.LOGIN_STATUS_UNKNOWN }); + }); + }); + + describe("refreshToken", () => { + it("stores new token and refresh token on success", async () => { + appStorage.getRefreshToken.mockReturnValue("old-refresh"); + mockDispatch.mockReturnValue({ + unwrap: () => + Promise.resolve({ + token: "new-token", + refresh_token: "new-refresh", + }), + unsubscribe: vi.fn(), + reset: vi.fn(), + }); + + await tokenService.refreshToken(); + + expect(appStorage.setToken).toHaveBeenCalledWith("new-token"); + expect(appStorage.setRefreshToken).toHaveBeenCalledWith("new-refresh"); + }); + + it("resets refreshingToken flag after success", async () => { + appStorage.getRefreshToken.mockReturnValue("old-refresh"); + mockDispatch.mockReturnValue({ + unwrap: () => + Promise.resolve({ + token: "new-token", + refresh_token: "new-refresh", + }), + unsubscribe: vi.fn(), + reset: vi.fn(), + }); + + await tokenService.refreshToken(); + + expect(tokenService.refreshingToken).toBe(false); + expect(tokenService.refreshPromise).toBeNull(); + }); + + it("resets refreshingToken flag after failure", async () => { + appStorage.getRefreshToken.mockReturnValue("old-refresh"); + mockDispatch.mockReturnValue({ + unwrap: () => Promise.reject(new Error("401")), + unsubscribe: vi.fn(), + reset: vi.fn(), + }); + + await expect(tokenService.refreshToken()).rejects.toThrow("401"); + + expect(tokenService.refreshingToken).toBe(false); + expect(tokenService.refreshPromise).toBeNull(); + }); + + it("deduplicates concurrent refresh calls", async () => { + appStorage.getRefreshToken.mockReturnValue("old-refresh"); + mockDispatch.mockReturnValue({ + unwrap: () => + Promise.resolve({ + token: "new-token", + refresh_token: "new-refresh", + }), + unsubscribe: vi.fn(), + reset: vi.fn(), + }); + + const p1 = tokenService.refreshToken(); + const p2 = tokenService.refreshToken(); + + expect(p1).toBe(p2); + + await p1; + expect(mockDispatch).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/assets/tests/client/touch-region.test.jsx b/assets/tests/client/touch-region.test.jsx new file mode 100644 index 000000000..4831c7a63 --- /dev/null +++ b/assets/tests/client/touch-region.test.jsx @@ -0,0 +1,193 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, act, fireEvent, cleanup, within } from "@testing-library/react"; +import TouchRegion from "../../client/components/touch-region.jsx"; + +vi.mock("../../client/core/logger.js", () => ({ + default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), log: vi.fn() }, +})); +vi.mock("../../client/components/touch-region.scss", () => ({})); +vi.mock("../../client/components/slide.scss", () => ({})); +vi.mock("../../client/components/error-boundary.scss", () => ({})); +vi.mock("../../client/assets/fallback.png", () => ({ + default: "fallback.png", +})); +vi.mock("../../client/assets/icon-close.svg", () => ({ + default: () => , +})); +vi.mock("../../client/assets/icon-pointer.svg", () => ({ + default: () => , +})); +vi.mock("../../shared/grid-generator/grid-generator", () => ({ + createGridArea: (gridArea) => gridArea.join(" / "), +})); +vi.mock("../../client/util/id-from-path", () => ({ + default: () => "TOUCH01", +})); + +let capturedSlideDone = null; +vi.mock("../../client/components/slide.jsx", () => ({ + default: ({ slide, slideDone }) => { + capturedSlideDone = () => slideDone(slide); + return
{slide.title}
; + }, +})); + +// Mock context +const mockCallbacks = { + current: { + onRegionReady: vi.fn(), + onRegionRemoved: vi.fn(), + setScreen: vi.fn(), + setIsContentEmpty: vi.fn(), + updateRegionSlides: vi.fn(), + }, +}; +let mockRegionSlides = {}; + +vi.mock("../../client/client-state-context.jsx", () => ({ + useClientState: () => ({ + regionSlides: mockRegionSlides, + callbacks: mockCallbacks, + }), +})); + +describe("TouchRegion", () => { + const region = { + "@id": "/v2/layouts/regions/TOUCH01", + gridArea: ["a"], + type: "touch-buttons", + }; + + const slides = [ + { executionId: "EXE-1", title: "Slide 1" }, + { + executionId: "EXE-2", + title: "Slide 2", + content: { touchRegionButtonText: "Press Me" }, + }, + ]; + + afterEach(() => { + capturedSlideDone = null; + mockRegionSlides = {}; + mockCallbacks.current.onRegionReady.mockClear(); + mockCallbacks.current.onRegionRemoved.mockClear(); + cleanup(); + vi.restoreAllMocks(); + }); + + function renderWithSlides() { + mockRegionSlides = { TOUCH01: slides }; + return render(); + } + + it("calls onRegionReady on mount", () => { + render(); + + expect(mockCallbacks.current.onRegionReady).toHaveBeenCalledWith("TOUCH01"); + }); + + it("calls onRegionRemoved on unmount", () => { + const { unmount } = render(); + unmount(); + + expect(mockCallbacks.current.onRegionRemoved).toHaveBeenCalledWith("TOUCH01"); + }); + + it("renders buttons for each slide when regionSlides has data", () => { + const { container } = renderWithSlides(); + + const buttons = within(container).getAllByRole("button"); + expect(buttons.length).toBeGreaterThanOrEqual(2); + }); + + it("uses slide title for button text", () => { + const { container } = renderWithSlides(); + + expect(within(container).getByText("Slide 1")).toBeInTheDocument(); + }); + + it("uses touchRegionButtonText when available", () => { + const { container } = renderWithSlides(); + + expect(within(container).getByText("Press Me")).toBeInTheDocument(); + }); + + it("opens slide when button is clicked", () => { + const { container } = renderWithSlides(); + + act(() => { + fireEvent.click(within(container).getByText("Slide 1")); + }); + + expect(within(container).getByTestId("slide-EXE-1")).toBeInTheDocument(); + }); + + it("shows close button when slide is active", () => { + const { container } = renderWithSlides(); + + act(() => { + fireEvent.click(within(container).getByText("Slide 1")); + }); + + expect(within(container).getByText("LUK")).toBeInTheDocument(); + }); + + it("dismisses slide when close button is clicked", () => { + const { container } = renderWithSlides(); + + act(() => { + fireEvent.click(within(container).getByText("Slide 1")); + }); + + act(() => { + fireEvent.click(within(container).getByText("LUK")); + }); + + expect( + within(container).queryByTestId("slide-EXE-1") + ).not.toBeInTheDocument(); + }); + + it("renders no buttons when regionSlides has empty array", () => { + mockRegionSlides = { TOUCH01: [] }; + const { container } = render(); + + const buttons = within(container).queryAllByRole("button"); + expect(buttons).toHaveLength(0); + }); + + it("filters out invalid slides from buttons", () => { + mockRegionSlides = { + TOUCH01: [ + { executionId: "EXE-VALID", title: "Valid Slide" }, + { executionId: "EXE-INVALID", title: "Invalid Slide", invalid: true }, + ], + }; + const { container } = render(); + + expect(within(container).getByText("Valid Slide")).toBeInTheDocument(); + expect(within(container).queryByText("Invalid Slide")).not.toBeInTheDocument(); + }); + + it("opens slide when Enter is pressed on a button", () => { + const { container } = renderWithSlides(); + + act(() => { + fireEvent.keyDown(within(container).getByText("Slide 1"), { key: "Enter" }); + }); + + expect(within(container).getByTestId("slide-EXE-1")).toBeInTheDocument(); + }); + + it("opens slide when Space is pressed on a button", () => { + const { container } = renderWithSlides(); + + act(() => { + fireEvent.keyDown(within(container).getByText("Slide 1"), { key: " " }); + }); + + expect(within(container).getByTestId("slide-EXE-1")).toBeInTheDocument(); + }); + +}); diff --git a/config/api_platform/media.yaml b/config/api_platform/media.yaml index ef1c96254..d527a20ca 100644 --- a/config/api_platform/media.yaml +++ b/config/api_platform/media.yaml @@ -13,7 +13,7 @@ resources: openapiContext: description: Retrieves a Media resource. summary: Retrieve a Media resource. - operationId: getv2MediaById + operationId: get-v2-media-by-id tags: - Media parameters: diff --git a/public/api-spec-v2.json b/public/api-spec-v2.json index df8506599..1cc5f7dab 100644 --- a/public/api-spec-v2.json +++ b/public/api-spec-v2.json @@ -1651,7 +1651,7 @@ }, "/v2/media/{id}": { "get": { - "operationId": "getv2MediaById", + "operationId": "get-v2-media-by-id", "tags": [ "Media" ], diff --git a/public/api-spec-v2.yaml b/public/api-spec-v2.yaml index 9c1b21eb1..757cd8f64 100644 --- a/public/api-spec-v2.yaml +++ b/public/api-spec-v2.yaml @@ -1259,7 +1259,7 @@ paths: deprecated: false '/v2/media/{id}': get: - operationId: getv2MediaById + operationId: get-v2-media-by-id tags: - Media responses: