Skip to content
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
56 changes: 28 additions & 28 deletions Source/ObjectContentEditor/ObjectContentEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -162,9 +162,9 @@ export const ObjectContentEditor = ({
}
}, [validationErrors, editMode, onValidationChange]);

const navigateToProperty = useCallback(
(key: string) => {
setNavigationPath([...navigationPath, key]);
const navigateTo = useCallback(
(segments: string[]) => {
setNavigationPath([...navigationPath, ...segments]);
},
[navigationPath],
);
Expand All @@ -185,28 +185,15 @@ export const ObjectContentEditor = ({
return object;
}

const lastKey = navigationPath[navigationPath.length - 1];
const pathToParent = navigationPath.slice(0, -1);
const value = getValueAtPath(object, navigationPath);
return value !== null && typeof value === 'object' ? value : null;
}, [object, navigationPath]);

const parentValue =
pathToParent.length > 0 ? getValueAtPath(object, pathToParent) : object;

if (
parentValue &&
typeof parentValue === 'object' &&
!Array.isArray(parentValue)
) {
const value = (parentValue as { [k: string]: Json })[lastKey];

if (Array.isArray(value)) {
return value;
} else if (value && typeof value === 'object') {
return value;
}
useEffect(() => {
if (navigationPath.length > 0 && currentData === null) {
setNavigationPath([]);
}

return object;
}, [object, navigationPath, getValueAtPath]);
}, [currentData, navigationPath]);

