Merge pull request #133 from Hexastack/fix/update-content-issue

fix: content update
This commit is contained in:
Mohamed Marrouchi 2024-10-03 23:12:27 +01:00 committed by GitHub
commit 5df35d94f7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 59 additions and 180 deletions

View File

@ -6,13 +6,7 @@
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
*/
import {
forwardRef,
MiddlewareConsumer,
Module,
NestModule,
RequestMethod,
} from '@nestjs/common';
import { forwardRef, Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { AttachmentModule } from '@/attachment/attachment.module';
@ -21,7 +15,6 @@ import { ChatModule } from '@/chat/chat.module';
import { ContentTypeController } from './controllers/content-type.controller';
import { ContentController } from './controllers/content.controller';
import { MenuController } from './controllers/menu.controller';
import { ContentMiddleWare } from './middlewares/content.middleware';
import { ContentTypeRepository } from './repositories/content-type.repository';
import { ContentRepository } from './repositories/content.repository';
import { MenuRepository } from './repositories/menu.repository';
@ -55,13 +48,4 @@ import { AttachmentModel } from '../attachment/schemas/attachment.schema';
],
exports: [MenuService, ContentService, ContentTypeService],
})
export class CmsModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer
.apply(ContentMiddleWare)
.forRoutes(
{ path: 'content', method: RequestMethod.POST },
{ path: 'content/:id', method: RequestMethod.PATCH },
);
}
}
export class CmsModule {}

View File

