Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(history): restore version #20102

Merged
merged 9 commits into from
Apr 16, 2024
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useQueryParams } from '@strapi/admin/strapi-admin';
import { BaseHeaderLayout, Typography } from '@strapi/design-system';
import { BaseHeaderLayout, Button, Typography } from '@strapi/design-system';
import { Link } from '@strapi/design-system/v2';
import { ArrowLeft } from '@strapi/icons';
import { stringify } from 'qs';
Expand All @@ -8,7 +8,7 @@ import { NavLink, useParams, type To } from 'react-router-dom';

import { COLLECTION_TYPES } from '../../constants/collections';
import { useHistoryContext } from '../pages/History';

import { useRestoreVersionMutation } from '../services/historyVersion';
interface VersionHeaderProps {
markkaylor marked this conversation as resolved.
Show resolved Hide resolved
headerId: string;
}
Expand All @@ -24,6 +24,7 @@ export const VersionHeader = ({ headerId }: VersionHeaderProps) => {
plugins?: Record<string, unknown>;
}>();
const { collectionType } = useParams<{ collectionType: string }>();
const [restoreVersion] = useRestoreVersionMutation();

const mainFieldValue = version.data[mainField];

Expand All @@ -43,6 +44,10 @@ export const VersionHeader = ({ headerId }: VersionHeaderProps) => {
};
};

const handleRestore = async () => {
await restoreVersion({ params: { versionId: version.id } });
};

return (
<BaseHeaderLayout
id={headerId}
Expand Down Expand Up @@ -83,6 +88,7 @@ export const VersionHeader = ({ headerId }: VersionHeaderProps) => {
</Link>
}
sticky={false}
primaryAction={<Button onClick={handleRestore}>Restore</Button>}
markkaylor marked this conversation as resolved.
Show resolved Hide resolved
/>
);
};
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { GetHistoryVersions } from '../../../../shared/contracts/history-versions';
import {
GetHistoryVersions,
RestoreHistoryVersion,
} from '../../../../shared/contracts/history-versions';
import { contentManagerApi } from '../../services/api';

const historyVersionsApi = contentManagerApi.injectEndpoints({
Expand All @@ -18,9 +21,20 @@ const historyVersionsApi = contentManagerApi.injectEndpoints({
},
providesTags: ['HistoryVersion'],
}),
restoreVersion: builder.mutation<RestoreHistoryVersion.Response, RestoreHistoryVersion.Request>(
{
query({ params }) {
return {
markkaylor marked this conversation as resolved.
Show resolved Hide resolved
url: `/content-manager/history-versions/${params.versionId}/restore`,
method: 'PUT',
};
},
invalidatesTags: ['HistoryVersion'],
}
),
}),
});

const { useGetHistoryVersionsQuery } = historyVersionsApi;
const { useGetHistoryVersionsQuery, useRestoreVersionMutation } = historyVersionsApi;

export { useGetHistoryVersionsQuery };
export { useGetHistoryVersionsQuery, useRestoreVersionMutation };
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { Core, UID } from '@strapi/types';
import { getService as getContentManagerService } from '../../utils';
import { getService } from '../utils';
import { HistoryVersions } from '../../../../shared/contracts';
import { RestoreHistoryVersion } from '../../../../shared/contracts/history-versions';

