Skip to content

Commit

Permalink
Add html helpers and string parsing
Browse files Browse the repository at this point in the history
  • Loading branch information
rtexelm committed Jun 20, 2024
1 parent 914ebd9 commit 1933461
Show file tree
Hide file tree
Showing 4 changed files with 198 additions and 18 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import {
t,
SupersetError,
ErrorTypeEnum,
isProbablyHTML,
isJsonString,
} from '@superset-ui/core';

// The response always contains an error attribute, can contain anything from the
Expand Down Expand Up @@ -88,6 +90,19 @@ export function parseErrorJson(responseObject: JsonObject): ClientErrorObject {
return { ...error, error: error.error }; // explicit ClientErrorObject
}

export function parseErrorString(response: string): ClientErrorObject {
if (!isJsonString(response) && isProbablyHTML(response)) {
if (/500|server error/i.test(response)) {
return { error: t('Server error') };
}
if (/404|not found/i.test(response)) {
return { error: t('Page not found') };
}
return { error: 'Server error' };
}
return { error: response };
}

export function getClientErrorObject(
response:
| SupersetClientResponse
Expand All @@ -99,7 +114,7 @@ export function getClientErrorObject(
// and returns a Promise that resolves to a plain object with error key and text value.
return new Promise(resolve => {
if (typeof response === 'string') {
resolve({ error: response });
resolve(parseErrorString(response));
return;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import {
sanitizeHtmlIfNeeded,
safeHtmlSpan,
removeHTMLTags,
isJsonString,
getParagraphContents,
} from './html';

describe('sanitizeHtml', () => {
Expand Down Expand Up @@ -113,3 +115,77 @@ describe('removeHTMLTags', () => {
expect(output).toBe('Unclosed tag');
});
});

describe('isJsonString', () => {
test('valid JSON object', () => {
const jsonString = '{"name": "John", "age": 30, "city": "New York"}';
expect(isJsonString(jsonString)).toBe(true);
});

test('valid JSON array', () => {
const jsonString = '[1, 2, 3, 4, 5]';
expect(isJsonString(jsonString)).toBe(true);
});

test('valid JSON string', () => {
const jsonString = '"Hello, world!"';
expect(isJsonString(jsonString)).toBe(true);
});

test('invalid JSON with syntax error', () => {
const jsonString = '{"name": "John", "age": 30, "city": "New York"';
expect(isJsonString(jsonString)).toBe(false);
});

test('empty string', () => {
const jsonString = '';
expect(isJsonString(jsonString)).toBe(false);
});

test('non-JSON string', () => {
const jsonString = '<p>Hello, <strong>World!</strong></p>';
expect(isJsonString(jsonString)).toBe(false);
});

test('non-JSON formatted number', () => {
const jsonString = '12345abc';
expect(isJsonString(jsonString)).toBe(false);
});
});

describe('getParagraphContents', () => {
test('should return an object with keys for each paragraph tag', () => {
const htmlString =
'<div><p>First paragraph.</p><p>Second paragraph.</p></div>';
const result = getParagraphContents(htmlString);
expect(result).toEqual({
p1: 'First paragraph.',
p2: 'Second paragraph.',
});
});

test('should return null if the string is not HTML', () => {
const nonHtmlString = 'Just a plain text string.';
expect(getParagraphContents(nonHtmlString)).toBeNull();
});

test('should return null if there are no <p> tags in the HTML string', () => {
const htmlStringWithoutP = '<div><span>No paragraph here.</span></div>';
expect(getParagraphContents(htmlStringWithoutP)).toBeNull();
});

test('should return an object with empty string for empty <p> tag', () => {
const htmlStringWithEmptyP = '<div><p></p></div>';
const result = getParagraphContents(htmlStringWithEmptyP);
expect(result).toEqual({ p1: '' });
});

test('should handle HTML strings with nested <p> tags correctly', () => {
const htmlStringWithNestedP =
'<div><p>First paragraph <span>with nested</span> content.</p></div>';
const result = getParagraphContents(htmlStringWithNestedP);
expect(result).toEqual({
p1: 'First paragraph with nested content.',
});
});
});
55 changes: 52 additions & 3 deletions superset-frontend/packages/superset-ui-core/src/utils/html.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,26 @@ export function sanitizeHtml(htmlString: string) {
return xssFilter.process(htmlString);
}

export function hasHtmlTagPattern(str: string): boolean {
const htmlTagPattern =
/<(html|head|body|div|span|a|p|h[1-6]|title|meta|link|script|style)/i;

return htmlTagPattern.test(str);
}

export function isProbablyHTML(text: string) {
return Array.from(
new DOMParser().parseFromString(text, 'text/html').body.childNodes,
).some(({ nodeType }) => nodeType === 1);
const cleanedStr = text.trim().toLowerCase();

if (
cleanedStr.startsWith('<!doctype html>') &&
hasHtmlTagPattern(cleanedStr)
) {
return true;
}

const parser = new DOMParser();
const doc = parser.parseFromString(cleanedStr, 'text/html');
return Array.from(doc.body.childNodes).some(({ nodeType }) => nodeType === 1);
}

export function sanitizeHtmlIfNeeded(htmlString: string) {
Expand All @@ -70,3 +86,36 @@ export function safeHtmlSpan(possiblyHtmlString: string) {
export function removeHTMLTags(str: string): string {
return str.replace(/<[^>]*>/g, '');
}

export function isJsonString(str: string): boolean {
try {
JSON.parse(str);
return true;
} catch (e) {
return false;
}
}

export function getParagraphContents(
str: string,
): { [key: string]: string } | null {
if (!isProbablyHTML(str)) {
return null;
}

const parser = new DOMParser();
const doc = parser.parseFromString(str, 'text/html');
const pTags = doc.querySelectorAll('p');

if (pTags.length === 0) {
return null;
}

const paragraphContents: { [key: string]: string } = {};

pTags.forEach((pTag, index) => {
paragraphContents[`p${index + 1}`] = pTag.textContent || '';
});

return paragraphContents;
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,27 +25,67 @@ import {
ErrorTypeEnum,
} from '@superset-ui/core';

it('Returns a Promise', () => {
test('Returns a Promise', () => {
const response = getClientErrorObject('error');
expect(response instanceof Promise).toBe(true);
});

it('Returns a Promise that resolves to an object with an error key', async () => {
test('Returns a Promise that resolves to an object with an error key', async () => {
const error = 'error';

const errorObj = await getClientErrorObject(error);
expect(errorObj).toMatchObject({ error });
});

it('Handles Response that can be parsed as json', async () => {
test('should handle HTML response with "500" or "server error"', async () => {
const htmlString500 = '<div>500: Internal Server Error</div>';
const clientErrorObject500 = await getClientErrorObject(htmlString500);
expect(clientErrorObject500).toEqual({ error: 'Server error' });

const htmlStringServerError = '<div>Server error message</div>';
const clientErrorObjectServerError = await getClientErrorObject(
htmlStringServerError,
);
expect(clientErrorObjectServerError).toEqual({
error: 'Server error',
});
});

test('should handle HTML response with "404" or "not found"', async () => {
const htmlString404 = '<div>404: Page not found</div>';
const clientErrorObject404 = await getClientErrorObject(htmlString404);
expect(clientErrorObject404).toEqual({ error: 'Page not found' });

const htmlStringNotFoundError = '<div>Not found message</div>';
const clientErrorObjectNotFoundError = await getClientErrorObject(
htmlStringNotFoundError,
);
expect(clientErrorObjectNotFoundError).toEqual({
error: 'Page not found',
});
});

test('should handle HTML response without common error code', async () => {
const htmlString = '<!doctype html><div>Foo bar Lorem Ipsum</div>';
const clientErrorObject = await getClientErrorObject(htmlString);
expect(clientErrorObject).toEqual({ error: 'Server error' });

const htmlString2 = '<div><p>An error occurred</p></div>';
const clientErrorObject2 = await getClientErrorObject(htmlString2);
expect(clientErrorObject2).toEqual({
error: 'Server error',
});
});

test('Handles Response that can be parsed as json', async () => {
const jsonError = { something: 'something', error: 'Error message' };
const jsonErrorString = JSON.stringify(jsonError);

const errorObj = await getClientErrorObject(new Response(jsonErrorString));
expect(errorObj).toMatchObject(jsonError);
});

it('Handles backwards compatibility between old error messages and the new SIP-40 errors format', async () => {
test('Handles backwards compatibility between old error messages and the new SIP-40 errors format', async () => {
const jsonError = {
errors: [
{
Expand All @@ -63,22 +103,22 @@ it('Handles backwards compatibility between old error messages and the new SIP-4
expect(errorObj.link).toEqual(jsonError.errors[0].extra.link);
});

it('Handles Response that can be parsed as text', async () => {
test('Handles Response that can be parsed as text', async () => {
const textError = 'Hello I am a text error';

const errorObj = await getClientErrorObject(new Response(textError));
expect(errorObj).toMatchObject({ error: textError });
});

it('Handles TypeError Response', async () => {
test('Handles TypeError Response', async () => {
const error = new TypeError('Failed to fetch');

// @ts-ignore
const errorObj = await getClientErrorObject(error);
expect(errorObj).toMatchObject({ error: 'Network error' });
});

it('Handles timeout error', async () => {
test('Handles timeout error', async () => {
const errorObj = await getClientErrorObject({
timeout: 1000,
statusText: 'timeout',
Expand Down Expand Up @@ -110,14 +150,14 @@ it('Handles timeout error', async () => {
});
});

it('Handles plain text as input', async () => {
test('Handles plain text as input', async () => {
const error = 'error';

const errorObj = await getClientErrorObject(error);
expect(errorObj).toMatchObject({ error });
});

it('Handles error with status text and message', async () => {
test('Handles error with status text and message', async () => {
const statusText = 'status';
const message = 'message';

Expand All @@ -135,7 +175,7 @@ it('Handles error with status text and message', async () => {
});
});

it('getClientErrorMessage', () => {
test('getClientErrorMessage', () => {
expect(getClientErrorMessage('error')).toEqual('error');
expect(
getClientErrorMessage('error', {
Expand All @@ -150,7 +190,7 @@ it('getClientErrorMessage', () => {
).toEqual('error:\nclient error');
});

it('parseErrorJson with message', () => {
test('parseErrorJson with message', () => {
expect(parseErrorJson({ message: 'error message' })).toEqual({
message: 'error message',
error: 'error message',
Expand Down Expand Up @@ -181,7 +221,7 @@ it('parseErrorJson with message', () => {
});
});

it('parseErrorJson with stacktrace', () => {
test('parseErrorJson with stacktrace', () => {
expect(
parseErrorJson({ error: 'error message', stack: 'stacktrace' }),
).toEqual({
Expand All @@ -204,7 +244,7 @@ it('parseErrorJson with stacktrace', () => {
});
});

it('parseErrorJson with CSRF', () => {
test('parseErrorJson with CSRF', () => {
expect(
parseErrorJson({
responseText: 'CSRF',
Expand All @@ -215,7 +255,7 @@ it('parseErrorJson with CSRF', () => {
});
});

it('getErrorText', async () => {
test('getErrorText', async () => {
expect(await getErrorText('error', 'dashboard')).toEqual(
'Sorry, there was an error saving this dashboard: error',
);
Expand Down

0 comments on commit 1933461

Please sign in to comment.