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 }) => (
- } onClick={onOpenArrayDrawer}>
+ } onClick={onOpenArrayDrawer}>
{t('topicFilter.listing.action.add.aria-label')}
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'),
+ }
+ }
+}