diff --git a/api-catalog-ui/frontend/src/components/DetailPage/DetailPage.jsx b/api-catalog-ui/frontend/src/components/DetailPage/DetailPage.jsx
index 41db1ecc70..8f2593bc16 100644
--- a/api-catalog-ui/frontend/src/components/DetailPage/DetailPage.jsx
+++ b/api-catalog-ui/frontend/src/components/DetailPage/DetailPage.jsx
@@ -79,6 +79,7 @@ export default class DetailPage extends Component {
history,
currentTileId,
fetchNewTiles,
+ selectedService,
} = this.props;
let { tiles } = this.props;
const iconBack = ;
@@ -102,7 +103,15 @@ export default class DetailPage extends Component {
}
const apiPortalEnabled = isAPIPortal();
const hasTiles = !fetchTilesError && tiles && tiles.length > 0;
- const { useCasesCounter, tutorialsCounter, videosCounter } = countAdditionalContents(services);
+ const {
+ useCasesCounter,
+ tutorialsCounter,
+ videosCounter,
+ filteredUseCases,
+ filteredTutorials,
+ videos,
+ documentation,
+ } = countAdditionalContents(selectedService);
const onlySwaggerPresent = tutorialsCounter === 0 && videosCounter === 0 && useCasesCounter === 0;
const showSideBar = false;
if (
@@ -202,7 +211,7 @@ export default class DetailPage extends Component {
className="links"
onClick={(e) => this.handleLinkClick(e, '#tutorials-label')}
>
- Tutorials ({tutorialsCounter})
+ TechDocs Resources ({tutorialsCounter})
(
@@ -271,4 +284,5 @@ DetailPage.propTypes = {
history: PropTypes.shape({
push: PropTypes.func.isRequired,
}).isRequired,
+ selectedService: PropTypes.object.isRequired,
};
diff --git a/api-catalog-ui/frontend/src/components/DetailPage/DetailPage.test.jsx b/api-catalog-ui/frontend/src/components/DetailPage/DetailPage.test.jsx
index 15a1db1958..4af0ac8d0e 100644
--- a/api-catalog-ui/frontend/src/components/DetailPage/DetailPage.test.jsx
+++ b/api-catalog-ui/frontend/src/components/DetailPage/DetailPage.test.jsx
@@ -8,7 +8,7 @@
* Copyright Contributors to the Zowe Project.
*/
import { shallow } from 'enzyme';
-import { describe, expect, it, jest } from '@jest/globals';
+import { describe, expect, it } from '@jest/globals';
import DetailPage from './DetailPage';
const tile = {
@@ -51,6 +51,10 @@ describe('>>> Detailed Page component tests', () => {
process.env.REACT_APP_API_PORTAL = false;
});
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
it('should start epic on mount', () => {
const fetchTilesStart = jest.fn();
const fetchNewTiles = jest.fn();
@@ -252,9 +256,19 @@ describe('>>> Detailed Page component tests', () => {
process.env.REACT_APP_API_PORTAL = true;
const fetchTilesStart = jest.fn();
const fetchNewTiles = jest.fn();
- tile.services[0].videos = ['video1', 'video2'];
- tile.services[0].tutorials = ['tutorial1', 'tutorial2'];
- tile.services[0].useCases = ['useCase1', 'useCase2'];
+ // eslint-disable-next-line global-require
+ const utils = require('../../utils/utilFunctions');
+ const spyOnCountAdditionalContents = jest.spyOn(utils, 'default');
+ spyOnCountAdditionalContents.mockImplementation(() => ({
+ useCasesCounter: 2,
+ tutorialsCounter: 2,
+ videosCounter: 2,
+ hasSwagger: true,
+ useCases: [],
+ tutorials: [],
+ videos: [],
+ documentation: '',
+ }));
const wrapper = shallow(
>> Detailed Page component tests', () => {
/>
);
expect(wrapper.find('#right-resources-menu').exists()).toEqual(true);
+ spyOnCountAdditionalContents.mockRestore();
});
it('should click on the links', () => {
diff --git a/api-catalog-ui/frontend/src/components/DetailPage/DetailPageContainer.jsx b/api-catalog-ui/frontend/src/components/DetailPage/DetailPageContainer.jsx
index b07cfd7cc5..17c8160bea 100644
--- a/api-catalog-ui/frontend/src/components/DetailPage/DetailPageContainer.jsx
+++ b/api-catalog-ui/frontend/src/components/DetailPage/DetailPageContainer.jsx
@@ -28,6 +28,7 @@ const mapStateToProps = (state) => ({
fetchTilesError: state.tilesReducer.error,
selectedTile: state.selectedServiceReducer.selectedTile,
selectedServiceId: state.selectedServiceReducer.selectedService.serviceId,
+ selectedService: state.selectedServiceReducer.selectedService,
isLoading: loadingSelector(state),
currentTileId: state.tilesReducer.currentTileId,
});
diff --git a/api-catalog-ui/frontend/src/components/ExtraContents/BlogContainer.jsx b/api-catalog-ui/frontend/src/components/ExtraContents/BlogContainer.jsx
new file mode 100644
index 0000000000..47343d1008
--- /dev/null
+++ b/api-catalog-ui/frontend/src/components/ExtraContents/BlogContainer.jsx
@@ -0,0 +1,110 @@
+/*
+ * This program and the accompanying materials are made available under the terms of the
+ * Eclipse Public License v2.0 which accompanies this distribution, and is available at
+ * https://www.eclipse.org/legal/epl-v20.html
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ *
+ * Copyright Contributors to the Zowe Project.
+ */
+import React, { useState, useEffect } from 'react';
+import PropTypes from 'prop-types';
+import BlogTile from './BlogTile';
+
+export default function BlogContainer({ user, url, title }) {
+ const rss2json = `https://api.rss2json.com/v1/api.json?rss_url=https%3A%2F%2Fmedium.com%2Ffeed%2F%40${user}`;
+ const [myBlog, setMyBlog] = useState([]);
+
+ const fetchData = async () => {
+ try {
+ const res = await fetch(url);
+ const data = await res.text();
+
+ const parser = new DOMParser();
+ const doc = parser.parseFromString(data, 'text/html');
+ const divs = doc.querySelector('.linklist.relatedlinks');
+ if (divs) {
+ divs.parentNode.removeChild(divs);
+ }
+
+ let content = doc.querySelector('.shortdesc');
+ if (!content?.textContent) {
+ content = doc.querySelector('.p');
+ }
+ const tutorialTitle = doc.querySelector('h1.title');
+ const blogTitle = tutorialTitle?.textContent;
+ const blogContent = content?.textContent;
+
+ const blogData = {
+ content: blogContent,
+ description: blogContent,
+ title: blogTitle,
+ link: url,
+ };
+
+ setMyBlog(blogData);
+ } catch (error) {
+ // eslint-disable-next-line no-console
+ console.error('Error fetching data:', error);
+ return null;
+ }
+ };
+
+ useEffect(() => {
+ const fetchDataEffect = async () => {
+ if (!url?.includes('medium.com') && !url?.includes('docs.zowe.org')) {
+ await fetchData();
+ } else if (url?.includes('docs.zowe.org')) {
+ const blogData = {
+ content: '',
+ description: `Tutorial from the Zowe documentation related to ${title}`,
+ title,
+ link: url,
+ };
+ setMyBlog(blogData);
+ } else {
+ try {
+ const res = await fetch(rss2json);
+ const data = await res.json();
+ setMyBlog(data);
+ } catch (error) {
+ // eslint-disable-next-line no-console
+ console.error('Error fetching data:', error);
+ return null;
+ }
+ }
+ };
+
+ fetchDataEffect();
+ }, [rss2json]);
+
+ function displayBlogs() {
+ if (myBlog?.items) {
+ const correctBlog = myBlog.items.find((blog) => blog?.link.includes(url));
+ return correctBlog && ;
+ }
+ }
+ if (url?.includes('medium.com')) {
+ return (
+
+ {displayBlogs()}
+
+ );
+ }
+
+ return (
+ myBlog && (
+
+
+
+ )
+ );
+}
+
+BlogContainer.propTypes = {
+ url: PropTypes.shape({
+ includes: PropTypes.func.isRequired,
+ }).isRequired,
+ user: PropTypes.string.isRequired,
+ title: PropTypes.string.isRequired,
+};
diff --git a/api-catalog-ui/frontend/src/components/ExtraContents/BlogContainer.test.jsx b/api-catalog-ui/frontend/src/components/ExtraContents/BlogContainer.test.jsx
new file mode 100644
index 0000000000..1ef2536c84
--- /dev/null
+++ b/api-catalog-ui/frontend/src/components/ExtraContents/BlogContainer.test.jsx
@@ -0,0 +1,250 @@
+/*
+ * This program and the accompanying materials are made available under the terms of the
+ * Eclipse Public License v2.0 which accompanies this distribution, and is available at
+ * https://www.eclipse.org/legal/epl-v20.html
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ *
+ * Copyright Contributors to the Zowe Project.
+ */
+import { shallow, mount } from 'enzyme';
+import React from 'react';
+import BlogContainer from './BlogContainer';
+
+describe('>>> BlogContainer component tests', () => {
+ it('should render medium blog', async () => {
+ jest.spyOn(global, 'fetch').mockResolvedValueOnce({
+ text: jest.fn().mockResolvedValueOnce(),
+ });
+
+ const blogContainer = shallow();
+
+ expect(blogContainer.find('[data-testid="medium-blog-container"]').exists()).toEqual(true);
+
+ global.fetch.mockRestore();
+ });
+
+ it('should not render medium blog if url missing', async () => {
+ jest.spyOn(global, 'fetch').mockResolvedValueOnce({
+ text: jest.fn().mockResolvedValueOnce(),
+ });
+
+ const blogContainer = shallow();
+
+ expect(blogContainer.find('[data-testid="medium-blog-container"]').exists()).toEqual(false);
+
+ global.fetch.mockRestore();
+ });
+
+ it('should render other blog (non-Medium and non-Zowe)', async () => {
+ jest.spyOn(global, 'fetch').mockResolvedValueOnce({
+ text: jest.fn().mockResolvedValueOnce(),
+ });
+
+ const blogContainer = shallow();
+
+ expect(blogContainer.find('[data-testid="tech-blog-container"]').exists()).toEqual(true);
+
+ global.fetch.mockRestore();
+ });
+
+ it('should render correctly for Zowe documentation URL', () => {
+ const blogContainer = shallow();
+
+ expect(blogContainer.find('[data-testid="tech-blog-container"]').exists()).toEqual(true);
+ });
+
+ it('should handle missing items in the fetched data', async () => {
+ jest.spyOn(global, 'fetch').mockResolvedValueOnce({
+ text: jest.fn().mockResolvedValueOnce('HTML content with missing items'),
+ });
+
+ const blogContainer = shallow();
+ expect(blogContainer.find('[data-testid="tech-blog-container"]').exists()).toEqual(true);
+
+ global.fetch.mockRestore();
+ });
+
+ it('should handle empty content and description', async () => {
+ jest.spyOn(global, 'fetch').mockResolvedValueOnce({
+ text: jest.fn().mockResolvedValueOnce(''),
+ });
+
+ const blogContainer = shallow();
+
+ expect(blogContainer.find('[data-testid="tech-blog-container"]').exists()).toEqual(true);
+
+ global.fetch.mockRestore();
+ });
+
+ it('should handle missing RSS feed items', async () => {
+ jest.spyOn(global, 'fetch').mockResolvedValueOnce({
+ json: jest.fn().mockResolvedValueOnce({}),
+ });
+
+ const blogContainer = shallow();
+
+ expect(blogContainer.find('[data-testid="medium-blog-container"]').exists()).toEqual(true);
+
+ global.fetch.mockRestore();
+ });
+
+ it('should render zowe blogs', async () => {
+ jest.spyOn(global, 'fetch').mockResolvedValueOnce({
+ json: jest.fn().mockResolvedValueOnce({
+ items: [
+ {
+ link: 'https://docs.zowe.org/some',
+ title: 'Zowe Blog Title',
+ description: 'Zowe Blog Description',
+ content: 'Zowe Blog Content',
+ },
+ ],
+ }),
+ });
+
+ const wrapper = mount();
+
+ await wrapper.update();
+
+ expect(wrapper.find('[data-testid="tech-blog-container"]').exists()).toEqual(true);
+
+ expect(wrapper.find('BlogTile').exists()).toEqual(true);
+
+ global.fetch.mockRestore();
+ });
+
+ it('should not render medium articles if the URL is not in the items from feed response', async () => {
+ jest.spyOn(global, 'fetch').mockResolvedValueOnce({
+ json: jest.fn().mockResolvedValueOnce({
+ items: [
+ {
+ link: 'https://medium.com/some/medium',
+ title: 'Zowe Blog Title',
+ description: 'Zowe Blog Description',
+ content: 'Zowe Blog Content',
+ },
+ ],
+ }),
+ });
+
+ const wrapper = mount();
+
+ await wrapper.update();
+
+ expect(wrapper.find('[data-testid="medium-blog-container"]').exists()).toEqual(true);
+
+ expect(wrapper.find('BlogTile').exists()).toEqual(false);
+
+ global.fetch.mockRestore();
+ });
+
+ it('should fetch data and render blog correctly', async () => {
+ const mockFetch = jest.spyOn(global, 'fetch');
+ mockFetch.mockResolvedValueOnce({
+ text: jest.fn().mockResolvedValueOnce('Blog content
'),
+ });
+
+ const wrapper = shallow();
+ expect(wrapper.find('[data-testid="tech-blog-container"]').exists()).toEqual(true);
+
+ // Restore the original fetch function
+ mockFetch.mockRestore();
+ });
+
+ it('should use description from diffeent element', async () => {
+ const mockFetch = jest.spyOn(global, 'fetch');
+ mockFetch.mockResolvedValueOnce({
+ text: jest.fn().mockResolvedValueOnce('Blog content
'),
+ });
+
+ const wrapper = shallow();
+ expect(wrapper.find('[data-testid="tech-blog-container"]').exists()).toEqual(true);
+
+ // Restore the original fetch function
+ mockFetch.mockRestore();
+ });
+
+ it('should render multiple medium blogs', async () => {
+ const myBlogData = {
+ items: [{ link: 'https://medium.com/blog1' }, { link: 'https:///medium.com/blog2' }],
+ };
+ jest.spyOn(global, 'fetch').mockResolvedValueOnce({
+ json: jest.fn().mockResolvedValueOnce(myBlogData),
+ });
+
+ const useStateSpy = jest.spyOn(React, 'useState');
+ const setMyBlog = jest.fn();
+ useStateSpy.mockImplementation((init) => [init, setMyBlog]);
+
+ const blogContainer = mount();
+
+ blogContainer.update();
+
+ expect(blogContainer.find('[data-testid="medium-blog-container"]').exists()).toEqual(true);
+
+ // Clean up mocks
+ global.fetch.mockRestore();
+ useStateSpy.mockRestore();
+ });
+
+ it('should not render a blog when items do not contain matching URLs', async () => {
+ const myBlogData = {
+ items: [{ link: 'https://someother.com/blog1' }],
+ };
+
+ jest.spyOn(global, 'fetch').mockResolvedValueOnce({
+ json: jest.fn().mockResolvedValueOnce(myBlogData),
+ });
+
+ const setMyBlog = jest.fn();
+ jest.spyOn(React, 'useState').mockImplementation((init) => [init, setMyBlog]);
+
+ const blogContainer = mount();
+
+ expect(blogContainer.find('[data-testid="medium-blog-container"]').exists()).toEqual(true);
+
+ expect(blogContainer.find('BlogTile').exists()).toEqual(false);
+
+ // Clean up mocks
+ global.fetch.mockRestore();
+ React.useState.mockRestore();
+ });
+
+ it('should handle errors during data fetching inside fetchDataEffect and return null', async () => {
+ jest.spyOn(global, 'fetch').mockRejectedValueOnce(new Error('Fetch error'));
+
+ jest.spyOn(console, 'error').mockImplementation(() => {});
+
+ const setMyBlog = jest.fn();
+ jest.spyOn(React, 'useState').mockImplementation((init) => [init, setMyBlog]);
+
+ const blogContainer = mount();
+ expect(blogContainer.find('[data-testid="tech-blog-container"]').exists()).toEqual(true);
+
+ expect(blogContainer.find('BlogTile').exists()).toEqual(true);
+ expect(blogContainer.find('.blog-description').exists()).toEqual(false);
+
+ // Clean up mocks
+ global.fetch.mockRestore();
+ React.useState.mockRestore();
+ });
+
+ it('should handle errors during data fetching when url is medium', async () => {
+ jest.spyOn(global, 'fetch').mockRejectedValueOnce(new Error('Fetch error'));
+
+ jest.spyOn(console, 'error').mockImplementation(() => {});
+
+ const setMyBlog = jest.fn();
+ jest.spyOn(React, 'useState').mockImplementation((init) => [init, setMyBlog]);
+
+ const blogContainer = mount();
+ expect(blogContainer.find('[data-testid="tech-blog-container"]').exists()).toEqual(false);
+
+ expect(blogContainer.find('BlogTile').exists()).toEqual(false);
+
+ // Clean up mocks
+ global.fetch.mockRestore();
+ React.useState.mockRestore();
+ });
+});
diff --git a/api-catalog-ui/frontend/src/components/ExtraContents/BlogTile.jsx b/api-catalog-ui/frontend/src/components/ExtraContents/BlogTile.jsx
new file mode 100644
index 0000000000..0437eba493
--- /dev/null
+++ b/api-catalog-ui/frontend/src/components/ExtraContents/BlogTile.jsx
@@ -0,0 +1,96 @@
+/*
+ * This program and the accompanying materials are made available under the terms of the
+ * Eclipse Public License v2.0 which accompanies this distribution, and is available at
+ * https://www.eclipse.org/legal/epl-v20.html
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ *
+ * Copyright Contributors to the Zowe Project.
+ */
+
+import { Typography } from '@material-ui/core';
+import PropTypes from 'prop-types';
+
+export default function BlogTile(props) {
+ const { title, link, thumbnail, description, pubDate, author } = props.blogData;
+ function cleanTitle(checkTitle) {
+ return checkTitle?.replace('amp;', '');
+ }
+
+ function truncateText(text, start, len) {
+ return text?.length > len ? text?.slice(start, len) : text;
+ }
+
+ function toText(block) {
+ const tag = document.createElement('div');
+ tag.innerHTML = block;
+ return tag.innerText;
+ }
+
+ function convertDate(date) {
+ const dateArray = date?.slice(0, 10).split('-');
+ const year = dateArray?.shift();
+ dateArray?.push(year);
+ return `Published: ${dateArray?.join('/')}`;
+ }
+ function blogPost() {
+ return (
+
+ {thumbnail && (
+
+ )}
+ {title && (
+
+ {truncateText(cleanTitle(title), 0, 60)}
+
+ )}
+ {description && (
+ {`${truncateText(
+ toText(description),
+ 0,
+ 180
+ )}...`}
+ )}
+
+
+ {author}
+
+ {pubDate && (
+
+ {convertDate(pubDate)}
+
+ )}
+
+ );
+ }
+
+ return {blogPost()}
;
+}
+
+BlogTile.propTypes = {
+ blogData: PropTypes.oneOfType([
+ PropTypes.shape({
+ author: PropTypes.string,
+ title: PropTypes.string.isRequired,
+ link: PropTypes.string.isRequired,
+ thumbnail: PropTypes.string,
+ description: PropTypes.string.isRequired,
+ pubDate: PropTypes.string,
+ }),
+ PropTypes.arrayOf(
+ PropTypes.shape({
+ author: PropTypes.string,
+ title: PropTypes.string.isRequired,
+ link: PropTypes.string.isRequired,
+ thumbnail: PropTypes.string,
+ description: PropTypes.string.isRequired,
+ pubDate: PropTypes.string,
+ })
+ ),
+ ]).isRequired,
+};
diff --git a/api-catalog-ui/frontend/src/components/ExtraContents/BlogTile.test.jsx b/api-catalog-ui/frontend/src/components/ExtraContents/BlogTile.test.jsx
new file mode 100644
index 0000000000..a5be6e6325
--- /dev/null
+++ b/api-catalog-ui/frontend/src/components/ExtraContents/BlogTile.test.jsx
@@ -0,0 +1,45 @@
+/*
+ * This program and the accompanying materials are made available under the terms of the
+ * Eclipse Public License v2.0 which accompanies this distribution, and is available at
+ * https://www.eclipse.org/legal/epl-v20.html
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ *
+ * Copyright Contributors to the Zowe Project.
+ */
+import { shallow } from 'enzyme';
+import BlogTile from './BlogTile';
+
+describe('>>> BlogTile component tests', () => {
+ let props;
+ beforeEach(() => {
+ process.env.REACT_APP_API_PORTAL = false;
+ props = {
+ blogData: {
+ title: 'title',
+ link: 'link',
+ thumbnail: 'img',
+ description: 'desc',
+ pubDate: '123343',
+ author: 'author',
+ },
+ };
+ });
+ it('should render blog tile', () => {
+ const blogTile = shallow();
+ expect(blogTile.find('[data-testid="blogs-image"]').exists()).toEqual(true);
+ expect(blogTile.find('[data-testid="blog-title"]').first().prop('children')).toEqual('title');
+ expect(blogTile.find('[data-testid="blog-description"]').exists()).toEqual(true);
+ expect(blogTile.find('[data-testid="author"]').first().prop('children')).toEqual('author');
+ expect(blogTile.find('[data-testid="pub-date"]').first().prop('children')).toEqual('Published: 123343');
+ });
+
+ it('should truncate text', () => {
+ props.blogData.title =
+ 'looooong title hdswqduwqduqwdhuwqdqwhdswqduwqduqwdhuwqdqwhdswqduwqduqwdhuwqdqwhdswqduwqduqwdhuwqdqwhdswqduwqduqwdhuwqdqwhdswqduwqduqwdhuwqdqwhdswqduwqduqwdhuwqdqwhdswqduwqduqwdhuwqdqwhdswqduwqduqwdhuwqdqwhdswqduwqduqwdhuwqdqwhdswqduwqduqwdhuwqdqwhdswqduwqduqwdhuwqdqwhdswqduwqduqwdhuwqdqwhdswqduwqduqwdhuwqdqw';
+ const blogTile = shallow();
+ expect(blogTile.find('[data-testid="blog-title"]').first().prop('children')).toEqual(
+ 'looooong title hdswqduwqduqwdhuwqdqwhdswqduwqduqwdhuwqdqwhds'
+ );
+ });
+});
diff --git a/api-catalog-ui/frontend/src/components/ExtraContents/VideoWrapper.jsx b/api-catalog-ui/frontend/src/components/ExtraContents/VideoWrapper.jsx
new file mode 100644
index 0000000000..2d162c5fbd
--- /dev/null
+++ b/api-catalog-ui/frontend/src/components/ExtraContents/VideoWrapper.jsx
@@ -0,0 +1,36 @@
+/*
+ * This program and the accompanying materials are made available under the terms of the
+ * Eclipse Public License v2.0 which accompanies this distribution, and is available at
+ * https://www.eclipse.org/legal/epl-v20.html
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ *
+ * Copyright Contributors to the Zowe Project.
+ */
+import React from 'react';
+import PropTypes from 'prop-types';
+import { isValidUrl } from '../../utils/utilFunctions';
+
+function VideoWrapper({ url }) {
+ if (!isValidUrl(url)) return null;
+ return (
+
+
+
+
+ );
+}
+
+VideoWrapper.propTypes = {
+ url: PropTypes.string.isRequired,
+};
+
+export default VideoWrapper;
diff --git a/api-catalog-ui/frontend/src/components/ExtraContents/VideoWrapper.test.jsx b/api-catalog-ui/frontend/src/components/ExtraContents/VideoWrapper.test.jsx
new file mode 100644
index 0000000000..aa58e12666
--- /dev/null
+++ b/api-catalog-ui/frontend/src/components/ExtraContents/VideoWrapper.test.jsx
@@ -0,0 +1,23 @@
+/*
+ * This program and the accompanying materials are made available under the terms of the
+ * Eclipse Public License v2.0 which accompanies this distribution, and is available at
+ * https://www.eclipse.org/legal/epl-v20.html
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ *
+ * Copyright Contributors to the Zowe Project.
+ */
+import { shallow } from 'enzyme';
+import VideoWrapper from './VideoWrapper';
+
+describe('>>> BlogTile component tests', () => {
+ it('should not render videos if url is not valid', () => {
+ const video = shallow();
+ expect(video.find('[data-testid="video-container"]').exists()).toEqual(false);
+ });
+
+ it('should render videos', () => {
+ const video = shallow();
+ expect(video.find('[data-testid="video-container"]').exists()).toEqual(true);
+ });
+});
diff --git a/api-catalog-ui/frontend/src/components/ExtraContents/educational_contents.json b/api-catalog-ui/frontend/src/components/ExtraContents/educational_contents.json
new file mode 100644
index 0000000000..fe506fbf0f
--- /dev/null
+++ b/api-catalog-ui/frontend/src/components/ExtraContents/educational_contents.json
@@ -0,0 +1,3 @@
+{
+ "text": "just a json placeholder for the portal"
+}
diff --git a/api-catalog-ui/frontend/src/components/ServiceTab/ServiceTab.jsx b/api-catalog-ui/frontend/src/components/ServiceTab/ServiceTab.jsx
index d70f6173bd..a0dfc7b459 100644
--- a/api-catalog-ui/frontend/src/components/ServiceTab/ServiceTab.jsx
+++ b/api-catalog-ui/frontend/src/components/ServiceTab/ServiceTab.jsx
@@ -7,12 +7,15 @@
*
* Copyright Contributors to the Zowe Project.
*/
-import { Link, Typography, Tooltip, MenuItem, Select, Button } from '@material-ui/core';
+import { Link, Typography, Tooltip, MenuItem, Select, Button, IconButton } from '@material-ui/core';
+import PropTypes from 'prop-types';
import { Component } from 'react';
import Shield from '../ErrorBoundary/Shield/Shield';
import SwaggerContainer from '../Swagger/SwaggerContainer';
import ServiceVersionDiffContainer from '../ServiceVersionDiff/ServiceVersionDiffContainer';
import { isAPIPortal } from '../../utils/utilFunctions';
+import VideoWrapper from '../ExtraContents/VideoWrapper';
+import BlogContainer from '../ExtraContents/BlogContainer';
export default class ServiceTab extends Component {
constructor(props) {
@@ -21,6 +24,9 @@ export default class ServiceTab extends Component {
selectedVersion: null,
previousVersion: null,
isDialogOpen: false,
+ displayVideosCount: 2,
+ displayUseCasesCount: 3,
+ displayBlogsCount: 3,
};
this.handleDialogClose = this.handleDialogClose.bind(this);
}
@@ -137,6 +143,21 @@ export default class ServiceTab extends Component {
this.setState({ isDialogOpen: false, selectedVersion: null });
};
+ showMoreVideos = () => {
+ const { videos } = this.props;
+ this.setState((prevState) => ({ displayVideosCount: prevState.displayVideosCount + videos.length }));
+ };
+
+ showMoreBlogs = () => {
+ const { tutorials } = this.props;
+ this.setState((prevState) => ({ displayBlogsCount: prevState.displayBlogsCount + tutorials.length }));
+ };
+
+ showMoreUseCases = () => {
+ const { useCases } = this.props;
+ this.setState((prevState) => ({ displayUseCasesCount: prevState.displayUseCasesCount + useCases.length }));
+ };
+
render() {
const {
match: {
@@ -144,10 +165,15 @@ export default class ServiceTab extends Component {
},
tiles,
selectedService,
+ useCases,
+ tutorials,
+ videos,
useCasesCounter,
tutorialsCounter,
videosCounter,
+ documentation,
} = this.props;
+ const { displayVideosCount, displayBlogsCount, displayUseCasesCount } = this.state;
if (tiles === null || tiles === undefined || tiles.length === 0) {
throw new Error('No tile is selected.');
}
@@ -160,7 +186,9 @@ export default class ServiceTab extends Component {
const message = 'The API documentation was retrieved but could not be displayed.';
const sso = selectedService.ssoAllInstances ? 'supported' : 'not supported';
const apiPortalEnabled = isAPIPortal();
- const additionalContentsPresent = useCasesCounter !== 0 && tutorialsCounter !== 0 && videosCounter !== 0;
+ const useCasesPresent = useCasesCounter !== 0;
+ const videosPresent = videosCounter !== 0;
+ const tutorialsPresent = tutorialsCounter !== 0;
return (
<>
{currentService === null && (
@@ -245,6 +273,21 @@ export default class ServiceTab extends Component {
{selectedService.description}
+ {isAPIPortal() && documentation?.label && documentation?.url && (
+
+ To know more about the {selectedService.title} service, see the
+
+ {documentation.label}
+
+ .
+
+ )}
Swagger
@@ -300,37 +343,86 @@ export default class ServiceTab extends Component {
isDialogOpen={isDialogOpen}
/>
)}
- {isAPIPortal() && additionalContentsPresent && (
+ {isAPIPortal() && (
)}
@@ -339,3 +431,33 @@ export default class ServiceTab extends Component {
);
}
}
+
+ServiceTab.propTypes = {
+ videos: PropTypes.shape({
+ length: PropTypes.func.isRequired,
+ slice: PropTypes.func.isRequired,
+ }).isRequired,
+ tutorials: PropTypes.shape({
+ length: PropTypes.func.isRequired,
+ slice: PropTypes.func.isRequired,
+ }).isRequired,
+ useCases: PropTypes.shape({
+ length: PropTypes.func.isRequired,
+ slice: PropTypes.func.isRequired,
+ }).isRequired,
+ documentation: PropTypes.oneOfType([
+ PropTypes.shape({
+ label: PropTypes.string.isRequired,
+ url: PropTypes.string.isRequired,
+ }),
+ PropTypes.arrayOf(
+ PropTypes.shape({
+ label: PropTypes.string.isRequired,
+ url: PropTypes.string.isRequired,
+ })
+ ),
+ ]).isRequired,
+ selectedService: PropTypes.shape({
+ title: PropTypes.string.isRequired,
+ }).isRequired,
+};
diff --git a/api-catalog-ui/frontend/src/components/ServiceTab/ServiceTab.test.jsx b/api-catalog-ui/frontend/src/components/ServiceTab/ServiceTab.test.jsx
index 8477a4966b..b01c3c7a15 100644
--- a/api-catalog-ui/frontend/src/components/ServiceTab/ServiceTab.test.jsx
+++ b/api-catalog-ui/frontend/src/components/ServiceTab/ServiceTab.test.jsx
@@ -58,9 +58,21 @@ const tiles = {
'The API Mediation Layer for z/OS internal API services. The API Mediation Layer provides a single point of access to mainframe REST APIs and offers enterprise cloud-like features such as high-availability, scalability, dynamic API discovery, and documentation.',
services: [selectedService],
};
+let videos;
+let tutorials;
+let useCases;
describe('>>> ServiceTab component tests', () => {
beforeEach(() => {
process.env.REACT_APP_API_PORTAL = false;
+ videos = ['url1', 'url2'];
+ tutorials = [
+ { url: 'url1', user: 'user', title: 'title' },
+ { url: 'url2', user: 'user', title: 'title' },
+ ];
+ useCases = [
+ { url: 'url1', user: 'user' },
+ { url: 'url2', user: 'user' },
+ ];
});
it('should display service tab information', () => {
const selectService = jest.fn();
@@ -186,7 +198,6 @@ describe('>>> ServiceTab component tests', () => {
/>
);
expect(serviceTab.find('.footer-labels').exists()).toEqual(false);
- expect(serviceTab.find('#detail-footer').exists()).toEqual(false);
});
it('should display home page link if service down', () => {
@@ -277,4 +288,162 @@ describe('>>> ServiceTab component tests', () => {
color: '#0056B3',
});
});
+
+ it('should show more videos', () => {
+ process.env.REACT_APP_API_PORTAL = true;
+ const selectService = jest.fn();
+ const wrapper = shallow(
+
+ );
+
+ wrapper.instance().showMoreVideos();
+
+ expect(wrapper.state().displayVideosCount).toEqual(4);
+ expect(wrapper.state().displayBlogsCount).toEqual(3);
+ expect(wrapper.state().displayUseCasesCount).toEqual(3);
+ });
+
+ it('should show more use cases', () => {
+ process.env.REACT_APP_API_PORTAL = true;
+ const selectService = jest.fn();
+ const wrapper = shallow(
+
+ );
+
+ wrapper.instance().showMoreUseCases();
+
+ expect(wrapper.state().displayVideosCount).toEqual(2);
+ expect(wrapper.state().displayBlogsCount).toEqual(3);
+ expect(wrapper.state().displayUseCasesCount).toEqual(5);
+ });
+
+ it('should show more tutorials', () => {
+ process.env.REACT_APP_API_PORTAL = true;
+ const selectService = jest.fn();
+ const wrapper = shallow(
+
+ );
+
+ wrapper.instance().showMoreBlogs();
+
+ expect(wrapper.state().displayVideosCount).toEqual(2);
+ expect(wrapper.state().displayBlogsCount).toEqual(5);
+ expect(wrapper.state().displayUseCasesCount).toEqual(3);
+ });
+
+ it('should call handle dialog close', () => {
+ const selectService = jest.fn();
+ const wrapper = shallow(
+
+ );
+ const instance = wrapper.instance();
+
+ // Call the handleDialogClose method
+ instance.handleDialogClose();
+
+ // Check if the state is updated correctly
+ expect(wrapper.state('isDialogOpen')).toEqual(false);
+ expect(wrapper.state('selectedVersion')).toBeNull();
+ });
+
+ it('should call handle dialog open with selected version null', () => {
+ const currentService = {
+ defaultApiVersion: '1.0.0',
+ };
+ const selectService = jest.fn();
+ const wrapper = shallow(
+
+ );
+ const instance = wrapper.instance();
+
+ // Call the handleDialogClose method
+ instance.handleDialogOpen(currentService);
+
+ // Check if the state is updated correctly
+ expect(wrapper.state('isDialogOpen')).toEqual(true);
+ expect(wrapper.state('selectedVersion')).toEqual('diff');
+ expect(wrapper.state('previousVersion')).toEqual('1.0.0');
+ });
+
+ it('should display documentation when portal enabled', () => {
+ process.env.REACT_APP_API_PORTAL = true;
+ const documentation = { label: 'title', url: 'url' };
+ const selectService = jest.fn();
+ const wrapper = shallow(
+
+ );
+
+ expect(wrapper.find('.service-doc-link')).toExist();
+ expect(wrapper.find('.more-content-button').exists()).toEqual(false);
+ });
+
+ it('extra contents should be more than the default counters', () => {
+ process.env.REACT_APP_API_PORTAL = true;
+ const selectService = jest.fn();
+ videos = ['url1', 'url2', 'url3', 'url4', 'url5'];
+ tutorials = [
+ { url: 'url1', user: 'user', title: 'title' },
+ { url: 'url2', user: 'user', title: 'title' },
+ { url: 'url3', user: 'user', title: 'title' },
+ { url: 'url4', user: 'user', title: 'title' },
+ { url: 'url5', user: 'user', title: 'title' },
+ { url: 'url6', user: 'user', title: 'title' },
+ ];
+ useCases = [
+ { url: 'url1', user: 'user' },
+ { url: 'url2', user: 'user' },
+ { url: 'url3', user: 'user' },
+ { url: 'url4', user: 'user' },
+ { url: 'url5', user: 'user' },
+ ];
+ const wrapper = shallow(
+
+ );
+
+ expect(wrapper.find('.more-content-button').exists()).toEqual(true);
+ });
});
diff --git a/api-catalog-ui/frontend/src/components/ServiceTab/_serviceTab.scss b/api-catalog-ui/frontend/src/components/ServiceTab/_serviceTab.scss
index cc8bee9bdb..03513f37da 100644
--- a/api-catalog-ui/frontend/src/components/ServiceTab/_serviceTab.scss
+++ b/api-catalog-ui/frontend/src/components/ServiceTab/_serviceTab.scss
@@ -59,7 +59,7 @@ body #version-div {
}
}
#detail-footer {
- margin-left: var( --spaceSmall );
+ margin-top: var( --spaceLargest );
margin-bottom: var( --spaceLargest );
display: grid;
}
@@ -73,3 +73,55 @@ body #version-div {
font-size: var( --fontMedium );
color: var( --criticalShade10 );
}
+#more-videos-button {
+ margin-top: 13px;
+ margin-right: auto;
+ padding: 0;
+}
+button.MuiButtonBase-root.MuiIconButton-root.more-content-button {
+ @extend .button-link;
+ width: fit-content;
+ border-radius: 3px;
+ margin-left: 25%;
+ border: 1px solid var(--tertiary, #0056B3);
+ margin-top: 13px;
+ padding: 8px 12px;
+ align-items: center;
+}
+.BlogsContainer {
+ width: 229px;
+ height: 174px;
+ display: inline-table;
+}
+.blogs-image {
+ width: 229px;
+ height: 174px;
+}
+.PostContainer {
+ display: inline-table;
+ margin-right: 25px;
+ margin-bottom: 13px;
+}
+.service-doc-link {
+ color: var( --link20 );
+}
+.video-responsive {
+ display: inline-table;
+ margin-right: var( --spaceLargest );
+ margin-top: var( --spaceLargest );
+}
+.blog_content_link {
+ text-decoration: none;
+}
+.blog-title {
+ font-size: var( --fontLarge );
+}
+.pub-date {
+ color: var(--text-secondary, #3B4151);
+}
+.blog-description {
+ color: var(--text-secondary, #3B4151);
+}
+.author {
+ color: var(--text-secondary, #3B4151);
+}
diff --git a/api-catalog-ui/frontend/src/utils/utilFunctions.js b/api-catalog-ui/frontend/src/utils/utilFunctions.js
index 04e48f9875..2edaf454b6 100644
--- a/api-catalog-ui/frontend/src/utils/utilFunctions.js
+++ b/api-catalog-ui/frontend/src/utils/utilFunctions.js
@@ -9,6 +9,15 @@
*/
import getBaseUrl from '../helpers/urls';
+import contents from '../components/ExtraContents/educational_contents.json';
+
+export const isValidUrl = (url) => {
+ try {
+ return Boolean(new URL(url));
+ } catch (e) {
+ return false;
+ }
+};
function checkForSwagger(service) {
let hasSwagger = false;
@@ -27,42 +36,48 @@ function checkForSwagger(service) {
return hasSwagger;
}
+function countValidItems(items, validator) {
+ return items.reduce((counter, item) => (validator(item) ? counter + 1 : counter), 0);
+}
+
/**
* Counts the additional contents
* @param service
- * @returns {{videosCounter: number, useCasesCounter: number, tutorialsCounter: number}}
+ * @returns {{videosCounter, useCases: *[], useCasesCounter, tutorialsCounter, tutorials: *[], documentation: null, hasSwagger: boolean, videos: *[]}}
*/
export default function countAdditionalContents(service) {
- let useCasesCounter = 0;
- let tutorialsCounter = 0;
- let videosCounter = 0;
let hasSwagger = false;
- if (service) {
- if ('useCases' in service && service.useCases) {
- useCasesCounter = service.useCases.length;
- }
- if ('tutorials' in service && service.tutorials) {
- tutorialsCounter = service.tutorials.length;
- }
- if ('videos' in service && service.videos) {
- videosCounter = service.videos.length;
- }
- if (service.apis) {
- hasSwagger = checkForSwagger(service);
- }
+ const { useCases, tutorials, videos, documentation } = contents?.products?.find(
+ (product) => service?.serviceId === product.name
+ ) || { useCases: [], tutorials: [], videos: [], documentation: null };
+
+ const filteredUseCases = useCases?.filter(({ url, user }) => isValidUrl(url) && user);
+ const filteredTutorials = tutorials?.filter(({ url }) => isValidUrl(url));
+ const useCasesCounter = countValidItems(filteredUseCases, (item) => isValidUrl(item.url));
+ const tutorialsCounter = countValidItems(filteredTutorials, (item) => isValidUrl(item.url));
+ const videosCounter = countValidItems(videos, (item) => isValidUrl(item));
+
+ if (service?.apis) {
+ hasSwagger = checkForSwagger(service);
}
- return { useCasesCounter, tutorialsCounter, videosCounter, hasSwagger };
+
+ return {
+ useCasesCounter,
+ tutorialsCounter,
+ videosCounter,
+ hasSwagger,
+ filteredUseCases,
+ filteredTutorials,
+ videos,
+ documentation,
+ };
}
function setButtonsColor(wizardButton, uiConfig, refreshButton) {
const color =
uiConfig.headerColor === 'white' || uiConfig.headerColor === '#FFFFFF' ? 'black' : uiConfig.headerColor;
- if (wizardButton) {
- wizardButton.style.setProperty('color', color);
- }
- if (refreshButton) {
- refreshButton.style.setProperty('color', color);
- }
+ wizardButton?.style?.setProperty('color', color);
+ refreshButton?.style?.setProperty('color', color);
}
function setMultipleElements(uiConfig) {
@@ -77,18 +92,10 @@ function setMultipleElements(uiConfig) {
if (header && header.length > 0) {
header[0].style.setProperty('background-color', uiConfig.headerColor);
}
- if (divider) {
- divider.style.setProperty('background-color', uiConfig.headerColor);
- }
- if (title1) {
- title1.style.setProperty('color', uiConfig.headerColor);
- }
- if (swaggerLabel) {
- swaggerLabel.style.setProperty('color', uiConfig.headerColor);
- }
- if (logoutButton) {
- logoutButton.style.setProperty('color', uiConfig.headerColor);
- }
+ divider?.style?.setProperty('background-color', uiConfig.headerColor);
+ title1?.style?.setProperty('color', uiConfig.headerColor);
+ swaggerLabel?.style?.setProperty('color', uiConfig.headerColor);
+ logoutButton?.style?.setProperty('color', uiConfig.headerColor);
setButtonsColor(wizardButton, uiConfig, refreshButton);
}
}
@@ -126,22 +133,12 @@ function handleWhiteHeader(uiConfig) {
if (uiConfig.headerColor === 'white' || uiConfig.headerColor === '#FFFFFF') {
if (uiConfig.docLink) {
const docText = document.querySelector('#internal-link');
- if (docText) {
- docText.style.color = 'black';
- }
- }
- if (goBackButton) {
- goBackButton.style.color = 'black';
- }
- if (swaggerLabel) {
- swaggerLabel.style.color = 'black';
- }
- if (title) {
- title.style.color = 'black';
- }
- if (productTitle) {
- productTitle.style.color = 'black';
+ docText?.style?.setProperty('color', 'black');
}
+ goBackButton?.style?.setProperty('color', 'black');
+ swaggerLabel?.style?.setProperty('color', 'black');
+ title?.style?.setProperty('color', 'black');
+ productTitle?.style?.setProperty('color', 'black');
}
}
@@ -189,16 +186,12 @@ export const customUIStyle = async (uiConfig) => {
element.style.setProperty('font-family', uiConfig.fontFamily);
});
const tileLabel = document.querySelector('p#tileLabel');
- if (tileLabel) {
- tileLabel.style.removeProperty('font-family');
- tileLabel.style.fontFamily = uiConfig.fontFamily;
- }
+ tileLabel?.style?.removeProperty('font-family');
+ tileLabel?.style?.setProperty('font-family', uiConfig.fontFamily);
}
if (uiConfig.textColor) {
const description = document.getElementById('description');
- if (description) {
- description.style.color = uiConfig.textColor;
- }
+ description?.style?.setProperty('color', uiConfig.textColor);
}
handleWhiteHeader(uiConfig);
};
diff --git a/api-catalog-ui/frontend/src/utils/utilFunctions.test.js b/api-catalog-ui/frontend/src/utils/utilFunctions.test.js
index 10498c65f6..ae8dc7e7ab 100644
--- a/api-catalog-ui/frontend/src/utils/utilFunctions.test.js
+++ b/api-catalog-ui/frontend/src/utils/utilFunctions.test.js
@@ -7,7 +7,7 @@
*
* Copyright Contributors to the Zowe Project.
*/
-import countAdditionalContents, { closeMobileMenu, customUIStyle, openMobileMenu } from './utilFunctions';
+import countAdditionalContents, { closeMobileMenu, customUIStyle, isValidUrl, openMobileMenu } from './utilFunctions';
describe('>>> Util Functions tests', () => {
function mockFetch() {
@@ -45,18 +45,19 @@ describe('>>> Util Functions tests', () => {
afterEach(() => {
document.body.innerHTML = '';
});
- it('should count medias', () => {
+ it('should return default count when no medias are provided', () => {
const service = {
- id: 'service',
+ id: 'apicatalog',
hasSwagger: false,
- useCases: ['usecase1', 'usecase2'],
- tutorials: [],
- videos: [],
};
expect(countAdditionalContents(service)).toEqual({
+ documentation: null,
hasSwagger: false,
+ filteredTutorials: [],
tutorialsCounter: 0,
- useCasesCounter: 2,
+ filteredUseCases: [],
+ useCasesCounter: 0,
+ videos: [],
videosCounter: 0,
});
});
@@ -64,9 +65,6 @@ describe('>>> Util Functions tests', () => {
it('should check for swagger when not default one available', () => {
const service = {
id: 'service',
- useCases: ['usecase1', 'usecase2'],
- tutorials: [],
- videos: [],
apis: {
'org.zowe v1': {
swaggerUrl: 'swagger',
@@ -74,9 +72,51 @@ describe('>>> Util Functions tests', () => {
},
};
expect(countAdditionalContents(service)).toEqual({
+ documentation: null,
hasSwagger: true,
+ filteredTutorials: [],
+ tutorialsCounter: 0,
+ filteredUseCases: [],
+ useCasesCounter: 0,
+ videos: [],
+ videosCounter: 0,
+ });
+ });
+
+ it('should check for swagger and set false when no swagger URL available', () => {
+ const service = {
+ id: 'service',
+ apis: {
+ 'org.zowe v1': {},
+ },
+ };
+ expect(countAdditionalContents(service)).toEqual({
+ documentation: null,
+ hasSwagger: false,
+ filteredTutorials: [],
+ tutorialsCounter: 0,
+ filteredUseCases: [],
+ useCasesCounter: 0,
+ videos: [],
+ videosCounter: 0,
+ });
+ });
+
+ it('should check for swagger when default API is available', () => {
+ const service = {
+ id: 'service',
+ apis: {
+ default: { apiId: 'enabler' },
+ },
+ };
+ expect(countAdditionalContents(service)).toEqual({
+ documentation: null,
+ hasSwagger: false,
+ filteredTutorials: [],
tutorialsCounter: 0,
- useCasesCounter: 2,
+ filteredUseCases: [],
+ useCasesCounter: 0,
+ videos: [],
videosCounter: 0,
});
});
@@ -123,6 +163,28 @@ describe('>>> Util Functions tests', () => {
global.fetch.mockRestore();
});
+ it('should not set color if header not found', async () => {
+ document.body.innerHTML = `
+
+ `;
+ const uiConfig = {
+ logo: '/path/img.png',
+ headerColor: 'red',
+ backgroundColor: 'blue',
+ fontFamily: 'Arial',
+ textColor: 'white',
+ };
+
+ global.URL.createObjectURL = jest.fn().mockReturnValue('img-url');
+ global.fetch = mockFetch();
+ await customUIStyle(uiConfig);
+ const header = document.getElementsByClassName('header')[0];
+ expect(header).toBeUndefined();
+ // Clean up the mocks
+ jest.restoreAllMocks();
+ global.fetch.mockRestore();
+ });
+
it('should handle elements in case of white header', async () => {
const uiConfig = {
logo: '/path/img.png',
@@ -178,4 +240,12 @@ describe('>>> Util Functions tests', () => {
closeMobileMenu();
expect(spyToggle).toHaveBeenCalledWith('mobile-menu-open');
});
+
+ it('should return false when URL is invalid', async () => {
+ expect(isValidUrl('invalidurl')).toBe(false);
+ });
+
+ it('should return true when URL is valid', async () => {
+ expect(isValidUrl('https://localhost.com/hello')).toBe(true);
+ });
});