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 6 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,6 +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 {
headerId: string;
Expand All @@ -24,6 +25,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 +45,13 @@ export const VersionHeader = ({ headerId }: VersionHeaderProps) => {
};
};

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

return (
<BaseHeaderLayout
id={headerId}
Expand Down Expand Up @@ -83,6 +92,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,21 @@ const historyVersionsApi = contentManagerApi.injectEndpoints({
},
providesTags: ['HistoryVersion'],
}),
restoreVersion: builder.mutation<RestoreHistoryVersion.Response, RestoreHistoryVersion.Request>(
{
query({ params, body }) {
return {
url: `/content-manager/history-versions/${params.versionId}/restore`,
method: 'PUT',
data: body,
};
},
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,27 @@ 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 request = ctx.request as unknown as RestoreHistoryVersion.Request;

const permissionChecker = getContentManagerService('permission-checker').create({
userAbility: ctx.state.userAbility,
model: request.body.contentType,
});

if (permissionChecker.cannot.update()) {
throw new errors.ForbiddenError();
}

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

return {
data: { documentId: restoredDocument.documentId },
} satisfies RestoreHistoryVersion.Response;
},
} 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
119 changes: 115 additions & 4 deletions packages/core/content-manager/server/src/history/services/history.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Core, Modules, UID, Data, Schema } from '@strapi/types';
import { contentTypes } from '@strapi/utils';
import type { Core, Modules, UID, Data, Schema, Struct } from '@strapi/types';
import { contentTypes, errors } from '@strapi/utils';
import { omit, pick } from 'lodash/fp';

import { scheduleJob } from 'node-schedule';
Expand Down Expand Up @@ -46,8 +46,10 @@ const createHistoryService = ({ strapi }: { strapi: Core.Strapi }) => {
return Math.min(licenseRetentionDays, DEFAULT_RETENTION_DAYS);
};

const localesService = strapi.plugin('i18n').service('locales');
const localesService = strapi.plugin('i18n')?.service('locales');
const getLocaleDictionary = async () => {
if (!localesService) return {};

const locales = (await localesService.find()) || [];
return locales.reduce(
(
Expand Down Expand Up @@ -161,7 +163,8 @@ const createHistoryService = ({ strapi }: { strapi: Core.Strapi }) => {
? { documentId: result.documentId, locale: context.params?.locale }
: { documentId: context.params.documentId, locale: context.params?.locale };

const locale = documentContext.locale ?? (await localesService.getDefaultLocale());
const defaultLocale = localesService ? await localesService.getDefaultLocale() : null;
const locale = documentContext.locale || defaultLocale;
const document = await strapi.documents(contentTypeUid).findOne({
documentId: documentContext.documentId,
locale,
Expand Down Expand Up @@ -395,6 +398,114 @@ 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 dataWithoutMissingRelations = await Object.entries(sanitizedSchemaAttributes).reduce(
async (
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(
// @ts-expect-error Fix the type definitions so this isn't any
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

const data = omit(['id', ...Object.keys(schemaDiff.removed)], dataWithoutMissingRelations);
const restoredDocument = await strapi.documents(version.contentType).update({
documentId: version.relatedDocumentId,
data,
});

if (!restoredDocument) {
throw new errors.ApplicationError('Failed to restore version');
}

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,27 @@ export declare namespace GetHistoryVersions {
error: errors.ApplicationError;
};
}

export declare namespace RestoreHistoryVersion {
export interface Request {
params: {
versionId: Data.ID;
};
body: {
contentType: UID.ContentType;
};
}

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