const currentProperties = useMemo(() => {
const properties = schema.properties || {};
Expand Down Expand Up @@ -447,15 +434,19 @@ export const ObjectContentEditor = ({
);
};

const renderValue = (value: Json, propertyName: string) => {
const renderValue = (
value: Json,
propertyName: string,
pathSegments: string[],
) => {
if (value === null || value === undefined) return '';

if (Array.isArray(value)) {
return (
<button
type='button'
className='cratis:flex cratis:items-center cratis:gap-2 cratis:cursor-pointer'
onClick={() => navigateToProperty(propertyName)}
onClick={() => navigateTo(pathSegments)}
style={{
color: 'var(--cratis-primary-color)',
display: 'flex',
Expand All @@ -481,7 +472,7 @@ export const ObjectContentEditor = ({
<button
type='button'
className='cratis:flex cratis:items-center cratis:gap-2 cratis:cursor-pointer'
onClick={() => navigateToProperty(propertyName)}
onClick={() => navigateTo(pathSegments)}
style={{
color: 'var(--cratis-primary-color)',
display: 'flex',
Expand All @@ -506,6 +497,10 @@ export const ObjectContentEditor = ({
};

const renderTable = () => {
if (currentData === null) {
return null;
}

if (Array.isArray(currentData)) {
if (currentData.length === 0)
return (
Expand Down Expand Up @@ -549,6 +544,7 @@ export const ObjectContentEditor = ({
{renderValue(
(item as Record<string, Json>)[key],
key,
[String(index), key],
)}
</td>
</tr>
Expand All @@ -566,7 +562,9 @@ export const ObjectContentEditor = ({
<tr key={index} style={rowStyle}>
<td style={labelStyle}>[{index}]</td>
<td style={valueStyle}>
{renderValue(item, `[${index}]`)}
{renderValue(item, `[${index}]`, [
String(index),
])}
</td>
</tr>
))}
Expand Down Expand Up @@ -647,7 +645,9 @@ export const ObjectContentEditor = ({
property,
value,
)
: renderValue(value as Json, propertyName)}
: renderValue(value as Json, propertyName, [
propertyName,
])}
</td>
</tr>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

// @vitest-environment jsdom

import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { expect } from 'chai';
import { afterEach, beforeEach, describe, it } from 'vitest';
import { ObjectContentEditor } from '../ObjectContentEditor';
import type { Json } from '../../types/JsonSchema';

const object: Json = {
eventType: 'PurchaseOrderRaised',
causation: [
{
type: 'Command',
properties: { commandType: 'RaisePurchaseOrder' },
},
{
type: 'Reactor',
properties: { reactorType: 'PurchaseOrderReactor' },
},
],
};

const schema = {
type: 'object' as const,
properties: {
eventType: { type: 'string' as const },
causation: {
type: 'array' as const,
items: {
type: 'object' as const,
properties: {
type: { type: 'string' as const },
properties: { type: 'object' as const },
},
},
},
},
};

describe('when navigating into an object within an array', () => {
let container: HTMLDivElement;
let root: Root;

const click = async (selector: string) => {
const element = container.querySelector<HTMLButtonElement>(selector);
if (!element) throw new Error(`Nothing matched '${selector}'.`);
await act(async () => element.click());
};

const labels = () =>
Array.from(container.querySelectorAll('tbody tr td:first-child')).map(
(cell) => cell.textContent,
);

const breadcrumb = () =>
container.querySelector('.cratis-object-navigational-bar')?.textContent ?? '';

beforeEach(async () => {
(
globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement('div');
document.body.append(container);
root = createRoot(container);
await act(async () => {
root.render(<ObjectContentEditor object={object} schema={schema} />);
});
await click('button[aria-label="Open causation, 2 items"]');
});

afterEach(async () => {
await act(async () => root.unmount());
container.remove();
});

it('should show the nested object of the element that was navigated into', async () => {
await click('button[aria-label="Open properties"]');
expect(labels()).to.deep.equal(['commandType']);
});

it('should show the nested object of a later element', async () => {
const buttons = Array.from(
container.querySelectorAll<HTMLButtonElement>(
'button[aria-label="Open properties"]',
),
);
await act(async () => buttons[1].click());
expect(labels()).to.deep.equal(['reactorType']);
});

it('should include the array index in the breadcrumb', async () => {
await click('button[aria-label="Open properties"]');
expect(breadcrumb()).to.contain('causation').and.to.contain('[0]').and.to.contain('properties');
});

it('should navigate back out to the array element', async () => {
await click('button[aria-label="Open properties"]');
await click('button[aria-label="Navigate back"]');
expect(labels()).to.deep.equal(['type', 'properties']);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

import { getValueAtPath } from '../objectHelpers';
import type { Json } from '../../types/JsonSchema';

describe('when addressing an array by property name rather than index', () => {
const data: Json = { causation: [{ properties: { eventType: 'Raised' } }] };
let result: Json | null;

beforeEach(() => {
result = getValueAtPath(data, ['causation', 'properties']);
});

it('should return null rather than falling back to another value', () => {
(result === null).should.be.true;
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

import { getValueAtPath } from '../objectHelpers';
import type { Json } from '../../types/JsonSchema';

describe('when getting a value at an array index that is out of range', () => {
const data: Json = { causation: [{ type: 'Command' }] };
let result: Json | null;

beforeEach(() => {
result = getValueAtPath(data, ['causation', '1']);
});

it('should return null', () => {
(result === null).should.be.true;
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

import { getValueAtPath } from '../objectHelpers';
import type { Json } from '../../types/JsonSchema';

describe('when getting a value inside an array element', () => {
const data: Json = { causation: [{ properties: { eventType: 'Raised' } }] };
let result: Json | null;

beforeEach(() => {
result = getValueAtPath(data, ['causation', '0', 'properties', 'eventType']);
});

it('should return the value reached through the array index', () => {
result.should.equal('Raised');
});
});
12 changes: 10 additions & 2 deletions Source/ObjectContentEditor/objectHelpers.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,29 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

import { isArrayIndexSegment } from '../ObjectNavigationalBar/breadcrumbHelpers';
import { Json } from '../types/JsonSchema';

/**
* Retrieves the value at the specified path within a JSON data structure.
* Object properties are addressed by name and array elements by their
* zero-based index (e.g. `['causation', '0', 'properties']`).
* Returns null if the path cannot be followed.
*/
export function getValueAtPath(data: Json, path: string[]): Json | null {
let current: Json = data;
for (const segment of path) {
if (current === null || current === undefined) return null;
if (typeof current === 'object' && !Array.isArray(current) && current !== null) {
if (Array.isArray(current)) {
if (!isArrayIndexSegment(segment)) return null;
const index = Number(segment);
if (index >= current.length) return null;
current = current[index];
} else if (typeof current === 'object') {
current = (current as { [key: string]: Json })[segment];
} else {
return null;
}
}
return current;
return current ?? null;
}
16 changes: 14 additions & 2 deletions Source/ObjectNavigationalBar/breadcrumbHelpers.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,27 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

/**
* Determines whether a navigation path segment addresses an element in an array
* rather than a property on an object. Array elements are addressed by their
* zero-based index, written as a plain decimal string (e.g. `'0'`).
*/
export function isArrayIndexSegment(segment: string): boolean {
return /^\d+$/.test(segment);
}

/**
* Builds the breadcrumb items for an object navigation bar from a navigation path.
* Always starts with a 'Root' item at index 0.
* Always starts with a 'Root' item at index 0. Array index segments are rendered
* in bracket notation (`'0'` becomes `'[0]'`) so they read as elements rather than
* as properties.
*/
export function buildNavigationBreadcrumbs(navigationPath: string[]): { name: string; index: number }[] {
const items: { name: string; index: number }[] = [{ name: 'Root', index: 0 }];
for (let i = 0; i < navigationPath.length; i++) {
const segment = navigationPath[i];
items.push({
name: navigationPath[i],
name: isArrayIndexSegment(segment) ? `[${segment}]` : segment,
index: i + 1,
});
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

import { buildNavigationBreadcrumbs } from '../breadcrumbHelpers';

describe('when building breadcrumbs with an array index in the navigation path', () => {
let result: { name: string; index: number }[];

beforeEach(() => {
result = buildNavigationBreadcrumbs(['causation', '0', 'properties']);
});

it('should render the index segment in bracket notation', () => {
result[2].name.should.equal('[0]');
});

it('should leave property segments unchanged', () => {
result[1].name.should.equal('causation');
result[3].name.should.equal('properties');
});
});
Loading