diff --git a/CHANGELOG.md b/CHANGELOG.md index 226d38a0..e280a948 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +- [#254](https://github.com/os2display/display-admin-client/pull/254) + - Changed playlist.slides list columns. + - Set published.to to now when creating new slides. + - Added option to sort slides in playlist by published.to and status. - [#253](https://github.com/os2display/display-admin-client/pull/253) - Refactored scheduling to increase user experience. - Added interval and count to rrule inputs. diff --git a/e2e/campaign.spec.js b/e2e/campaign.spec.js index 5b1c7fef..3cb54cf9 100644 --- a/e2e/campaign.spec.js +++ b/e2e/campaign.spec.js @@ -336,7 +336,7 @@ test.describe("Campaign pages work", () => { .click(); await expect( page.locator("#slides-section").locator("tbody").locator("tr td") - ).toHaveCount(7); + ).toHaveCount(6); // Remove slide await page diff --git a/e2e/playlist.spec.js b/e2e/playlist.spec.js index 5590ac22..465a1e94 100644 --- a/e2e/playlist.spec.js +++ b/e2e/playlist.spec.js @@ -105,6 +105,7 @@ test.describe("Playlist create tests", () => { await expect(page.locator("#cancel_playlist")).not.toBeVisible(); }); }); + test.describe("Playlist list tests", () => { test.beforeEach(async ({ page }) => { await page.goto("/admin/playlist/list"); @@ -238,6 +239,6 @@ test.describe("Playlist list tests", () => { test("The correct amount of column headers loaded (playlist list)", async ({ page, }) => { - await expect(page.locator("thead").locator("th")).toHaveCount(6); + await expect(page.locator("thead").locator("th")).toHaveCount(8); }); }); diff --git a/e2e/slides.spec.js b/e2e/slides.spec.js index 9cc0de47..7895caeb 100644 --- a/e2e/slides.spec.js +++ b/e2e/slides.spec.js @@ -491,7 +491,7 @@ test.describe("Slides list works", () => { }); test("The correct amount of column headers loaded", async ({ page }) => { - await expect(page.locator("thead").locator("th")).toHaveCount(7); + await expect(page.locator("thead").locator("th")).toHaveCount(9); }); test("It removes all selected", async ({ page }) => { diff --git a/src/components/playlist/playlist-campaign-form.jsx b/src/components/playlist/playlist-campaign-form.jsx index 69f9fd65..c2806682 100644 --- a/src/components/playlist/playlist-campaign-form.jsx +++ b/src/components/playlist/playlist-campaign-form.jsx @@ -82,7 +82,7 @@ function PlaylistCampaignForm({ type="text" label={t("playlist-campaign-form.playlist-description-label")} placeholder={t( - "playlist-campaign-form.playlist-description-placeholder" + "playlist-campaign-form.playlist-description-placeholder", )} value={playlist.description} onChange={handleInput} diff --git a/src/components/playlist/playlists-columns.jsx b/src/components/playlist/playlists-columns.jsx index 06605e61..cd71f724 100644 --- a/src/components/playlist/playlists-columns.jsx +++ b/src/components/playlist/playlists-columns.jsx @@ -4,7 +4,10 @@ import ColumnHoc from "../util/column-hoc"; import SelectColumnHoc from "../util/select-column-hoc"; import UserContext from "../../context/user-context"; import ListButton from "../util/list/list-button"; -import Published from "../util/published"; +import Publishing from "../util/publishing.jsx"; +import DateValue from "../util/date-value.jsx"; +import { Button } from "react-bootstrap"; +import PublishingStatus from "../util/publishingStatus.jsx"; /** * Columns for playlists lists. @@ -28,16 +31,6 @@ function getPlaylistColumns({ }); const columns = [ - { - path: "published", - label: t("published"), - // eslint-disable-next-line react/prop-types - content: ({ publishedFrom, publishedTo, published }) => ( - - ), - }, { key: "slides", label: t("number-of-slides"), @@ -61,6 +54,21 @@ function getPlaylistColumns({ /> ), }, + { + key: "publishing-from", + content: ({ published }) => , + label: t("publishing-from"), + }, + { + key: "publishing-to", + content: ({ published }) => , + label: t("publishing-to"), + }, + { + key: "status", + content: ({ published }) => , + label: t("status"), + }, ]; return columns; diff --git a/src/components/playlist/shared-playlists-column.jsx b/src/components/playlist/shared-playlists-column.jsx index ee50fbe7..6a8300bf 100644 --- a/src/components/playlist/shared-playlists-column.jsx +++ b/src/components/playlist/shared-playlists-column.jsx @@ -1,6 +1,6 @@ import { React } from "react"; import { useTranslation } from "react-i18next"; -import Published from "../util/published"; +import Publishing from "../util/publishing.jsx"; /** * Columns for shared playlists lists. @@ -25,7 +25,7 @@ function getSharedPlaylistColumns() { path: "published", label: t("published"), // eslint-disable-next-line react/prop-types - content: ({ published }) => , + content: ({ published }) => , }, ]; diff --git a/src/components/slide/slide-create.jsx b/src/components/slide/slide-create.jsx index 49e10aab..bb5a39d2 100644 --- a/src/components/slide/slide-create.jsx +++ b/src/components/slide/slide-create.jsx @@ -30,7 +30,7 @@ function SlideCreate() { theme: themeInfo, content: {}, media: [], - published: { from: null, to: null }, + published: { from: new Date(), to: null }, }; return ; diff --git a/src/components/slide/slides-columns.jsx b/src/components/slide/slides-columns.jsx index 1dc0dbc2..425c4fad 100644 --- a/src/components/slide/slides-columns.jsx +++ b/src/components/slide/slides-columns.jsx @@ -1,10 +1,12 @@ import { React } from "react"; import { useTranslation } from "react-i18next"; +import { Button } from "react-bootstrap"; import TemplateLabelInList from "../util/template-label-in-list"; import ListButton from "../util/list/list-button"; -import Published from "../util/published"; import ColumnHoc from "../util/column-hoc"; import SelectColumnHoc from "../util/select-column-hoc"; +import PublishingStatus from "../util/publishingStatus"; +import DateValue from "../util/date-value"; /** * Columns for slides lists. @@ -14,6 +16,8 @@ import SelectColumnHoc from "../util/select-column-hoc"; * @param {string} props.infoModalRedirect - The url for redirecting in the info modal. * @param {string} props.infoModalTitle - The info modal title. * @param {string} props.dataKey The data key for mapping the data. the list button + * @param {object} props.hideColumns Columns to hide. + * @param {object} props.sortColumns Columns to sort. * @returns {object} The columns for the slides lists. */ function getSlidesColumns({ @@ -21,19 +25,40 @@ function getSlidesColumns({ infoModalRedirect, infoModalTitle, dataKey, + hideColumns = {}, + sortColumns = {}, }) { const { t } = useTranslation("common", { keyPrefix: "slides-list" }); - const columns = [ - { + const columns = []; + + if (!hideColumns?.title) { + columns.push({ + path: "title", + label: t("columns.name"), + }); + } + + if (!hideColumns?.createdBy) { + columns.push({ + path: "createdBy", + label: t("columns.created-by"), + }); + } + + if (!hideColumns?.template) { + columns.push({ // eslint-disable-next-line react/prop-types content: ({ templateInfo }) => ( ), key: "template", label: t("columns.template"), - }, - { + }); + } + + if (!hideColumns?.playlists) { + columns.push({ key: "playlists", // eslint-disable-next-line react/prop-types content: ({ onPlaylists }) => ( @@ -46,19 +71,55 @@ function getSlidesColumns({ /> ), label: t("columns.slide-on-playlists"), - }, - { - key: "published", - // eslint-disable-next-line react/prop-types - content: ({ published }) => , - label: t("columns.published"), - }, - ]; + }); + } + + if (!hideColumns?.publishingFrom) { + columns.push({ + key: "publishing-from", + content: ({ published }) => , + label: t("columns.publishing-from"), + }); + } + + if (!hideColumns?.publishingTo) { + columns.push({ + key: "publishing-to", + content: ({ published }) => , + label: t("columns.publishing-to"), + actions: sortColumns.publishedTo ? ( + + ) : null, + }); + } + + if (!hideColumns?.status) { + columns.push({ + key: "status", + content: ({ published }) => , + label: t("columns.status"), + actions: sortColumns.status ? ( + + ) : null, + }); + } return columns; } -const SlideColumns = ColumnHoc(getSlidesColumns); -const SelectSlideColumns = SelectColumnHoc(getSlidesColumns); +const SlideColumns = ColumnHoc(getSlidesColumns, true); +const SelectSlideColumns = SelectColumnHoc(getSlidesColumns, true); export { SelectSlideColumns, SlideColumns }; diff --git a/src/components/util/date-value.jsx b/src/components/util/date-value.jsx new file mode 100644 index 00000000..aed9235f --- /dev/null +++ b/src/components/util/date-value.jsx @@ -0,0 +1,18 @@ +import { React } from "react"; +import PropTypes from "prop-types"; +import dayjs from "dayjs"; + +/** + * @param {object} props The props. + * @param {string} props.date Date string to format. + * @returns {object} Formatted date + */ +function DateValue({ date }) { + return date ? dayjs(date).format("D/M/YYYY HH:mm") : ""; +} + +DateValue.propTypes = { + date: PropTypes.string, +}; + +export default DateValue; diff --git a/src/components/util/drag-and-drop-table/drag-and-drop-table.jsx b/src/components/util/drag-and-drop-table/drag-and-drop-table.jsx index 9dd1ebc8..e2d7ae46 100644 --- a/src/components/util/drag-and-drop-table/drag-and-drop-table.jsx +++ b/src/components/util/drag-and-drop-table/drag-and-drop-table.jsx @@ -15,7 +15,7 @@ import "./drag-and-drop-table.scss"; * @param {Array} props.columns The columns for the table. * @param {Array} props.data The data to display in the table. * @param {string} props.name The id of the form element - * @param {Function} props.onDropped Callback for when an items is dropped and + * @param {Function} props.onDropped Callback for when an item is dropped and * the list is reordered. * @param {Function} props.callback - The callback. * @param {string} props.label - The label. @@ -134,6 +134,7 @@ function DragAndDropTable({ providedSnapshot.isDragging, providedDraggable.draggableProps.style )} + className={data.className ?? ''} > { + const expired = []; + const active = []; + const future = []; + + const now = dayjs(new Date()); + + selectedData.forEach((entry) => { + const { published } = entry; + const from = published.from ? dayjs(published.from) : null; + const to = published.to ? dayjs(published.to) : null; + + if (to !== null && to.isBefore(now)) { + expired.push(entry); + } else if (from !== null && from.isAfter(now)) { + future.push(entry); + } else { + active.push(entry); + } + }); + + const newData = [...expired, ...active, ...future]; + setSelectedData(newData); + + const order = selectedData.map((entry) => entry["@id"]); + const newOrder = newData.map((entry) => entry["@id"]); + + if (JSON.stringify(order) !== JSON.stringify(newOrder)) { + displayWarning(t("data-changed")); + } + }; + + const sortByPublishedTo = () => { + const newData = [...selectedData]; + + newData.sort((a, b) => { + if (a.published?.to === null) { + return 1; + } + if (b.published?.to === null) { + return -1; + } + + return a.published.to > b.published?.to ? 1 : -1; + }); + + setSelectedData(newData); + + const order = selectedData.map((entry) => entry["@id"]); + const newOrder = newData.map((entry) => entry["@id"]); + + if (JSON.stringify(order) !== JSON.stringify(newOrder)) { + displayWarning(t("data-changed")); + } + }; useEffect(() => { if (data) { @@ -50,6 +106,7 @@ function SelectSlidesTable({ handleChange, name, slideId = "" }) { const newSlides = data["hydra:member"].map(({ slide }) => { return slide; }); + setSelectedData([...selectedData, ...newSlides]); // Get all selected slides. If a next page is defined, get the next page. @@ -111,6 +168,15 @@ function SelectSlidesTable({ handleChange, name, slideId = "" }) { editTarget: "slide", infoModalRedirect: "/playlist/edit", infoModalTitle: t("info-modal.slide-on-playlists"), + hideColumns: { + createdBy: true, + template: true, + playlists: true, + }, + sortColumns: { + publishedTo: sortByPublishedTo, + status: sortByStatus, + }, }); return ( @@ -126,6 +192,7 @@ function SelectSlidesTable({ handleChange, name, slideId = "" }) { /> {selectedData?.length > 0 && ( <> +
Afspilningsrækkefølge
{ dayjs.extend(localizedFormat); }, []); - useEffect(() => { - setIsPublished(calculateIsPublished(published)); - }, [published]); - - if (!published.from && !published.to) { - return
{isPublished ? t("yes") : t("no")}
; - } - - let publishedFrom = "-"; - let publishedTo = "-"; + let publishedFrom = null; + let publishedTo = null; if (published.from) { - publishedFrom = dayjs(published.from).locale(localeDa).format("LLLL"); + publishedFrom = dayjs(published.from).locale(localeDa).format("lll"); } + if (published.to) { - publishedTo = dayjs(published.to).locale(localeDa).format("LLLL"); + publishedTo = dayjs(published.to).locale(localeDa).format("lll"); } + return ( <> -
- {t("from")}: {publishedFrom} -
-
- {t("to")}: {publishedTo} -
+ {publishedFrom && ( +
+ {t("from")}: {publishedFrom} +
+ )} + {publishedTo && ( +
+ {t("to")}: {publishedTo} +
+ )} + {publishedFrom === null && publishedTo === null && ( +
{t("always")}
+ )} ); } -Published.propTypes = { +Publishing.propTypes = { published: PropTypes.shape({ from: PropTypes.string, to: PropTypes.string, }).isRequired, }; -export default Published; +export default Publishing; diff --git a/src/components/util/publishingStatus.jsx b/src/components/util/publishingStatus.jsx new file mode 100644 index 00000000..8eba5430 --- /dev/null +++ b/src/components/util/publishingStatus.jsx @@ -0,0 +1,85 @@ +import { React, useEffect, useState } from "react"; +import PropTypes from "prop-types"; +import dayjs from "dayjs"; +import localizedFormat from "dayjs/plugin/localizedFormat"; +import { useTranslation } from "react-i18next"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { + faMinusCircle, + faCheckCircle, + faClock, +} from "@fortawesome/free-solid-svg-icons"; + +/** + * @param {object} props The props. + * @param {object} props.published Object with from and to. + * @returns {object} The published yes/no component. + */ +function PublishingStatus({ published }) { + const PUBLISHED_EXPIRED = "EXPIRED"; + const PUBLISHED_ACTIVE = "ACTIVE"; + const PUBLISHED_FUTURE = "FUTURE"; + + const { t } = useTranslation("common", { keyPrefix: "published-state" }); + const [publishedState, setPublishedState] = useState(null); + + useEffect(() => { + dayjs.extend(localizedFormat); + }, []); + + useEffect(() => { + let newPublishedState = null; + const now = dayjs(new Date()); + + const from = published.from ? dayjs(published.from) : null; + const to = published.to ? dayjs(published.to) : null; + + if (from !== null) { + if (from.isAfter(now)) { + newPublishedState = PUBLISHED_FUTURE; + } + } + + if (to !== null) { + if (to.isBefore(now)) { + newPublishedState = PUBLISHED_EXPIRED; + } + } + + setPublishedState(newPublishedState ?? PUBLISHED_ACTIVE); + + return () => {}; + }, [published]); + + return ( + + {publishedState === PUBLISHED_EXPIRED && ( + <> + {" "} + {t("expired")} + + )} + {publishedState === PUBLISHED_ACTIVE && ( + <> + {" "} + {t("active")} + + )} + {publishedState === PUBLISHED_FUTURE && ( + <> + {" "} + {t("future")} + + )} + + ); +} + +PublishingStatus.propTypes = { + published: PropTypes.shape({ + from: PropTypes.string, + to: PropTypes.string, + }).isRequired, +}; + +export default PublishingStatus; diff --git a/src/components/util/table/table-header.jsx b/src/components/util/table/table-header.jsx index 33ddfe13..9373cf1b 100644 --- a/src/components/util/table/table-header.jsx +++ b/src/components/util/table/table-header.jsx @@ -25,7 +25,7 @@ function TableHeader({ columns, draggable = false }) { )} {columns.map((column) => ( - {column.label} + {column.label}{column.actions} ))} diff --git a/src/translations/da/common.json b/src/translations/da/common.json index 406f1720..52baada3 100644 --- a/src/translations/da/common.json +++ b/src/translations/da/common.json @@ -26,9 +26,13 @@ }, "slides-list": { "columns": { + "name": "Titel", + "created-by": "Oprettet af", "template": "Skabelon", "slide-on-playlists": "Spillelister", - "published": "Udgivet" + "publishing-from": "Udgivelse fra", + "publishing-to": "Udgivelse til", + "status": "Status" }, "info-modal": { "slide-on-playlists": "Slidet er på de følgende spillelister" @@ -404,7 +408,9 @@ "name": "Navn", "created-by": "Oprettet af", "delete-button": "Slet", - "published": "Udgivet", + "publishing-from": "Udgivelse fra", + "publishing-to": "Udgivelse til", + "status": "Status", "number-of-slides": "Slides tilknyttede" }, "playlist-campaign-manager": { @@ -521,7 +527,8 @@ "remove-from-list": "Fjern fra liste", "error-messages": { "load-selected-slides-error": "Fejl da valgte slides skulle loades" - } + }, + "data-changed": "Rækkefølge ændret. Husk at gemme." }, "select-screens-table": { "columns": { @@ -556,7 +563,8 @@ "yes": "Ja", "no": "Nej", "from": "Fra", - "to": "Til" + "to": "Til", + "always": "Ubegrænset" }, "modal-dialog": { "cancel": "Annuller", @@ -641,7 +649,7 @@ "drag-and-drop-table": { "not-available": "Ikke tilgængeligt", "table-header-for-indicator": "Drag and drop indikator", - "help-text": "Brug space til at vælge et element til flytning, og brug derefter piltasterne til at trække op eller ned. Brug space til at slippe elementet igen, og brug escape til at annullere. Nogle skærmlæsere skal være i fokus før det virker." + "help-text": "Brug space til at vælge et element til flytning, og brug derefter piletasterne til at trække op eller ned. Brug space til at slippe elementet igen, og brug escape til at annullere. Nogle skærmlæsere skal være i fokus før det virker." }, "link-for-list": { "label": "Rediger" @@ -1000,5 +1008,10 @@ "loading-messages": { "saving-activation-code": "Opretter aktiveringskode" } + }, + "published-state": { + "active": "Aktiv", + "future": "Fremtidig", + "expired": "Udløbet" } }