Skip to content

Commit

Permalink
Merge pull request #682
Browse files Browse the repository at this point in the history
* fix(28664): fix the edit button

* feat(28664): add data-uri validation

* feat(28664): redesign the schema manager

* feat(28664): add schema validation

* feat(28664): add schema manager for topic filters

* feat(28664): add uploader for schemas

* feat(28664): update translations

* refactor(28664): refactor validation mark

* fix(28664): fix translations

* test(28664): add mocks

* test(28664): fix tests

* test(28664): add tests

* test(28664): fix tests

* test(28664): add tests

* feat(28664): add remove schema

* feat(28664): add data-url encoding

* fix(28664): validation

* refactor(28664): refactor the sampler and add assign CTA

* refactor(28664): refactor uploader

* fix(28664): fix translations

* refactor(28664): refactor the schema manager

* fix(28664): show examples
  • Loading branch information
vanch3d authored Dec 10, 2024
1 parent 16e4add commit d840087
Show file tree
Hide file tree
Showing 17 changed files with 436 additions and 52 deletions.
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

0 comments on commit d840087

Please sign in to comment.