mirror of
https://github.com/hexastack/hexabot
synced 2024-11-22 01:27:41 +00:00
Merge pull request #265 from Hexastack/256-request-move-blocks-between-categories
feat: move blocks between categories
This commit is contained in:
commit
cdb753f230
@ -255,6 +255,28 @@ export class BlockController extends BaseController<
|
||||
return await this.blockService.create(block);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates multiple blocks by their IDs.
|
||||
* @param ids - IDs of blocks to be updated.
|
||||
* @param payload - The data to update blocks with.
|
||||
* @returns A Promise that resolves to the updates if successful.
|
||||
*/
|
||||
@CsrfCheck(true)
|
||||
@Patch('bulk')
|
||||
async updateMany(@Body() body: { ids: string[]; payload: BlockUpdateDto }) {
|
||||
if (!body.ids || body.ids.length === 0) {
|
||||
throw new BadRequestException('No IDs provided to perform the update');
|
||||
}
|
||||
const updates = await this.blockService.updateMany(
|
||||
{
|
||||
_id: { $in: body.ids },
|
||||
},
|
||||
body.payload,
|
||||
);
|
||||
|
||||
return updates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a specific block by ID.
|
||||
*
|
||||
|
@ -20,8 +20,8 @@ import {
|
||||
rootMongooseTestModule,
|
||||
} from '@/utils/test/test';
|
||||
|
||||
import { BlockModel, Block } from '../schemas/block.schema';
|
||||
import { CategoryModel, Category } from '../schemas/category.schema';
|
||||
import { Block, BlockModel } from '../schemas/block.schema';
|
||||
import { Category, CategoryModel } from '../schemas/category.schema';
|
||||
import { LabelModel } from '../schemas/label.schema';
|
||||
|
||||
import { BlockRepository } from './block.repository';
|
||||
@ -34,6 +34,9 @@ describe('BlockRepository', () => {
|
||||
let category: Category;
|
||||
let hasPreviousBlocks: Block;
|
||||
let hasNextBlocks: Block;
|
||||
let validIds: string[];
|
||||
let validCategory: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const module = await Test.createTestingModule({
|
||||
imports: [
|
||||
@ -45,6 +48,9 @@ describe('BlockRepository', () => {
|
||||
blockRepository = module.get<BlockRepository>(BlockRepository);
|
||||
categoryRepository = module.get<CategoryRepository>(CategoryRepository);
|
||||
blockModel = module.get<Model<Block>>(getModelToken('Block'));
|
||||
validIds = ['64abc1234def567890fedcba', '64abc1234def567890fedcbc'];
|
||||
validCategory = '64def5678abc123490fedcba';
|
||||
|
||||
category = await categoryRepository.findOne({ label: 'default' });
|
||||
hasPreviousBlocks = await blockRepository.findOne({
|
||||
name: 'hasPreviousBlocks',
|
||||
@ -107,4 +113,195 @@ describe('BlockRepository', () => {
|
||||
expect(result).toEqualPayload(blocksWithCategory);
|
||||
});
|
||||
});
|
||||
|
||||
describe('preUpdate', () => {
|
||||
it('should remove references to a moved block when updating category', async () => {
|
||||
const mockUpdateMany = jest.spyOn(blockRepository, 'updateMany');
|
||||
const criteria = { _id: validIds[0] };
|
||||
const updates = { $set: { category: validCategory } };
|
||||
|
||||
const mockFindOne = jest
|
||||
.spyOn(blockRepository, 'findOne')
|
||||
.mockResolvedValue({
|
||||
id: validIds[0],
|
||||
category: 'oldCategory',
|
||||
} as Block);
|
||||
|
||||
await blockRepository.preUpdate({} as any, criteria, updates);
|
||||
|
||||
expect(mockFindOne).toHaveBeenCalledWith(criteria);
|
||||
expect(mockUpdateMany).toHaveBeenCalledTimes(2);
|
||||
expect(mockUpdateMany).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
{ nextBlocks: validIds[0] },
|
||||
{ $pull: { nextBlocks: validIds[0] } },
|
||||
);
|
||||
expect(mockUpdateMany).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
{ attachedBlock: validIds[0] },
|
||||
{ $set: { attachedBlock: null } },
|
||||
);
|
||||
});
|
||||
|
||||
it('should do nothing if no block is found for the criteria', async () => {
|
||||
const mockFindOne = jest
|
||||
.spyOn(blockRepository, 'findOne')
|
||||
.mockResolvedValue(null);
|
||||
const mockUpdateMany = jest.spyOn(blockRepository, 'updateMany');
|
||||
|
||||
await blockRepository.preUpdate(
|
||||
{} as any,
|
||||
{ _id: 'nonexistent' },
|
||||
{ $set: { category: 'newCategory' } },
|
||||
);
|
||||
|
||||
expect(mockFindOne).toHaveBeenCalledWith({ _id: 'nonexistent' });
|
||||
expect(mockUpdateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('prepareBlocksInCategoryUpdateScope', () => {
|
||||
it('should update blocks within the scope based on category and ids', async () => {
|
||||
jest.spyOn(blockRepository, 'findOne').mockResolvedValue({
|
||||
id: validIds[0],
|
||||
category: 'oldCategory',
|
||||
nextBlocks: [validIds[1]],
|
||||
attachedBlock: validIds[1],
|
||||
} as Block);
|
||||
|
||||
const mockUpdateOne = jest.spyOn(blockRepository, 'updateOne');
|
||||
|
||||
await blockRepository.prepareBlocksInCategoryUpdateScope(
|
||||
validCategory,
|
||||
validIds,
|
||||
);
|
||||
|
||||
expect(mockUpdateOne).toHaveBeenCalledWith(validIds[0], {
|
||||
nextBlocks: [validIds[1]],
|
||||
attachedBlock: validIds[1],
|
||||
});
|
||||
});
|
||||
|
||||
it('should not update blocks if the category already matches', async () => {
|
||||
jest.spyOn(blockRepository, 'findOne').mockResolvedValue({
|
||||
id: validIds[0],
|
||||
category: validCategory,
|
||||
nextBlocks: [],
|
||||
attachedBlock: null,
|
||||
} as Block);
|
||||
|
||||
const mockUpdateOne = jest.spyOn(blockRepository, 'updateOne');
|
||||
|
||||
await blockRepository.prepareBlocksInCategoryUpdateScope(
|
||||
validCategory,
|
||||
validIds,
|
||||
);
|
||||
|
||||
expect(mockUpdateOne).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('prepareBlocksOutOfCategoryUpdateScope', () => {
|
||||
it('should update blocks outside the scope by removing references from attachedBlock', async () => {
|
||||
const otherBlocks = [
|
||||
{
|
||||
id: '64abc1234def567890fedcab',
|
||||
attachedBlock: validIds[0],
|
||||
nextBlocks: [validIds[0]],
|
||||
},
|
||||
] as Block[];
|
||||
|
||||
const mockUpdateOne = jest.spyOn(blockRepository, 'updateOne');
|
||||
|
||||
await blockRepository.prepareBlocksOutOfCategoryUpdateScope(
|
||||
otherBlocks,
|
||||
validIds,
|
||||
);
|
||||
|
||||
expect(mockUpdateOne).toHaveBeenCalledWith('64abc1234def567890fedcab', {
|
||||
attachedBlock: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('should update blocks outside the scope by removing references from nextBlocks', async () => {
|
||||
const otherBlocks = [
|
||||
{
|
||||
id: '64abc1234def567890fedcab',
|
||||
attachedBlock: null,
|
||||
nextBlocks: [validIds[0], validIds[1]],
|
||||
},
|
||||
] as Block[];
|
||||
|
||||
const mockUpdateOne = jest.spyOn(blockRepository, 'updateOne');
|
||||
|
||||
await blockRepository.prepareBlocksOutOfCategoryUpdateScope(otherBlocks, [
|
||||
validIds[0],
|
||||
]);
|
||||
|
||||
expect(mockUpdateOne).toHaveBeenCalledWith('64abc1234def567890fedcab', {
|
||||
nextBlocks: [validIds[1]],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('preUpdateMany', () => {
|
||||
it('should update blocks in and out of the scope', async () => {
|
||||
const mockFind = jest.spyOn(blockRepository, 'find').mockResolvedValue([
|
||||
{
|
||||
id: '64abc1234def567890fedcab',
|
||||
attachedBlock: validIds[0],
|
||||
nextBlocks: [validIds[0]],
|
||||
},
|
||||
] as Block[]);
|
||||
|
||||
const prepareBlocksInCategoryUpdateScope = jest.spyOn(
|
||||
blockRepository,
|
||||
'prepareBlocksInCategoryUpdateScope',
|
||||
);
|
||||
const prepareBlocksOutOfCategoryUpdateScope = jest.spyOn(
|
||||
blockRepository,
|
||||
'prepareBlocksOutOfCategoryUpdateScope',
|
||||
);
|
||||
|
||||
await blockRepository.preUpdateMany(
|
||||
{} as any,
|
||||
{ _id: { $in: validIds } },
|
||||
{ $set: { category: validCategory } },
|
||||
);
|
||||
|
||||
expect(mockFind).toHaveBeenCalled();
|
||||
expect(prepareBlocksInCategoryUpdateScope).toHaveBeenCalledWith(
|
||||
validCategory,
|
||||
['64abc1234def567890fedcab'],
|
||||
);
|
||||
expect(prepareBlocksOutOfCategoryUpdateScope).toHaveBeenCalledWith(
|
||||
[
|
||||
{
|
||||
id: '64abc1234def567890fedcab',
|
||||
attachedBlock: validIds[0],
|
||||
nextBlocks: [validIds[0]],
|
||||
},
|
||||
],
|
||||
['64abc1234def567890fedcab'],
|
||||
);
|
||||
});
|
||||
|
||||
it('should not perform updates if no category is provided', async () => {
|
||||
const mockFind = jest.spyOn(blockRepository, 'find');
|
||||
const prepareBlocksInCategoryUpdateScope = jest.spyOn(
|
||||
blockRepository,
|
||||
'prepareBlocksInCategoryUpdateScope',
|
||||
);
|
||||
const prepareBlocksOutOfCategoryUpdateScope = jest.spyOn(
|
||||
blockRepository,
|
||||
'prepareBlocksOutOfCategoryUpdateScope',
|
||||
);
|
||||
|
||||
await blockRepository.preUpdateMany({} as any, {}, { $set: {} });
|
||||
|
||||
expect(mockFind).not.toHaveBeenCalled();
|
||||
expect(prepareBlocksInCategoryUpdateScope).not.toHaveBeenCalled();
|
||||
expect(prepareBlocksOutOfCategoryUpdateScope).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -89,14 +89,138 @@ export class BlockRepository extends BaseRepository<
|
||||
Block,
|
||||
'findOneAndUpdate'
|
||||
>,
|
||||
_criteria: TFilterQuery<Block>,
|
||||
_updates:
|
||||
criteria: TFilterQuery<Block>,
|
||||
updates:
|
||||
| UpdateWithAggregationPipeline
|
||||
| UpdateQuery<Document<Block, any, any>>,
|
||||
): Promise<void> {
|
||||
const updates: BlockUpdateDto = _updates?.['$set'];
|
||||
const update: BlockUpdateDto = updates?.['$set'];
|
||||
|
||||
this.checkDeprecatedAttachmentUrl(updates);
|
||||
if (update?.category) {
|
||||
const movedBlock: Block = await this.findOne(criteria);
|
||||
|
||||
if (!movedBlock) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find and update blocks that reference the moved block
|
||||
await this.updateMany(
|
||||
{ nextBlocks: movedBlock.id },
|
||||
{ $pull: { nextBlocks: movedBlock.id } },
|
||||
);
|
||||
|
||||
await this.updateMany(
|
||||
{ attachedBlock: movedBlock.id },
|
||||
{ $set: { attachedBlock: null } },
|
||||
);
|
||||
}
|
||||
this.checkDeprecatedAttachmentUrl(update);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-processing logic for updating blocks.
|
||||
*
|
||||
* @param query - The query to update blocks.
|
||||
* @param criteria - The filter criteria for the update query.
|
||||
* @param updates - The update data.
|
||||
*/
|
||||
async preUpdateMany(
|
||||
_query: Query<
|
||||
Document<Block, any, any>,
|
||||
Document<Block, any, any>,
|
||||
unknown,
|
||||
Block,
|
||||
'updateMany',
|
||||
Record<string, never>
|
||||
>,
|
||||
criteria: TFilterQuery<Block>,
|
||||
updates: UpdateQuery<Document<Block, any, any>>,
|
||||
): Promise<void> {
|
||||
const categoryId: string = updates.$set.category;
|
||||
if (categoryId) {
|
||||
const movedBlocks = await this.find(criteria);
|
||||
|
||||
if (movedBlocks.length) {
|
||||
const ids: string[] = movedBlocks.map(({ id }) => id);
|
||||
|
||||
// Step 1: Map IDs and Category
|
||||
const objIds = ids.map((id) => new Types.ObjectId(id));
|
||||
const objCategoryId = new Types.ObjectId(categoryId);
|
||||
|
||||
// Step 2: Find other blocks
|
||||
const otherBlocks = await this.find({
|
||||
_id: { $nin: objIds },
|
||||
category: { $ne: objCategoryId },
|
||||
$or: [
|
||||
{ attachedBlock: { $in: objIds } },
|
||||
{ nextBlocks: { $in: objIds } },
|
||||
],
|
||||
});
|
||||
// Step 3: Update blocks in the provided scope
|
||||
await this.prepareBlocksInCategoryUpdateScope(categoryId, ids);
|
||||
|
||||
// Step 4: Update external blocks
|
||||
await this.prepareBlocksOutOfCategoryUpdateScope(otherBlocks, ids);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates blocks within a specified category scope.
|
||||
* Ensures nextBlocks and attachedBlock are consistent with the provided IDs and category.
|
||||
*
|
||||
* @param category - The category
|
||||
* @param ids - IDs representing the blocks to update.
|
||||
* @returns A promise that resolves once all updates within the scope are complete.
|
||||
*/
|
||||
async prepareBlocksInCategoryUpdateScope(
|
||||
category: string,
|
||||
ids: string[],
|
||||
): Promise<void> {
|
||||
for (const id of ids) {
|
||||
const oldState: Block = await this.findOne(id);
|
||||
if (oldState.category !== category) {
|
||||
const updatedNextBlocks = oldState.nextBlocks.filter((nextBlock) =>
|
||||
ids.includes(nextBlock),
|
||||
);
|
||||
|
||||
const updatedAttachedBlock = ids.includes(oldState.attachedBlock || '')
|
||||
? oldState.attachedBlock
|
||||
: null;
|
||||
|
||||
await this.updateOne(id, {
|
||||
nextBlocks: updatedNextBlocks,
|
||||
attachedBlock: updatedAttachedBlock,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates blocks outside the specified category scope by removing references to the provided IDs.
|
||||
* Handles updates to both attachedBlock and nextBlocks.
|
||||
*
|
||||
* @param otherBlocks - An array of blocks outside the provided category scope.
|
||||
* @param ids - An array of the Ids to disassociate.
|
||||
* @returns A promise that resolves once all external block updates are complete.
|
||||
*/
|
||||
async prepareBlocksOutOfCategoryUpdateScope(
|
||||
otherBlocks: Block[],
|
||||
ids: string[],
|
||||
): Promise<void> {
|
||||
for (const block of otherBlocks) {
|
||||
if (ids.includes(block.attachedBlock)) {
|
||||
await this.updateOne(block.id, { attachedBlock: null });
|
||||
}
|
||||
|
||||
const nextBlocks = block.nextBlocks.filter(
|
||||
(nextBlock) => !ids.includes(nextBlock),
|
||||
);
|
||||
|
||||
if (nextBlocks.length > 0) {
|
||||
await this.updateOne(block.id, { nextBlocks });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -8,7 +8,7 @@
|
||||
|
||||
import { getModelToken } from '@nestjs/mongoose';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import mongoose, { Model } from 'mongoose';
|
||||
import { Model, Types } from 'mongoose';
|
||||
|
||||
import { DummyRepository } from '@/utils/test/dummy/repositories/dummy.repository';
|
||||
import { closeInMongodConnection } from '@/utils/test/test';
|
||||
@ -150,7 +150,7 @@ describe('BaseRepository', () => {
|
||||
expect(spyBeforeUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ $useProjection: true }),
|
||||
{
|
||||
_id: new mongoose.Types.ObjectId(created.id),
|
||||
_id: new Types.ObjectId(created.id),
|
||||
},
|
||||
expect.objectContaining({ $set: expect.objectContaining(mockUpdate) }),
|
||||
);
|
||||
@ -202,7 +202,7 @@ describe('BaseRepository', () => {
|
||||
expect(spyBeforeDelete).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ $useProjection: true }),
|
||||
{
|
||||
_id: new mongoose.Types.ObjectId(createdId),
|
||||
_id: new Types.ObjectId(createdId),
|
||||
},
|
||||
);
|
||||
expect(spyAfterDelete).toHaveBeenCalledWith(
|
||||
|
@ -38,10 +38,12 @@ export type DeleteResult = {
|
||||
export enum EHook {
|
||||
preCreate = 'preCreate',
|
||||
preUpdate = 'preUpdate',
|
||||
preUpdateMany = 'preUpdateMany',
|
||||
preDelete = 'preDelete',
|
||||
preValidate = 'preValidate',
|
||||
postCreate = 'postCreate',
|
||||
postUpdate = 'postUpdate',
|
||||
postUpdateMany = 'postUpdateMany',
|
||||
postDelete = 'postDelete',
|
||||
postValidate = 'postValidate',
|
||||
}
|
||||
@ -157,6 +159,28 @@ export abstract class BaseRepository<
|
||||
);
|
||||
});
|
||||
|
||||
hooks?.updateMany.pre.execute(async function () {
|
||||
const query = this as Query<D, D, unknown, T, 'updateMany'>;
|
||||
const criteria = query.getFilter();
|
||||
const updates = query.getUpdate();
|
||||
|
||||
await repository.preUpdateMany(query, criteria, updates);
|
||||
repository.emitter.emit(
|
||||
repository.getEventName(EHook.preUpdateMany),
|
||||
criteria,
|
||||
updates?.['$set'],
|
||||
);
|
||||
});
|
||||
|
||||
hooks?.updateMany.post.execute(async function (updated: any) {
|
||||
const query = this as Query<D, D, unknown, T, 'updateMany'>;
|
||||
await repository.postUpdateMany(query, updated);
|
||||
repository.emitter.emit(
|
||||
repository.getEventName(EHook.postUpdateMany),
|
||||
updated,
|
||||
);
|
||||
});
|
||||
|
||||
hooks?.findOneAndUpdate.post.execute(async function (
|
||||
updated: HydratedDocument<T>,
|
||||
) {
|
||||
@ -319,7 +343,7 @@ export abstract class BaseRepository<
|
||||
|
||||
async updateOne<D extends Partial<U>>(
|
||||
criteria: string | TFilterQuery<T>,
|
||||
dto: D,
|
||||
dto: UpdateQuery<D>,
|
||||
): Promise<T> {
|
||||
const query = this.model.findOneAndUpdate<T>(
|
||||
{
|
||||
@ -335,7 +359,10 @@ export abstract class BaseRepository<
|
||||
return await this.executeOne(query, this.cls);
|
||||
}
|
||||
|
||||
async updateMany<D extends Partial<U>>(filter: TFilterQuery<T>, dto: D) {
|
||||
async updateMany<D extends Partial<U>>(
|
||||
filter: TFilterQuery<T>,
|
||||
dto: UpdateQuery<D>,
|
||||
) {
|
||||
return await this.model.updateMany<T>(filter, {
|
||||
$set: dto,
|
||||
});
|
||||
@ -375,6 +402,21 @@ export abstract class BaseRepository<
|
||||
// Nothing ...
|
||||
}
|
||||
|
||||
async preUpdateMany(
|
||||
_query: Query<D, D, unknown, T, 'updateMany'>,
|
||||
_criteria: TFilterQuery<T>,
|
||||
_updates: UpdateWithAggregationPipeline | UpdateQuery<D>,
|
||||
) {
|
||||
// Nothing ...
|
||||
}
|
||||
|
||||
async postUpdateMany(
|
||||
_query: Query<D, D, unknown, T, 'updateMany'>,
|
||||
_updated: any,
|
||||
) {
|
||||
// Nothing ...
|
||||
}
|
||||
|
||||
async postUpdate(
|
||||
_query: Query<D, D, unknown, T, 'findOneAndUpdate'>,
|
||||
_updated: T,
|
||||
|
@ -17,7 +17,7 @@ enum LifecycleOperation {
|
||||
// InsertMany = 'insertMany',
|
||||
// Update = 'update',
|
||||
// UpdateOne = 'updateOne',
|
||||
// UpdateMany = 'updateMany',
|
||||
UpdateMany = 'updateMany',
|
||||
}
|
||||
|
||||
type PreHook = (...args: any[]) => void;
|
||||
@ -69,7 +69,7 @@ export class LifecycleHookManager {
|
||||
// insertMany: ['pre'],
|
||||
// update: ['pre', 'post'],
|
||||
// updateOne: ['pre', 'post'],
|
||||
// updateMany: ['pre', 'post'],
|
||||
updateMany: ['pre', 'post'],
|
||||
};
|
||||
|
||||
const lifecycleHooks: LifecycleHooks = {} as LifecycleHooks;
|
||||
|
@ -106,7 +106,8 @@
|
||||
"no_label_found": "No label found",
|
||||
"code_is_required": "Language code is required",
|
||||
"text_is_required": "Text is required",
|
||||
"invalid_file_type": "Invalid file type"
|
||||
"invalid_file_type": "Invalid file type",
|
||||
"select_category": "Select a flow"
|
||||
},
|
||||
"menu": {
|
||||
"terms": "Terms of Use",
|
||||
@ -505,6 +506,7 @@
|
||||
"rename": "Rename",
|
||||
"duplicate": "Duplicate",
|
||||
"remove": "Remove",
|
||||
"move": "Move",
|
||||
"remove_permanently": "Remove",
|
||||
"restore": "Restore",
|
||||
"edit": "Edit",
|
||||
|
@ -106,7 +106,8 @@
|
||||
"no_label_found": "Aucune étiquette trouvée",
|
||||
"code_is_required": "Le code est requis",
|
||||
"text_is_required": "Texte requis",
|
||||
"invalid_file_type": "Type de fichier invalide"
|
||||
"invalid_file_type": "Type de fichier invalide",
|
||||
"select_category": "Sélectionner une catégorie"
|
||||
},
|
||||
"menu": {
|
||||
"terms": "Conditions d'utilisation",
|
||||
@ -506,6 +507,7 @@
|
||||
"rename": "Renommer",
|
||||
"duplicate": "Dupliquer",
|
||||
"remove": "Supprimer",
|
||||
"move": "Déplacer",
|
||||
"remove_permanently": "Supprimer de façon permanente",
|
||||
"restore": "Restaurer",
|
||||
"edit": "Modifier",
|
||||
|
86
frontend/src/app-components/dialogs/MoveDialog.tsx
Normal file
86
frontend/src/app-components/dialogs/MoveDialog.tsx
Normal file
@ -0,0 +1,86 @@
|
||||
/*
|
||||
* 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 {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
Grid,
|
||||
MenuItem,
|
||||
Select,
|
||||
} from "@mui/material";
|
||||
import { FC, useState } from "react";
|
||||
|
||||
import { DialogTitle } from "@/app-components/dialogs/DialogTitle";
|
||||
import { DialogControl } from "@/hooks/useDialog";
|
||||
import { useTranslate } from "@/hooks/useTranslate";
|
||||
import { ICategory } from "@/types/category.types";
|
||||
|
||||
export interface MoveDialogProps extends DialogControl<string> {
|
||||
categories: ICategory[];
|
||||
callback?: (newCategoryId?: string) => Promise<void>;
|
||||
openDialog: (data?: string) => void;
|
||||
}
|
||||
|
||||
export const MoveDialog: FC<MoveDialogProps> = ({
|
||||
open,
|
||||
callback,
|
||||
closeDialog,
|
||||
categories,
|
||||
}: MoveDialogProps) => {
|
||||
const { t } = useTranslate();
|
||||
const [selectedCategoryId, setSelectedCategoryId] = useState<string>("");
|
||||
const handleMove = async () => {
|
||||
if (selectedCategoryId && callback) {
|
||||
await callback(selectedCategoryId);
|
||||
closeDialog();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} fullWidth onClose={closeDialog}>
|
||||
<DialogTitle onClose={closeDialog}>
|
||||
{t("message.select_category")}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Grid container direction="column" gap={2}>
|
||||
<Grid item>
|
||||
<Select
|
||||
value={selectedCategoryId}
|
||||
onChange={(e) => setSelectedCategoryId(e.target.value as string)}
|
||||
fullWidth
|
||||
displayEmpty
|
||||
>
|
||||
<MenuItem value="" disabled>
|
||||
{t("label.category")}
|
||||
</MenuItem>
|
||||
{categories.map((category) => (
|
||||
<MenuItem key={category.id} value={category.id}>
|
||||
{category.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleMove}
|
||||
disabled={!selectedCategoryId}
|
||||
>
|
||||
{t("button.move")}
|
||||
</Button>
|
||||
<Button variant="outlined" onClick={closeDialog}>
|
||||
{t("button.cancel")}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
@ -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 { Add } from "@mui/icons-material";
|
||||
import { Add, MoveUp } from "@mui/icons-material";
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
import EditIcon from "@mui/icons-material/Edit";
|
||||
import FitScreenIcon from "@mui/icons-material/FitScreen";
|
||||
@ -36,18 +36,22 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useQueryClient } from "react-query";
|
||||
|
||||
import { DeleteDialog } from "@/app-components/dialogs";
|
||||
import { MoveDialog } from "@/app-components/dialogs/MoveDialog";
|
||||
import { CategoryDialog } from "@/components/categories/CategoryDialog";
|
||||
import { isSameEntity } from "@/hooks/crud/helpers";
|
||||
import { useDelete, useDeleteFromCache } from "@/hooks/crud/useDelete";
|
||||
import { useFind } from "@/hooks/crud/useFind";
|
||||
import { useGetFromCache } from "@/hooks/crud/useGet";
|
||||
import { useUpdate, useUpdateCache } from "@/hooks/crud/useUpdate";
|
||||
import { useUpdateMany } from "@/hooks/crud/useUpdateMany";
|
||||
import useDebouncedUpdate from "@/hooks/useDebouncedUpdate";
|
||||
import { getDisplayDialogs, useDialog } from "@/hooks/useDialog";
|
||||
import { useSearch } from "@/hooks/useSearch";
|
||||
import { useTranslate } from "@/hooks/useTranslate";
|
||||
import { EntityType, Format } from "@/services/types";
|
||||
import { EntityType, Format, QueryType } from "@/services/types";
|
||||
import { IBlock } from "@/types/block.types";
|
||||
import { ICategory } from "@/types/category.types";
|
||||
import { BlockPorts } from "@/types/visual-editor.types";
|
||||
@ -67,7 +71,9 @@ const Diagrams = () => {
|
||||
const [canvas, setCanvas] = useState<JSX.Element | undefined>();
|
||||
const [selectedBlockId, setSelectedBlockId] = useState<string | undefined>();
|
||||
const deleteDialogCtl = useDialog<string>(false);
|
||||
const moveDialogCtl = useDialog<string[] | string>(false);
|
||||
const addCategoryDialogCtl = useDialog<ICategory>(false);
|
||||
const { mutateAsync: updateBlocks } = useUpdateMany(EntityType.BLOCK);
|
||||
const {
|
||||
buildDiagram,
|
||||
setViewerZoom,
|
||||
@ -144,6 +150,7 @@ const Diagrams = () => {
|
||||
},
|
||||
[selectedCategoryId, debouncedUpdateCategory],
|
||||
);
|
||||
const queryClient = useQueryClient();
|
||||
const getBlockFromCache = useGetFromCache(EntityType.BLOCK);
|
||||
const updateCachedBlock = useUpdateCache(EntityType.BLOCK);
|
||||
const deleteCachedBlock = useDeleteFromCache(EntityType.BLOCK);
|
||||
@ -292,6 +299,7 @@ const Diagrams = () => {
|
||||
offsetUpdated: debouncedOffsetEvent,
|
||||
});
|
||||
}, [
|
||||
selectedCategoryId,
|
||||
JSON.stringify(
|
||||
blocks.map((b) => {
|
||||
return { ...b, position: undefined, updatedAt: undefined };
|
||||
@ -316,6 +324,14 @@ const Diagrams = () => {
|
||||
deleteDialogCtl.openDialog(ids);
|
||||
}
|
||||
};
|
||||
const handleMoveButton = () => {
|
||||
const selectedEntities = engine?.getModel().getSelectedEntities().reverse();
|
||||
const ids = selectedEntities?.map((model) => model.getID());
|
||||
|
||||
if (ids && selectedEntities) {
|
||||
moveDialogCtl.openDialog(ids);
|
||||
}
|
||||
};
|
||||
const onDelete = async () => {
|
||||
const id = deleteDialogCtl?.data;
|
||||
|
||||
@ -429,6 +445,32 @@ const Diagrams = () => {
|
||||
deleteDialogCtl.closeDialog();
|
||||
}
|
||||
};
|
||||
const onMove = async (newCategoryId?: string) => {
|
||||
if (!newCategoryId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ids = moveDialogCtl?.data;
|
||||
|
||||
if (ids?.length && Array.isArray(ids)) {
|
||||
await updateBlocks({ ids, payload: { category: newCategoryId } });
|
||||
|
||||
queryClient.invalidateQueries({
|
||||
predicate: ({ queryKey }) => {
|
||||
const [qType, qEntity] = queryKey;
|
||||
|
||||
return (
|
||||
qType === QueryType.collection &&
|
||||
isSameEntity(qEntity, EntityType.BLOCK)
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
setSelectedCategoryId(newCategoryId);
|
||||
setSelectedBlockId(undefined);
|
||||
moveDialogCtl.closeDialog();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -462,6 +504,13 @@ const Diagrams = () => {
|
||||
<CategoryDialog {...getDisplayDialogs(addCategoryDialogCtl)} />
|
||||
<BlockDialog {...getDisplayDialogs(editDialogCtl)} />
|
||||
<DeleteDialog {...deleteDialogCtl} callback={onDelete} />
|
||||
<MoveDialog
|
||||
open={moveDialogCtl.open}
|
||||
openDialog={moveDialogCtl.openDialog}
|
||||
callback={onMove}
|
||||
closeDialog={moveDialogCtl.closeDialog}
|
||||
categories={categories}
|
||||
/>
|
||||
<Grid sx={{ bgcolor: "#fff", padding: "0" }}>
|
||||
<Grid
|
||||
sx={{
|
||||
@ -569,6 +618,15 @@ const Diagrams = () => {
|
||||
>
|
||||
{t("button.edit")}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
startIcon={<MoveUp />}
|
||||
onClick={handleMoveButton}
|
||||
disabled={!selectedBlockId || selectedBlockId.length !== 24}
|
||||
>
|
||||
{t("button.move")}
|
||||
</Button>
|
||||
<Button
|
||||
sx={{}}
|
||||
size="small"
|
||||
|
83
frontend/src/hooks/crud/useUpdateMany.tsx
Normal file
83
frontend/src/hooks/crud/useUpdateMany.tsx
Normal file
@ -0,0 +1,83 @@
|
||||
/*
|
||||
* 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 { useMutation, useQueryClient } from "react-query";
|
||||
|
||||
import { QueryType, TMutationOptions } from "@/services/types";
|
||||
import { IBaseSchema, IDynamicProps, TType } from "@/types/base.types";
|
||||
|
||||
import { useEntityApiClient } from "../useApiClient";
|
||||
|
||||
import { isSameEntity } from "./helpers";
|
||||
|
||||
export const useUpdateMany = <
|
||||
TEntity extends IDynamicProps["entity"],
|
||||
TAttr = TType<TEntity>["attributes"],
|
||||
TBasic extends IBaseSchema = TType<TEntity>["basic"],
|
||||
TFull extends IBaseSchema = TType<TEntity>["full"],
|
||||
>(
|
||||
entity: TEntity,
|
||||
options?: Omit<
|
||||
TMutationOptions<
|
||||
string,
|
||||
Error,
|
||||
{
|
||||
ids: string[];
|
||||
payload: Partial<TAttr>;
|
||||
},
|
||||
TBasic
|
||||
>,
|
||||
"mutationFn" | "mutationKey"
|
||||
> & {
|
||||
invalidate?: boolean;
|
||||
},
|
||||
) => {
|
||||
const api = useEntityApiClient<TAttr, TBasic, TFull>(entity);
|
||||
const queryClient = useQueryClient();
|
||||
const { invalidate = true, ...otherOptions } = options || {};
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
ids,
|
||||
payload,
|
||||
}: {
|
||||
ids: string[];
|
||||
payload: Partial<TAttr>;
|
||||
}) => {
|
||||
const result = await api.updateMany(ids, payload);
|
||||
|
||||
queryClient.removeQueries({
|
||||
predicate: ({ queryKey }) => {
|
||||
const [qType, qEntity, qId] = queryKey;
|
||||
|
||||
return (
|
||||
qType === QueryType.item &&
|
||||
isSameEntity(qEntity, entity) &&
|
||||
ids.includes(qId as string)
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
if (invalidate) {
|
||||
queryClient.invalidateQueries({
|
||||
predicate: ({ queryKey }) => {
|
||||
const [qType, qEntity] = queryKey;
|
||||
|
||||
return (
|
||||
(qType === QueryType.count || qType === QueryType.collection) &&
|
||||
isSameEntity(qEntity, entity)
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
...otherOptions,
|
||||
});
|
||||
};
|
@ -342,6 +342,23 @@ export class EntityApiClient<TAttr, TBasic, TFull> extends ApiClient {
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk Update entries.
|
||||
*/
|
||||
async updateMany(ids: string[], payload: Partial<TAttr>) {
|
||||
const { _csrf } = await this.getCsrf();
|
||||
const { data } = await this.request.patch<string>(
|
||||
`${ROUTES[this.type]}/bulk`,
|
||||
{
|
||||
_csrf,
|
||||
ids,
|
||||
payload,
|
||||
},
|
||||
);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an entry.
|
||||
*/
|
||||
|
Loading…
Reference in New Issue
Block a user