@ -35,7 +35,6 @@ import {
import { ContentController } from './content.controller';
import { ContentCreateDto } from '../dto/content.dto';
import { ContentTransformInterceptor } from '../interceptors/content.interceptor';
import { ContentTypeRepository } from '../repositories/content-type.repository';
import { ContentRepository } from '../repositories/content.repository';
import { ContentType, ContentTypeModel } from '../schemas/content-type.schema';
@ -48,7 +47,6 @@ describe('ContentController', () => {
let contentService: ContentService;
let contentTypeService: ContentTypeService;
let attachmentService: AttachmentService;
let transformInterceptor: ContentTransformInterceptor;
let contentType: ContentType;
let content: Content;
let attachment: Attachment;
@ -74,7 +72,6 @@ describe('ContentController', () => {
AttachmentService,
ContentTypeRepository,
AttachmentRepository,
ContentTransformInterceptor,
EventEmitter2,
],
}).compile();
@ -82,9 +79,6 @@ describe('ContentController', () => {
contentService = module.get<ContentService>(ContentService);
attachmentService = module.get<AttachmentService>(AttachmentService);
contentTypeService = module.get<ContentTypeService>(ContentTypeService);
transformInterceptor = module.get<ContentTransformInterceptor>(
ContentTransformInterceptor,
);
contentType = await contentTypeService.findOne({ name: 'Product' });
content = await contentService.findOne({
title: 'Jean',
@ -237,28 +231,6 @@ describe('ContentController', () => {
});
});
describe('filterDynamicFields', () => {
it('should flatten dynamic fields', () => {
const result = transformInterceptor.transformDynamicFields(
contentFixtures[0],
);
expect(result).toEqualPayload(
{
title: 'Jean',
status: true,
subtitle: 'Jean Droit Taille Normale',
image: {
payload: {
url: 'https://images-na.ssl-images-amazon.com/images/I/31DY09uzLDL._SX38_SY50_CR,0,0,38,50_.jpg',
},
},
},
['entity', 'rag', ...IGNORED_TEST_FIELDS],
);
});
});
describe('count', () => {
it('should return the number of contents', async () => {
jest.spyOn(contentService, 'count');

View File

@ -39,8 +39,7 @@ import { SearchFilterPipe } from '@/utils/pipes/search-filter.pipe';
import { ContentTypeService } from './../services/content-type.service';
import { ContentService } from './../services/content.service';
import { ContentCreateDto } from '../dto/content.dto';
import { ContentTransformInterceptor } from '../interceptors/content.interceptor';
import { ContentCreateDto, ContentUpdateDto } from '../dto/content.dto';
import { ContentType } from '../schemas/content-type.schema';
import {
Content,
@ -48,9 +47,8 @@ import {
ContentPopulate,
ContentStub,
} from '../schemas/content.schema';
import { preprocessDynamicFields } from '../utilities';
@UseInterceptors(ContentTransformInterceptor, CsrfInterceptor)
@UseInterceptors(CsrfInterceptor)
@Controller('content')
export class ContentController extends BaseController<
Content,
@ -116,8 +114,7 @@ export class ContentController extends BaseController<
entity: contentType?.id,
},
});
const newContent = this.filterDynamicFields(contentDto, contentType);
return await this.contentService.create(newContent);
return await this.contentService.create(contentDto);
}
/**
@ -186,12 +183,22 @@ export class ContentController extends BaseController<
});
}
const contentsDto = result.data.map((content) => {
content.entity = targetContentType;
const dto = preprocessDynamicFields(content);
// Match headers against entity fields
return this.filterDynamicFields(dto, contentType);
});
const contentsDto = result.data.reduce(
(acc, { title, status, ...rest }) => [
...acc,
{
title,
status,
entity: targetContentType,
dynamicFields: Object.keys(rest)
.filter((key) =>
contentType.fields.map((field) => field.name).includes(key),
)
.reduce((filtered, key) => ({ ...filtered, [key]: rest[key] }), {}),
},
],
[],
);
// Create content
return await this.contentService.createMany(contentsDto);
@ -319,16 +326,12 @@ export class ContentController extends BaseController<
* @returns The updated content document.
*/
@CsrfCheck(true)
@Patch('/:id')
@Patch(':id')
async updateOne(
@Body() contentDto: ContentCreateDto,
@Body() contentDto: ContentUpdateDto,
@Param('id') id: string,
): Promise<Content> {
const contentType = await this.contentTypeService.findOne(
contentDto.entity,
);
const newContent = this.filterDynamicFields(contentDto, contentType);
const updatedContent = await this.contentService.updateOne(id, newContent);
const updatedContent = await this.contentService.updateOne(id, contentDto);
if (!updatedContent) {
this.logger.warn(
`Failed to update content with id ${id}. Content not found.`,

View File

@ -6,7 +6,7 @@
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
*/
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger';
import { IsString, IsBoolean, IsNotEmpty, IsOptional } from 'class-validator';
import { IsObjectId } from '@/utils/validation-rules/is-object-id';
@ -32,3 +32,5 @@ export class ContentCreateDto {
@IsOptional()
dynamicFields?: Record<string, any>;
}
export class ContentUpdateDto extends PartialType(ContentCreateDto) {}

View File

@ -1,54 +0,0 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
*/
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
} from '@nestjs/common';
import { Observable, map } from 'rxjs';
import { Content } from '../schemas/content.schema';
@Injectable()
export class ContentTransformInterceptor
implements NestInterceptor<Content, Content>
{
/*
-This interceptor is designed to provide a flattened representation of the 'dynamicFields'.
-The incoming data contains a 'dynamicField' object, and the interceptor is expanding it,
extracting its content as separate entries.
-After the expansion, the 'dynamicFields' property is removed.
-The interceptor will be applied on each endpoint of this controller.
*/
transformDynamicFields(data) {
if (data.dynamicFields) {
Object.keys(data.dynamicFields).forEach((key) => {
data[key] = data.dynamicFields[key];
});
delete data.dynamicFields;
}
return data;
}
intercept(context: ExecutionContext, next: CallHandler): Observable<Content> {
return next.handle().pipe(
map((data) => {
// If the data is not an array, the 'transformDynamicFields' method is applied once
if (!Array.isArray(data)) {
return this.transformDynamicFields(data);
}
return data.map((content) => {
return this.transformDynamicFields(content);
});
}),
);
}
}

View File

@ -1,20 +0,0 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
*/
import { Injectable, NestMiddleware } from '@nestjs/common';
import { NextFunction, Request, Response } from 'express';
import { preprocessDynamicFields } from '../utilities';
@Injectable()
export class ContentMiddleWare implements NestMiddleware {
use(req: Request, _res: Response, next: NextFunction) {
req.body = preprocessDynamicFields(req.body);
next();
}
}

View File

@ -1,24 +0,0 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
*/
import { ContentCreateDto } from '../dto/content.dto';
export const preprocessDynamicFields = (
content: Record<string, string | boolean | number>,
) => {
const { _csrf, title, status, entity, ...dynamicFields } = content;
const processed: ContentCreateDto & { _csrf?: string } = {
_csrf: _csrf?.toString(),
entity: entity.toString(),
status: !!status,
title: title.toString(),
dynamicFields,
};
return processed;
};

View File

@ -41,7 +41,6 @@ export const ContentTypes = () => {
const router = useRouter();
// Dialog Controls
const addDialogCtl = useDialog<IContentType>(false);
const editDialogCtl = useDialog<IContentType>(false);
const deleteDialogCtl = useDialog<string>(false);
const fieldsDialogCtl = useDialog<IContentType>(false);
// data fetching
@ -119,7 +118,6 @@ export const ContentTypes = () => {
<Grid item xs={12}>
<Paper>
<ContentTypeDialog {...getDisplayDialogs(addDialogCtl)} />
<ContentTypeDialog {...getDisplayDialogs(editDialogCtl)} />
<DeleteDialog
{...deleteDialogCtl}
callback={() => {

View File

@ -119,6 +119,20 @@ const ContentFieldInput: React.FC<ContentFieldInput> = ({
return <Input {...field} error={!!errors[contentField.name]} />;
}
};
const INITIAL_FIELDS = ["title", "status"];
const buildDynamicFields = (
content: IContentAttributes,
contentType?: IContentType,
) => ({
title: content.title,
entity: content.entity,
status: content.status,
dynamicFields: {
...contentType?.fields
?.filter(({ name }) => !INITIAL_FIELDS.includes(name))
.reduce((acc, { name }) => ({ ...acc, [name]: content[name] }), {}),
},
});
export type ContentDialogProps = DialogControlProps<{
content?: IContent;
@ -163,7 +177,7 @@ export const ContentDialog: FC<ContentDialogProps> = ({
const onSubmitForm = async (params: IContentAttributes) => {
if (content) {
updateContent(
{ id: content.id, params },
{ id: content.id, params: buildDynamicFields(params, contentType) },
{
onError: () => {
toast.error(t("message.internal_server_error"));
@ -176,7 +190,7 @@ export const ContentDialog: FC<ContentDialogProps> = ({
);
} else if (contentType) {
createContent(
{ ...params, entity: contentType.id },
{ ...buildDynamicFields(params, contentType), entity: contentType.id },
{
onError: (error) => {
toast.error(error);
@ -198,11 +212,7 @@ export const ContentDialog: FC<ContentDialogProps> = ({
useEffect(() => {
if (content) {
reset({
entity: content.entity,
status: content.status,
title: content.title,
});
reset(content);
} else {
reset();
}
@ -221,7 +231,11 @@ export const ContentDialog: FC<ContentDialogProps> = ({
<Controller
name={contentField.name}
control={control}
defaultValue={content ? content[contentField.name] : null}
defaultValue={
content
? content?.["dynamicFields"]?.[contentField.name]
: null
}
rules={
contentField.name === "title"
? validationRules.title

View File

@ -165,8 +165,8 @@ export const Contents = () => {
field: "entity",
headerName: t("label.entity"),
flex: 1,
valueGetter: (row: IContent) => {
const contentType = getEntityFromCache(row.id);
valueGetter: (entityId) => {
const contentType = getEntityFromCache(entityId);
return contentType?.name;
},

View File

@ -164,10 +164,14 @@ export const ContentTypeEntity = new schema.Entity(
},
);
export const ContentEntity = new schema.Entity(EntityType.CONTENT, undefined, {
idAttribute: ({ id }) => id,
processStrategy: processCommonStrategy,
});
export const ContentEntity = new schema.Entity(
EntityType.CONTENT,
{ entity: ContentTypeEntity },
{
idAttribute: ({ id }) => id,
processStrategy: processCommonStrategy,
},
);
export const SettingEntity = new schema.Entity(EntityType.SETTING, {
idAttribute: ({ id }) => id,