mirror of
https://github.com/CorentinTh/it-tools.git
synced 2025-04-23 08:16:16 -04:00
feat(new-tool): diff of two json objects
This commit is contained in:
parent
61ece2387f
commit
362f2fa280
12 changed files with 751 additions and 134 deletions
119
src/tools/json-diff/diff-viewer/diff-viewer.models.tsx
Normal file
119
src/tools/json-diff/diff-viewer/diff-viewer.models.tsx
Normal file
|
@ -0,0 +1,119 @@
|
|||
import _ from 'lodash';
|
||||
import { useCopy } from '@/composable/copy';
|
||||
import type { Difference, ArrayDifference, ObjectDifference } from '../json-diff.types';
|
||||
|
||||
export const DiffRootViewer = ({ diff }: { diff: Difference }) => {
|
||||
return (
|
||||
<div class={'diffs-viewer'}>
|
||||
<ul>{DiffViewer({ diff, showKeys: false })}</ul>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const DiffViewer = ({ diff, showKeys = true }: { diff: Difference; showKeys?: boolean }) => {
|
||||
const { type, status } = diff;
|
||||
|
||||
if (status === 'updated') {
|
||||
return ComparisonViewer({ diff, showKeys });
|
||||
}
|
||||
|
||||
if (type === 'array') {
|
||||
return ChildrenViewer({ diff, showKeys, showChildrenKeys: false, openTag: '[', closeTag: ']' });
|
||||
}
|
||||
|
||||
if (type === 'object') {
|
||||
return ChildrenViewer({ diff, showKeys, openTag: '{', closeTag: '}' });
|
||||
}
|
||||
|
||||
return LineDiffViewer({ diff, showKeys });
|
||||
};
|
||||
|
||||
const LineDiffViewer = ({ diff, showKeys }: { diff: Difference; showKeys?: boolean }) => {
|
||||
const { value, key, status, oldValue } = diff;
|
||||
const valueToDisplay = status === 'removed' ? oldValue : value;
|
||||
|
||||
return (
|
||||
<li>
|
||||
<span class={[status, 'result']}>
|
||||
{showKeys && (
|
||||
<>
|
||||
<span class={'key'}>{key}</span>
|
||||
{': '}
|
||||
</>
|
||||
)}
|
||||
{Value({ value: valueToDisplay, status })}
|
||||
</span>
|
||||
,
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
const ComparisonViewer = ({ diff, showKeys }: { diff: Difference; showKeys?: boolean }) => {
|
||||
const { value, key, oldValue } = diff;
|
||||
|
||||
return (
|
||||
<li class={'updated-line'}>
|
||||
{showKeys && (
|
||||
<>
|
||||
<span class={'key'}>{key}</span>
|
||||
{': '}
|
||||
</>
|
||||
)}
|
||||
{Value({ value: oldValue, status: 'removed' })}
|
||||
{Value({ value, status: 'added' })},
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
const ChildrenViewer = ({
|
||||
diff,
|
||||
openTag,
|
||||
closeTag,
|
||||
showKeys,
|
||||
showChildrenKeys = true,
|
||||
}: {
|
||||
diff: ArrayDifference | ObjectDifference;
|
||||
showKeys: boolean;
|
||||
showChildrenKeys?: boolean;
|
||||
openTag: string;
|
||||
closeTag: string;
|
||||
}) => {
|
||||
const { children, key, status, type } = diff;
|
||||
|
||||
return (
|
||||
<li>
|
||||
<div class={[type, status]} style={{ display: 'inline-block' }}>
|
||||
{showKeys && (
|
||||
<>
|
||||
<span class={'key'}>{key}</span>
|
||||
{': '}
|
||||
</>
|
||||
)}
|
||||
|
||||
{openTag}
|
||||
{children.length > 0 && <ul>{children.map((diff) => DiffViewer({ diff, showKeys: showChildrenKeys }))}</ul>}
|
||||
{closeTag + ','}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
function formatValue(value: unknown) {
|
||||
if (_.isNull(value)) {
|
||||
return 'null';
|
||||
}
|
||||
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
const Value = ({ value, status }: { value: unknown; status: string }) => {
|
||||
const formatedValue = formatValue(value);
|
||||
|
||||
const { copy } = useCopy({ source: formatedValue });
|
||||
|
||||
return (
|
||||
<span class={['value', status]} onClick={copy}>
|
||||
{formatedValue}
|
||||
</span>
|
||||
);
|
||||
};
|
110
src/tools/json-diff/diff-viewer/diff-viewer.vue
Normal file
110
src/tools/json-diff/diff-viewer/diff-viewer.vue
Normal file
|
@ -0,0 +1,110 @@
|
|||
<template>
|
||||
<div v-if="showResults">
|
||||
<n-space justify="center">
|
||||
<n-form-item label="Only show differences" label-placement="left">
|
||||
<n-switch v-model:value="onlyShowDifferences" />
|
||||
</n-form-item>
|
||||
</n-space>
|
||||
|
||||
<c-card data-test-id="diff-result">
|
||||
<n-text v-if="jsonAreTheSame" depth="3" block text-center italic> The provided JSONs are the same </n-text>
|
||||
<diff-root-viewer v-else :diff="result" />
|
||||
</c-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useAppTheme } from '@/ui/theme/themes';
|
||||
import _ from 'lodash';
|
||||
import { DiffRootViewer } from './diff-viewer.models';
|
||||
import { diff } from '../json-diff.models';
|
||||
|
||||
const onlyShowDifferences = ref(false);
|
||||
const props = defineProps<{ leftJson: unknown; rightJson: unknown }>();
|
||||
const { leftJson, rightJson } = toRefs(props);
|
||||
const appTheme = useAppTheme();
|
||||
|
||||
const result = computed(() =>
|
||||
diff(leftJson.value, rightJson.value, { onlyShowDifferences: onlyShowDifferences.value }),
|
||||
);
|
||||
|
||||
const jsonAreTheSame = computed(() => _.isEqual(leftJson.value, rightJson.value));
|
||||
const showResults = computed(() => !_.isUndefined(leftJson.value) && !_.isUndefined(rightJson.value));
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
::v-deep(.diffs-viewer) {
|
||||
color: v-bind('appTheme.text.mutedColor');
|
||||
|
||||
& > ul {
|
||||
padding-left: 0 !important;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
padding-left: 20px;
|
||||
margin: 0;
|
||||
|
||||
li {
|
||||
.updated-line {
|
||||
padding: 3px 0;
|
||||
}
|
||||
|
||||
.result,
|
||||
.array,
|
||||
.object,
|
||||
.value {
|
||||
&:not(:last-child) {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
&.added {
|
||||
padding: 3px 5px;
|
||||
border-radius: 4px;
|
||||
background-color: v-bind('appTheme.success.colorFaded');
|
||||
color: v-bind('appTheme.success.color');
|
||||
.key {
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
&.removed {
|
||||
padding: 3px 5px;
|
||||
border-radius: 4px;
|
||||
background-color: v-bind('appTheme.error.colorFaded');
|
||||
color: v-bind('appTheme.error.color');
|
||||
|
||||
.key {
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.value {
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
transition: border-color 0.2s ease-in-out;
|
||||
|
||||
&.added:hover {
|
||||
border-color: v-bind('appTheme.success.color');
|
||||
}
|
||||
|
||||
&.removed:hover {
|
||||
border-color: v-bind('appTheme.error.color');
|
||||
}
|
||||
}
|
||||
|
||||
.added .added,
|
||||
.removed .removed {
|
||||
background-color: transparent;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.key {
|
||||
font-weight: 500;
|
||||
color: v-bind('appTheme.text.baseColor');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
12
src/tools/json-diff/index.ts
Normal file
12
src/tools/json-diff/index.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
import { CompareArrowsRound } from '@vicons/material';
|
||||
import { defineTool } from '../tool';
|
||||
|
||||
export const tool = defineTool({
|
||||
name: 'JSON diff',
|
||||
path: '/json-diff',
|
||||
description: 'Compare two JSON objects and get the differences between them.',
|
||||
keywords: ['json', 'diff', 'compare', 'difference', 'object', 'data'],
|
||||
component: () => import('./json-diff.vue'),
|
||||
icon: CompareArrowsRound,
|
||||
createdAt: new Date('2023-04-20'),
|
||||
});
|
39
src/tools/json-diff/json-diff.e2e.spec.ts
Normal file
39
src/tools/json-diff/json-diff.e2e.spec.ts
Normal file
|
@ -0,0 +1,39 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Tool - JSON diff', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/json-diff');
|
||||
});
|
||||
|
||||
test('Has correct title', async ({ page }) => {
|
||||
await expect(page).toHaveTitle('JSON diff - IT Tools');
|
||||
});
|
||||
|
||||
test('Identical JSONs have a custom result message', async ({ page }) => {
|
||||
await page.getByTestId('leftJson').fill('{"foo":"bar"}');
|
||||
await page.getByTestId('rightJson').fill('{ "foo": "bar" } ');
|
||||
|
||||
const result = await page.getByTestId('diff-result').innerText();
|
||||
|
||||
expect(result).toContain('The provided JSONs are the same');
|
||||
});
|
||||
|
||||
test('Different JSONs have differences listed', async ({ page }) => {
|
||||
await page.getByTestId('leftJson').fill('{"foo":"bar"}');
|
||||
await page.getByTestId('rightJson').fill('{"foo":"buz","baz":"qux"}');
|
||||
|
||||
const result = await page.getByTestId('diff-result').innerText();
|
||||
|
||||
expect(result).toContain(`{\nfoo: "bar""buz",\nbaz: "qux",\n},`);
|
||||
});
|
||||
|
||||
test('Different JSONs have only differences listed when "Only show differences" is checked', async ({ page }) => {
|
||||
await page.getByTestId('leftJson').fill('{"foo":"bar"}');
|
||||
await page.getByTestId('rightJson').fill('{"foo":"bar","baz":"qux"}');
|
||||
await page.getByRole('switch').click();
|
||||
|
||||
const result = await page.getByTestId('diff-result').innerText();
|
||||
|
||||
expect(result).toContain(`{\nbaz: "qux",\n},`);
|
||||
});
|
||||
});
|
80
src/tools/json-diff/json-diff.models.test.ts
Normal file
80
src/tools/json-diff/json-diff.models.test.ts
Normal file
|
@ -0,0 +1,80 @@
|
|||
import { expect, describe, it } from 'vitest';
|
||||
import { diff } from './json-diff.models';
|
||||
|
||||
describe('json-diff models', () => {
|
||||
describe('diff', () => {
|
||||
it('list object differences', () => {
|
||||
const obj = { a: 1, b: 2 };
|
||||
const newObj = { a: 1, b: 2, c: 3 };
|
||||
const result = diff(obj, newObj);
|
||||
|
||||
expect(result).toEqual({
|
||||
key: '',
|
||||
type: 'object',
|
||||
children: [
|
||||
{
|
||||
key: 'a',
|
||||
type: 'value',
|
||||
value: 1,
|
||||
oldValue: 1,
|
||||
status: 'unchanged',
|
||||
},
|
||||
{
|
||||
key: 'b',
|
||||
type: 'value',
|
||||
value: 2,
|
||||
oldValue: 2,
|
||||
status: 'unchanged',
|
||||
},
|
||||
{
|
||||
key: 'c',
|
||||
type: 'value',
|
||||
value: 3,
|
||||
oldValue: undefined,
|
||||
status: 'added',
|
||||
},
|
||||
],
|
||||
oldValue: { a: 1, b: 2 },
|
||||
value: { a: 1, b: 2, c: 3 },
|
||||
status: 'children-updated',
|
||||
});
|
||||
});
|
||||
|
||||
it('list array differences', () => {
|
||||
const obj = [1, 2];
|
||||
const newObj = [1, 2, 3];
|
||||
const result = diff(obj, newObj);
|
||||
|
||||
expect(result).toEqual({
|
||||
key: '',
|
||||
type: 'array',
|
||||
children: [
|
||||
{
|
||||
key: 0,
|
||||
type: 'value',
|
||||
value: 1,
|
||||
oldValue: 1,
|
||||
status: 'unchanged',
|
||||
},
|
||||
{
|
||||
key: 1,
|
||||
type: 'value',
|
||||
value: 2,
|
||||
oldValue: 2,
|
||||
status: 'unchanged',
|
||||
},
|
||||
{
|
||||
key: 2,
|
||||
type: 'value',
|
||||
value: 3,
|
||||
oldValue: undefined,
|
||||
status: 'added',
|
||||
},
|
||||
],
|
||||
oldValue: [1, 2],
|
||||
value: [1, 2, 3],
|
||||
status: 'children-updated',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
140
src/tools/json-diff/json-diff.models.ts
Normal file
140
src/tools/json-diff/json-diff.models.ts
Normal file
|
@ -0,0 +1,140 @@
|
|||
import _ from 'lodash';
|
||||
import type { Difference, DifferenceStatus } from './json-diff.types';
|
||||
|
||||
export { diff };
|
||||
|
||||
function diff(
|
||||
obj: unknown,
|
||||
newObj: unknown,
|
||||
{ onlyShowDifferences = false }: { onlyShowDifferences?: boolean } = {},
|
||||
): Difference {
|
||||
if (_.isArray(obj) && _.isArray(newObj)) {
|
||||
return {
|
||||
key: '',
|
||||
type: 'array',
|
||||
children: diffArrays(obj, newObj, { onlyShowDifferences }),
|
||||
oldValue: obj,
|
||||
value: newObj,
|
||||
status: getStatus(obj, newObj),
|
||||
};
|
||||
}
|
||||
|
||||
if (_.isObject(obj) && _.isObject(newObj)) {
|
||||
return {
|
||||
key: '',
|
||||
type: 'object',
|
||||
children: diffObjects(obj as Record<string, unknown>, newObj as Record<string, unknown>, { onlyShowDifferences }),
|
||||
oldValue: obj,
|
||||
value: newObj,
|
||||
status: getStatus(obj, newObj),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
key: '',
|
||||
type: 'value',
|
||||
oldValue: obj,
|
||||
value: newObj,
|
||||
status: getStatus(obj, newObj),
|
||||
};
|
||||
}
|
||||
|
||||
function diffObjects(
|
||||
obj: Record<string, unknown>,
|
||||
newObj: Record<string, unknown>,
|
||||
{ onlyShowDifferences = false }: { onlyShowDifferences?: boolean } = {},
|
||||
): Difference[] {
|
||||
const keys = Object.keys({ ...obj, ...newObj });
|
||||
return keys
|
||||
.map((key) => createDifference(obj?.[key], newObj?.[key], key, { onlyShowDifferences }))
|
||||
.filter((diff) => !onlyShowDifferences || diff.status !== 'unchanged');
|
||||
}
|
||||
|
||||
function createDifference(
|
||||
value: unknown,
|
||||
newValue: unknown,
|
||||
key: string | number,
|
||||
{ onlyShowDifferences = false }: { onlyShowDifferences?: boolean } = {},
|
||||
): Difference {
|
||||
const type = getType(value);
|
||||
|
||||
if (type === 'object') {
|
||||
return {
|
||||
key,
|
||||
type,
|
||||
children: diffObjects(value as Record<string, unknown>, newValue as Record<string, unknown>, {
|
||||
onlyShowDifferences,
|
||||
}),
|
||||
oldValue: value,
|
||||
value: newValue,
|
||||
status: getStatus(value, newValue),
|
||||
};
|
||||
}
|
||||
|
||||
if (type === 'array') {
|
||||
return {
|
||||
key,
|
||||
type,
|
||||
children: diffArrays(value as unknown[], newValue as unknown[], { onlyShowDifferences }),
|
||||
value: newValue,
|
||||
oldValue: value,
|
||||
status: getStatus(value, newValue),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
key,
|
||||
type,
|
||||
value: newValue,
|
||||
oldValue: value,
|
||||
status: getStatus(value, newValue),
|
||||
};
|
||||
}
|
||||
|
||||
function diffArrays(
|
||||
arr: unknown[],
|
||||
newArr: unknown[],
|
||||
{ onlyShowDifferences = false }: { onlyShowDifferences?: boolean } = {},
|
||||
): Difference[] {
|
||||
const maxLength = Math.max(0, arr?.length, newArr?.length);
|
||||
return Array.from({ length: maxLength }, (_, i) =>
|
||||
createDifference(arr?.[i], newArr?.[i], i, { onlyShowDifferences }),
|
||||
).filter((diff) => !onlyShowDifferences || diff.status !== 'unchanged');
|
||||
}
|
||||
|
||||
function getType(value: unknown): 'object' | 'array' | 'value' {
|
||||
if (value === null) {
|
||||
return 'value';
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return 'array';
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
return 'object';
|
||||
}
|
||||
return 'value';
|
||||
}
|
||||
|
||||
function getStatus(value: unknown, newValue: unknown): DifferenceStatus {
|
||||
if (value === undefined) {
|
||||
return 'added';
|
||||
}
|
||||
|
||||
if (newValue === undefined) {
|
||||
return 'removed';
|
||||
}
|
||||
|
||||
const bothAreObjects = getType(value) === 'object' && getType(newValue) === 'object';
|
||||
const bothAreArrays = getType(value) === 'array' && getType(newValue) === 'array';
|
||||
const bothAreDeepEqual = _.isEqual(value, newValue);
|
||||
|
||||
if (bothAreDeepEqual) {
|
||||
return 'unchanged';
|
||||
}
|
||||
|
||||
if (bothAreObjects || bothAreArrays) {
|
||||
return 'children-updated';
|
||||
}
|
||||
|
||||
return 'updated';
|
||||
}
|
29
src/tools/json-diff/json-diff.types.ts
Normal file
29
src/tools/json-diff/json-diff.types.ts
Normal file
|
@ -0,0 +1,29 @@
|
|||
export type DifferenceStatus = 'added' | 'removed' | 'updated' | 'unchanged' | 'children-updated';
|
||||
|
||||
export type ObjectDifference = {
|
||||
key: string | number;
|
||||
type: 'object';
|
||||
children: Difference[];
|
||||
status: DifferenceStatus;
|
||||
oldValue: unknown;
|
||||
value: unknown;
|
||||
};
|
||||
|
||||
export type ValueDifference = {
|
||||
key: string | number;
|
||||
type: 'value';
|
||||
value: unknown;
|
||||
oldValue: unknown;
|
||||
status: DifferenceStatus;
|
||||
};
|
||||
|
||||
export type ArrayDifference = {
|
||||
key: number | string;
|
||||
type: 'array';
|
||||
children: Difference[];
|
||||
status: DifferenceStatus;
|
||||
oldValue: unknown;
|
||||
value: unknown;
|
||||
};
|
||||
|
||||
export type Difference = ObjectDifference | ValueDifference | ArrayDifference;
|
59
src/tools/json-diff/json-diff.vue
Normal file
59
src/tools/json-diff/json-diff.vue
Normal file
|
@ -0,0 +1,59 @@
|
|||
<template>
|
||||
<n-form-item label="Your first json" v-bind="leftJsonValidation.attrs">
|
||||
<n-input
|
||||
v-model:value="rawLeftJson"
|
||||
placeholder="Paste your first json here..."
|
||||
type="textarea"
|
||||
rows="20"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
:input-props="{ 'data-test-id': 'leftJson' }"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="Your json to compare" v-bind="rightJsonValidation.attrs">
|
||||
<n-input
|
||||
v-model:value="rawRightJson"
|
||||
placeholder="Paste your json to compare here..."
|
||||
type="textarea"
|
||||
rows="20"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
:input-props="{ 'data-test-id': 'rightJson' }"
|
||||
/>
|
||||
</n-form-item>
|
||||
|
||||
<DiffsViewer :left-json="leftJson" :right-json="rightJson" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import JSON5 from 'json5';
|
||||
|
||||
import { withDefaultOnError } from '@/utils/defaults';
|
||||
import { useValidation } from '@/composable/validation';
|
||||
import { isNotThrowing } from '@/utils/boolean';
|
||||
import DiffsViewer from './diff-viewer/diff-viewer.vue';
|
||||
|
||||
const rawLeftJson = ref('');
|
||||
const rawRightJson = ref('');
|
||||
|
||||
const leftJson = computed(() => withDefaultOnError(() => JSON5.parse(rawLeftJson.value), undefined));
|
||||
const rightJson = computed(() => withDefaultOnError(() => JSON5.parse(rawRightJson.value), undefined));
|
||||
|
||||
const createJsonValidation = (json: Ref) =>
|
||||
useValidation({
|
||||
source: json,
|
||||
rules: [
|
||||
{
|
||||
validator: (value) => value === '' || isNotThrowing(() => JSON5.parse(value)),
|
||||
message: 'Invalid JSON',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const leftJsonValidation = createJsonValidation(rawLeftJson);
|
||||
const rightJsonValidation = createJsonValidation(rawRightJson);
|
||||
</script>
|
Loading…
Add table
Add a link
Reference in a new issue