diff --git a/src/components/document/ModelNode.spec.ts b/src/components/document/ModelNode.spec.ts
new file mode 100644
index 000000000..0df7844ef
--- /dev/null
+++ b/src/components/document/ModelNode.spec.ts
@@ -0,0 +1,60 @@
+import { describe, it, expect } from 'vitest'
+import { shallowMount } from '@vue/test-utils'
+import ModelNode from './ModelNode.vue'
+import type { SchemaObject } from '@/types'
+
+const data: SchemaObject = {
+ description: "I'm a model's description.",
+ type: 'object',
+ title: 'Todo',
+ example: {
+ id: 1,
+ name: 'Buy milk',
+ completed: true,
+ completed_at: '2021-01-01T00:00:00.000Z',
+ },
+ properties: {
+ id: {
+ type: 'number',
+ minimum: 0,
+ maximum: 9999,
+ description: 'ID of the task',
+ readOnly: true,
+ },
+ name: {
+ type: 'string',
+ minLength: 1,
+ maxLength: 100,
+ description: 'Name of the task',
+ },
+ completed: {
+ type: 'boolean',
+ default: false,
+ description: 'Boolean indicating if the task has been completed or not',
+ },
+ completed_at: {
+ type: 'string',
+ format: 'date-time',
+ description: 'Time when the task was completed',
+ readOnly: true,
+ },
+ },
+ required: ['id', 'name'],
+}
+
+const title = 'Todo'
+
+describe('', () => {
+ it('renders all properties of a model', () => {
+ const wrapper = shallowMount(ModelNode, {
+ props: {
+ data,
+ title,
+ },
+ })
+
+ for (const property in data.properties) {
+ expect(wrapper.findTestId(`model-property-${property}`).exists()).toBe(true)
+ }
+ })
+})
diff --git a/src/components/document/ModelNode.vue b/src/components/document/ModelNode.vue
index e1213650e..3f601dea8 100644
--- a/src/components/document/ModelNode.vue
+++ b/src/components/document/ModelNode.vue
@@ -12,21 +12,25 @@
Allowed values: {{ data.enum }}
-
+
+
+
diff --git a/src/components/document/ModelProperties.vue b/src/components/document/ModelProperties.vue
deleted file mode 100644
index 46171b5b8..000000000
--- a/src/components/document/ModelProperties.vue
+++ /dev/null
@@ -1,108 +0,0 @@
-
-
-
-
- {{ key }}
-
- {{ property.type ?? '' }}
- {{ isValidSchemaObject(property.items) && property.items.type ? `[${property.items.type}]` : '' }}
- {{ property.format ? `(${property.format})` : '' }}
-
-
- required
-
-
-
- {{ property.description }}
-
-
- Example: {{ property.example }}
-
-
- Allowed values: {{ property.enum }}
-
-
-
-
- Allowed values: {{ property.items.enum }}
- Allowed pattern: {{ property.items.pattern }}
-
-
- Max: {{ property.items.maximum }}
- |
- Min: {{ property.items.minimum }}
-
-
- Properties of items in {{ key }}
-
-
-
-
-
- Properties of {{ key }}
-
-
-
-
-
- {{ key }}
-
-
-
-
-
-
-
diff --git a/src/components/document/ModelProperty.spec.ts b/src/components/document/ModelProperty.spec.ts
new file mode 100644
index 000000000..ff6892dbb
--- /dev/null
+++ b/src/components/document/ModelProperty.spec.ts
@@ -0,0 +1,117 @@
+import { describe, it, expect } from 'vitest'
+import { mount } from '@vue/test-utils'
+import ModelProperty from './ModelProperty.vue'
+
+describe('', () => {
+ it('renders all fields of a property', () => {
+ const wrapper = mount(ModelProperty, {
+ props: {
+ property: {
+ type: 'integer',
+ format: 'int32',
+ description: 'sample description',
+ example: 'lorem ipsum',
+ enum: [100, 200, 300],
+ pattern: '^[0-9]{3}$',
+ maximum: 999,
+ minimum: 100,
+ items: {
+ type: 'string',
+ },
+ },
+ propertyName: 'sample-property',
+ requiredFields: ['sample-property', 'property-a', 'property-b'],
+ },
+ })
+
+ const componentList = [
+ 'property-field-description',
+ 'property-field-example',
+ 'property-field-enum',
+ 'property-field-info',
+ 'property-field-pattern',
+ 'property-field-range',
+ ]
+
+ for (const component of componentList) {
+ expect(wrapper.findTestId(component).exists()).toBe(true)
+ }
+ })
+
+ it('renders all fields of a nested property', () => {
+ const wrapper = mount(ModelProperty, {
+ props: {
+ property: {
+ type: 'object',
+ description: 'Period of time data is returned for.',
+ properties: {
+ start: {
+ type: 'string',
+ format: 'date-time',
+ description:
+ "Timestamp specifying the lower bound of the query's time range.",
+ },
+ end: {
+ type: 'string',
+ format: 'date-time',
+ description:
+ "Timestamp specifying the upper bound of the query's time range.",
+ },
+ },
+ },
+ propertyName: 'time-range',
+ requiredFields: ['time_range', 'query_id', 'size', 'offset'],
+ },
+ })
+
+ const componentList = [
+ // top level property
+ 'model-property-time-range',
+ // nested properties
+ 'model-property-start',
+ 'model-property-end',
+ ]
+
+ for (const component of componentList) {
+ expect(wrapper.findTestId(component).exists()).toBe(true)
+ }
+ })
+
+ it('renders all fields of items of an array property', () => {
+ const wrapper = mount(ModelProperty, {
+ props: {
+ property: {
+ type: 'array',
+ description: 'A sample array description',
+ items: {
+ properties: {
+ 'sample-item-1': {
+ type: 'integer',
+ format: 'int32',
+ example: '34',
+ },
+ 'sample-item-2': {
+ type: 'string',
+ example: 'abc',
+ },
+ },
+ required: ['sample-item-1'],
+ },
+ },
+ propertyName: 'sample-property',
+ },
+ })
+
+ const componentList = [
+ // top level property
+ 'model-property-sample-property',
+ // nested item properties
+ 'model-property-sample-item-1',
+ 'model-property-sample-item-2',
+ ]
+
+ for (const component of componentList) {
+ expect(wrapper.findTestId(component).exists()).toBe(true)
+ }
+ })
+})
diff --git a/src/components/document/ModelProperty.vue b/src/components/document/ModelProperty.vue
new file mode 100644
index 000000000..75c946bb2
--- /dev/null
+++ b/src/components/document/ModelProperty.vue
@@ -0,0 +1,141 @@
+
+
+
+
+
+
+ Properties of {{ propertyName }}
+
+
+
+
+
+ {{ propertyName }}
+
+
+
+
+
+
+
diff --git a/src/components/document/property-fields/PropertyDescription.spec.ts b/src/components/document/property-fields/PropertyDescription.spec.ts
new file mode 100644
index 000000000..d1b77feb2
--- /dev/null
+++ b/src/components/document/property-fields/PropertyDescription.spec.ts
@@ -0,0 +1,18 @@
+import { describe, it, expect } from 'vitest'
+import { shallowMount } from '@vue/test-utils'
+import PropertyDescription from './PropertyDescription.vue'
+
+const description = 'sample description'
+
+describe('', () => {
+ it('renders description correctly', () => {
+ const wrapper = shallowMount(PropertyDescription, {
+ props: {
+ description,
+ },
+ })
+
+ expect(wrapper.findTestId('property-field-description').exists()).toBe(true)
+ expect(wrapper.text()).toEqual(description)
+ })
+})
diff --git a/src/components/document/property-fields/PropertyDescription.vue b/src/components/document/property-fields/PropertyDescription.vue
new file mode 100644
index 000000000..0e999f1d8
--- /dev/null
+++ b/src/components/document/property-fields/PropertyDescription.vue
@@ -0,0 +1,14 @@
+
+
+ {{ description }}
+
+
+
+
diff --git a/src/components/document/property-fields/PropertyEnum.spec.ts b/src/components/document/property-fields/PropertyEnum.spec.ts
new file mode 100644
index 000000000..551f98faa
--- /dev/null
+++ b/src/components/document/property-fields/PropertyEnum.spec.ts
@@ -0,0 +1,18 @@
+import { describe, it, expect } from 'vitest'
+import { shallowMount } from '@vue/test-utils'
+import PropertyEnum from './PropertyEnum.vue'
+
+const enumValue = ['dropdown', 'numeric', 'text']
+
+describe('', () => {
+ it('renders enum as valid values correctly', () => {
+ const wrapper = shallowMount(PropertyEnum, {
+ props: {
+ enumValue,
+ },
+ })
+
+ expect(wrapper.findTestId('property-field-enum').exists()).toBe(true)
+ expect(wrapper.text()).toEqual(`Allowed values: ${enumValue.join(', ')}`)
+ })
+})
diff --git a/src/components/document/property-fields/PropertyEnum.vue b/src/components/document/property-fields/PropertyEnum.vue
new file mode 100644
index 000000000..d25cf424e
--- /dev/null
+++ b/src/components/document/property-fields/PropertyEnum.vue
@@ -0,0 +1,17 @@
+
+
+ Allowed values: {{ enumValue?.join(', ') }}
+
+
+
+
diff --git a/src/components/document/property-fields/PropertyExample.spec.ts b/src/components/document/property-fields/PropertyExample.spec.ts
new file mode 100644
index 000000000..de7fe21eb
--- /dev/null
+++ b/src/components/document/property-fields/PropertyExample.spec.ts
@@ -0,0 +1,18 @@
+import { describe, it, expect } from 'vitest'
+import { shallowMount } from '@vue/test-utils'
+import PropertyExample from './PropertyExample.vue'
+
+const example = 'sample example string'
+
+describe('', () => {
+ it('renders', () => {
+ const wrapper = shallowMount(PropertyExample, {
+ props: {
+ example,
+ },
+ })
+
+ expect(wrapper.findTestId('property-field-example').exists()).toBe(true)
+ expect(wrapper.text()).toEqual(`Example: ${example}`)
+ })
+})
diff --git a/src/components/document/property-fields/PropertyExample.vue b/src/components/document/property-fields/PropertyExample.vue
new file mode 100644
index 000000000..4f56b8a6f
--- /dev/null
+++ b/src/components/document/property-fields/PropertyExample.vue
@@ -0,0 +1,15 @@
+
+
+ Example: {{ example }}
+
+
+
+
diff --git a/src/components/document/property-fields/PropertyInfo.spec.ts b/src/components/document/property-fields/PropertyInfo.spec.ts
new file mode 100644
index 000000000..e84b6dbca
--- /dev/null
+++ b/src/components/document/property-fields/PropertyInfo.spec.ts
@@ -0,0 +1,30 @@
+import { describe, it, expect } from 'vitest'
+import { shallowMount } from '@vue/test-utils'
+import PropertyInfo from './PropertyInfo.vue'
+
+describe('', () => {
+ it('correctly renders all data provided as props', () => {
+ const wrapper = shallowMount(PropertyInfo, {
+ props: {
+ title: 'sample-title',
+ propertyType: 'string',
+ format: 'date-time',
+ propertyItemType: 'string',
+ requiredFields: ['sample-title', 'another-property'],
+ },
+ })
+
+ const componentList = [
+ 'property-field-info',
+ 'property-field-title',
+ 'property-field-type',
+ 'property-field-item-type',
+ 'property-field-format',
+ 'property-field-required',
+ ]
+
+ for (const component of componentList) {
+ expect(wrapper.findTestId(component).exists()).toBe(true)
+ }
+ })
+})
diff --git a/src/components/document/property-fields/PropertyInfo.vue b/src/components/document/property-fields/PropertyInfo.vue
new file mode 100644
index 000000000..afe553b59
--- /dev/null
+++ b/src/components/document/property-fields/PropertyInfo.vue
@@ -0,0 +1,79 @@
+
+
+ {{ title }}
+
+
+ {{ propertyType }}
+
+
+ {{ `[${propertyItemType}]` }}
+
+
+ {{ `(${format})` }}
+
+
+
+ required
+
+
+
+
+
+
+
diff --git a/src/components/document/property-fields/PropertyPattern.spec.ts b/src/components/document/property-fields/PropertyPattern.spec.ts
new file mode 100644
index 000000000..bf1b548e7
--- /dev/null
+++ b/src/components/document/property-fields/PropertyPattern.spec.ts
@@ -0,0 +1,18 @@
+import { describe, it, expect } from 'vitest'
+import { shallowMount } from '@vue/test-utils'
+import PropertyPattern from './PropertyPattern.vue'
+
+const pattern = '^[0-9]{3}$'
+
+describe('', () => {
+ it('renders enum as valid values correctly', () => {
+ const wrapper = shallowMount(PropertyPattern, {
+ props: {
+ pattern,
+ },
+ })
+
+ expect(wrapper.findTestId('property-field-pattern').exists()).toBe(true)
+ expect(wrapper.text()).toEqual(`Allowed pattern: ${pattern}`)
+ })
+})
diff --git a/src/components/document/property-fields/PropertyPattern.vue b/src/components/document/property-fields/PropertyPattern.vue
new file mode 100644
index 000000000..99aa21ade
--- /dev/null
+++ b/src/components/document/property-fields/PropertyPattern.vue
@@ -0,0 +1,17 @@
+
+
+ Allowed pattern: {{ pattern }}
+
+
+
+
diff --git a/src/components/document/property-fields/PropertyRange.spec.ts b/src/components/document/property-fields/PropertyRange.spec.ts
new file mode 100644
index 000000000..210988ef4
--- /dev/null
+++ b/src/components/document/property-fields/PropertyRange.spec.ts
@@ -0,0 +1,34 @@
+import { describe, it, expect } from 'vitest'
+import { shallowMount } from '@vue/test-utils'
+import PropertyRange from './PropertyRange.vue'
+
+describe('', () => {
+ it('renders with both max and min', () => {
+ const wrapper = shallowMount(PropertyRange, {
+ props: {
+ max: 100,
+ min: 10,
+ },
+ })
+ expect(wrapper.findTestId('property-field-range').exists()).toBe(true)
+ expect(wrapper.text()).toEqual('Max: 100 | Min: 10')
+ })
+ it('renders with only max', () => {
+ const wrapper = shallowMount(PropertyRange, {
+ props: {
+ max: 100,
+ },
+ })
+ expect(wrapper.findTestId('property-field-range').exists()).toBe(true)
+ expect(wrapper.text()).toEqual('Max: 100')
+ })
+ it('renders with both max and min', () => {
+ const wrapper = shallowMount(PropertyRange, {
+ props: {
+ min: 10,
+ },
+ })
+ expect(wrapper.findTestId('property-field-range').exists()).toBe(true)
+ expect(wrapper.text()).toEqual('Min: 10')
+ })
+})
diff --git a/src/components/document/property-fields/PropertyRange.vue b/src/components/document/property-fields/PropertyRange.vue
new file mode 100644
index 000000000..efdcfdca7
--- /dev/null
+++ b/src/components/document/property-fields/PropertyRange.vue
@@ -0,0 +1,23 @@
+
+
+ Max: {{ max }}
+ |
+ Min: {{ min }}
+
+
+
+
diff --git a/src/utils/index.ts b/src/utils/index.ts
index af73f1a24..094b93312 100644
--- a/src/utils/index.ts
+++ b/src/utils/index.ts
@@ -1,3 +1,4 @@
// Only export external utilities
export * from './resolve-refs'
export * from './schema-parser'
+export * from './schema-model'
diff --git a/src/utils/schema-model.spec.ts b/src/utils/schema-model.spec.ts
new file mode 100644
index 000000000..ef3c27129
--- /dev/null
+++ b/src/utils/schema-model.spec.ts
@@ -0,0 +1,188 @@
+import { describe, it, expect } from 'vitest'
+import { isNestedObj, isValidSchemaObject, orderedFieldList, schemaObjectProperties } from './schema-model'
+import type { ReferenceObject, SchemaObject } from '@/types'
+
+describe('isNestedObj', () => {
+ it('returns true for property that is a nested object', () => {
+ const nestedProperty: SchemaObject = {
+ type: 'object',
+ properties: {
+ name: {
+ type: 'string',
+ },
+ },
+ }
+ expect(isNestedObj(nestedProperty)).toBe(true)
+ })
+
+ it('returns false for invalid properties', () => {
+ const invalidPropertyList: Array = [
+ {
+ type: 'string',
+ },
+ {
+ type: 'object',
+ properties: {},
+ },
+ {
+ type: 'object',
+ },
+ ]
+ for (const property of invalidPropertyList) {
+ expect(isNestedObj(property)).toBe(false)
+ }
+ })
+})
+
+describe('isValidSchemaObject', () => {
+ it('returns true for valid properties', () => {
+ const validPropertyList: Array = [
+ {
+ type: 'object',
+ properties: {
+ name: {
+ type: 'string',
+ },
+ },
+ },
+ {
+ type: 'object',
+ properties: {
+ name: {
+ type: 'string',
+ },
+ },
+ required: ['name'],
+ },
+ {},
+ ]
+ for (const property of validPropertyList) {
+ expect(isValidSchemaObject(property)).toBe(true)
+ }
+ })
+ it('returns false for invalid properties', () => {
+ const invalidPropertyList: Array = [
+ {
+ type: 'object',
+ $ref: '#/components/schemas/Pet',
+ },
+ {
+ type: 'object',
+ properties: {
+ name: {
+ type: 'string',
+ },
+ },
+ $ref: '#/components/schemas/Store',
+ },
+ ]
+ for (const property of invalidPropertyList) {
+ expect(isValidSchemaObject(property)).toBe(false)
+ }
+ })
+})
+
+describe('schemaObjectProperties', () => {
+ it('returns properties and required fields of a Schema Object', () => {
+ const nestedSchemaObject: SchemaObject = {
+ type: 'object',
+ properties: {
+ name: {
+ type: 'string',
+ },
+ },
+ required: ['name'],
+ }
+ expect(schemaObjectProperties(nestedSchemaObject)?.properties).toEqual(nestedSchemaObject.properties)
+ expect(schemaObjectProperties(nestedSchemaObject)?.required).toEqual(nestedSchemaObject.required)
+ })
+ it('returns properties and required fields of a Schema Object of type array', () => {
+ const itemProperties: Record = {
+ name: {
+ type: 'string',
+ },
+ }
+ const itemRequiredFields = ['name']
+
+ const schemaObject: SchemaObject = {
+ type: 'array',
+ items: {
+ type: 'object',
+ properties: itemProperties,
+ required: itemRequiredFields,
+ },
+ }
+ expect(schemaObjectProperties(schemaObject)?.properties).toEqual(itemProperties)
+ expect(schemaObjectProperties(schemaObject)?.required).toEqual(itemRequiredFields)
+ })
+ it('returns null for invalid Schema Object', () => {
+ const invalidSchemaObjectList: Array = [
+ {
+ type: 'string',
+ },
+ {
+ type: 'object',
+ properties: {},
+ },
+ {
+ type: 'object',
+ },
+ ]
+
+ for (const invalidSchemaObject of invalidSchemaObjectList) {
+ expect(schemaObjectProperties(invalidSchemaObject)).toBe(null)
+ }
+ })
+ it('returns null for invalid Schema Object from array', () => {
+ const invalidSchemaObjectList: Array = [
+ {
+ type: 'string',
+ },
+ {
+ type: 'array',
+ },
+ {
+ type: 'array',
+ items: {
+ type: 'object',
+ properties: {
+ name: {
+ type: 'string',
+ },
+ },
+ $ref: '#/components/schemas/Pet',
+ },
+ },
+ ]
+
+ for (const invalidSchemaObject of invalidSchemaObjectList) {
+ expect(schemaObjectProperties(invalidSchemaObject)).toBe(null)
+ }
+ })
+})
+
+describe('orderedFieldList', () => {
+ it('returns the fields in the correct order', () => {
+ const itemData: SchemaObject = {
+ title: 'sample-title',
+ description: 'sample-description',
+ enum: ['sample-enum'],
+ pattern: '^[0-9]{3}$',
+ maximum: 999,
+ minimum: 100,
+ example: 'lorem ipsum',
+ }
+ expect(orderedFieldList(itemData)).toEqual(['title', 'description', 'enum', 'pattern', 'maximum', 'example'])
+ })
+
+ it('returns the fields in the correct order when title field is not provided', () => {
+ const itemData: SchemaObject = {
+ description: 'sample-description',
+ enum: ['sample-enum'],
+ pattern: '^[0-9]{3}$',
+ minimum: 100,
+ example: 'lorem ipsum',
+ }
+ expect(orderedFieldList(itemData)).toEqual(['description', 'enum', 'pattern', 'maximum', 'example'])
+ })
+})
diff --git a/src/utils/schema-model.ts b/src/utils/schema-model.ts
new file mode 100644
index 000000000..d5804b636
--- /dev/null
+++ b/src/utils/schema-model.ts
@@ -0,0 +1,53 @@
+import type { ReferenceObject, SchemaObject } from '@/types'
+
+export const isNestedObj = (property: SchemaObject) => Boolean(property.type === 'object' && property.properties && Reflect.ownKeys(property.properties).length)
+
+/**
+ * Type guard for verifying object is of type SchemaObject
+ */
+export function isValidSchemaObject(candidate?: SchemaObject | ReferenceObject): candidate is SchemaObject {
+ return Boolean(candidate && !Object.prototype.hasOwnProperty.call(candidate, '$ref'))
+}
+
+export const schemaObjectProperties = (candidate: SchemaObject) => {
+ let computedObj: Partial | null = null
+
+ /**
+ * We have to enumerate over the properties of the Schema Model and render them out via `ModelProperty` component.
+ * For this, we need to compute the properties and required fields of the Schema Model.
+ * If the top level Schema Model is an object, we can directly use the `properties` field of the object.
+ * If it's an array, we need to derive the properties from the `items` field of the Schema Model.
+ */
+ if (isNestedObj(candidate)) {
+ computedObj = { properties: candidate.properties, required: candidate.required }
+ } else if (candidate.type === 'array' && isValidSchemaObject(candidate.items)) {
+ computedObj = { properties: candidate.items.properties, required: candidate.items.required }
+ }
+
+ return computedObj
+}
+
+// We need to fix the order in which the components for these fields are rendered
+export const orderedFieldList = (itemData: SchemaObject, itemName?: string) => {
+ const fields : Array = []
+
+ if (itemData.title || itemName) {
+ fields.push('title')
+ }
+ if (itemData.description) {
+ fields.push('description')
+ }
+ if (itemData.enum) {
+ fields.push('enum')
+ }
+ if (itemData.pattern) {
+ fields.push('pattern')
+ }
+ if (itemData.maximum || itemData.minimum) {
+ fields.push('maximum')
+ }
+ if (itemData.example) {
+ fields.push('example')
+ }
+ return fields
+}
diff --git a/src/utils/schema-parser.ts b/src/utils/schema-parser.ts
index 8344d0b65..e1860b31d 100644
--- a/src/utils/schema-parser.ts
+++ b/src/utils/schema-parser.ts
@@ -1,5 +1,4 @@
import composables from '../composables'
-import type { SchemaObject, ReferenceObject } from '@/types'
const {
parse,
@@ -9,18 +8,10 @@ const {
validationResults,
} = composables.useSchemaParser()
-/**
- * Type guard for verifying object is of type SchemaObject
- */
-function isValidSchemaObject(candidate?: SchemaObject | ReferenceObject): candidate is SchemaObject {
- return Boolean(candidate && !Object.prototype.hasOwnProperty.call(candidate, '$ref'))
-}
-
export {
parse,
parsedDocument,
jsonDocument,
tableOfContents,
validationResults,
- isValidSchemaObject,
}