Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(28664): Add schema management for the topic filters #682

Merged
merged 22 commits into from
Dec 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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 = [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand All @@ -27,6 +27,7 @@ describe('useListTopicFilters', () => {
{
description: 'This is a topic filter',
topicFilter: 'a/topic/+/filter',
schema: MOCK_TOPIC_FILTER_SCHEMA_VALID,
},
],
})
Expand Down
38 changes: 36 additions & 2 deletions hivemq-edge/src/frontend/src/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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(() => {
Expand All @@ -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
Expand All @@ -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')

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -100,7 +100,7 @@ const TopicFilterManager: FC = () => {
}}
trigger={({ onOpen: onOpenArrayDrawer }) => (
<ButtonGroup isAttached size="sm">
<Button leftIcon={<LuPlus />} onClick={onOpenArrayDrawer}>
<Button leftIcon={<LuClipboardEdit />} onClick={onOpenArrayDrawer}>
{t('topicFilter.listing.action.add.aria-label')}
</Button>
</ButtonGroup>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,21 +1,18 @@
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', () => {
cy.intercept('/api/v1/management/sampling/topic/**', {
statusCode: 404,
body: { title: 'The schema for the tags cannot be found', status: 404 },
})
cy.mountWithProviders(<SchemaManager topicFilter={MOCK_TOPIC_FILTER} />)
cy.mountWithProviders(<SchemaSampler topicFilter={MOCK_TOPIC_FILTER} onUpload={cy.stub()} />)

cy.getByTestId('loading-spinner')
cy.get('[role="alert"]').should('have.attr', 'data-status', 'error').should('have.text', 'Not Found')
Expand All @@ -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(<SchemaManager topicFilter={MOCK_TOPIC_FILTER} />)
cy.mountWithProviders(<SchemaSampler topicFilter={MOCK_TOPIC_FILTER} onUpload={cy.stub()} />)

cy.getByTestId('loading-spinner')
cy.get('[role="alert"]')
Expand All @@ -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(<SchemaManager topicFilter={MOCK_TOPIC_FILTER} />)
const onUpload = cy.stub().as('onUpload')
cy.mountWithProviders(<SchemaSampler topicFilter={MOCK_TOPIC_FILTER} onUpload={onUpload} />)

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,')
})
})
Original file line number Diff line number Diff line change
@@ -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<SchemaManagerProps> = ({ topicFilter }) => {
const SchemaSampler: FC<SchemaManagerProps> = ({ topicFilter, onUpload }) => {
const { t } = useTranslation()
const { schema, isLoading, isError, error } = useSamplingForTopic(topicFilter.topicFilter)

Expand All @@ -24,7 +27,21 @@ const SchemaManager: FC<SchemaManagerProps> = ({ topicFilter }) => {
if (isError && error) return <ErrorMessage message={error.message} />
if (!isSchemaValid) return <ErrorMessage message={t('topicFilter.error.noSchemaSampled')} />

return <JsonSchemaBrowser schema={schema as JSONSchema7} />
return (
<Card>
<CardBody>
<JsonSchemaBrowser schema={schema as JSONSchema7} />
</CardBody>
<CardFooter justifyContent="flex-end">
<Button
data-testid="schema-sampler-upload"
onClick={() => onUpload(encodeDataUriJsonSchema(schema as JSONSchema7))}
>
{t('topicFilter.schema.actions.assign')}
</Button>
</CardFooter>
</Card>
)
}

export default SchemaManager
export default SchemaSampler
Original file line number Diff line number Diff line change
@@ -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(<SchemaUploader onUpload={onUpload} />)

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')
})
})
Original file line number Diff line number Diff line change
@@ -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<SchemaUploaderProps> = ({ 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 (
<Card variant="filled">
<CardBody
{...getRootProps()}
{...getDropZoneBorder('blue.500')}
minHeight="calc(250px - 2rem)"
display="flex"
flexDirection="column"
justifyContent="center"
alignItems="center"
id="dropzone"
>
<input {...getInputProps()} data-testid="schema-dropzone" />
{isDragActive && <Text>{t('rjsf.batchUpload.dropZone.dropping', { ns: 'components' })}</Text>}
{loading && <Text>{t('rjsf.batchUpload.dropZone.loading', { ns: 'components' })}</Text>}
{!isDragActive && !loading && (
<>
<Text>{t('topicFilter.schema.actions.upload')}</Text>
<Button onClick={open}>{t('rjsf.batchUpload.dropZone.selectFile', { ns: 'components' })}</Button>
</>
)}
</CardBody>
</Card>
)
}

export default SchemaUploader
Original file line number Diff line number Diff line change
@@ -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(<SchemaValidationMark topicFilter={MOCK_TOPIC_FILTER} />)

cy.getByTestId('validation-loading').should('be.visible')
cy.get('[role="alert"]').should('have.attr', 'data-status', 'success')
})
})
Original file line number Diff line number Diff line change
@@ -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<SchemaValidationMarkProps> = ({ topicFilter }) => {
const { schema, isLoading } = useSamplingForTopic(topicFilter.topicFilter)

const isSchemaValid = useMemo(() => {
return schema && Object.keys(schema).length !== 0 && schema.constructor === Object
}, [schema])
const schemaHandler = useMemo<SchemaHandler>(
() => validateSchemaFromDataURI(topicFilter.schema),
[topicFilter.schema]
)

if (isLoading) return <Spinner size="xs" data-testid="validation-loading" />
return (
<Alert status={isSchemaValid ? 'success' : 'error'} size="xs" py={1} pl={2}>
<Alert status={schemaHandler.status} size="xs" py={1} pl={2} w={10}>
<AlertIcon />
</Alert>
)
Expand Down
Loading
Loading