diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ee588cb..427ca56f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +- [#268](https://github.com/os2display/display-admin-client/pull/268) + - Added feed source UI. + ## [2.1.1] - 2024-10-23 - [#266](https://github.com/os2display/display-admin-client/pull/266) diff --git a/e2e/feed-sources.spec.js b/e2e/feed-sources.spec.js new file mode 100644 index 00000000..fe46480c --- /dev/null +++ b/e2e/feed-sources.spec.js @@ -0,0 +1,449 @@ +import { test, expect } from "@playwright/test"; + +const feedSourcesJson = { + "@context": "/contexts/FeedSource", + "@id": "/v2/feed-sources", + "@type": "hydra:Collection", + "hydra:totalItems": 5, + "hydra:member": [ + { + "@id": "/v2/feed-sources/01JBBP48CS9CV80XRWRP8CAETJ", + "@type": "FeedSource", + title: "test 2", + description: "test 2", + outputType: "", + feedType: "test 2", + secrets: [], + feeds: [], + admin: [], + supportedFeedOutputType: "test 2", + modifiedBy: "admin@example.com", + createdBy: "admin@example.com", + id: "01JBBP48CS9CV80XRWRP8CAETJ", + created: "2024-10-29T09:26:25.000Z", + modified: "2024-10-29T09:26:25.000Z", + }, + { + "@id": "/v2/feed-sources/01JB9MSQEH75HC3GG75XCVP2WH", + "@type": "FeedSource", + title: "Ny datakilde test 3", + description: "Ny datakilde test 3", + outputType: "", + feedType: "App\\Feed\\RssFeedType", + secrets: [], + feeds: [ + "/v2/feeds/01JB9R7EPN9NPW117C22NY31KH", + "/v2/feeds/01JBBQMF72W2V36TWF6VXFA5Z7", + ], + admin: [ + { + key: "rss-url", + input: "input", + name: "url", + type: "url", + label: "Kilde", + helpText: "Her kan du skrive rss kilden", + formGroupClasses: "col-md-6", + }, + { + key: "rss-number-of-entries", + input: "input", + name: "numberOfEntries", + type: "number", + label: "Antal indgange", + helpText: + "Her kan du skrive, hvor mange indgange, der maksimalt skal vises.", + formGroupClasses: "col-md-6 mb-3", + }, + { + key: "rss-entry-duration", + input: "input", + name: "entryDuration", + type: "number", + label: "Varighed pr. indgang (i sekunder)", + helpText: "Her skal du skrive varigheden pr. indgang.", + formGroupClasses: "col-md-6 mb-3", + }, + ], + supportedFeedOutputType: "rss", + modifiedBy: "admin@example.com", + createdBy: "admin@example.com", + id: "01JB9MSQEH75HC3GG75XCVP2WH", + created: "2024-10-28T14:24:43.000Z", + modified: "2024-10-28T15:23:28.000Z", + }, + { + "@id": "/v2/feed-sources/01JB1DH8G4CXKGX5JRTYDHDPSP", + "@type": "FeedSource", + title: "Calendar datakilde test", + description: "test", + outputType: "", + feedType: "App\\Feed\\CalendarApiFeedType", + secrets: [], + feeds: [], + admin: [], + supportedFeedOutputType: "calendar", + modifiedBy: "", + createdBy: "", + id: "01JB1DH8G4CXKGX5JRTYDHDPSP", + created: "2024-10-25T10:43:50.000Z", + modified: "2024-10-25T10:43:50.000Z", + }, + { + "@id": "/v2/feed-sources/01J711Y2Q01VBJ1Y7A1HZQ0ZN6", + "@type": "FeedSource", + title: "feed_source_abc_notified", + description: + "Ut magnam veritatis velit ut doloribus id. Consequatur ut ipsum exercitationem aliquam laudantium voluptate voluptates perspiciatis. Id occaecati ea rerum facilis molestias et.", + outputType: "", + feedType: "App\\Feed\\RssFeedType", + secrets: [], + feeds: ["/v2/feeds/01GJD7S1KR10811MTA176C001R"], + admin: [ + { + key: "rss-url", + input: "input", + name: "url", + type: "url", + label: "Kilde", + helpText: "Her kan du skrive rss kilden", + formGroupClasses: "col-md-6", + }, + { + key: "rss-number-of-entries", + input: "input", + name: "numberOfEntries", + type: "number", + label: "Antal indgange", + helpText: + "Her kan du skrive, hvor mange indgange, der maksimalt skal vises.", + formGroupClasses: "col-md-6 mb-3", + }, + { + key: "rss-entry-duration", + input: "input", + name: "entryDuration", + type: "number", + label: "Varighed pr. indgang (i sekunder)", + helpText: "Her skal du skrive varigheden pr. indgang.", + formGroupClasses: "col-md-6 mb-3", + }, + ], + supportedFeedOutputType: "instagram", + modifiedBy: "", + createdBy: "", + id: "01J711Y2Q01VBJ1Y7A1HZQ0ZN6", + created: "2024-09-05T12:18:20.000Z", + modified: "2024-09-17T09:33:12.000Z", + }, + { + "@id": "/v2/feed-sources/01J1H8GVVR1CVJ1SQK0JXN1X4Q", + "@type": "FeedSource", + title: "feed_source_abc_1", + description: + "Totam eos molestias omnis aliquam quia qui voluptas. Non eum nihil ut sunt dolor.", + outputType: "", + feedType: "App\\Feed\\RssFeedType", + secrets: [], + feeds: ["/v2/feeds/01HD49075G0FNY1FNX12VE17K1"], + admin: [ + { + key: "rss-url", + input: "input", + name: "url", + type: "url", + label: "Kilde", + helpText: "Her kan du skrive rss kilden", + formGroupClasses: "col-md-6", + }, + { + key: "rss-number-of-entries", + input: "input", + name: "numberOfEntries", + type: "number", + label: "Antal indgange", + helpText: + "Her kan du skrive, hvor mange indgange, der maksimalt skal vises.", + formGroupClasses: "col-md-6 mb-3", + }, + { + key: "rss-entry-duration", + input: "input", + name: "entryDuration", + type: "number", + label: "Varighed pr. indgang (i sekunder)", + helpText: "Her skal du skrive varigheden pr. indgang.", + formGroupClasses: "col-md-6 mb-3", + }, + ], + supportedFeedOutputType: "rss", + modifiedBy: "", + createdBy: "", + id: "01J1H8GVVR1CVJ1SQK0JXN1X4Q", + created: "2024-06-29T05:47:07.000Z", + modified: "2024-10-21T18:01:25.000Z", + }, + ], +}; + +test.describe("fest", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/admin/feed-sources/list"); + await page.route("**/feed-sources*", async (route) => { + await route.fulfill({ json: feedSourcesJson }); + }); + await page.route("**/token", async (route) => { + const json = { + token: "1", + refresh_token: "2", + tenants: [ + { + tenantKey: "ABC", + title: "ABC Tenant", + description: "Description", + roles: ["ROLE_ADMIN"], + }, + ], + user: { + fullname: "John Doe", + email: "johndoe@example.com", + }, + }; + await route.fulfill({ json }); + }); + await expect(page).toHaveTitle(/OS2Display admin/); + await page.getByLabel("Email").fill("admin@example.com"); + await page.getByLabel("Kodeord").fill("password"); + await page.locator("#login").click(); + }); + + test("It loads create datakilde page", async ({ page }) => { + page.getByText("Opret ny datakilde").click(); + await expect(page.locator("#save_feed-source")).toBeVisible(); + }); + + test("It display error toast on save error", async ({ page }) => { + await page.route("**/feed-sources", async (route) => { + const json = { + "@context": "/contexts/Error", + "@type": "hydra:Error", + "hydra:title": "An error occurred", + "hydra:description": "An error occurred", + }; + await route.fulfill({ status: 500, json }); + }); + page.getByText("Opret ny datakilde").click(); + + // Displays error toast and stays on page + await expect( + page.locator(".Toastify").locator(".Toastify__toast--error") + ).not.toBeVisible(); + await page.locator("#save_feed-source").click(); + await expect( + page.locator(".Toastify").locator(".Toastify__toast--error") + ).toBeVisible(); + await expect( + page + .locator(".Toastify") + .locator(".Toastify__toast--error") + .getByText(/An error occurred/) + .first() + ).toBeVisible(); + await expect(page).toHaveURL(/feed-sources\/create/); + }); + test("Cancel create datakilde", async ({ page }) => { + page.getByText("Opret ny datakilde").click(); + await expect(page.locator("#cancel_feed-source")).toBeVisible(); + await page.locator("#cancel_feed-source").click(); + await expect(page.locator("#cancel_feed-source")).not.toBeVisible(); + }); +}); + + +test.describe("datakilde list work", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/admin/feed-sources/list"); + await page.route("**/token", async (route) => { + const json = { + token: "1", + refresh_token: "2", + tenants: [ + { + tenantKey: "ABC", + title: "ABC Tenant", + description: "Description", + roles: ["ROLE_ADMIN"], + }, + ], + user: { + fullname: "John Doe", + email: "johndoe@example.com", + }, + }; + await route.fulfill({ json }); + }); + await page.route("**/feed-sources*", async (route) => { + await route.fulfill({ json: feedSourcesJson }); + }); + await expect(page).toHaveTitle(/OS2Display admin/); + await page.getByLabel("Email").fill("johndoe@example.com"); + await page.getByLabel("Kodeord").fill("password"); + await page.locator("#login").click(); + }); + + test("It loads datakilde list", async ({ page }) => { + await expect(page.locator("table").locator("tbody")).not.toBeEmpty(); + await expect(page.locator("tbody").locator("tr td").first()).toBeVisible(); + }); + + test("It goes to edit", async ({ page }) => { + await expect(page.locator("#feed-sourceTitle")).not.toBeVisible(); + + await page.route("**/feed-sources*", async (route) => { + const json = { + "@context": "/contexts/FeedSource", + "@id": "/v2/feed-sources", + "@type": "hydra:Collection", + "hydra:totalItems": 2, + "hydra:member": [ + { + "@id": "/v2/feed-sources/01J711Y2Q01VBJ1Y7A1HZQ0ZN6", + "@type": "FeedSource", + "title": "feed_source_abc_notified", + "description": "Ut magnam veritatis velit ut doloribus id. Consequatur ut ipsum exercitationem aliquam laudantium voluptate voluptates perspiciatis. Id occaecati ea rerum facilis molestias et.", + "outputType": "", + "feedType": "App\\Feed\\RssFeedType", + "secrets": [], + "feeds": [ + "/v2/feeds/01GJD7S1KR10811MTA176C001R" + ], + "admin": [ + { + "key": "rss-url", + "input": "input", + "name": "url", + "type": "url", + "label": "Kilde", + "helpText": "Her kan du skrive rss kilden", + "formGroupClasses": "col-md-6" + }, + { + "key": "rss-number-of-entries", + "input": "input", + "name": "numberOfEntries", + "type": "number", + "label": "Antal indgange", + "helpText": "Her kan du skrive, hvor mange indgange, der maksimalt skal vises.", + "formGroupClasses": "col-md-6 mb-3" + }, + { + "key": "rss-entry-duration", + "input": "input", + "name": "entryDuration", + "type": "number", + "label": "Varighed pr. indgang (i sekunder)", + "helpText": "Her skal du skrive varigheden pr. indgang.", + "formGroupClasses": "col-md-6 mb-3" + } + ], + "supportedFeedOutputType": "instagram", + "modifiedBy": "", + "createdBy": "", + "id": "01J711Y2Q01VBJ1Y7A1HZQ0ZN6", + "created": "2024-09-05T12:18:20.000Z", + "modified": "2024-09-17T09:33:12.000Z" + }, + { + "@id": "/v2/feed-sources/01J1H8GVVR1CVJ1SQK0JXN1X4Q", + "@type": "FeedSource", + "title": "feed_source_abc_1", + "description": "Totam eos molestias omnis aliquam quia qui voluptas. Non eum nihil ut sunt dolor.", + "outputType": "", + "feedType": "App\\Feed\\RssFeedType", + "secrets": [], + "feeds": [ + "/v2/feeds/01HD49075G0FNY1FNX12VE17K1" + ], + "admin": [ + { + "key": "rss-url", + "input": "input", + "name": "url", + "type": "url", + "label": "Kilde", + "helpText": "Her kan du skrive rss kilden", + "formGroupClasses": "col-md-6" + }, + { + "key": "rss-number-of-entries", + "input": "input", + "name": "numberOfEntries", + "type": "number", + "label": "Antal indgange", + "helpText": "Her kan du skrive, hvor mange indgange, der maksimalt skal vises.", + "formGroupClasses": "col-md-6 mb-3" + }, + { + "key": "rss-entry-duration", + "input": "input", + "name": "entryDuration", + "type": "number", + "label": "Varighed pr. indgang (i sekunder)", + "helpText": "Her skal du skrive varigheden pr. indgang.", + "formGroupClasses": "col-md-6 mb-3" + } + ], + "supportedFeedOutputType": "rss", + "modifiedBy": "", + "createdBy": "", + "id": "01J1H8GVVR1CVJ1SQK0JXN1X4Q", + "created": "2024-06-29T05:47:07.000Z", + "modified": "2024-10-21T18:01:25.000Z" + } + ] + }; + await route.fulfill({ json }); + }); + + await page.route("**/feed-sources/*", async (route) => { + const json = { + "@id": "/v2/feed-sources/01J711Y2Q01VBJ1Y7A1HZQ0ZN6", + "@type": "FeedSource", + "title": "feed_source_abc_notified", + "description": "Ut magnam veritatis velit ut doloribus id. Consequatur ut ipsum exercitationem aliquam laudantium voluptate voluptates perspiciatis. Id occaecati ea rerum facilis molestias et.", + "outputType": "", + "feedType": "App\\Feed\\RssFeedType", + "secrets": [], + "feeds": [ + "/v2/feeds/01GJD7S1KR10811MTA176C001R" + ], + "supportedFeedOutputType": "instagram", + "modifiedBy": "", + "createdBy": "", + "id": "01J711Y2Q01VBJ1Y7A1HZQ0ZN6", + "created": "2024-09-05T12:18:20.000Z", + "modified": "2024-09-17T09:33:12.000Z" + }; + + await route.fulfill({ json }); + }); + + await page.locator("tbody").locator("tr td a").first().click(); + await expect(page.locator("#feed-sourceTitle")).toBeVisible(); + }); + + test("It opens delete modal", async ({ page }) => { + await expect(page.locator("#delete-modal")).not.toBeVisible(); + await page + .locator("tbody") + .nth(0) + .locator(".remove-from-list") + .nth(1) + .click(); + await expect(page.locator("#delete-modal")).toBeVisible(); + }); + + test("The correct amount of column headers loaded", async ({ page }) => { + await expect(page.locator("thead").locator("th")).toHaveCount(6); + }); +}); diff --git a/e2e/top-bar.spec.js b/e2e/top-bar.spec.js index c0757daa..7307acd6 100644 --- a/e2e/top-bar.spec.js +++ b/e2e/top-bar.spec.js @@ -99,7 +99,7 @@ test.describe("Nav items loads", () => { await expect(page.locator("#basic-navbar-nav")).toBeVisible(); await expect( page.locator("#basic-navbar-nav").locator(".nav-item") - ).toHaveCount(13); + ).toHaveCount(14); await expect( page.locator("#basic-navbar-nav").locator(".nav-add-new") ).toHaveCount(3); diff --git a/src/app.jsx b/src/app.jsx index 2a417fb4..6f1d9c1a 100644 --- a/src/app.jsx +++ b/src/app.jsx @@ -42,6 +42,9 @@ import ActivationCodeActivate from "./components/activation-code/activation-code import ConfigLoader from "./config-loader"; import "react-toastify/dist/ReactToastify.css"; import "./app.scss"; +import FeedSourcesList from "./components/feed-sources/feed-sources-list"; +import FeedSourceCreate from "./components/feed-sources/feed-source-create"; +import FeedSourceEdit from "./components/feed-sources/feed-source-edit"; /** * App component. @@ -407,6 +410,38 @@ function App() { } /> + + + + + } + /> + + + + } + /> + + + + } + /> + } /> ; +} + +export default FeedSourceCreate; diff --git a/src/components/feed-sources/feed-source-edit.jsx b/src/components/feed-sources/feed-source-edit.jsx new file mode 100644 index 00000000..4766320b --- /dev/null +++ b/src/components/feed-sources/feed-source-edit.jsx @@ -0,0 +1,30 @@ +import { React } from "react"; +import { useParams } from "react-router-dom"; +import { useGetV2FeedSourcesByIdQuery } from "../../redux/api/api.generated.ts"; +import FeedSourceManager from "./feed-source-manager"; + +/** + * The feed source edit component. + * + * @returns {object} The feed sources edit page. + */ +function FeedSourceEdit() { + const { id } = useParams(); + const { + data, + error: loadingError, + isLoading, + } = useGetV2FeedSourcesByIdQuery({ id }); + + return ( + + ); +} + +export default FeedSourceEdit; diff --git a/src/components/feed-sources/feed-source-form.jsx b/src/components/feed-sources/feed-source-form.jsx new file mode 100644 index 00000000..f9a4b694 --- /dev/null +++ b/src/components/feed-sources/feed-source-form.jsx @@ -0,0 +1,147 @@ +import { React } from "react"; +import { Button } from "react-bootstrap"; +import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; +import PropTypes from "prop-types"; +import Form from "react-bootstrap/Form"; +import LoadingComponent from "../util/loading-component/loading-component"; +import FormInputArea from "../util/forms/form-input-area"; +import FormSelect from "../util/forms/select"; +import ContentBody from "../util/content-body/content-body"; +import ContentFooter from "../util/content-footer/content-footer"; +import FormInput from "../util/forms/form-input"; +import CalendarFeedType from "./templates/calendar-feed-type.jsx"; +import NotifiedFeedType from "./templates/notified-feed-type.jsx"; +import EventDatabaseFeedType from "./templates/event-database-feed-type.jsx"; + +/** + * The feed-source form component. + * + * @param {object} props - The props. + * @param {object} props.feedSource The feed-source object to modify in the form. + * @param {Function} props.handleInput Handles form input. + * @param {Function} props.handleSubmit Handles form submit. + * @param {string} props.headerText Headline text. + * @param {boolean} [props.isLoading=false] Indicator of whether the form is + * loading. Default is `false` + * @param {string} [props.loadingMessage=""] The loading message for the + * spinner. Default is `""` + * @param {object} props.feedSource The feed source object + * @param {object} props.feedSourceTypeOptions The options for feed source types + * @param {string} props.mode The mode + * @returns {object} The feed-source form. + */ +function FeedSourceForm({ + handleInput, + handleSubmit, + headerText, + isLoading = false, + loadingMessage = "", + feedSource = null, + feedSourceTypeOptions = null, + onFeedTypeChange = () => {}, + handleSecretInput = () => {}, + mode = null, +}) { + const { t } = useTranslation("common", { keyPrefix: "feed-source-form" }); + const navigate = useNavigate(); + + return ( + <> +
+ +

{headerText}

+ + + + + + {feedSource?.feedType === "App\\Feed\\CalendarApiFeedType" && + () + } + {feedSource?.feedType === "App\\Feed\\NotifiedFeedType" && + () + } + {feedSource?.feedType === "App\\Feed\\EventDatabaseApiFeedType" && + () + } + + + + + + + + ); +} + +FeedSourceForm.propTypes = { + feedSource: PropTypes.shape({ + title: PropTypes.string, + description: PropTypes.string, + feedType: PropTypes.string, + supportedFeedOutputType: PropTypes.string, + }), + handleInput: PropTypes.func.isRequired, + handleSubmit: PropTypes.func.isRequired, + headerText: PropTypes.string.isRequired, + isLoading: PropTypes.bool, + loadingMessage: PropTypes.string, + dynamicFormElement: PropTypes.oneOfType([ + PropTypes.element, + PropTypes.arrayOf(PropTypes.element), + ]), + feedSourceTypeOptions: PropTypes.arrayOf( + PropTypes.shape({ + value: PropTypes.string.isRequired, + title: PropTypes.string, + key: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + template: PropTypes.element, + }) + ).isRequired, + mode: PropTypes.string, +}; + +export default FeedSourceForm; diff --git a/src/components/feed-sources/feed-source-manager.jsx b/src/components/feed-sources/feed-source-manager.jsx new file mode 100644 index 00000000..455b1062 --- /dev/null +++ b/src/components/feed-sources/feed-source-manager.jsx @@ -0,0 +1,228 @@ +import { cloneElement, React, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import PropTypes from "prop-types"; +import { useNavigate } from "react-router-dom"; +import FeedSourceForm from "./feed-source-form"; +import { + usePostV2FeedSourcesMutation, + usePutV2FeedSourcesByIdMutation, +} from "../../redux/api/api.generated.ts"; +import { + displayError, + displaySuccess, +} from "../util/list/toast-component/display-toast"; +import EventDatabaseFeedType from "./templates/event-database-feed-type.jsx"; +import NotifiedFeedType from "./templates/notified-feed-type.jsx"; +import CalendarFeedType from "./templates/calendar-feed-type.jsx"; + +/** + * The theme manager component. + * + * @param {object} props The props. + * @param {object} props.initialState Initial theme state. + * @param {string} props.saveMethod POST or PUT. + * @param {string | null} props.id Theme id. + * @param {boolean} props.isLoading Is the theme state loading + * @param {object} props.loadingError Loading error. + * @returns {object} The theme form. + */ +function FeedSourceManager({ + saveMethod, + id = null, + isLoading = false, + loadingError = null, + initialState = null, +}) { + // Hooks + const { t } = useTranslation("common", { + keyPrefix: "feed-source-manager", + }); + const navigate = useNavigate(); + + // State + const [headerText] = useState( + saveMethod === "PUT" ? t("edit-feed-source") : t("create-new-feed-source") + ); + + const [loadingMessage, setLoadingMessage] = useState( + t("loading-messages.loading-feed-source") + ); + + const [dynamicFormElement, setDynamicFormElement] = useState(); + const [submitting, setSubmitting] = useState(false); + const [formStateObject, setFormStateObject] = useState({}); + + const [ + postV2FeedSources, + { error: saveErrorPost, isSuccess: isSaveSuccessPost }, + ] = usePostV2FeedSourcesMutation(); + + const [ + PutV2FeedSourcesById, + { error: saveErrorPut, isSuccess: isSaveSuccessPut }, + ] = usePutV2FeedSourcesByIdMutation(); + + const feedSourceTypeOptions = [ + { + value: "App\\Feed\\EventDatabaseApiFeedType", + title: t("dynamic-fields.event-database-api-feed-type.title"), + key: "1", + secretsDefault: { + "host": "" + }, + }, + { + value: "App\\Feed\\NotifiedFeedType", + title: t("dynamic-fields.notified-feed-type.title"), + key: "2", + secretsDefault: { + "token": "", + }, + }, + { + value: "App\\Feed\\CalendarApiFeedType", + title: t("dynamic-fields.calendar-api-feed-type.title"), + key: "3", + secretsDefault: { + "resources": [] + }, + }, + { + value: "App\\Feed\\RssFeedType", + title: t("dynamic-fields.rss-feed-type.title"), + key: "4", + secretsDefault: {}, + }, + ]; + + /** + * Set state on change in input field + * + * @param {object} props - The props. + * @param {object} props.target - Event target. + */ + const handleInput = ({ target }) => { + const localFormStateObject = { ...formStateObject }; + localFormStateObject[target.id] = target.value; + setFormStateObject(localFormStateObject); + }; + + /** Set loaded data into form state. */ + useEffect(() => { + setFormStateObject({ ...initialState }); + }, [initialState]); + + const handleSecretInput = ({target}) => { + const localFormStateObject = { ...formStateObject }; + if (!localFormStateObject.secrets) { + localFormStateObject.secrets = {}; + } + localFormStateObject.secrets[target.id] = target.value; + setFormStateObject(localFormStateObject); + }; + + const onFeedTypeChange = ({target}) => { + const value = target.value + const option = feedSourceTypeOptions.find((opt) => opt.value === value); + const newFormStateObject = {...formStateObject}; + newFormStateObject.feedType = value; + newFormStateObject.secrets = {...option.secretsDefault}; + setFormStateObject(newFormStateObject); + } + + /** Save feed source. */ + function saveFeedSource() { + setLoadingMessage(t("loading-messages.saving-feed-source")); + + if (saveMethod === "POST") { + postV2FeedSources({ + feedSourceFeedSourceInput: JSON.stringify(formStateObject), + }); + } else if (saveMethod === "PUT") { + PutV2FeedSourcesById({ + feedSourceFeedSourceInput: JSON.stringify(formStateObject), + id, + }); + } + } + + /** If the feed source is not loaded, display the error message */ + useEffect(() => { + if (loadingError) { + displayError( + t("error-messages.load-feed-source-error", { id }), + loadingError + ); + } + }, [loadingError]); + + /** When the media is saved, the theme will be saved. */ + useEffect(() => { + if (isSaveSuccessPost || isSaveSuccessPut) { + setSubmitting(false); + displaySuccess(t("success-messages.saved-feed-source")); + navigate("/feed-sources/list"); + } + }, [isSaveSuccessPut, isSaveSuccessPost]); + + /** Handles submit. */ + const handleSubmit = () => { + setSubmitting(true); + saveFeedSource(); + }; + + /** If the theme is saved with error, display the error message */ + useEffect(() => { + if (saveErrorPut || saveErrorPost) { + const saveError = saveErrorPut || saveErrorPost; + setSubmitting(false); + displayError(t("error-messages.save-feed-source-error"), saveError); + } + }, [saveErrorPut, saveErrorPost]); + + return ( + <> + {formStateObject && ( + + )} + + ); +} + +FeedSourceManager.propTypes = { + initialState: PropTypes.shape({ + title: PropTypes.string, + description: PropTypes.string, + feedType: PropTypes.string, + feedSourceType: PropTypes.string, + host: PropTypes.string, + token: PropTypes.string, + baseUrl: PropTypes.string, + clientId: PropTypes.string, + clientSecret: PropTypes.string, + feedSources: PropTypes.string, + }), + saveMethod: PropTypes.string.isRequired, + id: PropTypes.string, + isLoading: PropTypes.bool, + loadingError: PropTypes.shape({ + data: PropTypes.shape({ + status: PropTypes.number, + }), + }), + mode: PropTypes.string, +}; + +export default FeedSourceManager; diff --git a/src/components/feed-sources/feed-sources-columns.jsx b/src/components/feed-sources/feed-sources-columns.jsx new file mode 100644 index 00000000..83c6a8a3 --- /dev/null +++ b/src/components/feed-sources/feed-sources-columns.jsx @@ -0,0 +1,59 @@ +import { React, useContext } from "react"; +import { useTranslation } from "react-i18next"; +import ColumnHoc from "../util/column-hoc"; +import ListButton from "../util/list/list-button.jsx"; +import SelectColumnHoc from "../util/select-column-hoc.jsx"; +import UserContext from "../../context/user-context.jsx"; + +/** + * Retrieves the columns for feed sources data based on API call response. + * + * @param {object} props - The props. + * @param {Function} props.apiCall - The API call function to retrieve feed sources data. + * @param {string} props.infoModalRedirect - The redirect URL for information modal. + * @param {string} props.infoModalTitle - The title for information modal. + * @param {string} props.dataKey - The key for data retrieval. + * @returns {object} Columns - An array of objects representing the columns for + * feed sources data. + */ +function getFeedSourcesColumns({ apiCall, infoModalRedirect, infoModalTitle }) { + const context = useContext(UserContext); + const { t } = useTranslation("common", { keyPrefix: "feed-sources-list" }); + + const columns = [ + { + key: "publishing-from", + content: ({ feedType }) => <>{feedType}, + label: t("columns.feed-type"), + }, + { + key: "slides", + label: t("number-of-slides"), + render: ({ tenants }) => { + return ( + tenants?.length === 0 || + !tenants.find( + (tenant) => + tenant.tenantKey === context.selectedTenant.get.tenantKey + ) + ); + }, + // eslint-disable-next-line react/prop-types + content: ({ id }) => ( + + ), + }, + ]; + + return columns; +} + +const FeedSourceColumns = ColumnHoc(getFeedSourcesColumns); +const SelectFeedSourceColumns = SelectColumnHoc(getFeedSourcesColumns); + +export { SelectFeedSourceColumns, FeedSourceColumns }; diff --git a/src/components/feed-sources/feed-sources-list.jsx b/src/components/feed-sources/feed-sources-list.jsx new file mode 100644 index 00000000..3f0021dd --- /dev/null +++ b/src/components/feed-sources/feed-sources-list.jsx @@ -0,0 +1,157 @@ +import { React, useState, useEffect, useContext } from "react"; +import { useTranslation } from "react-i18next"; +import ContentHeader from "../util/content-header/content-header"; +import { + useGetV2FeedSourcesQuery, + useDeleteV2FeedSourcesByIdMutation, + useGetV2FeedSourcesByIdSlidesQuery, +} from "../../redux/api/api.generated.ts"; +import ListContext from "../../context/list-context.jsx"; +import ContentBody from "../util/content-body/content-body.jsx"; +import List from "../util/list/list.jsx"; +import { FeedSourceColumns } from "./feed-sources-columns"; +import { + displayError, + displaySuccess, +} from "../util/list/toast-component/display-toast.jsx"; +import idFromUrl from "../util/helpers/id-from-url.jsx"; +import UserContext from "../../context/user-context.jsx"; +import useModal from "../../context/modal-context/modal-context-hook.jsx"; + +/** + * The feed sources list component. + * + * @returns {object} The Feed sources list + */ +function FeedSourcesList() { + const { t } = useTranslation("common", { keyPrefix: "feed-sources-list" }); + const context = useContext(UserContext); + const { selected, setSelected } = useModal(); + + const [listData, setListData] = useState(); + const [isDeleting, setIsDeleting] = useState(false); + const [loadingMessage, setLoadingMessage] = useState( + t("loading-messages.loading-feed-sources") + ); + + // Delete call + const [ + DeleteV2FeedSources, + { isSuccess: isDeleteSuccess, error: isDeleteError }, + ] = useDeleteV2FeedSourcesByIdMutation(); // Insert feed source delete api; + + const { + searchText: { get: searchText }, + page: { get: page }, + createdBy: { get: createdBy }, + } = useContext(ListContext); + + const { + data, + error: feedSourcesGetError, + isLoading, + refetch, + } = useGetV2FeedSourcesQuery({ + page, + order: { createdAt: "desc" }, + title: searchText, + createdBy, + }); + + /** Deletes multiple feed sources. */ + useEffect(() => { + if (isDeleting && selected.length > 0) { + if (isDeleteSuccess) { + displaySuccess(t("success-messages.feed-source-delete")); + } + const feedSourceToDelete = selected[0]; + setSelected(selected.slice(1)); + const feedSourceToDeleteId = idFromUrl(feedSourceToDelete.id); + DeleteV2FeedSources({ id: feedSourceToDeleteId }); + } + }, [isDeleting, isDeleteSuccess]); + + // Display success messages + useEffect(() => { + if (isDeleteSuccess && selected.length === 0) { + displaySuccess(t("success-messages.feed-source-delete")); + refetch(); + setIsDeleting(false); + } + }, [isDeleteSuccess]); + + // If the tenant is changed, data should be refetched + useEffect(() => { + if (context.selectedTenant.get) { + refetch(); + } + }, [context.selectedTenant.get]); + + useEffect(() => { + refetch(); + }, [searchText, page, createdBy]); + + // Display error on unsuccessful deletion + useEffect(() => { + if (isDeleteError) { + setIsDeleting(false); + displayError(t("error-messages.feed-source-delete-error"), isDeleteError); + } + }, [isDeleteError]); + + const handleDelete = () => { + setIsDeleting(true); + setLoadingMessage(t("loading-messages.deleting-feed-source")); + }; + + // The columns for the table. + const columns = FeedSourceColumns({ + handleDelete, + apiCall: useGetV2FeedSourcesByIdSlidesQuery, + infoModalRedirect: "/slide/edit", + infoModalTitle: t(`info-modal.slides`), + }); + + useEffect(() => { + if (data) { + setListData(data); + } + }, [data]); + + // Error with retrieving list of feed sources + useEffect(() => { + if (feedSourcesGetError) { + displayError( + t("error-messages.feed-sources-load-error"), + feedSourcesGetError + ); + } + }, [feedSourcesGetError]); + + return ( + <> + + {data && data["hydra:member"] && ( + + <> + {listData && ( + + )} + + + )} + + ); +} + +export default FeedSourcesList; diff --git a/src/components/feed-sources/templates/calendar-feed-type.jsx b/src/components/feed-sources/templates/calendar-feed-type.jsx new file mode 100644 index 00000000..275d9a56 --- /dev/null +++ b/src/components/feed-sources/templates/calendar-feed-type.jsx @@ -0,0 +1,37 @@ +import { React } from "react"; +import PropTypes from "prop-types"; +import { useTranslation } from "react-i18next"; +import FormInput from "../../util/forms/form-input"; + +const CalendarFeedType = ({ handleInput, formStateObject, mode }) => { + const { t } = useTranslation("common", { + keyPrefix: "feed-source-manager.dynamic-fields.calendar-api-feed-type", + }); + + return ( + <> + + + ); +}; + +CalendarFeedType.propTypes = { + handleInput: PropTypes.func, + formStateObject: PropTypes.shape({ + resources: PropTypes.string, + }), + mode: PropTypes.string, +}; + +export default CalendarFeedType; diff --git a/src/components/feed-sources/templates/event-database-feed-type.jsx b/src/components/feed-sources/templates/event-database-feed-type.jsx new file mode 100644 index 00000000..d49d3ca1 --- /dev/null +++ b/src/components/feed-sources/templates/event-database-feed-type.jsx @@ -0,0 +1,40 @@ +import { React } from "react"; +import PropTypes from "prop-types"; +import FormInput from "../../util/forms/form-input"; +import { useTranslation } from "react-i18next"; + +const EventDatabaseApiTemplate = ({ + handleInput, + formStateObject, + mode, +}) => { + const { t } = useTranslation("common", { keyPrefix: "feed-source-manager.dynamic-fields.event-database-api-feed-type" }); + return ( + <> + + + ); +}; + +EventDatabaseApiTemplate.propTypes = { + handleInput: PropTypes.func, + formStateObject: PropTypes.shape({ + secrets: PropTypes.shape({ + host: PropTypes.string, + }), + }), + mode: PropTypes.string, +}; + +export default EventDatabaseApiTemplate; diff --git a/src/components/feed-sources/templates/notified-feed-type.jsx b/src/components/feed-sources/templates/notified-feed-type.jsx new file mode 100644 index 00000000..091556ce --- /dev/null +++ b/src/components/feed-sources/templates/notified-feed-type.jsx @@ -0,0 +1,41 @@ +import { React } from "react"; +import PropTypes from "prop-types"; +import { useTranslation } from "react-i18next"; +import FormInput from "../../util/forms/form-input"; + +const NotifiedFeedType = ({ + handleInput, + formStateObject, + mode, +}) => { + const { t } = useTranslation("common", { + keyPrefix: "feed-source-manager.dynamic-fields.notified-feed-type", + }); + + return ( + <> + + + ); +}; + +NotifiedFeedType.propTypes = { + handleInput: PropTypes.func, + formStateObject: PropTypes.shape({ + token: PropTypes.string, + }), + mode: PropTypes.string, +}; + +export default NotifiedFeedType; diff --git a/src/components/navigation/nav-items/nav-items.jsx b/src/components/navigation/nav-items/nav-items.jsx index 6e7bb6de..13155f01 100644 --- a/src/components/navigation/nav-items/nav-items.jsx +++ b/src/components/navigation/nav-items/nav-items.jsx @@ -196,7 +196,6 @@ function NavItems() { className={({ isActive }) => `nav-link ${isActive ? "disabled" : ""}` } - to="/themes/list" > {t("configuration")} @@ -214,6 +213,16 @@ function NavItems() { {t("configuration-themes")} + + + `nav-link ${isActive ? "disabled" : ""}` + } + to="/feed-sources/list" + > + {t("configuration-feedsources")} + + )} diff --git a/src/components/util/forms/form-input-area.jsx b/src/components/util/forms/form-input-area.jsx index f8c80235..180b0695 100644 --- a/src/components/util/forms/form-input-area.jsx +++ b/src/components/util/forms/form-input-area.jsx @@ -1,5 +1,6 @@ import React from "react"; import PropTypes from "prop-types"; +import { FormLabel } from "react-bootstrap"; /** * A text area for forms. @@ -17,11 +18,16 @@ function FormInputArea({ label, onChange, value = "", + formGroupClasses = "", placeholder = "", + required = false, }) { return ( -
- +
+ + {label} + {required && " *"} +