diff --git a/site/website/diff/index.xml b/site/website/diff/index.xml
deleted file mode 100644
index 630b26843b..0000000000
--- a/site/website/diff/index.xml
+++ /dev/null
@@ -1,28 +0,0 @@
-
-
-
-
- /page/diff /templates/web/diff.ftl
- inherit-levels
- 2074
- 20747fa3-693d-ebd8-0aa8-5109a588fced
- index.xml
- diff
- Diff
- 2014-3-20T21:21:4.000Z
- 2014-3-20T21:21:4.000Z
-
\ No newline at end of file
diff --git a/templates/web/diff.ftl b/templates/web/diff.ftl
deleted file mode 100644
index 7978a53a09..0000000000
--- a/templates/web/diff.ftl
+++ /dev/null
@@ -1,142 +0,0 @@
-<#assign mode = RequestParameters["mode"]!"" />
-<#assign ui = RequestParameters["ui"]!"" />
-
-
-
-
-
-
- <#include "/templates/web/common/page-fragments/head.ftl" />
-
- Crafter Studio
-
-
-
-
-
-
-
- <#assign path="/studio/static-assets/components/cstudio-common/resources/" />
-
-
-
-
-
- <#include "/templates/web/common/page-fragments/studio-context.ftl" />
-
-
-
-
-
-
-
-
-
-<#if mode != "iframe">
-
-#if>
-
-
-
-
-
-
-
-as-dialog#if>">
-
${diff}
-
-
-<#if mode == "iframe" && ui != "next">
-
-#if>
-
-<#include "/static-assets/app/pages/legacy.html">
-
-
-
-
diff --git a/ui/app/package.json b/ui/app/package.json
index be8fe61e4c..6ce26b2de3 100644
--- a/ui/app/package.json
+++ b/ui/app/package.json
@@ -59,6 +59,7 @@
"@emotion/react": "^11.13.3",
"@emotion/styled": "^11.13.0",
"@graphiql/plugin-explorer": "^3.0.1",
+ "@monaco-editor/react": "^4.6.0",
"@mui/icons-material": "^6.1.7",
"@mui/lab": "6.0.0-beta.15",
"@mui/material": "^6.1.7",
@@ -75,6 +76,7 @@
"@types/video.js": "^7.3.58",
"autosuggest-highlight": "^3.3.4",
"clsx": "^2.1.1",
+ "diff": "^7.0.0",
"fast-xml-parser": "^4.3.6",
"graphiql": "^3.2.2",
"graphql": "^16.8.1",
@@ -113,6 +115,7 @@
"devDependencies": {
"@formatjs/cli": "^6.2.12",
"@rollup/plugin-swc": "^0.3.0",
+ "@types/diff": "^6.0.0",
"@types/js-cookie": "^3.0.6",
"@types/react-infinite-scroller": "^1.2.5",
"@types/uuid": "^10",
diff --git a/ui/app/src/CHANGELOG.md b/ui/app/src/CHANGELOG.md
index 49a3b46a53..90bf879aea 100644
--- a/ui/app/src/CHANGELOG.md
+++ b/ui/app/src/CHANGELOG.md
@@ -19,6 +19,7 @@
* @mui/x-data-grid
* @mui/x-date-pickers
* @mui/x-tree-view
+* Removed LegacyVersionDialog and the entire associated `/studio/diff` route
## 4.2.0
diff --git a/ui/app/src/components/AccountManagement/utils.tsx b/ui/app/src/components/AccountManagement/utils.tsx
index a8d0ae5bca..13ed7c1fa1 100644
--- a/ui/app/src/components/AccountManagement/utils.tsx
+++ b/ui/app/src/components/AccountManagement/utils.tsx
@@ -35,7 +35,9 @@ import {
removeStoredPullBranch,
removeStoredPullMergeStrategy,
removeStoredPushBranch,
- removeStoredShowToolsPanel
+ removeStoredShowToolsPanel,
+ removeCompareVersionDialogViewModes,
+ removeViewVersionDialogViewModes
} from '../../utils/state';
export const preferencesGroups: Array<{
@@ -132,6 +134,8 @@ export const preferencesGroups: Array<{
removeStoredPreviewBackgroundMode(props.username);
removeStoredBrowseDialogViewMode(props.username);
removeStoredItems((key) => widgetsAccordionsKeyRegex.test(key));
+ removeCompareVersionDialogViewModes(props.username);
+ removeViewVersionDialogViewModes(props.username);
}
}
];
diff --git a/ui/app/src/components/CompareVersionsDialog/CompareAssetPanel.tsx b/ui/app/src/components/CompareVersionsDialog/CompareAssetPanel.tsx
new file mode 100644
index 0000000000..33ab4f2425
--- /dev/null
+++ b/ui/app/src/components/CompareVersionsDialog/CompareAssetPanel.tsx
@@ -0,0 +1,113 @@
+/*
+ * Copyright (C) 2007-2024 Crafter Software Corporation. All Rights Reserved.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 3 as published by
+ * the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+import React, { ReactNode, useMemo } from 'react';
+import { isImage, isPdfDocument, isVideo } from '../PathNavigator/utils';
+import TextDiffView from './FieldsTypesDiffViews/TextDiffView';
+import Box from '@mui/material/Box';
+import Typography from '@mui/material/Typography';
+import Divider from '@mui/material/Divider';
+import ImageView from '../ViewVersionDialog/FieldTypesViews/ImageView';
+import VideoView from '../ViewVersionDialog/FieldTypesViews/VideoView';
+import { PDFView } from '../ViewVersionDialog/AssetTypesViews/PDFView';
+
+const typesDiffMap = {
+ image: ImageView,
+ video: VideoView,
+ text: TextDiffView,
+ pdf: PDFView
+};
+
+export interface AssetDiffViewProps {
+ aContent?: string;
+ bContent?: string;
+ type: 'image' | 'video' | 'pdf' | 'text';
+ renderContent: (xml: string) => ReactNode;
+ noContent?: ReactNode;
+}
+
+function AssetDiffView(props: AssetDiffViewProps) {
+ const {
+ aContent,
+ bContent,
+ type,
+ renderContent,
+ noContent = (
+
+ no content set
+
+ )
+ } = props;
+ const verticalLayout = type === 'image' || type === 'video';
+ return (
+ div': {
+ flexGrow: verticalLayout && 1
+ }
+ }}
+ >
+ {aContent ? renderContent(aContent) : noContent}
+ {verticalLayout && }
+ {bContent ? renderContent(bContent) : noContent}
+
+ );
+}
+
+export function CompareAssetPanel(props) {
+ const { a, b, item } = props;
+ const assetType = useMemo(() => {
+ if (isImage(item)) {
+ return 'image';
+ } else if (isVideo(item)) {
+ return 'video';
+ } else if (isPdfDocument(item.mimeType)) {
+ return 'pdf';
+ } else {
+ return 'text';
+ }
+ }, [item]);
+ const ViewComponent = typesDiffMap[assetType];
+ const isVerticalLayout = assetType === 'image' || assetType === 'video';
+ const viewComponentProps = {
+ ...(isVerticalLayout ? { sxs: { image: { maxHeight: '100%' } } } : {})
+ };
+
+ if (assetType === 'text') {
+ return ;
+ } else {
+ return (
+ (
+
+
+
+ )}
+ />
+ );
+ }
+}
+
+export default CompareAssetPanel;
diff --git a/ui/app/src/components/CompareVersionsDialog/CompareFieldPanel.tsx b/ui/app/src/components/CompareVersionsDialog/CompareFieldPanel.tsx
new file mode 100644
index 0000000000..ea0b53dd58
--- /dev/null
+++ b/ui/app/src/components/CompareVersionsDialog/CompareFieldPanel.tsx
@@ -0,0 +1,103 @@
+/*
+ * Copyright (C) 2007-2022 Crafter Software Corporation. All Rights Reserved.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 3 as published by
+ * the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+import { ContentTypeField } from '../../models/ContentType';
+import React from 'react';
+import Box from '@mui/material/Box';
+import { getContentInstanceValueFromProp } from '../../utils/content';
+import { fromString, serialize } from '../../utils/xml';
+import { DiffViewComponentBaseProps, SelectionContentVersion, typesDiffMap } from './utils';
+import ContentFieldView from '../ViewVersionDialog/ContentFieldView';
+import { countLines } from '../../utils/string';
+import { ErrorBoundary } from '../ErrorBoundary';
+import { initialFieldViewState, useVersionsDialogContext } from './VersionsDialogContext';
+import DefaultDiffView from './FieldsTypesDiffViews/DefaultDiffView';
+import TextDiffView from './FieldsTypesDiffViews/TextDiffView';
+import { DiffEditorProps } from '@monaco-editor/react';
+import ContentInstance from '../../models/ContentInstance';
+
+export interface CompareFieldPanelProps {
+ a: SelectionContentVersion;
+ b: SelectionContentVersion;
+ field: ContentTypeField;
+ dynamicHeight?: boolean;
+ onSelectField?(field: ContentTypeField): void;
+}
+
+export interface DiffComponentProps extends Pick {
+ editorProps?: DiffEditorProps;
+}
+
+export function CompareFieldPanel(props: CompareFieldPanelProps) {
+ const { a, b, field, onSelectField, dynamicHeight } = props;
+ const [{ fieldsViewState }] = useVersionsDialogContext();
+ const fieldType = field.type;
+ const versionAXmlDoc = fromString(a.xml);
+ const versionBXmlDoc = fromString(b.xml);
+ const versionAFieldDoc =
+ versionAXmlDoc.querySelector(`page > ${field.id}`) ??
+ versionAXmlDoc.querySelector(`component > ${field.id}`) ??
+ versionAXmlDoc.querySelector(`item > ${field.id}`);
+ const versionBFieldDoc =
+ versionBXmlDoc.querySelector(`page > ${field.id}`) ??
+ versionBXmlDoc.querySelector(`component > ${field.id}`) ??
+ versionBXmlDoc.querySelector(`item > ${field.id}`);
+ const versionAFieldXml = versionAFieldDoc ? serialize(versionAFieldDoc) : '';
+ const versionBFieldXml = versionBFieldDoc ? serialize(versionBFieldDoc) : '';
+ const unchanged = versionAFieldXml === versionBFieldXml;
+ const contentA = getContentInstanceValueFromProp(a.content as ContentInstance, field.id);
+ const longerXmlContent = versionAFieldXml.length > versionBFieldXml.length ? versionAFieldXml : versionBFieldXml;
+ const monacoEditorHeight = dynamicHeight ? (countLines(longerXmlContent) < 15 ? '200px' : '600px') : '100%';
+ const DiffComponent = typesDiffMap[fieldType] ?? DefaultDiffView;
+ const viewState = fieldsViewState[field.id] ?? initialFieldViewState;
+ const { compareXml, monacoOptions } = viewState;
+ const diffComponentProps: DiffComponentProps = {
+ aXml: versionAFieldXml,
+ bXml: versionBFieldXml,
+ field,
+ editorProps: { options: monacoOptions, height: monacoEditorHeight }
+ };
+
+ return (
+
+ {unchanged ? (
+
+ ) : (
+ <>
+ {compareXml ? (
+
+ ) : (
+
+
+
+ )}
+ >
+ )}
+
+ );
+}
+
+export default CompareFieldPanel;
diff --git a/ui/app/src/components/CompareVersionsDialog/CompareVersions.tsx b/ui/app/src/components/CompareVersionsDialog/CompareVersions.tsx
index 48c078d155..e69de29bb2 100644
--- a/ui/app/src/components/CompareVersionsDialog/CompareVersions.tsx
+++ b/ui/app/src/components/CompareVersionsDialog/CompareVersions.tsx
@@ -1,408 +0,0 @@
-/*
- * Copyright (C) 2007-2022 Crafter Software Corporation. All Rights Reserved.
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License version 3 as published by
- * the Free Software Foundation.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see .
- */
-
-// Next UI code disabled temporarily
-
-import { makeStyles } from 'tss-react/mui';
-import ContentInstance from '../../models/ContentInstance';
-import React from 'react';
-import { useSelection } from '../../hooks/useSelection';
-
-// declare const monaco: any;
-
-/*const CompareVersionsStyles = makeStyles(() =>
- createStyles({
- monacoWrapper: {
- width: '100%',
- height: '150px',
- '&.unChanged': {
- height: 'auto'
- }
- },
- singleImage: {
- display: 'flex',
- width: '100%',
- justifyContent: 'center'
- },
- imagesCompare: {
- display: 'flex',
- alignItems: 'center',
- '& img': {
- width: '50%',
- padding: '20px'
- }
- },
- compareBoxHeader: {
- display: 'flex',
- justifyContent: 'space-around'
- },
- compareBoxHeaderItem: {
- flexBasis: '50%',
- margin: '0 10px 10px 10px',
- '& .blackText': {
- color: palette.black
- }
- },
- compareVersionsContent: {
- background: palette.white
- },
- root: {
- margin: 0,
- border: 0,
- boxShadow: 'none',
- '&.Mui-expanded': {
- margin: 0,
- borderBottom: '1px solid rgba(0,0,0,0.12)'
- }
- },
- bold: {
- fontWeight: 600
- },
- unchangedChip: {
- marginLeft: 'auto',
- height: '26px',
- color: palette.gray.medium4,
- backgroundColor: palette.gray.light1
- }
- })
-); */
-
-/*const ContentInstanceComponentsStyles = makeStyles(() =>
- createStyles({
- componentsWrapper: {
- display: 'flex',
- flexDirection: 'column',
- width: '100%'
- },
- component: {
- padding: '10px',
- marginBottom: '12px',
- display: 'flex',
- justifyContent: 'space-between',
- borderRadius: '5px',
- alignItems: 'center',
- '&.unchanged': {
- color: palette.gray.medium4,
- backgroundColor: palette.gray.light1
- },
- '&.new': {
- color: palette.green.shade,
- backgroundColor: palette.green.highlight,
- width: '50%',
- marginLeft: 'auto'
- },
- '&.changed': {
- color: palette.yellow.shade,
- backgroundColor: palette.yellow.highlight
- },
- '&.deleted': {
- color: palette.red.shade,
- backgroundColor: palette.red.highlight,
- width: '50%',
- marginRight: 'auto'
- },
- '&:last-child': {
- marginBottom: 0
- }
- },
- status: {
- fontSize: '0.8125rem',
- color: palette.gray.medium4
- }
- })
-); */
-
-/*const translations = defineMessages({
- changed: {
- id: 'words.changed',
- defaultMessage: 'Changed'
- },
- unchanged: {
- id: 'words.unchanged',
- defaultMessage: 'Unchanged'
- },
- deleted: {
- id: 'words.deleted',
- defaultMessage: 'Deleted'
- },
- empty: {
- id: 'words.empty',
- defaultMessage: 'Empty'
- },
- noItemsStatus: {
- id: 'compareVersionsDialog.noItemsStatus',
- defaultMessage: 'No items'
- }
-}); */
-
-interface CompareVersionsProps {
- versions: ContentInstance[];
-}
-
-const getLegacyDialogStyles = makeStyles()(() => ({
- iframe: {
- border: 'none',
- height: '80vh'
- }
-}));
-
-export function CompareVersions(props: CompareVersionsProps) {
- const [a, b] = props.versions;
- const { classes } = getLegacyDialogStyles();
- const authoringUrl = useSelection((state) => state.env.authoringBase);
- return (
-
- );
-}
-
-/*export function CompareVersions(props: CompareVersionsProps) {
- const classes = CompareVersionsStyles({});
- const { a, b, contentTypes } = props.resource.read();
- const values = Object.values(contentTypes[a.craftercms.contentTypeId].fields) as ContentTypeField[];
-
- return (
- <>
-
-
- }
- secondary={
- {msg}
- }}
- />
- }
- />
-
-
- }
- secondary={
- {msg}
- }}
- />
- }
- />
-
-
-
-
- {
- contentTypes &&
- values.filter(value => !systemProps.includes(value.id)).map((field) => (
-
- ))
- }
-
-
- >
- );
-} */
-
-/*interface CompareFieldPanelProps {
- a: ContentInstance;
- b: ContentInstance;
- field: ContentTypeField;
-} */
-
-/*function CompareFieldPanel(props: CompareFieldPanelProps) {
- const classes = CompareVersionsStyles({});
- const { a, b, field } = props;
- const [unChanged, setUnChanged] = useState(false);
- const { formatMessage } = useIntl();
- let contentA = a[field.id];
- let contentB = b[field.id];
-
- useMount(() => {
- switch (field.type) {
- case 'text':
- case 'html':
- case 'image':
- if (contentA === contentB) {
- setUnChanged(true);
- }
- break;
- case 'node-selector': {
- setUnChanged(false);
- break;
- }
- default:
- if (contentA === contentB) {
- setUnChanged(true);
- }
- break;
- }
- });
-
- return (
-
- }>
-
- {field.name} ({field.id})
-
- {
- unChanged &&
-
- }
- {
- field.type === 'node-selector' && !contentA.length && !contentB.length &&
-
- }
-
-
- {
- (field.type === 'text' || field.type === 'html') && (!unChanged ? (
-
- ) : (
-
- {contentA}
-
- )
- )
- }
- {
- (field.type === 'image') && (!unChanged ? (
-
-
-
-
- ) : (
-
-
-
- )
- )
- }
- {
- (field.type === 'node-selector') &&
-
- }
-
-
- );
-} */
-
-/*interface ContentInstanceComponentsProps {
- contentA: ContentInstance[];
- contentB: ContentInstance[];
-} */
-
-/*function ContentInstanceComponents(props: ContentInstanceComponentsProps) {
- const { contentA, contentB } = props;
- const classes = ContentInstanceComponentsStyles({});
- const [mergeContent, setMergeContent] = useState([]);
- const [status, setStatus] = useState({});
- const { formatMessage } = useIntl();
-
- useEffect(() => {
- let itemStatus = {};
- let merged = {};
- contentA.forEach((itemA, index: number) => {
- const itemB = contentB[index];
- if (!itemB || itemA.craftercms.id !== itemB.craftercms.id) {
- itemStatus[index] = 'deleted';
- } else {
- itemStatus[index] = itemA.craftercms.dateModified !== itemB.craftercms.dateModified ? 'changed' : 'unchanged';
- }
- merged[index] = itemA;
- });
- contentB.forEach((itemB, index: number) => {
- const itemA = contentA[index];
- if (!itemA || (itemB.craftercms.id !== itemA.craftercms.id)) {
- itemStatus[index] = 'new';
- }
- merged[index] = itemB;
- });
- setMergeContent(Object.values(merged));
- setStatus(itemStatus);
- }, [contentA, contentB]);
-
- return (
-
- {
- mergeContent.length ? (
- mergeContent.map((item, index) =>
-
- {item.craftercms.label ?? item.craftercms.id}
- {
- status[index] && status[index] !== 'new' &&
-
- {formatMessage(translations[status[index]])}
-
- }
-
- )
- ) : (
-
- )
- }
-
- );
-} */
-
-/*interface MonacoWrapperProps {
- contentA: string;
- contentB: string;
-} */
-
-/*function MonacoWrapper(props: MonacoWrapperProps) {
- const classes = CompareVersionsStyles({});
- const { contentA, contentB } = props;
- const ref = useRef();
-
- useEffect(() => {
- if (ref.current) {
- const originalModel = monaco.editor.createModel(contentA, 'text/plain');
- const modifiedModel = monaco.editor.createModel(contentB, 'text/plain');
- const diffEditor = monaco.editor.createDiffEditor(ref.current, {
- scrollbar: {
- alwaysConsumeMouseWheel: false
- }
- });
- diffEditor.setModel({
- original: originalModel,
- modified: modifiedModel
- });
- }
- }, [contentA, contentB]);
-
- return (
-
- );
-} */
diff --git a/ui/app/src/components/CompareVersionsDialog/CompareVersionsDialog.tsx b/ui/app/src/components/CompareVersionsDialog/CompareVersionsDialog.tsx
index 98f7e87e6e..5f9cb64bf1 100644
--- a/ui/app/src/components/CompareVersionsDialog/CompareVersionsDialog.tsx
+++ b/ui/app/src/components/CompareVersionsDialog/CompareVersionsDialog.tsx
@@ -14,16 +14,37 @@
* along with this program. If not, see .
*/
-import React from 'react';
-import { CompareVersionsDialogProps } from './utils';
+import React, { useMemo, useRef, useState } from 'react';
+import { CompareVersionsDialogProps, getDialogHeaderActions } from './utils';
import CompareVersionsDialogContainer from './CompareVersionsDialogContainer';
import EnhancedDialog from '../EnhancedDialog/EnhancedDialog';
-import { FormattedMessage } from 'react-intl';
+import { dialogClasses } from '@mui/material/Dialog';
+import { FormattedMessage, useIntl } from 'react-intl';
import { AsDayMonthDateTime } from '../VersionList';
import Slide from '@mui/material/Slide';
+import { translations } from './translations';
+import useMediaQuery from '@mui/material/useMediaQuery';
+import useLocale from '../../hooks/useLocale';
+import CompareArrowsIcon from '@mui/icons-material/CompareArrowsRounded';
+import { ViewVersionDialogProps } from '../ViewVersionDialog/utils';
+import { Backdrop } from '@mui/material';
+import Drawer from '@mui/material/Drawer';
+import Box from '@mui/material/Box';
+import { DialogHeader } from '../DialogHeader';
+import ViewVersionDialogContainer from '../ViewVersionDialog/ViewVersionDialogContainer';
+import { DiffEditorProps } from '@monaco-editor/react';
+import {
+ dialogInitialState,
+ FieldViewState,
+ VersionsDialogContext,
+ VersionsDialogContextProps,
+ VersionsDialogContextType
+} from './VersionsDialogContext';
export function CompareVersionsDialog(props: CompareVersionsDialogProps) {
+ // region const { ... } = props
const {
+ subtitle,
selectedA,
selectedB,
leftActions,
@@ -33,37 +54,206 @@ export function CompareVersionsDialog(props: CompareVersionsDialogProps) {
error,
disableItemSwitching,
contentTypesBranch,
+ selectionContent,
+ fields,
+ TransitionComponent = Slide,
+ TransitionProps,
+ onClose,
...rest
} = props;
+ // endregion
+ const [compareXml, setCompareXml] = useState(false);
+ const { formatMessage } = useIntl();
+ const largeHeightScreen = useMediaQuery('(min-height: 880px)');
+ const locale = useLocale();
+
+ // region Dialog Context
+ const [state, setState] = useState(dialogInitialState);
+ const contextRef = useRef(null);
+ const context = useMemo(() => {
+ contextRef.current = {
+ setState(nextState: Partial) {
+ setState({ ...state, ...nextState });
+ },
+ setCompareSlideOutState(props: Partial) {
+ setState({ ...state, compareSlideOutState: { ...state.compareSlideOutState, ...props } });
+ },
+ setViewSlideOutState(props: Partial) {
+ setState({ ...state, viewSlideOutState: { ...state.viewSlideOutState, ...props } });
+ },
+ setFieldViewState(fieldId: string, viewState: Partial) {
+ setState({
+ ...state,
+ fieldsViewState: { ...state.fieldsViewState, [fieldId]: { ...state.fieldsViewState[fieldId], ...viewState } }
+ });
+ },
+ setFieldViewEditorOptionsState(fieldId: string, options: DiffEditorProps['options']) {
+ setState({
+ ...state,
+ fieldsViewState: {
+ ...state.fieldsViewState,
+ [fieldId]: {
+ ...state.fieldsViewState[fieldId],
+ monacoOptions: { ...state.fieldsViewState[fieldId].monacoOptions, ...options }
+ }
+ }
+ });
+ },
+ closeSlideOuts() {
+ setState({
+ ...state,
+ compareSlideOutState: { ...state.compareSlideOutState, open: false },
+ viewSlideOutState: { ...state.viewSlideOutState, open: false }
+ });
+ }
+ };
+ return [state, contextRef];
+ }, [state]);
+ // endregion
+
+ const onDialogClose = (event: React.SyntheticEvent, reason: 'backdropClick' | 'escapeKeyDown'): void => {
+ if (!(state.compareSlideOutState?.open || state.viewSlideOutState?.open)) {
+ onClose?.(event, reason);
+ }
+ context[1].current.closeSlideOuts();
+ };
return (
,
- selectedB:
- }}
- />
+ title={}
+ subtitle={
+ subtitle ?? (
+ <>
+
+
+
+ >
+ )
}
dialogHeaderProps={{
leftActions,
- rightActions
+ rightActions: [
+ ...(state.enableDialogActions && !selectionContent
+ ? getDialogHeaderActions({
+ xmlMode: compareXml,
+ contentActionLabel: formatMessage(translations.compareContent),
+ xmlActionLabel: formatMessage(translations.compareXml),
+ onClickContent: () => setCompareXml(false),
+ onClickXml: () => setCompareXml(true)
+ })
+ : []),
+ ...(rightActions ?? [])
+ ],
+ sxs: {
+ subtitle: {
+ display: 'flex',
+ color: (theme) => theme.palette.text.secondary,
+ alignItems: 'center',
+ gap: 1
+ }
+ }
}}
maxWidth="xl"
- TransitionComponent={Slide}
+ TransitionComponent={TransitionComponent}
+ TransitionProps={TransitionProps}
+ sx={{
+ [`.${dialogClasses.paper}`]: {
+ height: largeHeightScreen ? 'calc(100% - 200px)' : 'calc(100% - 60px)',
+ maxHeight: '1000px',
+ width: 'calc(100% - 64px)',
+ overflow: 'hidden'
+ }
+ }}
+ onClose={onDialogClose}
{...rest}
>
-
+
+
+
+ {/* region In-dialog slide-out panel */}
+ theme.zIndex.drawer, position: 'absolute' }}
+ onClick={() => {
+ context[1].current.closeSlideOuts();
+ }}
+ />
+ .MuiDrawer-root': { position: 'absolute' },
+ '& > .MuiPaper-root': { width: '90%', position: 'absolute' }
+ }}
+ >
+ {/* region Compare */}
+ {state.compareSlideOutState.open && (
+
+ contextRef.current.setCompareSlideOutState({ compareXml: false }),
+ onClickXml: () => contextRef.current.setCompareSlideOutState({ compareXml: true })
+ })}
+ />
+
+
+ )}
+ {/* endregion */}
+ {/* region View */}
+ {state.viewSlideOutState.open && (
+ <>
+ contextRef.current.setViewSlideOutState({ showXml: false }),
+ sx: {
+ color: (theme) =>
+ !state.viewSlideOutState.showXml ? theme.palette.primary.main : theme.palette.text.secondary,
+ fontSize: 14
+ }
+ },
+ {
+ icon: { id: '@mui/icons-material/CodeRounded' },
+ text: formatMessage(translations.compareXml),
+ onClick: () => contextRef.current.setViewSlideOutState({ showXml: true }),
+ sx: {
+ color: (theme) =>
+ state.viewSlideOutState.showXml ? theme.palette.primary.main : theme.palette.text.secondary,
+ fontSize: 14
+ }
+ }
+ ]}
+ />
+
+ >
+ )}
+ {/* endregion */}
+
+ {/* endregion */}
+
);
}
diff --git a/ui/app/src/components/CompareVersionsDialog/CompareVersionsDialogContainer.tsx b/ui/app/src/components/CompareVersionsDialog/CompareVersionsDialogContainer.tsx
index 8fd9ee740c..7e8b05f6c4 100644
--- a/ui/app/src/components/CompareVersionsDialog/CompareVersionsDialogContainer.tsx
+++ b/ui/app/src/components/CompareVersionsDialog/CompareVersionsDialogContainer.tsx
@@ -14,50 +14,420 @@
* along with this program. If not, see .
*/
-import { CompareVersionsDialogContainerProps } from './utils';
-import React from 'react';
-import { CompareVersions } from './CompareVersions';
+import { CompareVersionsDialogContainerProps, SelectionContentVersion } from './utils';
+import React, { useEffect, useMemo, useRef, useState } from 'react';
+import { CompareFieldPanel } from './CompareFieldPanel';
import DialogBody from '../DialogBody/DialogBody';
-import { makeStyles } from 'tss-react/mui';
-import { ApiResponseErrorState } from '../ApiResponseErrorState';
import { LoadingState } from '../LoadingState';
-import { EmptyState } from '../EmptyState';
-import { FormattedMessage } from 'react-intl';
-
-const useStyles = makeStyles()(() => ({
- dialogBody: {
- overflow: 'auto',
- minHeight: '50vh'
- },
- noPadding: {
- padding: 0
- },
- singleItemSelector: {
- marginBottom: '10px'
- },
- typography: {
- lineHeight: '1.5'
- }
-}));
+import useSpreadState from '../../hooks/useSpreadState';
+import useActiveSiteId from '../../hooks/useActiveSiteId';
+import { forkJoin } from 'rxjs';
+import { fetchContentByCommitId } from '../../services/content';
+import { fromString } from '../../utils/xml';
+import { getContentInstanceXmlValueFromProp, parseContentXML } from '../../utils/content';
+import { ApiResponseErrorState } from '../ApiResponseErrorState';
+import { ResizeableDrawer } from '../ResizeableDrawer';
+import Box from '@mui/material/Box';
+import List from '@mui/material/List';
+import ListItemButton from '@mui/material/ListItemButton';
+import { FormattedMessage, useIntl } from 'react-intl';
+import EmptyState from '../EmptyState';
+import useSelection from '../../hooks/useSelection';
+import ListItemText, { listItemTextClasses } from '@mui/material/ListItemText';
+import Typography from '@mui/material/Typography';
+import { ItemTypeIcon } from '../ItemTypeIcon';
+import palette from '../../styles/palette';
+import { ErrorBoundary } from '../ErrorBoundary';
+import Badge, { badgeClasses } from '@mui/material/Badge';
+import Button from '@mui/material/Button';
+import { getStudioContentInternalFields } from '../../utils/contentType';
+import { FieldAccordionPanel } from './FieldAccordionPanel';
+import FieldVersionToolbar from './FieldVersionToolbar';
+import { initialFieldViewState, useVersionsDialogContext, VersionsDialogContextProps } from './VersionsDialogContext';
+import { ContentInstance, ContentTypeField } from '../../models';
+import { getCompareVersionDialogViewModes, setCompareVersionDialogViewModes } from '../../utils/state';
+import useActiveUser from '../../hooks/useActiveUser';
+import TextDiffView from './FieldsTypesDiffViews/TextDiffView';
+import CompareAssetPanel from './CompareAssetPanel';
export function CompareVersionsDialogContainer(props: CompareVersionsDialogContainerProps) {
- const { versionsBranch } = props;
- const { compareVersionsBranch } = versionsBranch;
- const { classes, cx } = useStyles();
+ const {
+ selectedA,
+ selectedB,
+ selectionContent: preFetchedContent,
+ fields,
+ versionsBranch,
+ contentTypesBranch,
+ compareXml
+ } = props;
+ const [{ fieldsViewState, viewSlideOutState, compareSlideOutState }, contextApiRef] = useVersionsDialogContext();
+ const disableKeyboardNavigation = viewSlideOutState.open || compareSlideOutState.open;
+ const fieldsViewStateRef = useRef();
+ fieldsViewStateRef.current = fieldsViewState;
+ const compareVersionsBranch = versionsBranch?.compareVersionsBranch;
+ const item = versionsBranch?.item;
+ const isAsset = item?.systemType === 'asset';
+ const baseUrl = useSelection((state) => state.env.authoringBase);
+ const { formatMessage } = useIntl();
+ const { username } = useActiveUser();
+ const viewModes = getCompareVersionDialogViewModes(username);
+ const [showOnlyChanges, setShowOnlyChanges] = useState(viewModes?.entireDiff ?? true);
+ const [accordionView, setAccordionView] = useState(viewModes?.accordionView ?? false);
+ const [selectionContent, setSelectionContent] = useSpreadState<{
+ a: SelectionContentVersion;
+ b: SelectionContentVersion;
+ }>(
+ preFetchedContent ?? {
+ a: {
+ content: null,
+ xml: null
+ },
+ b: {
+ content: null,
+ xml: null
+ }
+ }
+ );
+ const siteId = useActiveSiteId();
+ const isCompareDataReady = useMemo(() => {
+ if (preFetchedContent) {
+ // Check that selectionContent is complete and synced with prefetchedContent
+ return (
+ selectionContent.a.content &&
+ selectionContent.b.content &&
+ selectionContent.a.xml === preFetchedContent.a.xml &&
+ selectionContent.b.xml === preFetchedContent.b.xml
+ );
+ } else if (isAsset) {
+ return compareVersionsBranch?.compareVersions && selectionContent.a.content && selectionContent.b.content;
+ } else {
+ return (
+ compareVersionsBranch?.compareVersions &&
+ contentTypesBranch?.byId &&
+ item?.contentTypeId &&
+ selectionContent.a.content &&
+ selectionContent.b.content
+ );
+ }
+ }, [
+ preFetchedContent,
+ compareVersionsBranch?.compareVersions,
+ contentTypesBranch?.byId,
+ item?.contentTypeId,
+ selectionContent
+ ]);
+ const [selectedField, setSelectedField] = useState(null);
+ const selectedFieldRef = useRef(null);
+ selectedFieldRef.current = selectedField;
+ const contentType = contentTypesBranch?.byId[item.contentTypeId];
+ const contentTypeFields = useMemo(() => {
+ return !isAsset && isCompareDataReady
+ ? [
+ ...Object.values(fields ?? contentType.fields),
+ ...(((selectionContent.a.content ?? selectionContent.b.content) as ContentInstance).craftercms
+ ? getStudioContentInternalFields(formatMessage)
+ : [])
+ ]
+ : [];
+ }, [contentType, fields, isCompareDataReady, formatMessage, selectionContent, isAsset]);
+ const fieldIdsWithChanges = useMemo(
+ () =>
+ contentTypeFields
+ .filter(
+ (field) =>
+ getContentInstanceXmlValueFromProp(selectionContent.a.xml, field.id) !==
+ getContentInstanceXmlValueFromProp(selectionContent.b.xml, field.id)
+ )
+ .map((field) => field.id),
+ [contentTypeFields, selectionContent.a, selectionContent.b]
+ );
+ const filteredContentTypeFields = showOnlyChanges
+ ? contentTypeFields.filter((field) => fieldIdsWithChanges.includes(field.id))
+ : contentTypeFields;
+ const isFilteredFieldsEmpty = filteredContentTypeFields.length === 0;
+ const sidebarRefs = useRef({});
+ const fieldsRefs = useRef({});
+
+ useEffect(() => {
+ contextApiRef.current.setState({ enableDialogActions: !isAsset });
+ }, [isAsset, contextApiRef]);
+
+ useEffect(() => {
+ if (preFetchedContent) {
+ setSelectionContent(preFetchedContent);
+ }
+ }, [preFetchedContent, setSelectionContent]);
+
+ useEffect(() => {
+ // The dialog can handle 2 different set of props:
+ // - selected versions of the history of an item, so we need to fetch the content we're going to diff
+ // - pre-fetched content (and the fields of the content) so we don't need to fetch anything.
+ if (!preFetchedContent && selectedA && selectedB) {
+ forkJoin([
+ fetchContentByCommitId(siteId, selectedA.path, selectedA.versionNumber),
+ fetchContentByCommitId(siteId, selectedB.path, selectedB.versionNumber)
+ ]).subscribe(([contentA, contentB]) => {
+ if (isAsset) {
+ setSelectionContent({
+ a: { content: contentA as string },
+ b: { content: contentB as string }
+ });
+ } else {
+ setSelectionContent({
+ a: {
+ content: parseContentXML(fromString(contentA as string), selectedA.path, contentTypesBranch.byId, {}),
+ xml: contentA as string
+ },
+ b: {
+ content: parseContentXML(fromString(contentB as string), selectedB.path, contentTypesBranch.byId, {}),
+ xml: contentB as string
+ }
+ });
+ }
+ });
+ }
+ }, [preFetchedContent, selectedA, selectedB, siteId, setSelectionContent, contentTypesBranch, isAsset]);
+
+ useEffect(() => {
+ if (contentTypeFields?.length && !fieldIdsWithChanges.includes(selectedFieldRef.current?.id)) {
+ setSelectedField(
+ contentTypeFields.filter((field) => (showOnlyChanges ? fieldIdsWithChanges.includes(field.id) : true))[0]
+ );
+ }
+ }, [contentTypeFields, fieldIdsWithChanges, showOnlyChanges]);
+
+ useEffect(() => {
+ contentTypeFields?.forEach((field) => {
+ sidebarRefs.current[field.id] = React.createRef();
+ fieldsRefs.current[field.id] = React.createRef();
+ fieldsViewStateRef.current[field.id] = initialFieldViewState;
+ });
+ }, [contentTypeFields]);
+
+ const onSelectFieldFromContent = (field: ContentTypeField) => {
+ setSelectedField(field);
+ sidebarRefs.current[field.id].current?.scrollIntoView({ behavior: 'smooth' });
+ };
+
+ const onSelectFieldFromList = (field: ContentTypeField) => {
+ setSelectedField(field);
+ if (accordionView) {
+ fieldsRefs.current[field.id].current?.scrollIntoView({ behavior: 'smooth' });
+ }
+ };
+
+ const onToggleShowOnlyChanges = () => {
+ setShowOnlyChanges(!showOnlyChanges);
+ setCompareVersionDialogViewModes(username, { entireDiff: !showOnlyChanges, accordionView });
+ };
+
+ const onSetAccordionView = (value: boolean) => {
+ setAccordionView(value);
+ setCompareVersionDialogViewModes(username, { entireDiff: showOnlyChanges, accordionView: value });
+ };
return (
-
- {compareVersionsBranch &&
- (compareVersionsBranch.error ? (
-
- ) : compareVersionsBranch.isFetching ? (
+ <>
+
+ {!isCompareDataReady ? (
- ) : compareVersionsBranch.compareVersions?.length > 0 ? (
-
+ ) : compareVersionsBranch?.error || contentTypesBranch?.error ? (
+
+ ) : compareXml ? (
+
) : (
- } />
- ))}
-
+ <>
+
+ {contentType && (
+
+
+ {contentType.id}
+
+
+
+ {contentType.name}
+
+
+ )}
+
+ {contentTypeFields
+ .filter((field) => (showOnlyChanges ? fieldIdsWithChanges.includes(field.id) : true))
+ .map((field) => (
+
+ onSelectFieldFromList(field)}
+ selected={!accordionView && selectedField?.id === field.id}
+ ref={sidebarRefs.current[field.id]}
+ >
+
+
+
+ ))}
+
+
+
+
+
+
+
+
+ {isAsset ? (
+
+ ) : accordionView ? (
+ contentTypeFields
+ .filter((field) => (showOnlyChanges ? fieldIdsWithChanges.includes(field.id) : true))
+ .map((field) => (
+
+ }
+ details={
+
+ }
+ />
+ ))
+ ) : selectedField ? (
+
+
+
+
+ ) : (
+
+ ) : (
+
+ )
+ }
+ image={isFilteredFieldsEmpty ? undefined : `${baseUrl}/static-assets/images/choose_option.svg`}
+ />
+ )}
+
+
+ >
+ )}
+
+ >
);
}
diff --git a/ui/app/src/components/CompareVersionsDialog/FieldAccordionPanel.tsx b/ui/app/src/components/CompareVersionsDialog/FieldAccordionPanel.tsx
new file mode 100644
index 0000000000..a4eb999f91
--- /dev/null
+++ b/ui/app/src/components/CompareVersionsDialog/FieldAccordionPanel.tsx
@@ -0,0 +1,79 @@
+/*
+ * Copyright (C) 2007-2024 Crafter Software Corporation. All Rights Reserved.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 3 as published by
+ * the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+import React, { RefObject, useEffect, useState } from 'react';
+import Accordion from '@mui/material/Accordion';
+import AccordionSummary, { accordionSummaryClasses } from '@mui/material/AccordionSummary';
+import ExpandMoreIcon from '@mui/icons-material/ExpandMoreRounded';
+import Typography from '@mui/material/Typography';
+import Box from '@mui/material/Box';
+import AccordionDetails from '@mui/material/AccordionDetails';
+import { ContentTypeField } from '../../models';
+
+export interface CompareFieldPanelAccordionProps {
+ field: ContentTypeField;
+ details: React.ReactNode;
+ selected: boolean;
+ fieldRef: RefObject;
+ summary?: React.ReactNode;
+}
+
+export function FieldAccordionPanel(props: CompareFieldPanelAccordionProps) {
+ const { fieldRef, selected, summary, details } = props;
+ const [expanded, setExpanded] = useState(true);
+
+ useEffect(() => {
+ if (selected) {
+ setExpanded(true);
+ }
+ }, [selected]);
+
+ return (
+ setExpanded(!expanded)}
+ slotProps={{ transition: { mountOnEnter: true } }}
+ sx={{
+ margin: 0,
+ border: 0,
+ boxShadow: 'none',
+ background: 'none',
+ '&.Mui-expanded': {
+ margin: 0,
+ borderBottom: (theme) => `1px solid ${theme.palette.divider}`
+ }
+ }}
+ >
+ }
+ sx={{ [`.${accordionSummaryClasses.content}`]: { justifyContent: 'space-between', alignItems: 'center' } }}
+ >
+ {summary ? (
+ {summary}
+ ) : (
+
+
+ {props.field.name}{' '}
+
+ ({props.field.id})
+
+ )}
+
+ {details}
+
+ );
+}
diff --git a/ui/app/src/components/CompareVersionsDialog/FieldVersionToolbar.tsx b/ui/app/src/components/CompareVersionsDialog/FieldVersionToolbar.tsx
new file mode 100644
index 0000000000..000b291125
--- /dev/null
+++ b/ui/app/src/components/CompareVersionsDialog/FieldVersionToolbar.tsx
@@ -0,0 +1,261 @@
+/*
+ * Copyright (C) 2007-2024 Crafter Software Corporation. All Rights Reserved.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 3 as published by
+ * the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+import Button from '@mui/material/Button';
+import ChevronLeftRoundedIcon from '@mui/icons-material/ChevronLeftRounded';
+import Paper from '@mui/material/Paper';
+import Box from '@mui/material/Box';
+import Typography from '@mui/material/Typography';
+import Divider from '@mui/material/Divider';
+import IconButton from '@mui/material/IconButton';
+import TextSnippetOutlinedIcon from '@mui/icons-material/TextSnippetOutlined';
+import CodeOutlinedIcon from '@mui/icons-material/CodeOutlined';
+import NavigateNextRoundedIcon from '@mui/icons-material/NavigateNextRounded';
+import React, { ReactNode } from 'react';
+import { ContentTypeField } from '../../models';
+import Tooltip from '@mui/material/Tooltip';
+import { FormattedMessage } from 'react-intl';
+import NotesRoundedIcon from '@mui/icons-material/NotesRounded';
+import { useHotkeys } from 'react-hotkeys-hook';
+import CompareArrowsIcon from '@mui/icons-material/CompareArrowsRounded';
+import { initialFieldViewState, useVersionsDialogContext } from './VersionsDialogContext';
+import { typesViewMap } from '../ViewVersionDialog/ContentFieldView';
+import TextView from '../ViewVersionDialog/FieldTypesViews/TextView';
+import TextDiffView from './FieldsTypesDiffViews/TextDiffView';
+import { typesDiffMap } from './utils';
+
+interface FieldVersionToolbarProps {
+ field: ContentTypeField;
+ contentTypeFields: ContentTypeField[];
+ isDiff?: boolean;
+ actions?: ReactNode;
+ showFieldsNavigation?: boolean;
+ justContent?: boolean;
+ disableKeyboardNavigation?: boolean;
+ onSelectField?(field: ContentTypeField): void;
+}
+
+export function FieldVersionToolbar(props: FieldVersionToolbarProps) {
+ const {
+ field,
+ contentTypeFields,
+ onSelectField,
+ actions,
+ showFieldsNavigation = true,
+ disableKeyboardNavigation = false,
+ isDiff = true,
+ justContent
+ } = props;
+ const fieldType = field.type;
+ const [{ fieldsViewState }, contextApiRef] = useVersionsDialogContext();
+ const currentFieldIndex = contentTypeFields.findIndex((f) => f.id === field.id);
+ const nextField = contentTypeFields[currentFieldIndex + 1] || contentTypeFields[0];
+ const previousField = contentTypeFields[currentFieldIndex - 1] || contentTypeFields[contentTypeFields.length - 1];
+ const viewState = fieldsViewState[field.id] ?? initialFieldViewState;
+ const { compareXml, cleanText, monacoOptions, compareMode, compareModeDisabled } = viewState;
+ const showDivider =
+ (!compareXml && fieldType === 'repeat') ||
+ compareXml ||
+ typesDiffMap[fieldType] === TextView ||
+ typesDiffMap[fieldType] === TextDiffView ||
+ Boolean(actions);
+ const isMappedFieldType = isDiff ? Boolean(typesDiffMap[fieldType]) : Boolean(typesViewMap[fieldType]);
+
+ const onSelectNextField = (fieldId: string) => {
+ const index = contentTypeFields.findIndex((f) => f.id === fieldId);
+ const nextField = contentTypeFields[index + 1] || contentTypeFields[0];
+ onSelectField?.(nextField);
+ };
+
+ const onSelectPreviousField = (fieldId: string) => {
+ const index = contentTypeFields.findIndex((f) => f.id === fieldId);
+ const previousField = contentTypeFields[index - 1] || contentTypeFields[contentTypeFields.length - 1];
+ onSelectField?.(previousField);
+ };
+
+ // Keyboard navigation - Left and Up to select previous field, Right and Down to select next field
+ useHotkeys('ArrowLeft,ArrowRight,ArrowUp,ArrowDown', (event) => {
+ if (disableKeyboardNavigation) {
+ return;
+ } else {
+ switch (event.key) {
+ case 'ArrowLeft':
+ case 'ArrowUp':
+ onSelectPreviousField(field.id);
+ break;
+ case 'ArrowRight':
+ case 'ArrowDown':
+ onSelectNextField(field.id);
+ break;
+ }
+ }
+ });
+
+ return (
+
+ {showFieldsNavigation && contentTypeFields.length > 1 && (
+ }
+ onClick={() => onSelectPreviousField(field.id)}
+ title={previousField.name}
+ >
+
+ {previousField.name}
+
+
+ )}
+
+
+ {field.name}
+
+ e.stopPropagation()}>
+ {actions}
+ {!compareXml && fieldType === 'repeat' && (
+
+ )}
+ {(compareXml || typesDiffMap[fieldType] === TextDiffView || typesViewMap[fieldType] === TextView) && (
+ <>
+ {isDiff && (
+ <>
+
+
+ >
+ )}
+
+ >
+ )}
+ {showDivider && (
+
+ )}
+ {fieldType === 'html' && (
+ }>
+
+ contextApiRef.current.setFieldViewState?.(field.id, { cleanText: true, compareXml: false })
+ }
+ color={cleanText && !compareXml ? 'primary' : 'default'}
+ >
+
+
+
+ )}
+ {isMappedFieldType && (
+ <>
+ }>
+
+ contextApiRef.current.setFieldViewState?.(field.id, { cleanText: false, compareXml: false })
+ }
+ color={!cleanText && !compareXml ? 'primary' : 'default'}
+ >
+
+
+
+ }>
+ contextApiRef.current.setFieldViewState?.(field.id, { compareXml: true })}
+ color={compareXml ? 'primary' : 'default'}
+ >
+
+
+
+ >
+ )}
+
+
+ {showFieldsNavigation && contentTypeFields.length > 1 && (
+ }
+ onClick={() => onSelectNextField(field.id)}
+ title={nextField.name}
+ >
+
+ {nextField.name}
+
+
+ )}
+
+ );
+}
+
+export default FieldVersionToolbar;
diff --git a/ui/app/src/components/CompareVersionsDialog/FieldsTypesDiffViews/BooleanDiffView.tsx b/ui/app/src/components/CompareVersionsDialog/FieldsTypesDiffViews/BooleanDiffView.tsx
new file mode 100644
index 0000000000..415a218c7f
--- /dev/null
+++ b/ui/app/src/components/CompareVersionsDialog/FieldsTypesDiffViews/BooleanDiffView.tsx
@@ -0,0 +1,36 @@
+/*
+ * Copyright (C) 2007-2024 Crafter Software Corporation. All Rights Reserved.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 3 as published by
+ * the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+import React from 'react';
+import DiffViewLayout from './DiffViewLayout';
+import BooleanView from '../../ViewVersionDialog/FieldTypesViews/BooleanView';
+import { DiffViewComponentBaseProps } from '../utils';
+
+export interface BooleanDiffViewProps extends DiffViewComponentBaseProps {}
+
+export function BooleanDiffView(props: BooleanDiffViewProps) {
+ const { aXml, bXml, field } = props;
+ return (
+ }
+ />
+ );
+}
+
+export default BooleanDiffView;
diff --git a/ui/app/src/components/CompareVersionsDialog/FieldsTypesDiffViews/CheckboxGroupDiffView.tsx b/ui/app/src/components/CompareVersionsDialog/FieldsTypesDiffViews/CheckboxGroupDiffView.tsx
new file mode 100644
index 0000000000..7d6647ae2f
--- /dev/null
+++ b/ui/app/src/components/CompareVersionsDialog/FieldsTypesDiffViews/CheckboxGroupDiffView.tsx
@@ -0,0 +1,36 @@
+/*
+ * Copyright (C) 2007-2024 Crafter Software Corporation. All Rights Reserved.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 3 as published by
+ * the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+import React from 'react';
+import DiffViewLayout from './DiffViewLayout';
+import CheckboxGroupView from '../../ViewVersionDialog/FieldTypesViews/CheckboxGroupView';
+import { DiffViewComponentBaseProps } from '../utils';
+
+export interface CheckboxGroupDiffViewProps extends DiffViewComponentBaseProps {}
+
+export function CheckboxGroupDiffView(props: CheckboxGroupDiffViewProps) {
+ const { aXml, bXml, field } = props;
+ return (
+ }
+ />
+ );
+}
+
+export default CheckboxGroupDiffView;
diff --git a/ui/app/src/components/CompareVersionsDialog/FieldsTypesDiffViews/ContentInstanceComponents.tsx b/ui/app/src/components/CompareVersionsDialog/FieldsTypesDiffViews/ContentInstanceComponents.tsx
new file mode 100644
index 0000000000..86fbd2a555
--- /dev/null
+++ b/ui/app/src/components/CompareVersionsDialog/FieldsTypesDiffViews/ContentInstanceComponents.tsx
@@ -0,0 +1,219 @@
+/*
+ * Copyright (C) 2007-2024 Crafter Software Corporation. All Rights Reserved.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 3 as published by
+ * the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+import ContentInstance from '../../../models/ContentInstance';
+import React, { useEffect, useMemo, useState } from 'react';
+import { FormattedMessage } from 'react-intl';
+import useItemsByPath from '../../../hooks/useItemsByPath';
+import {
+ ContentInstanceComponentsDiffResult,
+ DiffViewComponentBaseProps,
+ getContentInstanceXmlItemFromIndex,
+ getItemDiffStatus,
+ SelectionContentVersion
+} from '../utils';
+import { diffArrays } from 'diff/lib/diff/array';
+import Box from '@mui/material/Box';
+import { EmptyState } from '../../EmptyState';
+import LookupTable from '../../../models/LookupTable';
+import DiffCollectionItem from './DiffCollectionItem';
+import { useVersionsDialogContext } from '../VersionsDialogContext';
+import { mockContentInstance, parseElementByContentType } from '../../../utils/content';
+import useContentTypes from '../../../hooks/useContentTypes';
+import { fromString } from '../../../utils/xml';
+
+export interface ContentInstanceComponentsProps extends DiffViewComponentBaseProps {}
+
+export function ContentInstanceComponents(props: ContentInstanceComponentsProps) {
+ const { aXml, bXml, field } = props;
+ const contentTypes = useContentTypes();
+ const { contentA, contentB } = useMemo(
+ () => ({
+ contentA: aXml
+ ? parseElementByContentType(fromString(aXml).querySelector(field.id), field, contentTypes, {})
+ : [],
+ contentB: bXml ? parseElementByContentType(fromString(bXml).querySelector(field.id), field, contentTypes, {}) : []
+ }),
+ [aXml, bXml, contentTypes, field]
+ );
+ const [diff, setDiff] = useState(null);
+ const itemsByPath = useItemsByPath();
+ const contentById: LookupTable = useMemo(() => {
+ const byId = {};
+ [...(contentA ?? []), ...(contentB ?? [])].forEach((item) => {
+ if (item.craftercms?.id) {
+ byId[item.craftercms.id] = item;
+ } else {
+ byId[item.key] = item;
+ }
+ });
+ return byId;
+ }, [contentA, contentB]);
+ const [{ viewSlideOutState, compareSlideOutState }, contextApiRef] = useVersionsDialogContext();
+
+ const getItemLabel = (item: ContentInstance): string => {
+ return item.craftercms?.label ?? itemsByPath?.[item.craftercms?.path]?.label ?? item.craftercms?.id ?? item.key;
+ };
+
+ const isEmbedded = (item: ContentInstance): boolean => {
+ return item?.craftercms && !item.craftercms.path;
+ };
+
+ const getEmbeddedVersions = (
+ id: string
+ ): {
+ embeddedA: SelectionContentVersion;
+ embeddedB: SelectionContentVersion;
+ } => {
+ const embeddedAIndex = contentA.findIndex((item) => item.craftercms?.id === id);
+ const embeddedBIndex = contentB.findIndex((item) => item.craftercms?.id === id);
+ return {
+ embeddedA:
+ embeddedAIndex !== -1
+ ? {
+ content: contentA[embeddedAIndex] ?? mockContentInstance,
+ xml: getContentInstanceXmlItemFromIndex(aXml, embeddedAIndex)
+ }
+ : null,
+ embeddedB:
+ embeddedBIndex !== -1
+ ? {
+ content: contentB[embeddedBIndex] ?? mockContentInstance,
+ xml: getContentInstanceXmlItemFromIndex(bXml, embeddedBIndex)
+ }
+ : null
+ };
+ };
+
+ const embeddedItemChanged = (id: string): boolean => {
+ const { embeddedA, embeddedB } = getEmbeddedVersions(id);
+ // If one of the embedded components doesn't exist at a specific version, we consider it unchanged (because it's a new or deleted state, not changed).
+ if (!embeddedA || !embeddedB) {
+ return false;
+ } else {
+ return embeddedA.xml !== embeddedB.xml;
+ }
+ };
+
+ const isEmbeddedWithChanges = (id: string): boolean => {
+ return isEmbedded(contentById[id]) && embeddedItemChanged(id);
+ };
+
+ const onCompareEmbedded = (id: string) => {
+ const { embeddedA, embeddedB } = getEmbeddedVersions(id);
+ const contentTypeId =
+ (embeddedA?.content as ContentInstance)?.craftercms.contentTypeId ??
+ (embeddedB?.content as ContentInstance)?.craftercms.contentTypeId;
+ const fields = contentTypes[contentTypeId].fields;
+ // It may happen that one of the embedded components we're comparing is null (doesn't exist at a specific version),
+ // in that scenario we use a mock (empty) content instance.
+ contextApiRef.current.setState({
+ viewSlideOutState: {
+ ...viewSlideOutState,
+ open: false
+ },
+ compareSlideOutState: {
+ ...compareSlideOutState,
+ open: true,
+ selectionContent: {
+ a: embeddedA,
+ b: embeddedB
+ },
+ fields,
+ title: field.name,
+ subtitle: ,
+ onClose: () => contextApiRef.current.closeSlideOuts()
+ }
+ });
+ };
+
+ const onViewEmbedded = (id: string) => {
+ const { embeddedA, embeddedB } = getEmbeddedVersions(id);
+ const embeddedComponent = embeddedA ?? embeddedB;
+ const fields = contentTypes[(embeddedComponent.content as ContentInstance).craftercms.contentTypeId].fields;
+
+ contextApiRef.current.setState({
+ compareSlideOutState: {
+ ...compareSlideOutState,
+ open: false
+ },
+ viewSlideOutState: {
+ ...viewSlideOutState,
+ open: true,
+ data: {
+ content: embeddedComponent.content as ContentInstance,
+ xml: embeddedComponent.xml,
+ fields
+ },
+ title: field.name,
+ subtitle: ,
+ onClose: () => contextApiRef.current.closeSlideOuts()
+ }
+ });
+ };
+
+ useEffect(() => {
+ setDiff(
+ diffArrays(
+ (contentA ?? []).map((item) => item.craftercms?.id ?? item.key),
+ (contentB ?? []).map((item) => item.craftercms?.id ?? item.key)
+ )
+ );
+ }, [contentA, contentB]);
+
+ return (
+
+
+ {diff?.length ? (
+ diff.map((part) =>
+ part.value.map((id) => (
+
+ ) : (
+ (contentById[id].craftercms?.path ?? contentById[id].value)
+ ))
+ }
+ onSelect={() => {
+ if (isEmbedded(contentById[id])) {
+ isEmbeddedWithChanges(id) ? onCompareEmbedded(id) : onViewEmbedded(id);
+ }
+ }}
+ disableHighlight={!isEmbedded(contentById[id])}
+ />
+ ))
+ )
+ ) : (
+ } />
+ )}
+
+
+ );
+}
+
+export default ContentInstanceComponents;
diff --git a/ui/app/src/components/CompareVersionsDialog/FieldsTypesDiffViews/DateTimeDiffView.tsx b/ui/app/src/components/CompareVersionsDialog/FieldsTypesDiffViews/DateTimeDiffView.tsx
new file mode 100644
index 0000000000..cc2bcacb36
--- /dev/null
+++ b/ui/app/src/components/CompareVersionsDialog/FieldsTypesDiffViews/DateTimeDiffView.tsx
@@ -0,0 +1,36 @@
+/*
+ * Copyright (C) 2007-2024 Crafter Software Corporation. All Rights Reserved.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 3 as published by
+ * the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+import React from 'react';
+import DiffViewLayout from './DiffViewLayout';
+import DateTimeView from '../../ViewVersionDialog/FieldTypesViews/DateTimeView';
+import { DiffViewComponentBaseProps } from '../utils';
+
+export interface DateTimeDiffViewProps extends DiffViewComponentBaseProps {}
+
+export function DateTimeDiffView(props: DateTimeDiffViewProps) {
+ const { aXml, bXml, field } = props;
+ return (
+ }
+ />
+ );
+}
+
+export default DateTimeDiffView;
diff --git a/ui/app/src/components/CompareVersionsDialog/FieldsTypesDiffViews/DefaultDiffView.tsx b/ui/app/src/components/CompareVersionsDialog/FieldsTypesDiffViews/DefaultDiffView.tsx
new file mode 100644
index 0000000000..b6783e2179
--- /dev/null
+++ b/ui/app/src/components/CompareVersionsDialog/FieldsTypesDiffViews/DefaultDiffView.tsx
@@ -0,0 +1,31 @@
+/*
+ * Copyright (C) 2007-2024 Crafter Software Corporation. All Rights Reserved.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 3 as published by
+ * the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+import React from 'react';
+import TextDiffView from './TextDiffView';
+import { DiffViewComponentBaseProps } from '../utils';
+import { DiffEditorProps } from '@monaco-editor/react';
+
+export interface DefaultDiffViewProps extends Pick {
+ editorProps?: DiffEditorProps;
+}
+
+export function DefaultDiffView(props: DefaultDiffViewProps) {
+ const { aXml, bXml, editorProps } = props;
+ return ;
+}
+
+export default DefaultDiffView;
diff --git a/ui/app/src/components/CompareVersionsDialog/FieldsTypesDiffViews/DiffCollectionItem.tsx b/ui/app/src/components/CompareVersionsDialog/FieldsTypesDiffViews/DiffCollectionItem.tsx
new file mode 100644
index 0000000000..8c0eaecbb7
--- /dev/null
+++ b/ui/app/src/components/CompareVersionsDialog/FieldsTypesDiffViews/DiffCollectionItem.tsx
@@ -0,0 +1,147 @@
+/*
+ * Copyright (C) 2007-2024 Crafter Software Corporation. All Rights Reserved.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 3 as published by
+ * the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+import Box from '@mui/material/Box';
+import FormControlLabel from '@mui/material/FormControlLabel';
+import React, { ReactNode } from 'react';
+import Checkbox from '@mui/material/Checkbox';
+import Typography from '@mui/material/Typography';
+import palette from '../../../styles/palette';
+import { FormattedMessage } from 'react-intl';
+import clsx from 'clsx';
+import { lighten, useTheme } from '@mui/material/styles';
+
+export interface DiffCollectionItemProps {
+ state: 'new' | 'deleted' | 'changed' | 'unchanged';
+ primaryText: ReactNode;
+ secondaryText?: ReactNode;
+ isSelected?: boolean;
+ isSelectionMode?: boolean;
+ disableHighlight?: boolean;
+ hideState?: boolean;
+ onSelect?(selected: boolean): void;
+}
+
+export function DiffCollectionItem(props: DiffCollectionItemProps) {
+ const {
+ state,
+ primaryText,
+ secondaryText,
+ isSelected = false,
+ disableHighlight = false,
+ onSelect,
+ isSelectionMode,
+ hideState
+ } = props;
+ const theme = useTheme();
+ const isDarkMode = theme.palette.mode === 'dark';
+
+ return (
+
+ onSelect?.(e.target.checked)}
+ />
+ }
+ label={
+ <>
+
+
+ >
+ }
+ sx={{
+ width: '100%',
+ py: 1,
+ px: 1.25,
+ marginLeft: !isSelectionMode && 0,
+ cursor: disableHighlight && 'default'
+ }}
+ />
+ {!hideState && state === 'changed' && (
+
+
+
+ )}
+ {!hideState && state === 'unchanged' && (
+
+
+
+ )}
+
+ );
+}
+
+export default DiffCollectionItem;
diff --git a/ui/app/src/components/CompareVersionsDialog/FieldsTypesDiffViews/DiffViewLayout.tsx b/ui/app/src/components/CompareVersionsDialog/FieldsTypesDiffViews/DiffViewLayout.tsx
new file mode 100644
index 0000000000..71e6d0a7e7
--- /dev/null
+++ b/ui/app/src/components/CompareVersionsDialog/FieldsTypesDiffViews/DiffViewLayout.tsx
@@ -0,0 +1,65 @@
+/*
+ * Copyright (C) 2007-2024 Crafter Software Corporation. All Rights Reserved.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 3 as published by
+ * the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+import Box from '@mui/material/Box';
+import Typography from '@mui/material/Typography';
+import Divider from '@mui/material/Divider';
+import React, { ReactNode } from 'react';
+import { ContentTypeField } from '../../../models';
+
+export interface DefaultFieldDiffViewProps {
+ aXml: string;
+ bXml: string;
+ field: ContentTypeField;
+ renderContent: (xml: string) => ReactNode;
+ noContent?: ReactNode;
+}
+
+export function DiffViewLayout(props: DefaultFieldDiffViewProps) {
+ const {
+ aXml,
+ bXml,
+ field,
+ renderContent,
+ noContent = (
+
+ no content set
+
+ )
+ } = props;
+ const verticalLayout = field.type === 'image' || field.type === 'video-picker';
+
+ return (
+ div': {
+ flexGrow: verticalLayout && 1
+ }
+ }}
+ >
+ {aXml ? renderContent(aXml) : noContent}
+ {verticalLayout && }
+ {bXml ? renderContent(bXml) : noContent}
+
+ );
+}
+
+export default DiffViewLayout;
diff --git a/ui/app/src/components/CompareVersionsDialog/FieldsTypesDiffViews/FileNameDiffView.tsx b/ui/app/src/components/CompareVersionsDialog/FieldsTypesDiffViews/FileNameDiffView.tsx
new file mode 100644
index 0000000000..4892213333
--- /dev/null
+++ b/ui/app/src/components/CompareVersionsDialog/FieldsTypesDiffViews/FileNameDiffView.tsx
@@ -0,0 +1,38 @@
+/*
+ * Copyright (C) 2007-2024 Crafter Software Corporation. All Rights Reserved.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 3 as published by
+ * the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+import React from 'react';
+import { DiffViewComponentBaseProps } from '../utils';
+import { DiffEditorProps } from '@monaco-editor/react';
+import { fromString } from '../../../utils/xml';
+import { getContentFileNameFromPath } from '../../../utils/content';
+import TextDiffView from './TextDiffView';
+
+export interface FileNameDiffViewProps extends Pick {
+ editorProps?: DiffEditorProps;
+}
+
+export function FileNameDiffView(props: FileNameDiffViewProps) {
+ const { aXml, bXml, editorProps } = props;
+ const pathA = fromString(aXml).querySelector('file-name').textContent;
+ const pathB = fromString(bXml).querySelector('file-name').textContent;
+ const fileNameA = getContentFileNameFromPath(pathA);
+ const fileNameB = getContentFileNameFromPath(pathB);
+
+ return ;
+}
+
+export default FileNameDiffView;
diff --git a/ui/app/src/components/CompareVersionsDialog/FieldsTypesDiffViews/ImageDiffView.tsx b/ui/app/src/components/CompareVersionsDialog/FieldsTypesDiffViews/ImageDiffView.tsx
new file mode 100644
index 0000000000..7698c05971
--- /dev/null
+++ b/ui/app/src/components/CompareVersionsDialog/FieldsTypesDiffViews/ImageDiffView.tsx
@@ -0,0 +1,41 @@
+/*
+ * Copyright (C) 2007-2024 Crafter Software Corporation. All Rights Reserved.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 3 as published by
+ * the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+import React from 'react';
+import ImageView from '../../ViewVersionDialog/FieldTypesViews/ImageView';
+import DiffViewLayout from './DiffViewLayout';
+import Box from '@mui/material/Box';
+import { DiffViewComponentBaseProps } from '../utils';
+
+export interface ImageDiffViewProps extends DiffViewComponentBaseProps {}
+
+export function ImageDiffView(props: ImageDiffViewProps) {
+ const { aXml, bXml, field } = props;
+ return (
+ (
+
+
+
+ )}
+ />
+ );
+}
+
+export default ImageDiffView;
diff --git a/ui/app/src/components/CompareVersionsDialog/FieldsTypesDiffViews/NumberDiffView.tsx b/ui/app/src/components/CompareVersionsDialog/FieldsTypesDiffViews/NumberDiffView.tsx
new file mode 100644
index 0000000000..cb6eab383a
--- /dev/null
+++ b/ui/app/src/components/CompareVersionsDialog/FieldsTypesDiffViews/NumberDiffView.tsx
@@ -0,0 +1,34 @@
+/*
+ * Copyright (C) 2007-2024 Crafter Software Corporation. All Rights Reserved.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 3 as published by
+ * the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+import React from 'react';
+import DiffViewLayout from './DiffViewLayout';
+import NumberView from '../../ViewVersionDialog/FieldTypesViews/NumberView';
+import { DiffViewComponentBaseProps } from '../utils';
+
+export interface NumberDiffViewProps extends DiffViewComponentBaseProps {}
+
+export function NumberDiffView(props: NumberDiffViewProps) {
+ const { aXml, bXml, field } = props;
+ return (
+ }
+ />
+ );
+}
diff --git a/ui/app/src/components/CompareVersionsDialog/FieldsTypesDiffViews/RepeatGroupItems.tsx b/ui/app/src/components/CompareVersionsDialog/FieldsTypesDiffViews/RepeatGroupItems.tsx
new file mode 100644
index 0000000000..c102069833
--- /dev/null
+++ b/ui/app/src/components/CompareVersionsDialog/FieldsTypesDiffViews/RepeatGroupItems.tsx
@@ -0,0 +1,278 @@
+/*
+ * Copyright (C) 2007-2024 Crafter Software Corporation. All Rights Reserved.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 3 as published by
+ * the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+import ContentInstance from '../../../models/ContentInstance';
+import { ContentTypeField } from '../../../models';
+import React, { useEffect, useMemo, useState } from 'react';
+import useSpreadState from '../../../hooks/useSpreadState';
+import { fromString, serialize } from '../../../utils/xml';
+import Box from '@mui/material/Box';
+import { FormattedMessage } from 'react-intl';
+import { deepCopy, nou } from '../../../utils/object';
+import { Alert } from '@mui/material';
+import { DiffViewComponentBaseProps, SelectionContentVersion } from '../utils';
+import DiffCollectionItem from './DiffCollectionItem';
+import { useVersionsDialogContext } from '../VersionsDialogContext';
+import useContentTypes from '../../../hooks/useContentTypes';
+import { parseElementByContentType } from '../../../utils/content';
+
+export interface RepeatGroupItemsProps {
+ contentA: ContentInstance[];
+ contentB: ContentInstance[];
+ aXml: string;
+ bXml: string;
+ field: ContentTypeField;
+}
+
+export type ItemDiffState = 'changed' | 'unchanged' | 'new' | 'deleted';
+type RepItemDiffSide = 'a' | 'b';
+
+export interface RepeatGroupItemsProps extends DiffViewComponentBaseProps {}
+
+export function RepeatGroupItems(props: RepeatGroupItemsProps) {
+ const { aXml, bXml, field } = props;
+ const contentTypes = useContentTypes();
+ const { contentA, contentB } = useMemo(
+ () => ({
+ contentA: aXml
+ ? parseElementByContentType(fromString(aXml).querySelector(field.id), field, contentTypes, {})
+ : [],
+ contentB: bXml ? parseElementByContentType(fromString(bXml).querySelector(field.id), field, contentTypes, {}) : []
+ }),
+ [aXml, bXml, contentTypes, field]
+ );
+ const fields = field.fields;
+ const [repeatGroupDiff, setRepeatGroupDiff] = useState([]);
+ const [itemsCompareModeSelection, setItemsCompareModeSelection] = useSpreadState<{
+ a: SelectionContentVersion & {
+ multiSide: boolean;
+ };
+ b: SelectionContentVersion & {
+ multiSide: boolean;
+ };
+ }>({
+ a: null,
+ b: null
+ });
+ const isCompareModeEnabled = itemsCompareModeSelection.a?.content && itemsCompareModeSelection.b?.content;
+ const areSelectedItemsEqual =
+ isCompareModeEnabled && itemsCompareModeSelection.a?.xml === itemsCompareModeSelection.b?.xml;
+ const [{ fieldsViewState, viewSlideOutState, compareSlideOutState }, contextApiRef] = useVersionsDialogContext();
+ const compareMode = fieldsViewState[field.id]?.compareMode;
+
+ const getItemDataAtVersion = (side: RepItemDiffSide, index: number): { content: ContentInstance; xml: string } => {
+ const content = side === 'a' ? contentA : contentB;
+ const xml = side === 'a' ? aXml : bXml;
+ // When selecting an item on the rep-group diff view, we need to calculate its xml (so the items can be compared
+ // using the CompareFieldPanel).
+ const doc = fromString(xml).querySelectorAll('item')[index];
+ const itemXml = doc ? serialize(doc) : '';
+ const item = content[index];
+
+ return {
+ content: item,
+ xml: itemXml
+ };
+ };
+
+ const onSelectItemToCompare = (checked: boolean, side: RepItemDiffSide, index: number, multiSide = false): void => {
+ const { content, xml } = getItemDataAtVersion(side, index);
+ setItemsCompareModeSelection({
+ [side]: {
+ multiSide,
+ content: checked ? content : null,
+ xml: checked ? xml : null
+ }
+ });
+ };
+
+ const onSelectDeletedItemToCompare = (checked: boolean, index: number): void => {
+ // Deleted items will always show on the left side of the comparison (side 'a'), and since when selecting an 'unchanged'
+ // or 'changed' item we default it to side 'a', if there's a 'multiSide' item selected, switch it to side 'b'
+ if (itemsCompareModeSelection.a?.multiSide) {
+ const switchItem = deepCopy(itemsCompareModeSelection.a);
+ onSelectItemToCompare(checked, 'a', index);
+ setItemsCompareModeSelection({ b: switchItem });
+ } else {
+ onSelectItemToCompare(checked, 'a', index);
+ }
+ };
+
+ const onViewItemVersion = (side: RepItemDiffSide, index: number): void => {
+ const { content, xml } = getItemDataAtVersion(side, index);
+ contextApiRef.current.setState({
+ compareSlideOutState: {
+ ...compareSlideOutState,
+ open: false
+ },
+ viewSlideOutState: {
+ ...viewSlideOutState,
+ open: true,
+ error: null,
+ isFetching: false,
+ data: { content, xml, fields },
+ title: field.name,
+ subtitle: ,
+ onClose: () => contextApiRef.current.closeSlideOuts()
+ }
+ });
+ };
+
+ const isItemSelected = (side: RepItemDiffSide, index: number): boolean => {
+ const contentToCompare = side === 'a' ? contentA?.[index] : contentB?.[index];
+ return compareMode && itemsCompareModeSelection[side]?.content === contentToCompare;
+ };
+
+ const onSelectItemAction = (
+ checked: boolean,
+ side: RepItemDiffSide,
+ index: number,
+ diffState: ItemDiffState
+ ): void => {
+ if (diffState === 'changed' || diffState === 'unchanged') {
+ if (compareMode) {
+ const oppositeSide = side === 'a' ? 'b' : 'a';
+ let selectSide = side;
+ // If current side already has a value (an item selected), and it's multi-side, add the selection to the opposite side.
+ if (itemsCompareModeSelection[side]?.multiSide) {
+ selectSide = oppositeSide;
+ }
+ onSelectItemToCompare(checked, selectSide, index, true);
+ } else {
+ if (diffState === 'changed') {
+ onSelectItemToCompare(checked, 'a', index);
+ // If items have changed Compare both versions on current item
+ onSelectItemToCompare(checked, 'b', index);
+ } else {
+ onViewItemVersion(side, index);
+ }
+ }
+ } else {
+ if (compareMode) {
+ if (diffState === 'new') {
+ onSelectItemToCompare(checked, side, index);
+ } else {
+ onSelectDeletedItemToCompare(checked, index);
+ }
+ } else {
+ onViewItemVersion(side, index);
+ }
+ }
+ };
+
+ useEffect(() => {
+ const contentALength = (contentA ?? []).length;
+ const contentBLength = (contentB ?? []).length;
+ const maxLength = contentALength > contentBLength ? contentALength : contentBLength;
+ const diffArray = [];
+
+ for (let x = 0; x < maxLength; x++) {
+ const itemA = contentA?.[x] ? JSON.stringify(contentA[x]) : null;
+ const itemB = contentB?.[x] ? JSON.stringify(contentB[x]) : null;
+
+ if (itemA && itemB) {
+ const result = itemA === itemB ? 'unchanged' : 'changed';
+ diffArray.push({ a: result, b: result });
+ } else {
+ diffArray.push({ a: itemA ? 'deleted' : null, b: itemB ? 'new' : null });
+ }
+ }
+ setRepeatGroupDiff(diffArray);
+ }, [contentA, contentB, setRepeatGroupDiff]);
+
+ useEffect(() => {
+ if (itemsCompareModeSelection.a?.content && itemsCompareModeSelection.b?.content) {
+ contextApiRef.current.setState({
+ viewSlideOutState: {
+ ...viewSlideOutState,
+ open: false
+ },
+ compareSlideOutState: {
+ ...compareSlideOutState,
+ open: true,
+ error: null,
+ isFetching: false,
+ selectionContent: deepCopy(itemsCompareModeSelection),
+ fields,
+ title: field.name,
+ subtitle: ,
+ onClose: () => contextApiRef.current.closeSlideOuts()
+ }
+ });
+ setItemsCompareModeSelection?.({ a: null, b: null });
+ }
+ }, [
+ itemsCompareModeSelection,
+ fields,
+ compareMode,
+ field.id,
+ setItemsCompareModeSelection,
+ field.name,
+ contextApiRef
+ ]);
+
+ useEffect(() => {
+ // When selecting 'unchanged' or 'changed' items (not 'new' or 'deleted'), we default the selection to side 'a',
+ // so if we then select a 'new' or 'deleted' item, we need to switch the selection to the opposite side. Here we
+ // check if the selected items are both in the same side and switch accordingly.
+ const diffItemsSameSide =
+ repeatGroupDiff.every((entry) => nou(entry.a)) || repeatGroupDiff.every((entry) => nou(entry.b));
+ contextApiRef.current.setFieldViewState(field.id, {
+ compareModeDisabled: diffItemsSameSide
+ });
+ }, [contextApiRef, repeatGroupDiff, field?.id]);
+
+ return (
+
+ {compareMode && areSelectedItemsEqual && (
+
+
+
+ )}
+ {repeatGroupDiff?.map((item, index) => (
+
+ }
+ isSelectionMode={compareMode}
+ isSelected={isItemSelected(item.a ? 'a' : 'b', index)}
+ onSelect={(selected) => onSelectItemAction(selected, item.a ? 'a' : 'b', index, item.a ?? item.b)}
+ />
+
+ ))}
+
+ );
+}
+
+export default RepeatGroupItems;
diff --git a/ui/app/src/components/CompareVersionsDialog/FieldsTypesDiffViews/TextDiffView.tsx b/ui/app/src/components/CompareVersionsDialog/FieldsTypesDiffViews/TextDiffView.tsx
new file mode 100644
index 0000000000..4b6ad93778
--- /dev/null
+++ b/ui/app/src/components/CompareVersionsDialog/FieldsTypesDiffViews/TextDiffView.tsx
@@ -0,0 +1,72 @@
+/*
+ * Copyright (C) 2007-2024 Crafter Software Corporation. All Rights Reserved.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 3 as published by
+ * the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+import { ContentTypeField } from '../../../models';
+import { DiffEditor, DiffEditorProps } from '@monaco-editor/react';
+import { useVersionsDialogContext } from '../VersionsDialogContext';
+import { DiffViewComponentBaseProps, removeTags } from '../utils';
+import useMediaQuery from '@mui/material/useMediaQuery';
+import React from 'react';
+import { parseElementByContentType } from '../../../utils/content';
+import { fromString } from '../../../utils/xml';
+import useContentTypes from '../../../hooks/useContentTypes';
+import { textViewLanguageMap } from '../../ViewVersionDialog/utils';
+
+export interface TextDiffViewProps extends Pick {
+ field?: ContentTypeField;
+ editorProps?: DiffEditorProps;
+}
+
+export function TextDiffView(props: TextDiffViewProps) {
+ const { aXml, bXml, field, editorProps } = props;
+ const contentTypes = useContentTypes();
+ const contentA =
+ aXml && field ? parseElementByContentType(fromString(aXml).querySelector(field.id), field, contentTypes, {}) : aXml;
+ const contentB =
+ bXml && field ? parseElementByContentType(fromString(bXml).querySelector(field.id), field, contentTypes, {}) : bXml;
+ const [{ fieldsViewState }] = useVersionsDialogContext();
+ const cleanText = field && fieldsViewState[field.id]?.cleanText;
+ const originalContent = cleanText ? removeTags(contentA ?? '') : contentA;
+ const modifiedContent = cleanText ? removeTags(contentB ?? '') : contentB;
+ const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)');
+ const language = textViewLanguageMap[field?.type] || 'xml';
+
+ const monacoOptions: DiffEditorProps['options'] = {
+ readOnly: true,
+ automaticLayout: true,
+ fontSize: 14,
+ contextmenu: false,
+ scrollBeyondLastLine: false,
+ renderWhitespace: 'none',
+ renderIndicators: false,
+ scrollbar: { alwaysConsumeMouseWheel: false },
+ ...editorProps?.options
+ };
+
+ return (
+
+ );
+}
+
+export default TextDiffView;
diff --git a/ui/app/src/components/CompareVersionsDialog/FieldsTypesDiffViews/TimeDiffView.tsx b/ui/app/src/components/CompareVersionsDialog/FieldsTypesDiffViews/TimeDiffView.tsx
new file mode 100644
index 0000000000..2ae23e4888
--- /dev/null
+++ b/ui/app/src/components/CompareVersionsDialog/FieldsTypesDiffViews/TimeDiffView.tsx
@@ -0,0 +1,36 @@
+/*
+ * Copyright (C) 2007-2024 Crafter Software Corporation. All Rights Reserved.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 3 as published by
+ * the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+import React from 'react';
+import DiffViewLayout from './DiffViewLayout';
+import TimeView from '../../ViewVersionDialog/FieldTypesViews/TimeView';
+import { DiffViewComponentBaseProps } from '../utils';
+
+export interface TimeDiffViewProps extends DiffViewComponentBaseProps {}
+
+export function TimeDiffView(props: TimeDiffViewProps) {
+ const { aXml, bXml, field } = props;
+ return (
+ }
+ />
+ );
+}
+
+export default TimeDiffView;
diff --git a/ui/app/src/components/CompareVersionsDialog/FieldsTypesDiffViews/VideoDiffView.tsx b/ui/app/src/components/CompareVersionsDialog/FieldsTypesDiffViews/VideoDiffView.tsx
new file mode 100644
index 0000000000..6d6a48e3ab
--- /dev/null
+++ b/ui/app/src/components/CompareVersionsDialog/FieldsTypesDiffViews/VideoDiffView.tsx
@@ -0,0 +1,36 @@
+/*
+ * Copyright (C) 2007-2024 Crafter Software Corporation. All Rights Reserved.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 3 as published by
+ * the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+import React from 'react';
+import VideoView from '../../ViewVersionDialog/FieldTypesViews/VideoView';
+import DiffViewLayout from './DiffViewLayout';
+import { DiffViewComponentBaseProps } from '../utils';
+
+export interface VideoDiffViewProps extends DiffViewComponentBaseProps {}
+
+export function VideoDiffView(props: VideoDiffViewProps) {
+ const { aXml, bXml, field } = props;
+ return (
+ }
+ />
+ );
+}
+
+export default VideoDiffView;
diff --git a/ui/app/src/components/CompareVersionsDialog/VersionsDialogContext.tsx b/ui/app/src/components/CompareVersionsDialog/VersionsDialogContext.tsx
new file mode 100644
index 0000000000..4029297ed1
--- /dev/null
+++ b/ui/app/src/components/CompareVersionsDialog/VersionsDialogContext.tsx
@@ -0,0 +1,80 @@
+/*
+ * Copyright (C) 2007-2024 Crafter Software Corporation. All Rights Reserved.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 3 as published by
+ * the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+import { createContext, MutableRefObject, useContext } from 'react';
+import { DiffEditorProps } from '@monaco-editor/react';
+import { CompareVersionsDialogProps } from './utils';
+import { ViewVersionDialogProps } from '../ViewVersionDialog/utils';
+import { LookupTable } from '../../models';
+import ContentType from '../../models/ContentType';
+
+export interface FieldViewState {
+ compareXml: boolean;
+ cleanText: boolean;
+ compareMode: boolean;
+ compareModeDisabled: boolean;
+ monacoOptions: DiffEditorProps['options'];
+}
+
+export interface VersionsDialogContextProps {
+ compareSlideOutState?: CompareVersionsDialogProps & { compareXml: boolean };
+ viewSlideOutState: ViewVersionDialogProps & { showXml: boolean };
+ fieldsViewState: LookupTable;
+ contentType: ContentType;
+ enableDialogActions: boolean;
+}
+
+export const initialFieldViewState = {
+ compareXml: false,
+ cleanText: false,
+ compareMode: false,
+ compareModeDisabled: false,
+ monacoOptions: {
+ ignoreTrimWhitespace: false,
+ renderSideBySide: true,
+ diffWordWrap: 'off' as DiffEditorProps['options']['diffWordWrap'],
+ wordWrap: 'on' as DiffEditorProps['options']['wordWrap']
+ }
+};
+
+export const dialogInitialState: VersionsDialogContextProps = {
+ compareSlideOutState: { open: false, isFetching: false, error: null, compareXml: false },
+ viewSlideOutState: { open: false, isFetching: false, error: null, showXml: false },
+ fieldsViewState: {},
+ contentType: null,
+ enableDialogActions: false
+};
+
+export interface VersionsDialogContextApi {
+ setState: (state: Partial) => void;
+ setCompareSlideOutState: (props: Partial) => void;
+ setViewSlideOutState: (props: Partial) => void;
+ setFieldViewState: (fieldId: string, viewState: Partial) => void;
+ setFieldViewEditorOptionsState: (fieldId: string, options: DiffEditorProps['options']) => void;
+ closeSlideOuts: () => void;
+}
+
+export type VersionsDialogContextType = [VersionsDialogContextProps, MutableRefObject];
+
+export const VersionsDialogContext = createContext(null);
+
+export function useVersionsDialogContext() {
+ const context = useContext(VersionsDialogContext);
+ if (!context) {
+ throw new Error('useVersionsDialogContext must be used within a VersionsDialogContext');
+ }
+ return context;
+}
diff --git a/ui/app/src/components/CompareVersionsDialog/translations.ts b/ui/app/src/components/CompareVersionsDialog/translations.ts
index e8c1203518..0ee69d44db 100644
--- a/ui/app/src/components/CompareVersionsDialog/translations.ts
+++ b/ui/app/src/components/CompareVersionsDialog/translations.ts
@@ -17,9 +17,11 @@
import { defineMessages } from 'react-intl';
export const translations = defineMessages({
- backToSelectRevision: {
- id: 'compareVersionsDialog.back.selectRevision',
- defaultMessage: 'Back to select revision'
+ compareXml: {
+ defaultMessage: 'XML'
+ },
+ compareContent: {
+ defaultMessage: 'Content'
}
});
diff --git a/ui/app/src/components/CompareVersionsDialog/utils.ts b/ui/app/src/components/CompareVersionsDialog/utils.ts
index 155ea58f4c..9efef92621 100644
--- a/ui/app/src/components/CompareVersionsDialog/utils.ts
+++ b/ui/app/src/components/CompareVersionsDialog/utils.ts
@@ -19,10 +19,25 @@ import StandardAction from '../../models/StandardAction';
import ApiResponse from '../../models/ApiResponse';
import { ItemHistoryEntry, VersionsStateProps } from '../../models/Version';
import { EntityState } from '../../models/EntityState';
-import ContentType from '../../models/ContentType';
+import ContentType, { ContentTypeField } from '../../models/ContentType';
import { EnhancedDialogProps } from '../EnhancedDialog';
import { EnhancedDialogState } from '../../hooks/useEnhancedDialogState';
import { DialogHeaderActionProps } from '../DialogHeaderAction';
+import ContentInstance from '../../models/ContentInstance';
+import { ElementType, ReactNode } from 'react';
+import { LookupTable } from '../../models';
+import RepeatGroupItems, { ItemDiffState } from './FieldsTypesDiffViews/RepeatGroupItems';
+import { fromString, serialize } from '../../utils/xml';
+import TextDiffView from './FieldsTypesDiffViews/TextDiffView';
+import ContentInstanceComponents from './FieldsTypesDiffViews/ContentInstanceComponents';
+import CheckboxGroupDiffView from './FieldsTypesDiffViews/CheckboxGroupDiffView';
+import ImageDiffView from './FieldsTypesDiffViews/ImageDiffView';
+import VideoDiffView from './FieldsTypesDiffViews/VideoDiffView';
+import TimeDiffView from './FieldsTypesDiffViews/TimeDiffView';
+import DateTimeDiffView from './FieldsTypesDiffViews/DateTimeDiffView';
+import BooleanDiffView from './FieldsTypesDiffViews/BooleanDiffView';
+import { NumberDiffView } from './FieldsTypesDiffViews/NumberDiffView';
+import FileNameDiffView from './FieldsTypesDiffViews/FileNameDiffView';
export interface CompareVersionsDialogBaseProps {
error: ApiResponse;
@@ -30,10 +45,21 @@ export interface CompareVersionsDialogBaseProps {
disableItemSwitching?: boolean;
}
+export interface SelectionContentVersion {
+ xml?: string;
+ content: ContentInstance | string;
+}
+
export interface CompareVersionsDialogProps extends CompareVersionsDialogBaseProps, EnhancedDialogProps {
- versionsBranch: VersionsStateProps;
- selectedA: ItemHistoryEntry;
- selectedB: ItemHistoryEntry;
+ subtitle?: ReactNode;
+ versionsBranch?: VersionsStateProps;
+ selectedA?: ItemHistoryEntry;
+ selectedB?: ItemHistoryEntry;
+ selectionContent?: {
+ a: SelectionContentVersion;
+ b: SelectionContentVersion;
+ };
+ fields?: LookupTable;
contentTypesBranch?: EntityState;
leftActions?: DialogHeaderActionProps[];
rightActions?: DialogHeaderActionProps[];
@@ -50,5 +76,112 @@ export interface CompareVersionsDialogContainerProps
extends CompareVersionsDialogBaseProps,
Pick<
CompareVersionsDialogProps,
- 'contentTypesBranch' | 'versionsBranch' | 'selectedA' | 'selectedB' | 'disableItemSwitching'
- > {}
+ | 'contentTypesBranch'
+ | 'versionsBranch'
+ | 'selectedA'
+ | 'selectedB'
+ | 'disableItemSwitching'
+ | 'selectionContent'
+ | 'fields'
+ > {
+ compareXml: boolean;
+}
+
+export interface DiffViewComponentBaseProps {
+ aXml?: string;
+ bXml?: string;
+ field?: ContentTypeField;
+ aContent?: string;
+ bContent?: string;
+}
+
+export type ContentInstanceComponentsDiffResult = {
+ count: number;
+ added: boolean;
+ removed: boolean;
+ value: string[];
+};
+
+/**
+ * Determines the ItemDiffState, based on the result object given by jsdiff.diffArrays.
+ *
+ * @param {ContentInstanceComponentsDiffResult} diff - The diff result of the content.
+ * @returns {ItemDiffState} - The status of the item difference: 'new', 'deleted', or 'unchanged'.
+ */
+export const getItemDiffStatus = (diff: ContentInstanceComponentsDiffResult): ItemDiffState => {
+ if (diff.added) {
+ return 'new';
+ }
+ if (diff.removed) {
+ return 'deleted';
+ }
+ return 'unchanged';
+};
+
+/**
+ * Removes all HTML tags from the given content string.
+ *
+ * @param {string} content - The content string from which to remove HTML tags.
+ * @returns {string} - The content string without HTML tags.
+ */
+export function removeTags(content: string): string {
+ return content.replace(/<[^>]*>?/gm, '');
+}
+
+export const getContentInstanceXmlItemFromIndex = (xml: string, index: number): string => {
+ const doc = fromString(xml).querySelectorAll('item')[index];
+ return doc ? serialize(doc) : '';
+};
+
+export const typesDiffMap: Record = {
+ 'file-name': FileNameDiffView,
+ text: TextDiffView,
+ textarea: TextDiffView,
+ html: TextDiffView,
+ 'node-selector': ContentInstanceComponents,
+ 'checkbox-group': CheckboxGroupDiffView,
+ repeat: RepeatGroupItems,
+ image: ImageDiffView,
+ 'video-picker': VideoDiffView,
+ time: TimeDiffView,
+ 'date-time': DateTimeDiffView,
+ boolean: BooleanDiffView,
+ 'page-nav-order': BooleanDiffView,
+ 'numeric-input': NumberDiffView,
+ dropdown: TextDiffView
+};
+
+export const getDialogHeaderActions = ({
+ xmlMode,
+ contentActionLabel,
+ xmlActionLabel,
+ onClickContent,
+ onClickXml
+}: {
+ xmlMode: boolean;
+ contentActionLabel: string;
+ xmlActionLabel: string;
+ onClickContent: () => void;
+ onClickXml: () => void;
+}) => {
+ return [
+ {
+ icon: { id: '@mui/icons-material/TextSnippetOutlined' },
+ text: contentActionLabel,
+ onClick: onClickContent,
+ sx: {
+ color: (theme) => (xmlMode ? theme.palette.text.secondary : theme.palette.primary.main),
+ fontSize: 14
+ }
+ },
+ {
+ icon: { id: '@mui/icons-material/CodeRounded' },
+ text: xmlActionLabel,
+ onClick: onClickXml,
+ sx: {
+ color: (theme) => (xmlMode ? theme.palette.primary.main : theme.palette.text.secondary),
+ fontSize: 14
+ }
+ }
+ ];
+};
diff --git a/ui/app/src/components/DialogHeaderAction/DialogHeaderAction.tsx b/ui/app/src/components/DialogHeaderAction/DialogHeaderAction.tsx
index 87bac321e8..4acf9567db 100644
--- a/ui/app/src/components/DialogHeaderAction/DialogHeaderAction.tsx
+++ b/ui/app/src/components/DialogHeaderAction/DialogHeaderAction.tsx
@@ -14,24 +14,36 @@
* along with this program. If not, see .
*/
-import IconButton, { IconButtonProps } from '@mui/material/IconButton';
+import IconButton from '@mui/material/IconButton';
import React from 'react';
import Tooltip from '@mui/material/Tooltip';
import SystemIcon, { SystemIconDescriptor } from '../SystemIcon';
+import Button, { ButtonProps } from '@mui/material/Button';
-export interface DialogHeaderActionProps extends IconButtonProps {
+export interface DialogHeaderActionProps extends ButtonProps {
icon: SystemIconDescriptor;
+ text?: string;
tooltip?: string;
}
export function DialogHeaderAction(props: DialogHeaderActionProps) {
- const { icon, tooltip, disabled = false, ...rest } = props;
+ const { icon, text, tooltip, disabled = false, ...rest } = props;
return tooltip ? (
-
-
-
+ {text ? (
+ } disabled={disabled} size="large">
+ {text}
+
+ ) : (
+
+
+
+ )}
+ ) : text ? (
+ } disabled={disabled} size="large">
+ {text}
+
) : (
diff --git a/ui/app/src/components/EnhancedDialog/EnhancedDialog.tsx b/ui/app/src/components/EnhancedDialog/EnhancedDialog.tsx
index 27344a8bd0..996ebc146a 100644
--- a/ui/app/src/components/EnhancedDialog/EnhancedDialog.tsx
+++ b/ui/app/src/components/EnhancedDialog/EnhancedDialog.tsx
@@ -41,7 +41,6 @@ export interface EnhancedDialogProps extends Omit, Enha
export function EnhancedDialog(props: EnhancedDialogProps) {
// region const { ... } = props
const {
- id,
open,
isSubmitting = false,
hasPendingChanges = false,
diff --git a/ui/app/src/components/HistoryDialog/HistoryDialogContainer.tsx b/ui/app/src/components/HistoryDialog/HistoryDialogContainer.tsx
index 4d3b3e4f80..0d5aab6e04 100644
--- a/ui/app/src/components/HistoryDialog/HistoryDialogContainer.tsx
+++ b/ui/app/src/components/HistoryDialog/HistoryDialogContainer.tsx
@@ -66,6 +66,7 @@ import { contentEvent } from '../../state/actions/system';
import { getHostToHostBus } from '../../utils/subjects';
import { filter } from 'rxjs/operators';
import { getRootPath } from '../../utils/path';
+import { isComparableAsset } from '../../utils/content';
export function HistoryDialogContainer(props: HistoryDialogContainerProps) {
const { versionsBranch, error } = props;
@@ -82,15 +83,14 @@ export function HistoryDialogContainer(props: HistoryDialogContainerProps) {
const timeoutRef = useRef(null);
const isItemPreviewable = isPreviewable(item);
// Item may be null for config items in config management.
- const isDiffSupported = ['page', 'component', 'taxonomy'].includes(item?.systemType);
- const [compareMode, setCompareMode] = useState(false);
- const [selectedCompareVersions, setSelectedCompareVersions] = useState([]);
-
+ const isDiffSupported = ['page', 'component', 'taxonomy'].includes(item?.systemType) || isComparableAsset(item);
+ const [compareMode, setCompareMode] = useState(false);
+ const [selectedCompareVersions, setSelectedCompareVersions] = useState([]);
const [menu, setMenu] = useSpreadState