From 0178b1e9985cb6324556e7187db62fdbdd5ec767 Mon Sep 17 00:00:00 2001 From: Vitali Pinchuk <146737590+vitPinchuk@users.noreply.github.com> Date: Thu, 12 Sep 2024 22:00:58 +0300 Subject: [PATCH] Update Partials loading (#358) * partials updated * Update CHANGELOG.md * Remove Loading Bar * Add link color * Update CHANGELOG.md --------- Co-authored-by: Mikhail Volkov --- CHANGELOG.md | 4 +- src/components/Text/Text.styles.ts | 15 +++++ src/components/Text/Text.test.tsx | 14 ----- src/components/Text/Text.tsx | 43 ++++++------- src/components/TextPanel/TextPanel.tsx | 5 +- src/hooks/index.ts | 1 - src/hooks/useContentPartials.test.ts | 40 ------------- src/hooks/useContentPartials.ts | 47 --------------- src/utils/html.test.tsx | 62 +++++++++++-------- src/utils/html.ts | 26 +++++--- src/utils/index.ts | 1 + src/utils/partials.test.ts | 83 ++++++++++++++++++++++++++ src/utils/partials.ts | 28 +++++++++ 13 files changed, 203 insertions(+), 166 deletions(-) delete mode 100644 src/hooks/useContentPartials.test.ts delete mode 100644 src/hooks/useContentPartials.ts create mode 100644 src/utils/partials.test.ts create mode 100644 src/utils/partials.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b5bfa4..d3460e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,13 @@ # Changelog -## 5.4.0 (IN PROGRESS) +## 5.4.0 (2024-09-12) ### Features / Enhancements - Updated panel render if first data source does not have data (#353) - Added wrap button in the code editor (#359) +- Updated Partials loading (#358) +- Updated hyperlinks style (#358) ## 5.3.0 (2024-08-22) diff --git a/src/components/Text/Text.styles.ts b/src/components/Text/Text.styles.ts index 12c3c83..d27c6a6 100644 --- a/src/components/Text/Text.styles.ts +++ b/src/components/Text/Text.styles.ts @@ -28,6 +28,10 @@ export const getStyles = (theme: GrafanaTheme2) => { margin-left: ${theme.spacing(2)}; } + a { + color: blue; + } + table { border-collapse: collapse; @@ -60,6 +64,16 @@ export const getStyles = (theme: GrafanaTheme2) => { } `; + /** + * Loading Bar + */ + const loadingBar = css` + position: absolute; + top: 0; + left: 0; + width: 100%; + overflow: hidden; + `; /** * Highlight */ @@ -68,5 +82,6 @@ export const getStyles = (theme: GrafanaTheme2) => { return { frame, highlight, + loadingBar, }; }; diff --git a/src/components/Text/Text.test.tsx b/src/components/Text/Text.test.tsx index cfc7b97..3e1e86a 100644 --- a/src/components/Text/Text.test.tsx +++ b/src/components/Text/Text.test.tsx @@ -48,7 +48,6 @@ describe('Text', () => { timeZone: '', replaceVariables: (str: string) => str, eventBus: {} as any, - htmlContents: [], }; await act(async () => render()); @@ -78,7 +77,6 @@ describe('Text', () => { timeZone: '', replaceVariables, eventBus: eventBus as any, - htmlContents: [], }; await act(async () => render()); @@ -113,7 +111,6 @@ describe('Text', () => { timeZone: '', replaceVariables, eventBus: eventBus as any, - htmlContents: [], }; await act(async () => render()); @@ -149,7 +146,6 @@ describe('Text', () => { timeZone: '', replaceVariables, eventBus: eventBus as any, - htmlContents: [], }; const { rerender } = await act(async () => render()); @@ -191,7 +187,6 @@ describe('Text', () => { timeZone: '', replaceVariables, eventBus: eventBus as any, - htmlContents: [], }; await act(async () => render()); @@ -235,7 +230,6 @@ describe('Text', () => { timeZone: '', replaceVariables, eventBus: {} as any, - htmlContents: [], }; await act(async () => render()); @@ -284,7 +278,6 @@ describe('Text', () => { timeZone: '', replaceVariables, eventBus: {} as any, - htmlContents: [], }; await act(async () => render()); @@ -344,7 +337,6 @@ describe('Text', () => { timeZone: '', replaceVariables, eventBus: {} as any, - htmlContents: [], }; await act(async () => render()); @@ -383,7 +375,6 @@ describe('Text', () => { timeZone: '', replaceVariables, eventBus: {} as any, - htmlContents: [], }; await act(async () => render()); @@ -425,7 +416,6 @@ describe('Text', () => { timeZone: '', replaceVariables: (str: string) => str, eventBus: {} as any, - htmlContents: [], }; await act(async () => render()); @@ -456,7 +446,6 @@ describe('Text', () => { timeZone: '', replaceVariables: (str: string) => str, eventBus: {} as any, - htmlContents: [], }; await act(async () => render()); @@ -496,7 +485,6 @@ describe('Text', () => { timeZone: '', replaceVariables: (str: string) => str, eventBus: {} as any, - htmlContents: [], }; await act(async () => render()); @@ -547,7 +535,6 @@ describe('Text', () => { timeZone: '', replaceVariables: (str: string) => str, eventBus: {} as any, - htmlContents: [], }; await act(async () => render()); @@ -610,7 +597,6 @@ describe('Text', () => { timeZone: '', replaceVariables: (str: string) => str, eventBus: {} as any, - htmlContents: [], }; await act(async () => render()); diff --git a/src/components/Text/Text.tsx b/src/components/Text/Text.tsx index 0073b14..001e35e 100644 --- a/src/components/Text/Text.tsx +++ b/src/components/Text/Text.tsx @@ -12,11 +12,11 @@ import { } from '@grafana/data'; import { getAppEvents } from '@grafana/runtime'; import { TimeZone } from '@grafana/schema'; -import { Alert, useStyles2, useTheme2 } from '@grafana/ui'; +import { Alert, LoadingBar, useStyles2, useTheme2 } from '@grafana/ui'; import React, { useCallback, useEffect, useState } from 'react'; import { TEST_IDS } from '../../constants'; -import { PanelOptions, PartialItem, RenderMode, RowItem } from '../../types'; +import { PanelOptions, RenderMode, RowItem } from '../../types'; import { generateHtml } from '../../utils'; import { Row } from '../Row'; import { getStyles } from './Text.styles'; @@ -73,13 +73,6 @@ interface Props { * @type {PanelData} */ data: PanelData; - - /** - * HTML contents - * - * @type {PartialItem[]} - */ - htmlContents: PartialItem[]; } /** @@ -93,13 +86,17 @@ export const Text: React.FC = ({ replaceVariables, eventBus, data: panelData, - htmlContents, }) => { /** * Generated rows */ const [rows, setRows] = useState([]); + /** + * Loading state + */ + const [isLoading, setIsLoading] = useState(false); + /** * Generate html error */ @@ -136,7 +133,8 @@ export const Text: React.FC = ({ */ const getHtml = useCallback( async (htmlData: Record, content: string) => { - return { + setIsLoading(true); + const result = { ...(await generateHtml({ data: htmlData, content, @@ -151,24 +149,14 @@ export const Text: React.FC = ({ notifySuccess, notifyError, theme, - htmlContents, + partials: options?.contentPartials, })), data: htmlData, }; + setIsLoading(false); + return result; }, - [ - options, - timeRange, - timeZone, - replaceVariables, - eventBus, - panelData, - frame, - notifySuccess, - notifyError, - theme, - htmlContents, - ] + [options, timeRange, timeZone, replaceVariables, eventBus, panelData, frame, notifySuccess, notifyError, theme] ); useEffect(() => { @@ -306,6 +294,11 @@ export const Text: React.FC = ({ return ( <> + {isLoading && ( +
+ +
+ )} {rows.map((row, index) => ( = ({ type: ResourceType.STYLES, }); - const htmlContents = useContentPartials(options?.contentPartials); - /** * Re-render on dashboard refresh */ @@ -145,7 +143,6 @@ export const TextPanel: React.FC = ({ replaceVariables={replaceVariables} eventBus={eventBus} data={data} - htmlContents={htmlContents} /> diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 9efc65d..2891502 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1,2 +1 @@ -export * from './useContentPartials'; export * from './useExternalResources'; diff --git a/src/hooks/useContentPartials.test.ts b/src/hooks/useContentPartials.test.ts deleted file mode 100644 index 793f0c5..0000000 --- a/src/hooks/useContentPartials.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { act, renderHook } from '@testing-library/react'; -import { useContentPartials } from './useContentPartials'; - -describe('useContentPartials', () => { - it('Should return an empty array when no items are provided', () => { - const { result } = renderHook(() => useContentPartials([])); - expect(result.current).toEqual([]); - }); - - describe('When data is fetched successfully', () => { - it('Should call fetchHtml and setHtmlContents when items are provided', async () => { - const items = [ - { url: '/partial1.html', name: 'Partial 1' }, - { url: '/partial2.html', name: 'Partial 2' }, - ] as any; - - jest.mocked(fetch).mockImplementation((url, options) => { - return Promise.resolve({ - ok: true, - json: Promise.resolve({}), - text: () => Promise.resolve('

test

'), - } as any); - }); - - let currentResult: any = []; - - await act(async () => { - const { result } = renderHook(() => useContentPartials(items)); - currentResult = result as any; - }); - - expect(currentResult.current.length).toBe(2); - expect(currentResult.current[0]).toEqual({ content: '

test

', name: 'Partial 1' }); - expect(currentResult.current[1]).toEqual({ content: '

test

', name: 'Partial 2' }); - expect(fetch).toHaveBeenCalledTimes(2); - expect(fetch).toHaveBeenCalledWith('/partial1.html'); - expect(fetch).toHaveBeenCalledWith('/partial2.html'); - }); - }); -}); diff --git a/src/hooks/useContentPartials.ts b/src/hooks/useContentPartials.ts deleted file mode 100644 index 4d93b8f..0000000 --- a/src/hooks/useContentPartials.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { useEffect, useState } from 'react'; - -import { PartialItem, PartialItemConfig } from '../types'; - -/** - * Use Content Partials - * @param items - */ -export const useContentPartials = (items: PartialItemConfig[]): PartialItem[] => { - /** - * State - */ - const [htmlContents, setHtmlContents] = useState([]); - - useEffect(() => { - /** - * Fetch content - */ - const fetchHtml = async (url: string, partialName: string): Promise => { - let content = 'Unable to load template\n'; - - try { - const response = await fetch(url); - - if (response && response.ok) { - content = await response.text(); - } - } catch {} - - return { - name: partialName, - content, - }; - }; - - const fetchAllHtml = async () => { - const fetchedHtml = await Promise.all(items.map((item) => fetchHtml(item.url, item.name))); - setHtmlContents(fetchedHtml); - }; - - if (items.length) { - fetchAllHtml(); - } - }, [items]); - - return htmlContents; -}; diff --git a/src/utils/html.test.tsx b/src/utils/html.test.tsx index b9dfe09..caec8c6 100644 --- a/src/utils/html.test.tsx +++ b/src/utils/html.test.tsx @@ -3,7 +3,7 @@ import { config } from '@grafana/runtime'; import { render, screen } from '@testing-library/react'; import Handlebars from 'handlebars'; import React from 'react'; - +import { fetchAllPartials } from './partials'; import { generateHtml } from './html'; /** @@ -13,6 +13,11 @@ jest.mock('handlebars', () => ({ registerHelper: jest.fn(), registerPartial: jest.fn(), compile: jest.fn((str: string) => () => str), + partials: {}, +})); + +jest.mock('./partials', () => ({ + fetchAllPartials: jest.fn(), })); /** @@ -151,30 +156,37 @@ describe('HTML helpers', () => { expect(variableValueHandler('varName')).toEqual('varName'); }); - it('Should use partial handler', () => { - let partialName: any; - let partialContent: any; - - jest.mocked(Handlebars.registerPartial).mockImplementation(((name: any, content: any) => { - if (name) { - partialName = name; - } - if (content) { - partialContent = content; - } - }) as any); - - generateHtml({ - content: '
', - replaceVariables: (str: string) => str, - options, - htmlContents: [{ name: 'aaaa', content: '

test

' }], - } as any); - - expect(Handlebars.registerPartial).toHaveBeenCalledWith('aaaa', '

test

'); - - expect(partialName).toEqual('aaaa'); - expect(partialContent).toEqual('

test

'); + it('Should use partial handler', async () => { + const returnFetchedPartials = [{ name: 'partialName', content: 'some content' }] as any; + const defaultParams = { + data: { key: 'value' }, + content: '{{> partialName}}', + partials: [{ id: 'test-partial', name: 'partialName', url: 'Partial Content' }], + helpers: '', + timeRange: {}, + timeZone: {}, + replaceVariables: jest.fn(), + eventBus: {}, + options: { wrap: true }, + panelData: {}, + notifySuccess: jest.fn(), + notifyError: jest.fn(), + theme: {}, + } as any; + + jest.mocked(fetchAllPartials).mockImplementation(() => returnFetchedPartials); + + const { html, unsubscribe } = await generateHtml(defaultParams); + + expect(fetchAllPartials).toHaveBeenCalledWith(defaultParams.partials); + + expect(Handlebars.registerPartial).toHaveBeenCalledWith( + returnFetchedPartials[0].name, + returnFetchedPartials[0].content + ); + + expect(html).toBeDefined(); + expect(unsubscribe).toBeUndefined(); }); it('Should wait until promise in code resolved', async () => { diff --git a/src/utils/html.ts b/src/utils/html.ts index 09f9353..d224a1f 100644 --- a/src/utils/html.ts +++ b/src/utils/html.ts @@ -17,10 +17,11 @@ import hljs from 'highlight.js'; // eslint-disable-next-line @typescript-eslint/naming-convention import MarkdownIt from 'markdown-it'; -import { PanelOptions, PartialItem } from '../types'; +import { PanelOptions, PartialItemConfig } from '../types'; import { createExecutionCode } from './code'; import { beforeRenderCodeParameters } from './code-parameters'; import { registerHelpers } from './handlebars'; +import { fetchAllPartials } from './partials'; import { replaceVariablesHelper } from './variable'; /** @@ -45,7 +46,7 @@ export const generateHtml = async ({ notifySuccess, notifyError, theme, - htmlContents, + partials, }: { data: Record; content: string; @@ -60,7 +61,7 @@ export const generateHtml = async ({ notifySuccess: (payload: AlertPayload) => void; notifyError: (payload: AlertErrorPayload) => void; theme: GrafanaTheme2; - htmlContents: PartialItem[]; + partials: PartialItemConfig[]; }): Promise<{ html: string; unsubscribe?: unknown }> => { /** * Variable @@ -138,12 +139,19 @@ export const generateHtml = async ({ */ const template = handlebars.compile(content); - /** - * Register partials in handlebars - */ - htmlContents.forEach((content) => { - handlebars.registerPartial(content.name, content.content); - }); + if (partials && !!partials.length) { + /** + * await fetching partials + */ + const fetchedPartials = await fetchAllPartials(partials); + + /** + * Register partials in handlebars + */ + fetchedPartials.forEach((content) => { + handlebars.registerPartial(content.name, content.content); + }); + } const markdown = template(data); diff --git a/src/utils/index.ts b/src/utils/index.ts index 11e12c2..126319f 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -3,5 +3,6 @@ export * from './code-parameters'; export * from './external-resources'; export * from './handlebars'; export * from './html'; +export * from './partials'; export * from './rows'; export * from './variable'; diff --git a/src/utils/partials.test.ts b/src/utils/partials.test.ts new file mode 100644 index 0000000..dc1306f --- /dev/null +++ b/src/utils/partials.test.ts @@ -0,0 +1,83 @@ +import { fetchHtml, fetchAllPartials } from './partials'; + +/** + * fetchHtml + */ +describe('fetchHtml', () => { + it('Should fetch HTML successfully', async () => { + const item = { id: 'test', name: 'test partial', url: 'http://example.com/template' }; + + /** + * Mock fetch + */ + jest.mocked(fetch).mockImplementation((url, options) => { + return Promise.resolve({ + ok: true, + json: Promise.resolve({}), + text: () => Promise.resolve('

test

'), + } as any); + }); + + const result = await fetchHtml(item.url, item.name); + + /** + * Check result + */ + expect(fetch).toHaveBeenCalledWith(item.url); + expect(result).toEqual({ name: item.name, content: '

test

' }); + }); + + it('Should fetch HTML with error', async () => { + const item = { id: 'test', name: 'test partial', url: 'http://example.com/template' }; + + /** + * Mock fetch + */ + jest.mocked(fetch).mockImplementation((url, options) => { + return Promise.resolve({ + ok: false, + } as any); + }); + + const result = await fetchHtml(item.url, item.name); + + /** + * Check result + */ + expect(fetch).toHaveBeenCalledWith(item.url); + expect(result).toEqual({ name: item.name, content: 'Unable to load template\n' }); + }); +}); + +/** + * fetchAllPartials + */ +describe('fetchAllPartials', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('Should fetch HTML successfully', async () => { + const item = { id: 'test', name: 'test partial', url: 'http://example.com/template' }; + const item2 = { id: 'test2', name: 'test partial 2', url: 'http://example.com/template2' }; + + jest.mocked(fetch).mockImplementation((url, options) => { + return Promise.resolve({ + ok: true, + json: Promise.resolve({}), + text: () => Promise.resolve('

test

'), + } as any); + }); + + const result = await fetchAllPartials([item, item2]); + + expect(fetch).toHaveBeenCalledWith(item.url); + expect(fetch).toHaveBeenCalledWith(item2.url); + expect(fetch).toHaveBeenCalledTimes(2); + + expect(result).toEqual([ + { name: item.name, content: '

test

' }, + { name: item2.name, content: '

test

' }, + ]); + }); +}); diff --git a/src/utils/partials.ts b/src/utils/partials.ts new file mode 100644 index 0000000..7065517 --- /dev/null +++ b/src/utils/partials.ts @@ -0,0 +1,28 @@ +import { PartialItem, PartialItemConfig } from '../types'; + +/** + * Fetch content + */ +export const fetchHtml = async (url: string, partialName: string): Promise => { + let content = 'Unable to load template\n'; + + try { + const response = await fetch(url); + + if (response && response.ok) { + content = await response.text(); + } + } catch {} + + return { + name: partialName, + content, + }; +}; + +/** + * Fetch partials + */ +export const fetchAllPartials = async (items: PartialItemConfig[]) => { + return await Promise.all(items.map((item) => fetchHtml(item.url, item.name))); +};