diff --git a/hivemq-edge/src/frontend/src/api/hooks/useTopicFilters/__handlers__/index.ts b/hivemq-edge/src/frontend/src/api/hooks/useTopicFilters/__handlers__/index.ts index 870a4f6ca8..2c8a85bd9b 100644 --- a/hivemq-edge/src/frontend/src/api/hooks/useTopicFilters/__handlers__/index.ts +++ b/hivemq-edge/src/frontend/src/api/hooks/useTopicFilters/__handlers__/index.ts @@ -1,9 +1,14 @@ import { http, HttpResponse } from 'msw' import { type TopicFilter, type TopicFilterList } from '@/api/__generated__' +export const MOCK_TOPIC_FILTER_SCHEMA_INVALID = 'data:application/json;base64,ewogICJ0ZXN0IjogMQp9Cg==' +export const MOCK_TOPIC_FILTER_SCHEMA_VALID = + 'data:application/json;base64,ewogICJ0eXBlIjogIm9iamVjdCIsCiAgInRpdGxlIjogIlRoaXMgaXMgYSBzaW1wbGUgc2NoZW1hIiwKICAicHJvcGVydGllcyI6IHsKICAgICJkZXNjcmlwdGlvbiI6IHsKICAgICAgInR5cGUiOiAic3RyaW5nIiwKICAgICAgInRpdGxlIjogImRlc2NyaXB0aW9uIiwKICAgICAgImRlc2NyaXB0aW9uIjogIlRoZSBkZXNjcmlwdGlvbiBvZiB0aGUgaXRlbSIKICAgIH0sCiAgICAibmFtZSI6IHsKICAgICAgInR5cGUiOiAic3RyaW5nIiwKICAgICAgInRpdGxlIjogIm5hbWUiLAogICAgICAiZGVzY3JpcHRpb24iOiAiVGhlIG5hbWUgb2YgdGhlIGl0ZW0iCiAgICB9CiAgfSwKICAicmVxdWlyZWQiOiBbCiAgICAiZGVzY3JpcHRpb24iLAogICAgIm5hbWUiCiAgXQp9Cg==' + export const MOCK_TOPIC_FILTER: TopicFilter = { topicFilter: 'a/topic/+/filter', description: 'This is a topic filter', + schema: MOCK_TOPIC_FILTER_SCHEMA_VALID, } export const handlers = [ diff --git a/hivemq-edge/src/frontend/src/api/hooks/useTopicFilters/useListTopicFilters.spec.ts b/hivemq-edge/src/frontend/src/api/hooks/useTopicFilters/useListTopicFilters.spec.ts index e819957ee6..7b9d83656c 100644 --- a/hivemq-edge/src/frontend/src/api/hooks/useTopicFilters/useListTopicFilters.spec.ts +++ b/hivemq-edge/src/frontend/src/api/hooks/useTopicFilters/useListTopicFilters.spec.ts @@ -5,7 +5,7 @@ import { server } from '@/__test-utils__/msw/mockServer.ts' import { SimpleWrapper as wrapper } from '@/__test-utils__/hooks/SimpleWrapper.tsx' import type { TopicFilterList } from '@/api/__generated__' import { useListTopicFilters } from '@/api/hooks/useTopicFilters/useListTopicFilters.ts' -import { handlers } from '@/api/hooks/useTopicFilters/__handlers__' +import { handlers, MOCK_TOPIC_FILTER_SCHEMA_VALID } from '@/api/hooks/useTopicFilters/__handlers__' describe('useListTopicFilters', () => { beforeEach(() => { @@ -27,6 +27,7 @@ describe('useListTopicFilters', () => { { description: 'This is a topic filter', topicFilter: 'a/topic/+/filter', + schema: MOCK_TOPIC_FILTER_SCHEMA_VALID, }, ], }) diff --git a/hivemq-edge/src/frontend/src/locales/en/translation.json b/hivemq-edge/src/frontend/src/locales/en/translation.json index 882a2deedf..2dc47eadfc 100755 --- a/hivemq-edge/src/frontend/src/locales/en/translation.json +++ b/hivemq-edge/src/frontend/src/locales/en/translation.json @@ -1022,19 +1022,48 @@ "aria-label": "Delete Topic Filter" }, "add": { - "aria-label": "Add a new Topic Filter" + "aria-label": "Edit the topic Filters" } } }, "schema": { "header": "Manage the schemas of the topic filter", + "title": "Topic Filter:", + "prompt": "You can upload a schema or try to automatically infer a schema from the MQTT traffic on your installation", + "actions": { + "remove": "Remove assigned schema", + "assign": "Assign schema", + "upload": "Upload a JSON-Schema file" + }, + "tabs": { + "current": "Current Schema", + "upload": "Upload a new schema", + "infer": "Infer a schema" + }, "submit": { "label": "OK" + }, + "status": { + "success": "Your topic filter is currently assigned a valid schema", + "missing": "Your topic filter is currently not assigned a schema", + "invalid": "Your topic filter is currently assigned a schema that is not valid", + "internalError": "Internal error - cannot extract your schema" } }, "error": { "loading": "We cannot load the topic filters. Please try again later.", - "noSchemaSampled": "No schema could be inferred from the traffic on this topic filter. Please try again later." + "noSchemaSampled": "No schema could be inferred from the traffic on this topic filter. Please try again later.", + "schema": { + "noDataUri": "Not a valid data-url encoded JSONSchema", + "noScheme": "No scheme defined in the URI", + "noSchemeData": "The scheme of the uri is not defined as 'data'", + "noJsonSchemaMimeType": "The media types doesn't include the mandatory `application/schema+json`", + "noBase64MediaType": "The media types doesn't include the mandatory `base64`", + "noBase64Data": "The data is not properly encoded as a `base64` string", + "noJSON": "The data is not properly encoded as a `JSON` object", + "ajvValidationFails": "Internal validation error", + "ajvNoProperties": "Not a valid JSONSchema: `properties` is missing" + } }, "toast": { "description_loading": "Processing the request", @@ -1043,6 +1072,11 @@ "description_success": "The Topic Filter has been deleted from the Edge", "description_error": "The Topic Filter could not be deleted" }, + "update": { + "title": "Update Topic Filter", + "description_success": "The Topic Filter has been updated on the Edge", + "description_error": "The Topic Filter could not be updated" + }, "updateCollection": { "title": "Edit the Topic Filters", "description_success": "The Topic Filters have been modified on the Edge", diff --git a/hivemq-edge/src/frontend/src/modules/TopicFilters/TopicFilterManager.spec.cy.tsx b/hivemq-edge/src/frontend/src/modules/TopicFilters/TopicFilterManager.spec.cy.tsx index f9cd58e04e..a0a10e07e2 100644 --- a/hivemq-edge/src/frontend/src/modules/TopicFilters/TopicFilterManager.spec.cy.tsx +++ b/hivemq-edge/src/frontend/src/modules/TopicFilters/TopicFilterManager.spec.cy.tsx @@ -1,6 +1,5 @@ import TopicFilterManager from '@/modules/TopicFilters/TopicFilterManager.tsx' -import { MOCK_TOPIC_FILTER } from '@/api/hooks/useTopicFilters/__handlers__' -import { GENERATE_DATA_MODELS } from '@/api/hooks/useDomainModel/__handlers__' +import { MOCK_TOPIC_FILTER, MOCK_TOPIC_FILTER_SCHEMA_INVALID } from '@/api/hooks/useTopicFilters/__handlers__' describe('TopicFilterManager', () => { beforeEach(() => { @@ -11,18 +10,10 @@ describe('TopicFilterManager', () => { { topicFilter: 'another/filter', description: 'This is a topic filter', + schema: MOCK_TOPIC_FILTER_SCHEMA_INVALID, }, ], }).as('getTopicFilters') - - cy.intercept('/api/v1/management/sampling/schema/**', (req) => { - if (req.url.includes(btoa('another/filter'))) req.reply({ statusCode: 400 }) - else req.reply(GENERATE_DATA_MODELS(true, req.query.topics as string)) - }) - - cy.intercept('/api/v1/management/sampling/topic/**', (req) => { - req.reply({ items: [] }) - }) }) // TODO[NVL] There is a problem with re-rendering in Cypress. Redesign the component @@ -45,7 +36,7 @@ describe('TopicFilterManager', () => { cy.get('@body').find('td').eq(0).should('have.text', 'a / topic / + / filter') cy.get('@body').find('td').eq(1).should('have.text', 'This is a topic filter') cy.get('@body').find('td').eq(2).children().should('have.attr', 'data-status', 'success') - cy.get('@body').find('td').eq(6).children().should('have.attr', 'data-status', 'success') + cy.get('@body').find('td').eq(6).children().should('have.attr', 'data-status', 'error') cy.get('@body').find('td').eq(3).find('button').as('topicActions') diff --git a/hivemq-edge/src/frontend/src/modules/TopicFilters/TopicFilterManager.tsx b/hivemq-edge/src/frontend/src/modules/TopicFilters/TopicFilterManager.tsx index 53ca3e4df8..82f9224ace 100644 --- a/hivemq-edge/src/frontend/src/modules/TopicFilters/TopicFilterManager.tsx +++ b/hivemq-edge/src/frontend/src/modules/TopicFilters/TopicFilterManager.tsx @@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom' import { useTranslation } from 'react-i18next' import { ColumnDef } from '@tanstack/react-table' import { Button, ButtonGroup, Card, CardBody, Text, useDisclosure } from '@chakra-ui/react' -import { LuPencil, LuPlus, LuTrash, LuView } from 'react-icons/lu' +import { LuClipboardEdit, LuPencil, LuTrash, LuView } from 'react-icons/lu' import { TopicFilter, TopicFilterList } from '@/api/__generated__' import { useTopicFilterManager } from '@/modules/TopicFilters/hooks/useTopicFilterManager.ts' @@ -100,7 +100,7 @@ const TopicFilterManager: FC = () => { }} trigger={({ onOpen: onOpenArrayDrawer }) => ( - diff --git a/hivemq-edge/src/frontend/src/modules/TopicFilters/components/SchemaManager.spec.cy.tsx b/hivemq-edge/src/frontend/src/modules/TopicFilters/components/SchemaSampler.spec.cy.tsx similarity index 67% rename from hivemq-edge/src/frontend/src/modules/TopicFilters/components/SchemaManager.spec.cy.tsx rename to hivemq-edge/src/frontend/src/modules/TopicFilters/components/SchemaSampler.spec.cy.tsx index 5368837b26..1b4491f685 100644 --- a/hivemq-edge/src/frontend/src/modules/TopicFilters/components/SchemaManager.spec.cy.tsx +++ b/hivemq-edge/src/frontend/src/modules/TopicFilters/components/SchemaSampler.spec.cy.tsx @@ -1,13 +1,10 @@ import { MOCK_TOPIC_FILTER } from '@/api/hooks/useTopicFilters/__handlers__' import { GENERATE_DATA_MODELS } from '@/api/hooks/useDomainModel/__handlers__' -import SchemaManager from '@/modules/TopicFilters/components/SchemaManager.tsx' +import SchemaSampler from '@/modules/TopicFilters/components/SchemaSampler.tsx' -describe('SchemaManager', () => { +describe('SchemaSampler', () => { beforeEach(() => { cy.viewport(800, 800) - // cy.intercept('/api/v1/management/domain/topics/schema?*', (req) => { - // req.reply(GENERATE_DATA_MODELS(true, req.query.topics as string)) - // }) }) it.skip('should render loading errors', () => { @@ -15,7 +12,7 @@ describe('SchemaManager', () => { statusCode: 404, body: { title: 'The schema for the tags cannot be found', status: 404 }, }) - cy.mountWithProviders() + cy.mountWithProviders() cy.getByTestId('loading-spinner') cy.get('[role="alert"]').should('have.attr', 'data-status', 'error').should('have.text', 'Not Found') @@ -24,7 +21,7 @@ describe('SchemaManager', () => { it.skip('should render validation errors', () => { cy.intercept('/api/v1/management/sampling/topic/**', { items: [] }) cy.intercept('/api/v1/management/sampling/schema/**', {}).as('getSchema') - cy.mountWithProviders() + cy.mountWithProviders() cy.getByTestId('loading-spinner') cy.get('[role="alert"]') @@ -37,10 +34,15 @@ describe('SchemaManager', () => { cy.intercept('/api/v1/management/sampling/schema/**', GENERATE_DATA_MODELS(true, MOCK_TOPIC_FILTER.topicFilter)).as( 'getSchema' ) - cy.mountWithProviders() + const onUpload = cy.stub().as('onUpload') + cy.mountWithProviders() cy.getByTestId('loading-spinner') cy.get('h4').should('contain.text', 'a/topic/+/filter') cy.get('[role="list"]').should('be.visible') + + cy.get('@onUpload').should('not.have.been.called') + cy.getByTestId('schema-sampler-upload').should('have.text', 'Assign schema').click() + cy.get('@onUpload').should('have.been.calledWithMatch', 'data:application/json;base64,') }) }) diff --git a/hivemq-edge/src/frontend/src/modules/TopicFilters/components/SchemaManager.tsx b/hivemq-edge/src/frontend/src/modules/TopicFilters/components/SchemaSampler.tsx similarity index 58% rename from hivemq-edge/src/frontend/src/modules/TopicFilters/components/SchemaManager.tsx rename to hivemq-edge/src/frontend/src/modules/TopicFilters/components/SchemaSampler.tsx index 4bfd194f4c..9128f9d156 100644 --- a/hivemq-edge/src/frontend/src/modules/TopicFilters/components/SchemaManager.tsx +++ b/hivemq-edge/src/frontend/src/modules/TopicFilters/components/SchemaSampler.tsx @@ -1,18 +1,21 @@ import { FC, useMemo } from 'react' import { useTranslation } from 'react-i18next' import type { JSONSchema7 } from 'json-schema' +import { Button, Card, CardBody, CardFooter } from '@chakra-ui/react' import { TopicFilter } from '@/api/__generated__' import { useSamplingForTopic } from '@/api/hooks/useDomainModel/useSamplingForTopic.ts' import LoaderSpinner from '@/components/Chakra/LoaderSpinner.tsx' import ErrorMessage from '@/components/ErrorMessage.tsx' import JsonSchemaBrowser from '@/components/rjsf/MqttTransformation/JsonSchemaBrowser.tsx' +import { encodeDataUriJsonSchema } from '@/modules/TopicFilters/utils/topic-filter.schema.ts' interface SchemaManagerProps { topicFilter: TopicFilter + onUpload: (s: string) => void } -const SchemaManager: FC = ({ topicFilter }) => { +const SchemaSampler: FC = ({ topicFilter, onUpload }) => { const { t } = useTranslation() const { schema, isLoading, isError, error } = useSamplingForTopic(topicFilter.topicFilter) @@ -24,7 +27,21 @@ const SchemaManager: FC = ({ topicFilter }) => { if (isError && error) return if (!isSchemaValid) return - return + return ( + + + + + + + + + ) } -export default SchemaManager +export default SchemaSampler diff --git a/hivemq-edge/src/frontend/src/modules/TopicFilters/components/SchemaUploader.spec.cy.tsx b/hivemq-edge/src/frontend/src/modules/TopicFilters/components/SchemaUploader.spec.cy.tsx new file mode 100644 index 0000000000..9c0237b409 --- /dev/null +++ b/hivemq-edge/src/frontend/src/modules/TopicFilters/components/SchemaUploader.spec.cy.tsx @@ -0,0 +1,16 @@ +import SchemaUploader from '@/modules/TopicFilters/components/SchemaUploader.tsx' + +describe('SchemaUploader', () => { + beforeEach(() => { + cy.viewport(800, 800) + }) + + it('should render properly', () => { + const onUpload = cy.stub().as('onUpload') + cy.mountWithProviders() + + cy.get('#dropzone').as('dropzone') + cy.get('#dropzone p').should('have.text', 'Upload a JSON-Schema file') + cy.get('#dropzone button').should('have.text', 'Select file') + }) +}) diff --git a/hivemq-edge/src/frontend/src/modules/TopicFilters/components/SchemaUploader.tsx b/hivemq-edge/src/frontend/src/modules/TopicFilters/components/SchemaUploader.tsx new file mode 100644 index 0000000000..bcfe7341fa --- /dev/null +++ b/hivemq-edge/src/frontend/src/modules/TopicFilters/components/SchemaUploader.tsx @@ -0,0 +1,76 @@ +import { FC, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useDropzone } from 'react-dropzone' +import { AlertStatus, Button, Card, CardBody, Text, useToast } from '@chakra-ui/react' + +import { DEFAULT_TOAST_OPTION } from '@/hooks/useEdgeToast/toast-utils.ts' +import { getDropZoneBorder } from '@/modules/Theme/utils.ts' +import { ACCEPT_JSON_SCHEMA } from '@/modules/TopicFilters/utils/topic-filter.schema.ts' + +interface SchemaUploaderProps { + onUpload: (s: string) => void +} + +const SchemaUploader: FC = ({ onUpload }) => { + const [loading, setLoading] = useState(false) + const { t } = useTranslation() + const toast = useToast() + + const { getRootProps, getInputProps, isDragActive, open } = useDropzone({ + noClick: true, + noKeyboard: true, + maxFiles: 1, + accept: ACCEPT_JSON_SCHEMA, + onDropRejected: (fileRejections) => { + const status: AlertStatus = 'error' + setLoading(false) + fileRejections.forEach((fileRejection) => { + toast({ + ...DEFAULT_TOAST_OPTION, + status, + title: t('rjsf.batchUpload.dropZone.status', { + ns: 'components', + context: status, + fileName: fileRejection.file.name, + }), + description: fileRejection.errors[0].message, + }) + }) + }, + onDropAccepted: async (files) => { + const [file] = files + const reader = new FileReader() + reader.readAsDataURL(file) + reader.onload = () => { + if (typeof reader.result === 'string') onUpload(reader.result as string) + } + }, + }) + + return ( + + + + {isDragActive && {t('rjsf.batchUpload.dropZone.dropping', { ns: 'components' })}} + {loading && {t('rjsf.batchUpload.dropZone.loading', { ns: 'components' })}} + {!isDragActive && !loading && ( + <> + {t('topicFilter.schema.actions.upload')} + + + )} + + + ) +} + +export default SchemaUploader diff --git a/hivemq-edge/src/frontend/src/modules/TopicFilters/components/SchemaValidationMark.spec.cy.tsx b/hivemq-edge/src/frontend/src/modules/TopicFilters/components/SchemaValidationMark.spec.cy.tsx index f92ea8e192..071bdf8796 100644 --- a/hivemq-edge/src/frontend/src/modules/TopicFilters/components/SchemaValidationMark.spec.cy.tsx +++ b/hivemq-edge/src/frontend/src/modules/TopicFilters/components/SchemaValidationMark.spec.cy.tsx @@ -1,21 +1,14 @@ import { MOCK_TOPIC_FILTER } from '@/api/hooks/useTopicFilters/__handlers__' -import { GENERATE_DATA_MODELS } from '@/api/hooks/useDomainModel/__handlers__' import SchemaValidationMark from '@/modules/TopicFilters/components/SchemaValidationMark.tsx' describe('SchemaValidationMark', () => { beforeEach(() => { cy.viewport(800, 800) - cy.intercept('/api/v1/management/sampling/topic/**', { items: [] }).as('getTopic') - - cy.intercept('/api/v1/management/sampling/schema/**', GENERATE_DATA_MODELS(true, MOCK_TOPIC_FILTER.topicFilter)).as( - 'getSchema' - ) }) it('should render properly', () => { cy.mountWithProviders() - cy.getByTestId('validation-loading').should('be.visible') cy.get('[role="alert"]').should('have.attr', 'data-status', 'success') }) }) diff --git a/hivemq-edge/src/frontend/src/modules/TopicFilters/components/SchemaValidationMark.tsx b/hivemq-edge/src/frontend/src/modules/TopicFilters/components/SchemaValidationMark.tsx index 3bdc6cb69c..45bc9886f1 100644 --- a/hivemq-edge/src/frontend/src/modules/TopicFilters/components/SchemaValidationMark.tsx +++ b/hivemq-edge/src/frontend/src/modules/TopicFilters/components/SchemaValidationMark.tsx @@ -1,23 +1,21 @@ import { FC, useMemo } from 'react' -import { Alert, AlertIcon, Spinner } from '@chakra-ui/react' +import { Alert, AlertIcon } from '@chakra-ui/react' import { TopicFilter } from '@/api/__generated__' -import { useSamplingForTopic } from '@/api/hooks/useDomainModel/useSamplingForTopic.ts' +import { SchemaHandler, validateSchemaFromDataURI } from '@/modules/TopicFilters/utils/topic-filter.schema.ts' interface SchemaValidationMarkProps { topicFilter: TopicFilter } const SchemaValidationMark: FC = ({ topicFilter }) => { - const { schema, isLoading } = useSamplingForTopic(topicFilter.topicFilter) - - const isSchemaValid = useMemo(() => { - return schema && Object.keys(schema).length !== 0 && schema.constructor === Object - }, [schema]) + const schemaHandler = useMemo( + () => validateSchemaFromDataURI(topicFilter.schema), + [topicFilter.schema] + ) - if (isLoading) return return ( - + ) diff --git a/hivemq-edge/src/frontend/src/modules/TopicFilters/components/TopicSchemaDrawer.tsx b/hivemq-edge/src/frontend/src/modules/TopicFilters/components/TopicSchemaDrawer.tsx index 3cd46e9dc6..19927f470f 100644 --- a/hivemq-edge/src/frontend/src/modules/TopicFilters/components/TopicSchemaDrawer.tsx +++ b/hivemq-edge/src/frontend/src/modules/TopicFilters/components/TopicSchemaDrawer.tsx @@ -19,7 +19,7 @@ import { import { TopicFilter } from '@/api/__generated__' import { Topic } from '@/components/MQTT/EntityTag.tsx' -import SchemaManager from '@/modules/TopicFilters/components/SchemaManager.tsx' +import TopicSchemaManager from '@/modules/TopicFilters/components/TopicSchemaManager.tsx' interface TopicSchemaDrawerProps { topicFilter: TopicFilter @@ -39,7 +39,7 @@ const TopicSchemaDrawer: FC = ({ topicFilter, trigger }) return ( <> {trigger(props)} - + @@ -48,12 +48,13 @@ const TopicSchemaDrawer: FC = ({ topicFilter, trigger }) - + + {t('topicFilter.schema.title')}{' '} - + diff --git a/hivemq-edge/src/frontend/src/modules/TopicFilters/components/TopicSchemaManager.tsx b/hivemq-edge/src/frontend/src/modules/TopicFilters/components/TopicSchemaManager.tsx new file mode 100644 index 0000000000..fc17c77f8c --- /dev/null +++ b/hivemq-edge/src/frontend/src/modules/TopicFilters/components/TopicSchemaManager.tsx @@ -0,0 +1,93 @@ +import { FC, useMemo } from 'react' +import { + Button, + Card, + CardBody, + CardFooter, + CardHeader, + Tab, + TabList, + TabPanel, + TabPanels, + Tabs, + Text, + VStack, +} from '@chakra-ui/react' + +import { TopicFilter } from '@/api/__generated__' + +import JsonSchemaBrowser from '@/components/rjsf/MqttTransformation/JsonSchemaBrowser.tsx' +import ErrorMessage from '@/components/ErrorMessage.tsx' +import SchemaUploader from '@/modules/TopicFilters/components/SchemaUploader.tsx' +import SchemaSampler from '@/modules/TopicFilters/components/SchemaSampler.tsx' +import { useTopicFilterManager } from '@/modules/TopicFilters/hooks/useTopicFilterManager.ts' +import { SchemaHandler, validateSchemaFromDataURI } from '@/modules/TopicFilters/utils/topic-filter.schema.ts' +import { useTranslation } from 'react-i18next' + +interface CurrentSchemaProps { + topicFilter: TopicFilter +} + +const TopicSchemaManager: FC = ({ topicFilter }) => { + const { t } = useTranslation() + const schemaHandler = useMemo( + () => validateSchemaFromDataURI(topicFilter.schema), + [topicFilter.schema] + ) + const { onUpdate } = useTopicFilterManager() + + const onHandleUpload = (dataUri: string) => { + onUpdate(topicFilter.topicFilter, { ...topicFilter, schema: dataUri }) + } + + const onHandleClear = () => { + onUpdate(topicFilter.topicFilter, { ...topicFilter, schema: undefined }) + } + + return ( + + + + + + {t('topicFilter.schema.prompt')} + + + + + {t('topicFilter.schema.tabs.current')} + {t('topicFilter.schema.tabs.infer')} + {t('topicFilter.schema.tabs.upload')} + + + + + + + {schemaHandler.error && ( + + )} + {schemaHandler.schema && } + + + + + + + + + + + + + + + + + + ) +} + +export default TopicSchemaManager diff --git a/hivemq-edge/src/frontend/src/modules/TopicFilters/hooks/useTopicFilterManager.spec.ts b/hivemq-edge/src/frontend/src/modules/TopicFilters/hooks/useTopicFilterManager.spec.ts index 87d3e7b967..9b27e86c4e 100644 --- a/hivemq-edge/src/frontend/src/modules/TopicFilters/hooks/useTopicFilterManager.spec.ts +++ b/hivemq-edge/src/frontend/src/modules/TopicFilters/hooks/useTopicFilterManager.spec.ts @@ -4,7 +4,7 @@ import { renderHook, waitFor } from '@testing-library/react' import { server } from '@/__test-utils__/msw/mockServer.ts' import { SimpleWrapper as wrapper } from '@/__test-utils__/hooks/SimpleWrapper.tsx' import { useTopicFilterManager } from '@/modules/TopicFilters/hooks/useTopicFilterManager.ts' -import { handlers } from '@/api/hooks/useTopicFilters/__handlers__' +import { handlers, MOCK_TOPIC_FILTER_SCHEMA_VALID } from '@/api/hooks/useTopicFilters/__handlers__' import '@/config/i18n.config.ts' @@ -27,6 +27,7 @@ describe('useTopicFilterManager', () => { { description: 'This is a topic filter', topicFilter: 'a/topic/+/filter', + schema: MOCK_TOPIC_FILTER_SCHEMA_VALID, }, ], }, diff --git a/hivemq-edge/src/frontend/src/modules/TopicFilters/hooks/useTopicFilterManager.ts b/hivemq-edge/src/frontend/src/modules/TopicFilters/hooks/useTopicFilterManager.ts index 23b616540e..827d973975 100644 --- a/hivemq-edge/src/frontend/src/modules/TopicFilters/hooks/useTopicFilterManager.ts +++ b/hivemq-edge/src/frontend/src/modules/TopicFilters/hooks/useTopicFilterManager.ts @@ -56,15 +56,15 @@ export const useTopicFilterManager = () => { // TODO[NVL] Insert Edge-wide toast configuration (need refactoring) const formatToast = (operation: string) => ({ success: { - title: t(`device.drawer.tagList.toast.${operation}.title`), + title: t(`topicFilter.toast.${operation}.title`), description: t(`topicFilter.toast.${operation}.description`, { context: 'success' }), }, error: { - title: t(`device.drawer.tagList.toast.${operation}.title`), + title: t(`topicFilter.toast.${operation}.title`), description: t(`topicFilter.toast.${operation}.description`, { context: 'error' }), }, loading: { - title: t(`device.drawer.tagList.toast.${operation}.title`), + title: t(`topicFilter.toast.${operation}.title`), description: t('topicFilter.toast.description', { context: 'loading' }), }, }) diff --git a/hivemq-edge/src/frontend/src/modules/TopicFilters/utils/topic-filter.schema.spec.ts b/hivemq-edge/src/frontend/src/modules/TopicFilters/utils/topic-filter.schema.spec.ts new file mode 100644 index 0000000000..a5328c80c8 --- /dev/null +++ b/hivemq-edge/src/frontend/src/modules/TopicFilters/utils/topic-filter.schema.spec.ts @@ -0,0 +1,59 @@ +import { describe, expect } from 'vitest' +import { + decodeDataUriJsonSchema, + encodeDataUriJsonSchema, + SchemaHandler, + validateSchemaFromDataURI, +} from '@/modules/TopicFilters/utils/topic-filter.schema.ts' +import { MOCK_TOPIC_FILTER_SCHEMA_VALID } from '@/api/hooks/useTopicFilters/__handlers__' + +interface Suite { + data: string + error: string +} + +describe('decodeDataUriJsonSchema', () => { + const tests: Suite[] = [ + { data: '', error: 'Not a valid data-url encoded JSONSchema' }, + { data: '123', error: 'Not a valid data-url encoded JSONSchema' }, + { data: '123,456', error: 'No scheme defined in the URI' }, + { data: '123:test,456', error: "The scheme of the uri is not defined as 'data'" }, + { data: 'data:test,456', error: "The media types doesn't include the mandatory `application/schema+json`" }, + { data: 'data:application/json,456', error: "The media types doesn't include the mandatory `base64`" }, + { data: 'data:application/json;base64,456', error: 'The data is not properly encoded as a `base64` string' }, + ] + + it.each(tests)('$data should throw $error', ({ data, error }) => { + expect(() => decodeDataUriJsonSchema(data)).toThrowError(error) + }) + + it('should return a valid json object', () => { + expect(decodeDataUriJsonSchema(MOCK_TOPIC_FILTER_SCHEMA_VALID)).toBeFalsy + }) +}) + +describe('encodeDataUriJsonSchema', () => { + it('should return a data-url', () => { + const schema = { test: 1 } + expect(encodeDataUriJsonSchema(schema)).toStrictEqual( + `data:application/json;base64,${btoa(JSON.stringify(schema))}` + ) + }) +}) + +describe('validateSchemaFromDataURI', () => { + it('should return a warning if not assigned', () => { + expect(validateSchemaFromDataURI(undefined)).toStrictEqual({ + status: 'warning', + message: expect.stringContaining(''), + }) + }) + + it('should return a schema', () => { + expect(validateSchemaFromDataURI(MOCK_TOPIC_FILTER_SCHEMA_VALID)).toStrictEqual({ + message: expect.stringContaining(''), + status: 'success', + schema: expect.objectContaining({}), + }) + }) +}) diff --git a/hivemq-edge/src/frontend/src/modules/TopicFilters/utils/topic-filter.schema.ts b/hivemq-edge/src/frontend/src/modules/TopicFilters/utils/topic-filter.schema.ts new file mode 100644 index 0000000000..cc0b256acc --- /dev/null +++ b/hivemq-edge/src/frontend/src/modules/TopicFilters/utils/topic-filter.schema.ts @@ -0,0 +1,97 @@ +// data:content/type;base64, +import { RJSFSchema } from '@rjsf/utils' +import { Accept } from 'react-dropzone' +import validator from '@rjsf/validator-ajv8' + +import i18n from '@/config/i18n.config.ts' +import type { JSONSchema7 } from 'json-schema' +import type { AlertStatus } from '@chakra-ui/react' + +export const MIMETYPE_JSON = 'application/json' +export const MIMETYPE_JSON_SCHEMA = 'application/schema+json' +export const ACCEPT_JSON_SCHEMA: Accept = { + [MIMETYPE_JSON_SCHEMA]: ['.json'], +} +const DECODE_HEADER_SEPARATOR = ',' +const DECODE_SCHEME_SEPARATOR = ':' +const DECODE_MEDIA_TYPES_SEPARATOR = ';' +const DECODE_DATA = 'data' +const DECODE_BASE64 = 'base64' + +export interface UriInfo { + mimeType: string + options?: string[] + body: RJSFSchema +} + +export const decodeDataUriJsonSchema = (dataUrl: string) => { + const [header, data] = dataUrl.split(DECODE_HEADER_SEPARATOR) + if (!data || !header) throw new Error(i18n.t('topicFilter.error.schema.noDataUri')) + + const [scheme, mediaTypes] = header.split(DECODE_SCHEME_SEPARATOR) + if (!mediaTypes) throw new Error(i18n.t('topicFilter.error.schema.noScheme')) + if (scheme !== DECODE_DATA) throw new Error(i18n.t('topicFilter.error.schema.noSchemeData')) + + const options = mediaTypes.split(DECODE_MEDIA_TYPES_SEPARATOR) + if (!options.includes(MIMETYPE_JSON)) throw new Error(i18n.t('topicFilter.error.schema.noJsonSchemaMimeType')) + if (!options.includes(DECODE_BASE64)) throw new Error(i18n.t('topicFilter.error.schema.noBase64MediaType')) + + try { + const decoded = atob(data) + const json: RJSFSchema = JSON.parse(decoded) + + // This will take care of some of the basic json error but not of a valid JSONSchema + validator.ajv.compile(json) + + // TODO[NVL] We need to decide what we want to require on the schema + const { properties } = json + if (!properties) throw new Error(i18n.t('topicFilter.error.schema.ajvNoProperties')) + + return { mimeType: MIMETYPE_JSON, options, body: json } as UriInfo + } catch (error) { + if (error instanceof SyntaxError) throw new Error(i18n.t('topicFilter.error.schema.noBase64Data')) + if (error instanceof DOMException) throw new Error(i18n.t('topicFilter.error.schema.noJSON')) + if (error instanceof Error) { + throw new Error(`${error.message}`) + } + } +} + +export const encodeDataUriJsonSchema = (schema: RJSFSchema) => { + return `data:${MIMETYPE_JSON};base64,${btoa(JSON.stringify(schema))}` +} + +export interface SchemaHandler { + schema?: JSONSchema7 + error?: string + status: AlertStatus + message: string +} + +export const validateSchemaFromDataURI = (topicFilterSchema: string | undefined): SchemaHandler => { + if (!topicFilterSchema) + return { + status: 'warning', + message: i18n.t('topicFilter.schema.status.missing'), + } + try { + const schema = decodeDataUriJsonSchema(topicFilterSchema) + if (!schema?.body) + return { + error: 'no body from the base64 payload', + status: 'error', + message: i18n.t('topicFilter.schema.status.internalError'), + } + return { + schema: schema.body, + status: 'success', + message: i18n.t('topicFilter.schema.status.success'), + } + } catch (e) { + return { + error: (e as Error).message, + status: 'error', + message: i18n.t('topicFilter.schema.status.invalid'), + } + } +}