/**
* Parses pagination params and makes sure they're within valid ranges
Expand Down Expand Up @@ -68,6 +69,18 @@ const createHistoryVersionController = ({ strapi }: { strapi: Core.Strapi }) =>

return { data: results, meta: { pagination } };
},

async restoreVersion(ctx) {
markkaylor marked this conversation as resolved.
Show resolved Hide resolved
const versionId: RestoreHistoryVersion.Request['params']['versionId'] = ctx.params.versionId;
markkaylor marked this conversation as resolved.
Show resolved Hide resolved

const restoredDocument = await getService(strapi, 'history').restoreVersion(versionId);

if (!restoredDocument) {
throw new errors.ApplicationError('Failed to restore version');
}
markkaylor marked this conversation as resolved.
Show resolved Hide resolved

return { data: { documentId: restoredDocument.documentId } };
markkaylor marked this conversation as resolved.
Show resolved Hide resolved
},
} satisfies Core.Controller;
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,15 @@ const historyVersionRouter: Plugin.LoadedPlugin['routes'][string] = {
policies: ['admin::isAuthenticatedAdmin'],
},
},
{
method: 'PUT',
info,
path: '/history-versions/:versionId/restore',
handler: 'history-version.restoreVersion',
config: {
policies: ['admin::isAuthenticatedAdmin'],
},
},
],
};

Expand Down
107 changes: 106 additions & 1 deletion packages/core/content-manager/server/src/history/services/history.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Core, Modules, UID, Data, Schema } from '@strapi/types';
import type { Core, Modules, UID, Data, Schema, Struct } from '@strapi/types';
import { contentTypes } from '@strapi/utils';
import { omit, pick } from 'lodash/fp';

Expand Down Expand Up @@ -395,6 +395,111 @@ const createHistoryService = ({ strapi }: { strapi: Core.Strapi }) => {
pagination,
};
},

async restoreVersion(versionId: Data.ID) {
const version = await query.findOne({ where: { id: versionId } });
const contentTypeSchemaAttributes = strapi.getModel(version.contentType).attributes;
const schemaDiff = getSchemaAttributesDiff(version.schema, contentTypeSchemaAttributes);

// Set all added attribute values to null
const dataWithoutAddedAttributes = Object.keys(schemaDiff.added).reduce(
(currentData, addedKey) => {
currentData[addedKey] = null;
return currentData;
},
// Clone to avoid mutating the original version data
structuredClone(version.data)
);
const sanitizedSchemaAttributes = omit(
FIELDS_TO_IGNORE,
contentTypeSchemaAttributes
) as Struct.SchemaAttributes;
// Set all deleted relation values to null
const dataWithoutMissingRelationsPromise = Object.entries(sanitizedSchemaAttributes).reduce(
async (
markkaylor marked this conversation as resolved.
Show resolved Hide resolved
markkaylor marked this conversation as resolved.
Show resolved Hide resolved
previousRelationAttributesPromise: Promise<Record<string, unknown>>,
[name, attribute]: [string, Schema.Attribute.AnyAttribute]
) => {
const previousRelationAttributes = await previousRelationAttributesPromise;

const relationData = version.data[name];
if (relationData === null) {
return previousRelationAttributes;
}

if (
attribute.type === 'relation' &&
// TODO: handle polymorphic relations
attribute.relation !== 'morphToOne' &&
attribute.relation !== 'morphToMany'
) {
if (Array.isArray(relationData)) {
if (relationData.length === 0) return previousRelationAttributes;

const existingAndMissingRelations = await Promise.all(
relationData.map((relation) => {
return strapi.documents(attribute.target).findOne({
documentId: relation.documentId,
locale: relation.locale || undefined,
});
})
);
const existingRelations = existingAndMissingRelations.filter(
(relation) => relation !== null
) as Modules.Documents.AnyDocument[];

previousRelationAttributes[name] = existingRelations;
} else {
const existingRelation = await strapi.documents(attribute.target).findOne({
documentId: relationData.documentId,
locale: relationData.locale || undefined,
});

if (!existingRelation) {
previousRelationAttributes[name] = null;
}
}
}

if (attribute.type === 'media') {
if (attribute.multiple) {
const existingAndMissingMedias = await Promise.all(
relationData.map((media) => {
return strapi.db
.query('plugin::upload.file')
.findOne({ where: { id: media.id } });
})
);

const existingMedias = existingAndMissingMedias.filter((media) => media != null);
previousRelationAttributes[name] = existingMedias;
} else {
const existingMedia = await strapi.db
.query('plugin::upload.file')
.findOne({ where: { id: version.data[name].id } });

if (!existingMedia) {
previousRelationAttributes[name] = null;
}
}
}

return previousRelationAttributes;
},
// Clone to avoid mutating the original version data
Promise.resolve(structuredClone(dataWithoutAddedAttributes))
);
markkaylor marked this conversation as resolved.
Show resolved Hide resolved

// Create a new draft with the version data
const dataWithoutMissingRelations = await dataWithoutMissingRelationsPromise;
const data = omit(['id', ...Object.keys(schemaDiff.removed)], dataWithoutMissingRelations);
const restoredDocument = await strapi.documents(version.contentType).update({
documentId: version.relatedDocumentId,
data,
});

return restoredDocument;
},
};
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ import { CreateHistoryVersion } from '../../../../shared/contracts/history-versi
import { FIELDS_TO_IGNORE } from '../constants';

/**
* @description
* Get the difference between the version schema and the content type schema
* Returns the attributes with their original shape
* @returns the attributes with their original shape
*/
export const getSchemaAttributesDiff = (
versionSchemaAttributes: CreateHistoryVersion['schema'],
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Data, Schema, Struct, UID } from '@strapi/types';
import type { Data, Struct, UID } from '@strapi/types';
import { type errors } from '@strapi/utils';

/**
Expand Down Expand Up @@ -77,3 +77,24 @@ export declare namespace GetHistoryVersions {
error: errors.ApplicationError;
};
}

export declare namespace RestoreHistoryVersion {
export interface Request {
params: {
versionId: Data.ID;
};
}

export type Response =
| {
data: {
documentId: HistoryVersionDataResponse['id'];
};
error?: never;
}
| {
data?: never;
meta?: never;
error: errors.ApplicationError;
};
}