Skip to content

Commit cbe4232

Browse files
authored
Merge pull request #356 from os2display/feature/release-3-align-with-2.6.1
Align release 3.0.0 with 2.6.1
2 parents 3e9ce04 + 2d3b0ef commit cbe4232

19 files changed

Lines changed: 1240 additions & 710 deletions

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ All notable changes to this project will be documented in this file.
2222
- Aligned with v. 2.5.2.
2323
- Removed themes.
2424
- Added command to migrate config.json files.
25-
- Fix data fetching bug + tests
25+
- Fix data fetching bug and tests
2626
- Refactored screen layout commands.
2727
- Moved list components (search and checkboxes) around.
2828
- Aligned environment variable names.

assets/admin/components/slide/slide-manager.jsx

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { ulid } from "ulid";
44
import { useDispatch } from "react-redux";
55
import dayjs from "dayjs";
66
import { useNavigate } from "react-router-dom";
7+
import rebuildMediaFromContent from "./slide-media-utils";
78
import UserContext from "../../context/user-context";
89
import {
910
enhancedApi,
@@ -337,13 +338,11 @@ function SlideManager({
337338
const localFormStateObject = { ...formStateObject };
338339
const localMediaData = { ...mediaData };
339340
// Set field as a field to look into for new references.
340-
setMediaFields([...new Set([...mediaFields, fieldId])]);
341+
const updatedMediaFields = [...new Set([...mediaFields, fieldId])];
342+
setMediaFields(updatedMediaFields);
341343

342344
const newField = [];
343345

344-
if (Array.isArray(fieldValue) && fieldValue.length === 0) {
345-
localFormStateObject.media = [];
346-
}
347346
// Handle each entry in field.
348347
if (Array.isArray(fieldValue)) {
349348
fieldValue.forEach((entry) => {
@@ -383,17 +382,19 @@ function SlideManager({
383382
!Object.prototype.hasOwnProperty.call(localMediaData, entry["@id"])
384383
) {
385384
set(localMediaData, entry["@id"], entry);
386-
387-
localFormStateObject.media.push(entry["@id"]);
388385
}
389386
}
390387
});
391388
}
392389

393390
set(localFormStateObject.content, fieldId, newField);
394-
set(localFormStateObject, "media", [
395-
...new Set([...localFormStateObject.media]),
396-
]);
391+
392+
// Rebuild the media array from all content fields to keep it in sync.
393+
set(
394+
localFormStateObject,
395+
"media",
396+
rebuildMediaFromContent(localFormStateObject.content),
397+
);
397398

398399
setFormStateObject(localFormStateObject);
399400
setMediaData(localMediaData);
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/**
2+
* Rebuild the media array from all content fields that reference media.
3+
*
4+
* This ensures that the top-level `media` array (sent to the API as slide_media
5+
* associations) always matches the media actually referenced in the slide's
6+
* `content` object.
7+
*
8+
* @param {object} content - The slide content object.
9+
* @returns {string[]} Deduplicated array of media IRIs.
10+
*/
11+
export default function rebuildMediaFromContent(content) {
12+
const media = [];
13+
14+
const mediaIriRegex = /\/v2\/media\/.+/;
15+
16+
const isMediaIri = (value) =>
17+
typeof value === "string" &&
18+
!value.startsWith("TEMP--") &&
19+
mediaIriRegex.test(value);
20+
21+
const collectMediaFromValue = (value, seen = new Set()) => {
22+
// 1) Ignore empty values early (nothing to scan)
23+
if (value === null || value === undefined) return;
24+
25+
// 2) If it's a string, it might be a media IRI; validate and collect it
26+
if (typeof value === "string") {
27+
if (isMediaIri(value)) media.push(value);
28+
return;
29+
}
30+
31+
// 3) If it's not an object (e.g. number/boolean/function), it cannot contain nested media
32+
if (typeof value !== "object") return;
33+
34+
// 4) Defensive guard against circular references:
35+
// - JSON content won't have cycles, but runtime objects might.
36+
// - If we've seen this object/array already, stop to avoid infinite recursion.
37+
if (seen.has(value)) return;
38+
seen.add(value);
39+
40+
// 5) If it's an array, scan each element (elements can be strings, objects, or more arrays)
41+
if (Array.isArray(value)) {
42+
value.forEach((item) => collectMediaFromValue(item, seen));
43+
return;
44+
}
45+
46+
// 6) Otherwise it's a plain object: scan its property values recursively
47+
Object.values(value).forEach((item) => collectMediaFromValue(item, seen));
48+
};
49+
50+
const fieldsToScan = new Set([]);
51+
52+
// Scan content for media references.
53+
if (content && typeof content === "object") {
54+
Object.keys(content).forEach((key) => fieldsToScan.add(key));
55+
}
56+
57+
// Scan the entire content object (one traversal)
58+
collectMediaFromValue(content);
59+
60+
return [...new Set(media)];
61+
}

assets/shared/templates/image-text.jsx

Lines changed: 26 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ function renderSlide(slide, run, slideDone) {
4242
*/
4343
function ImageText({ slide, content, run, slideDone, executionId }) {
4444
const imageTimeoutRef = useRef();
45+
const imagesRef = useRef([]);
4546
const [images, setImages] = useState([]);
4647
const [currentImage, setCurrentImage] = useState();
4748
const logo = slide?.theme?.logo;
@@ -132,13 +133,15 @@ function ImageText({ slide, content, run, slideDone, executionId }) {
132133
}
133134

134135
const changeImage = (newIndex) => {
135-
if (newIndex < images.length) {
136-
setCurrentImage(images[newIndex]);
136+
const currentImages = imagesRef.current;
137137

138-
if (newIndex < images.length - 1) {
138+
if (newIndex < currentImages.length) {
139+
setCurrentImage(currentImages[newIndex]);
140+
141+
if (newIndex < currentImages.length - 1) {
139142
imageTimeoutRef.current = setTimeout(
140143
() => changeImage(newIndex + 1),
141-
duration / images.length,
144+
duration / currentImages.length,
142145
);
143146
}
144147
}
@@ -152,33 +155,41 @@ function ImageText({ slide, content, run, slideDone, executionId }) {
152155
);
153156

154157
if (imageUrls?.length > 0) {
155-
const newImages = imageUrls.map((url) => {
156-
return {
157-
url,
158-
nodeRef: createRef(),
159-
};
160-
});
158+
const newImages = imageUrls.map((url) => ({
159+
url,
160+
nodeRef: createRef(),
161+
}));
162+
163+
imagesRef.current = newImages;
161164

162165
setImages(newImages);
166+
} else {
167+
imagesRef.current = [];
168+
setImages([]);
163169
}
164170
}
165171
}, [slide]);
166172

167173
const startTheShow = () => {
168-
if (images?.length > 0 && !currentImage) {
169-
setCurrentImage(images[0]);
174+
if (imageTimeoutRef.current) {
175+
clearTimeout(imageTimeoutRef.current);
170176
}
171177

172-
// If there are multiple images, we are going to loop through these WITHIN the set duration.
173-
if (images?.length > 1) {
178+
const currentImages = imagesRef.current;
179+
180+
if (currentImages.length > 1) {
174181
// Kickoff the display of multiple images with the zero indexed
175182
changeImage(0);
183+
} else if (currentImages.length === 1) {
184+
setCurrentImage(currentImages[0]);
176185
}
177186
};
178187

179188
useEffect(() => {
180-
if (!currentImage) {
189+
if (images.length > 0) {
181190
startTheShow();
191+
} else {
192+
setCurrentImage(undefined);
182193
}
183194
}, [images]);
184195

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { test, expect } from "@playwright/test";
2+
import rebuildMediaFromContent from "../../admin/components/slide/slide-media-utils";
3+
4+
test.describe("Slide media sync", () => {
5+
test("It returns media IRIs referenced in content fields", () => {
6+
const content = {
7+
mainImage: ["/v2/media/1", "/v2/media/2"],
8+
backgroundVideo: ["/v2/media/3"],
9+
};
10+
11+
const result = rebuildMediaFromContent(content);
12+
13+
expect(result).toEqual(["/v2/media/1", "/v2/media/2", "/v2/media/3"]);
14+
});
15+
16+
test("It excludes TEMP-- IDs that have not been uploaded yet", () => {
17+
const content = {
18+
mainImage: ["TEMP--abc123", "/v2/media/1"],
19+
};
20+
21+
const result = rebuildMediaFromContent(content);
22+
23+
expect(result).toEqual(["/v2/media/1"]);
24+
});
25+
26+
test("It removes media no longer referenced in any content field", () => {
27+
const content = {
28+
mainImage: ["/v2/media/2"],
29+
};
30+
31+
const result = rebuildMediaFromContent(content);
32+
33+
expect(result).toEqual(["/v2/media/2"]);
34+
expect(result).not.toContain("/v2/media/1");
35+
});
36+
37+
test("It returns empty array when all media is removed from content", () => {
38+
const content = {
39+
mainImage: [],
40+
backgroundVideo: ["/v2/media/3"],
41+
};
42+
43+
const result = rebuildMediaFromContent(content);
44+
45+
expect(result).toEqual(["/v2/media/3"]);
46+
});
47+
48+
test("It deduplicates media used across multiple content fields", () => {
49+
const content = {
50+
mainImage: ["/v2/media/1"],
51+
thumbnail: ["/v2/media/1"],
52+
};
53+
54+
const result = rebuildMediaFromContent(content);
55+
56+
expect(result).toEqual(["/v2/media/1"]);
57+
});
58+
59+
test("It handles non-existent content fields gracefully", () => {
60+
const content = {};
61+
62+
const result = rebuildMediaFromContent(content);
63+
64+
expect(result).toEqual([]);
65+
});
66+
67+
test("It handles nested content field paths", () => {
68+
const content = {
69+
sections: {
70+
hero: ["/v2/media/1"],
71+
},
72+
};
73+
74+
const result = rebuildMediaFromContent(content);
75+
76+
expect(result).toEqual(["/v2/media/1"]);
77+
});
78+
79+
test("It ignores non-media content values when scanning top-level keys", () => {
80+
const content = {
81+
images: ["/v2/media/1"],
82+
title: "Some text",
83+
separator: true,
84+
contacts: [{ name: "John", image: ["/v2/media/2"], tags: ["news"] }],
85+
};
86+
87+
const result = rebuildMediaFromContent(content);
88+
89+
expect(result).toContain("/v2/media/1");
90+
expect(result).toContain("/v2/media/2");
91+
expect(result).not.toContain("news");
92+
});
93+
94+
test("It does not include non-media string arrays from content", () => {
95+
const content = {
96+
images: ["/v2/media/1"],
97+
tags: ["news", "sports"],
98+
};
99+
100+
const result = rebuildMediaFromContent(content);
101+
102+
expect(result).toEqual(["/v2/media/1"]);
103+
expect(result).not.toContain("news");
104+
expect(result).not.toContain("sports");
105+
});
106+
107+
test("It avoids infinite recursion when content contains circular references", () => {
108+
const circular = { images: ["/v2/media/1"] };
109+
circular.self = circular; // create an explicit cycle
110+
111+
expect(() => rebuildMediaFromContent(circular)).not.toThrow();
112+
113+
const result = rebuildMediaFromContent(circular);
114+
expect(result).toContain("/v2/media/1");
115+
});
116+
});

0 commit comments

Comments
 (0)