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 && ( + {truncateText(cleanTitle(title), + )} + {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 ( +
+