mirror of
https://github.com/hexastack/hexabot
synced 2025-04-03 12:52:06 +00:00
Merge pull request #789 from Emnaghz/enhacement-content-import
fix: enhacement of content import
This commit is contained in:
commit
fb8467e715
@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright © 2024 Hexastack. All rights reserved.
|
||||
* Copyright © 2025 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.
|
||||
@ -9,11 +9,8 @@
|
||||
import { forwardRef, Module } from '@nestjs/common';
|
||||
import { MongooseModule } from '@nestjs/mongoose';
|
||||
|
||||
import { AttachmentModule } from '@/attachment/attachment.module';
|
||||
import { ChatModule } from '@/chat/chat.module';
|
||||
|
||||
import { AttachmentModel } from '../attachment/schemas/attachment.schema';
|
||||
|
||||
import { ContentTypeController } from './controllers/content-type.controller';
|
||||
import { ContentController } from './controllers/content.controller';
|
||||
import { MenuController } from './controllers/menu.controller';
|
||||
@ -29,13 +26,7 @@ import { MenuService } from './services/menu.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
MongooseModule.forFeature([
|
||||
ContentModel,
|
||||
ContentTypeModel,
|
||||
AttachmentModel,
|
||||
MenuModel,
|
||||
]),
|
||||
AttachmentModule,
|
||||
MongooseModule.forFeature([ContentModel, ContentTypeModel, MenuModel]),
|
||||
forwardRef(() => ChatModule),
|
||||
],
|
||||
controllers: [ContentController, ContentTypeController, MenuController],
|
||||
|
@ -1,24 +1,16 @@
|
||||
/*
|
||||
* Copyright © 2024 Hexastack. All rights reserved.
|
||||
* Copyright © 2025 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 fs from 'fs';
|
||||
|
||||
import { NotFoundException } from '@nestjs/common/exceptions';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { MongooseModule } from '@nestjs/mongoose';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { AttachmentRepository } from '@/attachment/repositories/attachment.repository';
|
||||
import {
|
||||
Attachment,
|
||||
AttachmentModel,
|
||||
} from '@/attachment/schemas/attachment.schema';
|
||||
import { AttachmentService } from '@/attachment/services/attachment.service';
|
||||
import { LoggerService } from '@/logger/logger.service';
|
||||
import { NOT_FOUND_ID } from '@/utils/constants/mock';
|
||||
import { PageQueryDto } from '@/utils/pagination/pagination-query.dto';
|
||||
@ -48,10 +40,8 @@ describe('ContentController', () => {
|
||||
let contentController: ContentController;
|
||||
let contentService: ContentService;
|
||||
let contentTypeService: ContentTypeService;
|
||||
let attachmentService: AttachmentService;
|
||||
let contentType: ContentType | null;
|
||||
let content: Content | null;
|
||||
let attachment: Attachment | null;
|
||||
let updatedContent;
|
||||
let pageQuery: PageQueryDto<Content>;
|
||||
|
||||
@ -60,34 +50,24 @@ describe('ContentController', () => {
|
||||
controllers: [ContentController],
|
||||
imports: [
|
||||
rootMongooseTestModule(installContentFixtures),
|
||||
MongooseModule.forFeature([
|
||||
ContentTypeModel,
|
||||
ContentModel,
|
||||
AttachmentModel,
|
||||
]),
|
||||
MongooseModule.forFeature([ContentTypeModel, ContentModel]),
|
||||
],
|
||||
providers: [
|
||||
LoggerService,
|
||||
ContentTypeService,
|
||||
ContentService,
|
||||
ContentRepository,
|
||||
AttachmentService,
|
||||
ContentTypeRepository,
|
||||
AttachmentRepository,
|
||||
EventEmitter2,
|
||||
],
|
||||
}).compile();
|
||||
contentController = module.get<ContentController>(ContentController);
|
||||
contentService = module.get<ContentService>(ContentService);
|
||||
attachmentService = module.get<AttachmentService>(AttachmentService);
|
||||
contentTypeService = module.get<ContentTypeService>(ContentTypeService);
|
||||
contentType = await contentTypeService.findOne({ name: 'Product' });
|
||||
content = await contentService.findOne({
|
||||
title: 'Jean',
|
||||
});
|
||||
attachment = await attachmentService.findOne({
|
||||
name: 'store1.jpg',
|
||||
});
|
||||
|
||||
pageQuery = getPageQuery<Content>({
|
||||
limit: 1,
|
||||
@ -243,91 +223,74 @@ describe('ContentController', () => {
|
||||
});
|
||||
|
||||
describe('import', () => {
|
||||
const mockCsvData: string = `other,title,status,image
|
||||
should not appear,store 3,true,image.jpg`;
|
||||
|
||||
const file: Express.Multer.File = {
|
||||
buffer: Buffer.from(mockCsvData, 'utf-8'),
|
||||
originalname: 'test.csv',
|
||||
mimetype: 'text/csv',
|
||||
size: mockCsvData.length,
|
||||
fieldname: 'file',
|
||||
encoding: '7bit',
|
||||
stream: null,
|
||||
destination: '',
|
||||
filename: '',
|
||||
path: '',
|
||||
} as unknown as Express.Multer.File;
|
||||
|
||||
it('should import content from a CSV file', async () => {
|
||||
const mockCsvData: string = `other,title,status,image
|
||||
should not appear,store 3,true,image.jpg`;
|
||||
|
||||
const mockCsvContentDto: ContentCreateDto = {
|
||||
entity: '0',
|
||||
title: 'store 3',
|
||||
status: true,
|
||||
dynamicFields: {
|
||||
image: 'image.jpg',
|
||||
},
|
||||
};
|
||||
jest.spyOn(contentService, 'createMany');
|
||||
jest.spyOn(fs, 'existsSync').mockReturnValueOnce(true);
|
||||
jest.spyOn(fs, 'readFileSync').mockReturnValueOnce(mockCsvData);
|
||||
|
||||
const contentType = await contentTypeService.findOne({
|
||||
const mockContentType = {
|
||||
id: '0',
|
||||
name: 'Store',
|
||||
});
|
||||
|
||||
const result = await contentController.import({
|
||||
idFileToImport: attachment!.id,
|
||||
idTargetContentType: contentType!.id,
|
||||
});
|
||||
expect(contentService.createMany).toHaveBeenCalledWith([
|
||||
{ ...mockCsvContentDto, entity: contentType!.id },
|
||||
} as unknown as ContentType;
|
||||
jest
|
||||
.spyOn(contentTypeService, 'findOne')
|
||||
.mockResolvedValueOnce(mockContentType);
|
||||
jest.spyOn(contentService, 'parseAndSaveDataset').mockResolvedValueOnce([
|
||||
{
|
||||
entity: mockContentType.id,
|
||||
title: 'store 3',
|
||||
status: true,
|
||||
dynamicFields: {
|
||||
image: 'image.jpg',
|
||||
},
|
||||
id: '',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
]);
|
||||
|
||||
expect(result).toEqualPayload(
|
||||
[
|
||||
{
|
||||
...mockCsvContentDto,
|
||||
entity: contentType!.id,
|
||||
},
|
||||
],
|
||||
[...IGNORED_TEST_FIELDS, 'rag'],
|
||||
const result = await contentController.import(file, mockContentType.id);
|
||||
expect(contentService.parseAndSaveDataset).toHaveBeenCalledWith(
|
||||
mockCsvData,
|
||||
mockContentType.id,
|
||||
mockContentType,
|
||||
);
|
||||
expect(result).toEqualPayload([
|
||||
{
|
||||
entity: mockContentType.id,
|
||||
title: 'store 3',
|
||||
status: true,
|
||||
dynamicFields: {
|
||||
image: 'image.jpg',
|
||||
},
|
||||
id: '',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException if content type is not found', async () => {
|
||||
jest.spyOn(contentTypeService, 'findOne').mockResolvedValueOnce(null);
|
||||
await expect(
|
||||
contentController.import({
|
||||
idFileToImport: attachment!.id,
|
||||
idTargetContentType: NOT_FOUND_ID,
|
||||
}),
|
||||
contentController.import(file, 'INVALID_ID'),
|
||||
).rejects.toThrow(new NotFoundException('Content type is not found'));
|
||||
});
|
||||
|
||||
it('should throw NotFoundException if file is not found in attachment database', async () => {
|
||||
const contentType = await contentTypeService.findOne({
|
||||
name: 'Product',
|
||||
});
|
||||
jest.spyOn(contentTypeService, 'findOne');
|
||||
await expect(
|
||||
contentController.import({
|
||||
idFileToImport: NOT_FOUND_ID,
|
||||
idTargetContentType: contentType!.id.toString(),
|
||||
}),
|
||||
).rejects.toThrow(new NotFoundException('File does not exist'));
|
||||
it('should throw NotFoundException if idTargetContentType is missing', async () => {
|
||||
await expect(contentController.import(file, '')).rejects.toThrow(
|
||||
new NotFoundException('Missing parameter'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException if file does not exist in the given path ', async () => {
|
||||
jest.spyOn(fs, 'existsSync').mockReturnValue(false);
|
||||
await expect(
|
||||
contentController.import({
|
||||
idFileToImport: attachment!.id,
|
||||
idTargetContentType: contentType!.id,
|
||||
}),
|
||||
).rejects.toThrow(new NotFoundException('File does not exist'));
|
||||
});
|
||||
|
||||
it.each([
|
||||
['file param and content type params are missing', '', ''],
|
||||
['content type param is missing', '', NOT_FOUND_ID],
|
||||
['file param is missing', NOT_FOUND_ID, ''],
|
||||
])(
|
||||
'should throw NotFoundException if %s',
|
||||
async (_message, fileToImport, targetContentType) => {
|
||||
await expect(
|
||||
contentController.import({
|
||||
idFileToImport: fileToImport,
|
||||
idTargetContentType: targetContentType,
|
||||
}),
|
||||
).rejects.toThrow(new NotFoundException('Missing params'));
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
@ -1,14 +1,11 @@
|
||||
/*
|
||||
* Copyright © 2024 Hexastack. All rights reserved.
|
||||
* Copyright © 2025 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 fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
@ -20,14 +17,12 @@ import {
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UploadedFile,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { BadRequestException } from '@nestjs/common/exceptions';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { CsrfCheck } from '@tekuconcept/nestjs-csrf';
|
||||
import Papa from 'papaparse';
|
||||
|
||||
import { AttachmentService } from '@/attachment/services/attachment.service';
|
||||
import { config } from '@/config';
|
||||
import { CsrfInterceptor } from '@/interceptors/csrf.interceptor';
|
||||
import { LoggerService } from '@/logger/logger.service';
|
||||
import { BaseController } from '@/utils/generics/base-controller';
|
||||
@ -59,7 +54,6 @@ export class ContentController extends BaseController<
|
||||
constructor(
|
||||
private readonly contentService: ContentService,
|
||||
private readonly contentTypeService: ContentTypeService,
|
||||
private readonly attachmentService: AttachmentService,
|
||||
private readonly logger: LoggerService,
|
||||
) {
|
||||
super(contentService);
|
||||
@ -92,29 +86,22 @@ export class ContentController extends BaseController<
|
||||
/**
|
||||
* Imports content from a CSV file based on the provided content type and file ID.
|
||||
*
|
||||
* @param idTargetContentType - The content type to match the CSV data against.
|
||||
* @param idFileToImport - The ID of the file to be imported.
|
||||
*
|
||||
* @param idTargetContentType - The content type to match the CSV data against. *
|
||||
* @returns A promise that resolves to the newly created content documents.
|
||||
*/
|
||||
@Get('import/:idTargetContentType/:idFileToImport')
|
||||
@CsrfCheck(true)
|
||||
@Post('import')
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
async import(
|
||||
@Param()
|
||||
{
|
||||
idTargetContentType: targetContentType,
|
||||
idFileToImport: fileToImport,
|
||||
}: {
|
||||
idTargetContentType: string;
|
||||
idFileToImport: string;
|
||||
},
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@Query('idTargetContentType')
|
||||
targetContentType: string,
|
||||
) {
|
||||
// Check params
|
||||
if (!fileToImport || !targetContentType) {
|
||||
this.logger.warn(`Parameters are missing`);
|
||||
throw new NotFoundException(`Missing params`);
|
||||
const datasetContent = file.buffer.toString('utf-8');
|
||||
if (!targetContentType) {
|
||||
this.logger.warn(`Parameter is missing`);
|
||||
throw new NotFoundException(`Missing parameter`);
|
||||
}
|
||||
|
||||
// Find the content type that corresponds to the given content
|
||||
const contentType =
|
||||
await this.contentTypeService.findOne(targetContentType);
|
||||
if (!contentType) {
|
||||
@ -124,56 +111,11 @@ export class ContentController extends BaseController<
|
||||
throw new NotFoundException(`Content type is not found`);
|
||||
}
|
||||
|
||||
// Get file location
|
||||
const file = await this.attachmentService.findOne(fileToImport);
|
||||
// Check if file is present
|
||||
const filePath = file
|
||||
? path.join(config.parameters.uploadDir, file.location)
|
||||
: undefined;
|
||||
|
||||
if (!file || !filePath || !fs.existsSync(filePath)) {
|
||||
this.logger.warn(`Failed to find file type with id ${fileToImport}.`);
|
||||
throw new NotFoundException(`File does not exist`);
|
||||
}
|
||||
//read file sync
|
||||
const data = fs.readFileSync(filePath, 'utf8');
|
||||
|
||||
const result = Papa.parse<Record<string, string | boolean | number>>(data, {
|
||||
header: true,
|
||||
skipEmptyLines: true,
|
||||
dynamicTyping: true,
|
||||
});
|
||||
|
||||
if (result.errors.length > 0) {
|
||||
this.logger.warn(
|
||||
`Errors parsing the file: ${JSON.stringify(result.errors)}`,
|
||||
);
|
||||
|
||||
throw new BadRequestException(result.errors, {
|
||||
cause: result.errors,
|
||||
description: 'Error while parsing CSV',
|
||||
});
|
||||
}
|
||||
|
||||
const contentsDto = result.data.reduce(
|
||||
(acc, { title, status, ...rest }) => [
|
||||
...acc,
|
||||
{
|
||||
title: String(title),
|
||||
status: Boolean(status),
|
||||
entity: targetContentType,
|
||||
dynamicFields: Object.keys(rest)
|
||||
.filter((key) =>
|
||||
contentType.fields?.map((field) => field.name).includes(key),
|
||||
)
|
||||
.reduce((filtered, key) => ({ ...filtered, [key]: rest[key] }), {}),
|
||||
},
|
||||
],
|
||||
[],
|
||||
return await this.contentService.parseAndSaveDataset(
|
||||
datasetContent,
|
||||
targetContentType,
|
||||
contentType,
|
||||
);
|
||||
|
||||
// Create content
|
||||
return await this.contentService.createMany(contentsDto);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -10,9 +10,6 @@ import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { MongooseModule } from '@nestjs/mongoose';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { AttachmentRepository } from '@/attachment/repositories/attachment.repository';
|
||||
import { AttachmentModel } from '@/attachment/schemas/attachment.schema';
|
||||
import { AttachmentService } from '@/attachment/services/attachment.service';
|
||||
import { OutgoingMessageFormat } from '@/chat/schemas/types/message';
|
||||
import { ContentOptions } from '@/chat/schemas/types/options';
|
||||
import { LoggerService } from '@/logger/logger.service';
|
||||
@ -44,19 +41,13 @@ describe('ContentService', () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
imports: [
|
||||
rootMongooseTestModule(installContentFixtures),
|
||||
MongooseModule.forFeature([
|
||||
ContentTypeModel,
|
||||
ContentModel,
|
||||
AttachmentModel,
|
||||
]),
|
||||
MongooseModule.forFeature([ContentTypeModel, ContentModel]),
|
||||
],
|
||||
providers: [
|
||||
ContentTypeRepository,
|
||||
ContentRepository,
|
||||
AttachmentRepository,
|
||||
ContentTypeService,
|
||||
ContentService,
|
||||
AttachmentService,
|
||||
LoggerService,
|
||||
EventEmitter2,
|
||||
],
|
||||
|
@ -6,9 +6,9 @@
|
||||
* 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 } from '@nestjs/common';
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import Papa from 'papaparse';
|
||||
|
||||
import { AttachmentService } from '@/attachment/services/attachment.service';
|
||||
import { StdOutgoingListMessage } from '@/chat/schemas/types/message';
|
||||
import { ContentOptions } from '@/chat/schemas/types/options';
|
||||
import { LoggerService } from '@/logger/logger.service';
|
||||
@ -17,6 +17,7 @@ import { TFilterQuery } from '@/utils/types/filter.types';
|
||||
|
||||
import { ContentDto } from '../dto/content.dto';
|
||||
import { ContentRepository } from '../repositories/content.repository';
|
||||
import { ContentType } from '../schemas/content-type.schema';
|
||||
import {
|
||||
Content,
|
||||
ContentFull,
|
||||
@ -32,7 +33,6 @@ export class ContentService extends BaseService<
|
||||
> {
|
||||
constructor(
|
||||
readonly repository: ContentRepository,
|
||||
private readonly attachmentService: AttachmentService,
|
||||
private readonly logger: LoggerService,
|
||||
) {
|
||||
super(repository);
|
||||
@ -103,4 +103,68 @@ export class ContentService extends BaseService<
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a CSV dataset and saves the content in the repository.
|
||||
*
|
||||
* @param data - The CSV data as a string to be parsed.
|
||||
* @param targetContentType - The content type to associate with the parsed data.
|
||||
* @param contentType - The content type metadata, including fields to validate the parsed data.
|
||||
* @return A promise resolving to the created content objects.
|
||||
*/
|
||||
async parseAndSaveDataset(
|
||||
data: string,
|
||||
targetContentType: string,
|
||||
contentType: ContentType,
|
||||
) {
|
||||
// Parse local CSV file
|
||||
const result: {
|
||||
errors: any[];
|
||||
data: Array<Record<string, string>>;
|
||||
} = Papa.parse(data, {
|
||||
header: true,
|
||||
skipEmptyLines: true,
|
||||
});
|
||||
|
||||
if (result.errors && result.errors.length > 0) {
|
||||
this.logger.warn(
|
||||
`Errors parsing the file: ${JSON.stringify(result.errors)}`,
|
||||
);
|
||||
throw new BadRequestException(result.errors, {
|
||||
cause: result.errors,
|
||||
description: 'Error while parsing CSV',
|
||||
});
|
||||
}
|
||||
if (!result.data.every((row) => row.title && row.status)) {
|
||||
throw new BadRequestException(
|
||||
'Missing required fields: "title" or "status"',
|
||||
{
|
||||
cause: 'Invalid CSV data',
|
||||
description: 'CSV must include "title" and "status" columns',
|
||||
},
|
||||
);
|
||||
}
|
||||
const contentsDto = result.data.reduce(
|
||||
(acc, { title, status, ...rest }) => [
|
||||
...acc,
|
||||
{
|
||||
title: String(title),
|
||||
status: Boolean(status),
|
||||
entity: targetContentType,
|
||||
dynamicFields: Object.keys(rest)
|
||||
.filter((key) =>
|
||||
contentType.fields?.map((field) => field.name).includes(key),
|
||||
)
|
||||
.reduce((filtered, key) => ({ ...filtered, [key]: rest[key] }), {}),
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
this.logger.log(`Parsed ${result.data.length} rows from CSV.`);
|
||||
try {
|
||||
return await this.createMany(contentsDto);
|
||||
} catch (err) {
|
||||
this.logger.error('Error occurred when extracting data. ', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,81 +0,0 @@
|
||||
/*
|
||||
* Copyright © 2025 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 { FC, Fragment, useState } from "react";
|
||||
import { useQuery } from "react-query";
|
||||
|
||||
import AttachmentInput from "@/app-components/attachment/AttachmentInput";
|
||||
import { ContentContainer, ContentItem } from "@/app-components/dialogs";
|
||||
import { useApiClient } from "@/hooks/useApiClient";
|
||||
import { useToast } from "@/hooks/useToast";
|
||||
import { useTranslate } from "@/hooks/useTranslate";
|
||||
import { AttachmentResourceRef } from "@/types/attachment.types";
|
||||
import { ComponentFormProps } from "@/types/common/dialogs.types";
|
||||
import { IContentType } from "@/types/content-type.types";
|
||||
|
||||
export type ContentImportFormData = { row: null; contentType: IContentType };
|
||||
export const ContentImportForm: FC<
|
||||
ComponentFormProps<ContentImportFormData>
|
||||
> = ({ data, Wrapper = Fragment, WrapperProps, ...rest }) => {
|
||||
const [attachmentId, setAttachmentId] = useState<string | null>(null);
|
||||
const { t } = useTranslate();
|
||||
const { toast } = useToast();
|
||||
const { apiClient } = useApiClient();
|
||||
const { refetch, isFetching } = useQuery(
|
||||
["importContent", data?.contentType.id, attachmentId],
|
||||
async () => {
|
||||
if (data?.contentType.id && attachmentId) {
|
||||
await apiClient.importContent(data.contentType.id, attachmentId);
|
||||
}
|
||||
},
|
||||
{
|
||||
enabled: false,
|
||||
onError: () => {
|
||||
rest.onError?.();
|
||||
toast.error(t("message.internal_server_error"));
|
||||
},
|
||||
onSuccess: () => {
|
||||
rest.onSuccess?.();
|
||||
toast.success(t("message.success_save"));
|
||||
},
|
||||
},
|
||||
);
|
||||
const handleImportClick = () => {
|
||||
if (attachmentId && data?.contentType.id) {
|
||||
refetch();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Wrapper
|
||||
onSubmit={handleImportClick}
|
||||
{...WrapperProps}
|
||||
confirmButtonProps={{
|
||||
...WrapperProps?.confirmButtonProps,
|
||||
disabled: !attachmentId || isFetching,
|
||||
}}
|
||||
>
|
||||
<form onSubmit={handleImportClick}>
|
||||
<ContentContainer>
|
||||
<ContentItem>
|
||||
<AttachmentInput
|
||||
format="basic"
|
||||
accept="text/csv"
|
||||
onChange={(id, _) => {
|
||||
setAttachmentId(id);
|
||||
}}
|
||||
label=""
|
||||
value={attachmentId}
|
||||
resourceRef={AttachmentResourceRef.ContentAttachment}
|
||||
/>
|
||||
</ContentItem>
|
||||
</ContentContainer>
|
||||
</form>
|
||||
</Wrapper>
|
||||
);
|
||||
};
|
@ -1,26 +0,0 @@
|
||||
/*
|
||||
* Copyright © 2025 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 { GenericFormDialog } from "@/app-components/dialogs";
|
||||
import { ComponentFormDialogProps } from "@/types/common/dialogs.types";
|
||||
|
||||
import { ContentImportForm, ContentImportFormData } from "./ContentImportForm";
|
||||
|
||||
export const ContentImportFormDialog = <
|
||||
T extends ContentImportFormData = ContentImportFormData,
|
||||
>(
|
||||
props: ComponentFormDialogProps<T>,
|
||||
) => (
|
||||
<GenericFormDialog<T>
|
||||
Form={ContentImportForm}
|
||||
rowKey="row"
|
||||
addText="button.import"
|
||||
confirmButtonProps={{ value: "button.import" }}
|
||||
{...props}
|
||||
/>
|
||||
);
|
@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright © 2024 Hexastack. All rights reserved.
|
||||
* Copyright © 2025 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.
|
||||
@ -9,12 +9,13 @@
|
||||
import { faAlignLeft } from "@fortawesome/free-solid-svg-icons";
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
|
||||
import UploadIcon from "@mui/icons-material/Upload";
|
||||
import { Button, Chip, Grid, Paper, Switch, Typography } from "@mui/material";
|
||||
import { Button, ButtonGroup, Chip, Grid, Paper, Switch, Typography } from "@mui/material";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { useQueryClient } from "react-query";
|
||||
|
||||
import { ConfirmDialogBody } from "@/app-components/dialogs";
|
||||
import FileUploadButton from "@/app-components/inputs/FileInput";
|
||||
import { FilterTextfield } from "@/app-components/inputs/FilterTextfield";
|
||||
import {
|
||||
ActionColumnLabel,
|
||||
@ -22,9 +23,11 @@ import {
|
||||
} from "@/app-components/tables/columns/getColumns";
|
||||
import { renderHeader } from "@/app-components/tables/columns/renderHeader";
|
||||
import { DataGrid } from "@/app-components/tables/DataGrid";
|
||||
import { isSameEntity } from "@/hooks/crud/helpers";
|
||||
import { useDelete } from "@/hooks/crud/useDelete";
|
||||
import { useFind } from "@/hooks/crud/useFind";
|
||||
import { useGet, useGetFromCache } from "@/hooks/crud/useGet";
|
||||
import { useImport } from "@/hooks/crud/useImport";
|
||||
import { useUpdate } from "@/hooks/crud/useUpdate";
|
||||
import { useDialogs } from "@/hooks/useDialogs";
|
||||
import { useHasPermission } from "@/hooks/useHasPermission";
|
||||
@ -38,12 +41,12 @@ import { PermissionAction } from "@/types/permission.types";
|
||||
import { getDateTimeFormatter } from "@/utils/date";
|
||||
|
||||
import { ContentFormDialog } from "./ContentFormDialog";
|
||||
import { ContentImportFormDialog } from "./ContentImportFormDialog";
|
||||
|
||||
export const Contents = () => {
|
||||
const { t } = useTranslate();
|
||||
const { toast } = useToast();
|
||||
const { query } = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const dialogs = useDialogs();
|
||||
// data fetching
|
||||
const { onSearch, searchPayload } = useSearch<IContent>({
|
||||
@ -51,7 +54,7 @@ export const Contents = () => {
|
||||
$iLike: ["title"],
|
||||
});
|
||||
const hasPermission = useHasPermission();
|
||||
const { dataGridProps, refetch } = useFind(
|
||||
const { dataGridProps } = useFind(
|
||||
{ entity: EntityType.CONTENT, format: Format.FULL },
|
||||
{
|
||||
params: searchPayload,
|
||||
@ -97,8 +100,36 @@ export const Contents = () => {
|
||||
const { data: contentType } = useGet(String(query.id), {
|
||||
entity: EntityType.CONTENT_TYPE,
|
||||
});
|
||||
const { mutate: importDataset, isLoading } = useImport(
|
||||
EntityType.CONTENT,
|
||||
{
|
||||
onError: () => {
|
||||
toast.error(t("message.import_failed"));
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
queryClient.removeQueries({
|
||||
predicate: ({ queryKey }) => {
|
||||
const [_qType, qEntity] = queryKey;
|
||||
|
||||
return (
|
||||
return (
|
||||
isSameEntity(qEntity, EntityType.CONTENT)
|
||||
);
|
||||
},
|
||||
});
|
||||
if (data.length) {
|
||||
toast.success(t("message.success_import"));
|
||||
} else {
|
||||
toast.error(t("message.import_duplicated_data"));
|
||||
}
|
||||
},
|
||||
},
|
||||
{ idTargetContentType: contentType?.id }
|
||||
);
|
||||
const handleImportChange = (file: File) => {
|
||||
importDataset(file);
|
||||
};
|
||||
|
||||
return (
|
||||
<Grid container flexDirection="column" gap={3}>
|
||||
<Grid item height="fit-content" container>
|
||||
<Link href="/content/types">
|
||||
@ -119,6 +150,7 @@ export const Contents = () => {
|
||||
<FilterTextfield onChange={onSearch} />
|
||||
</Grid>
|
||||
{hasPermission(EntityType.CONTENT, PermissionAction.CREATE) ? (
|
||||
<ButtonGroup sx={{ marginLeft: "auto" }}>
|
||||
<Grid item>
|
||||
<Button
|
||||
startIcon={<AddIcon />}
|
||||
@ -127,30 +159,19 @@ export const Contents = () => {
|
||||
dialogs.open(ContentFormDialog, { contentType })
|
||||
}
|
||||
sx={{ float: "right" }}
|
||||
>
|
||||
{t("button.add")}
|
||||
</Button>
|
||||
</Grid>
|
||||
) : null}
|
||||
{hasPermission(EntityType.CONTENT, PermissionAction.CREATE) ? (
|
||||
<Grid item>
|
||||
<Button
|
||||
startIcon={<UploadIcon />}
|
||||
variant="contained"
|
||||
onClick={async () => {
|
||||
if (contentType) {
|
||||
await dialogs.open(ContentImportFormDialog, {
|
||||
row: null,
|
||||
contentType,
|
||||
});
|
||||
refetch();
|
||||
}
|
||||
}}
|
||||
sx={{ float: "right" }}
|
||||
>
|
||||
{t("button.import")}
|
||||
</Button>
|
||||
</Grid>
|
||||
>
|
||||
{t("button.add")}
|
||||
</Button>
|
||||
</Grid>
|
||||
<Grid item>
|
||||
<FileUploadButton
|
||||
accept="text/csv"
|
||||
label={t("button.import")}
|
||||
onChange={handleImportChange}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</Grid>
|
||||
</ButtonGroup>
|
||||
) : null}
|
||||
</Grid>
|
||||
</PageHeader>
|
||||
|
@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright © 2024 Hexastack. All rights reserved.
|
||||
* Copyright © 2025 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.
|
||||
@ -25,6 +25,7 @@ export const useImport = <
|
||||
TMutationOptions<TBasic[], Error, TAttr, TBasic[]>,
|
||||
"mutationFn" | "mutationKey"
|
||||
> = {},
|
||||
params: Record<string, any> = {},
|
||||
) => {
|
||||
const api = useEntityApiClient<TAttr, TBasic>(entity);
|
||||
const queryClient = useQueryClient();
|
||||
@ -35,7 +36,7 @@ export const useImport = <
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (variables) => {
|
||||
const data = await api.import(variables);
|
||||
const data = await api.import(variables, params);
|
||||
const { result, entities } = normalizeAndCache(data);
|
||||
|
||||
// Invalidate current entity count and collection
|
||||
|
@ -239,14 +239,6 @@ export class ApiClient {
|
||||
return data;
|
||||
}
|
||||
|
||||
async importContent(contentTypeId: string, attachmentId: string) {
|
||||
const { data } = await this.request.get(
|
||||
`${ROUTES.CONTENT_IMPORT}/${contentTypeId}/${attachmentId}`,
|
||||
);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
async predictNlp(text: string) {
|
||||
const { data } = await this.request.get<INlpDatasetSampleAttributes>(
|
||||
`${ROUTES.NLP_SAMPLE_PREDICT}`,
|
||||
@ -288,15 +280,18 @@ export class EntityApiClient<TAttr, TBasic, TFull> extends ApiClient {
|
||||
return data;
|
||||
}
|
||||
|
||||
async import<T = TBasic>(file: File) {
|
||||
async import<T = TBasic>(file: File, params?: any) {
|
||||
const { _csrf } = await this.getCsrf();
|
||||
const formData = new FormData();
|
||||
|
||||
formData.append("file", file);
|
||||
|
||||
const { data } = await this.request.post<T[], AxiosResponse<T[]>, FormData>(
|
||||
`${ROUTES[this.type]}/import?_csrf=${_csrf}`,
|
||||
`${ROUTES[this.type]}/import`,
|
||||
formData,
|
||||
{
|
||||
params: { _csrf , ...params },
|
||||
}
|
||||
);
|
||||
|
||||
return data;
|
||||
|
Loading…
Reference in New Issue
Block a user