Merge branch 'main' into fix/cleanup-languageId-nlp-sample

This commit is contained in:
yassinedorbozgithub
2025-04-12 06:51:32 +01:00
45 changed files with 590 additions and 290 deletions

2
api/package-lock.json generated
View File

@@ -32,7 +32,6 @@
"cache-manager-redis-yet": "^4.1.2",
"connect-mongo": "^5.1.0",
"cookie-parser": "^1.4.6",
"dotenv": "^16.3.1",
"express-session": "^1.17.3",
"handlebars": "^4.7.8",
"module-alias": "^2.2.3",
@@ -86,6 +85,7 @@
"@types/uuid": "^9.0.7",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"dotenv": "^16.3.1",
"eslint": "^8.42.0",
"eslint-config-prettier": "^9.0.0",
"eslint-import-resolver-typescript": "~3.6.1",

View File

@@ -67,7 +67,6 @@
"cache-manager-redis-yet": "^4.1.2",
"connect-mongo": "^5.1.0",
"cookie-parser": "^1.4.6",
"dotenv": "^16.3.1",
"express-session": "^1.17.3",
"handlebars": "^4.7.8",
"module-alias": "^2.2.3",
@@ -121,6 +120,7 @@
"@types/uuid": "^9.0.7",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"dotenv": "^16.3.1",
"eslint": "^8.42.0",
"eslint-config-prettier": "^9.0.0",
"eslint-import-resolver-typescript": "~3.6.1",

View File

@@ -93,8 +93,9 @@ export class BotStatsService extends BaseService<BotStats> {
) {
this.eventEmitter.emit(
'hook:stats:entry',
'retention',
BotStatsType.retention,
'Retentioned users',
subscriber,
);
}
}
@@ -106,7 +107,11 @@ export class BotStatsService extends BaseService<BotStats> {
* @param name - The name or identifier of the statistics entry (e.g., a specific feature or component being tracked).
*/
@OnEvent('hook:stats:entry')
async handleStatEntry(type: BotStatsType, name: string): Promise<void> {
async handleStatEntry(
type: BotStatsType,
name: string,
_subscriber: Subscriber,
): Promise<void> {
const day = new Date();
day.setMilliseconds(0);
day.setSeconds(0);

View File

@@ -361,4 +361,30 @@ describe('BlockController', () => {
).toBeDefined();
expect(result.patterns).toEqual(updateBlock.patterns);
});
it('should update the block trigger with a content payloadType payload', async () => {
jest.spyOn(blockService, 'updateOne');
const updateBlock: BlockUpdateDto = {
patterns: [
{
label: 'Content label',
value: 'Content value',
type: PayloadType.content,
},
],
};
const result = await blockController.updateOne(block.id, updateBlock);
expect(blockService.updateOne).toHaveBeenCalledWith(block.id, updateBlock);
expect(
result.patterns.find(
(pattern) =>
typeof pattern === 'object' &&
'type' in pattern &&
pattern.type === PayloadType.content &&
pattern,
),
).toBeDefined();
expect(result.patterns).toEqual(updateBlock.patterns);
});
});

View File

@@ -171,42 +171,38 @@ describe('BlockRepository', () => {
describe('prepareBlocksInCategoryUpdateScope', () => {
it('should update blocks within the scope based on category and ids', async () => {
jest.spyOn(blockRepository, 'findOne').mockResolvedValue({
id: blockValidIds[0],
category: 'oldCategory',
nextBlocks: [blockValidIds[1]],
attachedBlock: blockValidIds[1],
} as Block);
const mockUpdateOne = jest.spyOn(blockRepository, 'updateOne');
jest.spyOn(blockRepository, 'find').mockResolvedValue([
{
id: blockValidIds[0],
category: category.id,
nextBlocks: [blockValidIds[1]],
attachedBlock: blockValidIds[2],
},
] as Block[]);
jest.spyOn(blockRepository, 'updateOne');
await blockRepository.prepareBlocksInCategoryUpdateScope(
validCategory,
blockValidIds,
);
expect(mockUpdateOne).toHaveBeenCalledWith(blockValidIds[0], {
expect(blockRepository.updateOne).toHaveBeenCalledWith(blockValidIds[0], {
nextBlocks: [blockValidIds[1]],
attachedBlock: blockValidIds[1],
attachedBlock: blockValidIds[2],
});
});
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 unknown as Block);
const mockUpdateOne = jest.spyOn(blockRepository, 'updateOne');
jest.spyOn(blockRepository, 'find').mockResolvedValue([]);
jest.spyOn(blockRepository, 'updateOne');
await blockRepository.prepareBlocksInCategoryUpdateScope(
validCategory,
validIds,
category.id,
blockValidIds,
);
expect(mockUpdateOne).not.toHaveBeenCalled();
expect(blockRepository.find).toHaveBeenCalled();
expect(blockRepository.updateOne).not.toHaveBeenCalled();
});
});

View File

@@ -172,22 +172,24 @@ export class BlockRepository extends BaseRepository<
category: string,
ids: string[],
): Promise<void> {
for (const id of ids) {
const oldState = await this.findOne(id);
if (oldState && oldState.category !== category) {
const updatedNextBlocks = oldState.nextBlocks?.filter((nextBlock) =>
ids.includes(nextBlock),
);
const blocks = await this.find({
_id: { $in: ids },
category: { $ne: category },
});
const updatedAttachedBlock = ids.includes(oldState.attachedBlock || '')
? oldState.attachedBlock
: null;
for (const { id, nextBlocks, attachedBlock } of blocks) {
const updatedNextBlocks = nextBlocks.filter((nextBlock) =>
ids.includes(nextBlock),
);
await this.updateOne(id, {
nextBlocks: updatedNextBlocks,
attachedBlock: updatedAttachedBlock,
});
}
const updatedAttachedBlock = ids.includes(attachedBlock || '')
? attachedBlock
: null;
await this.updateOne(id, {
nextBlocks: updatedNextBlocks,
attachedBlock: updatedAttachedBlock,
});
}
}

View File

@@ -16,6 +16,7 @@ import {
UpdateWithAggregationPipeline,
} from 'mongoose';
import { BotStatsType } from '@/analytics/schemas/bot-stats.schema';
import { BaseRepository } from '@/utils/generics/base-repository';
import { TFilterQuery } from '@/utils/types/filter.types';
@@ -47,7 +48,7 @@ export class SubscriberRepository extends BaseRepository<
async postCreate(created: SubscriberDocument): Promise<void> {
this.eventEmitter.emit(
'hook:stats:entry',
'new_users',
BotStatsType.new_users,
'New users',
created,
);

View File

@@ -42,4 +42,5 @@ export enum PayloadType {
button = 'button',
outcome = 'outcome',
menu = 'menu',
content = 'content',
}

View File

@@ -243,8 +243,8 @@ describe('BlockService', () => {
await botService.startConversation(event, block);
expect(hasBotSpoken).toEqual(true);
expect(triggeredEvents).toEqual([
['popular', 'hasNextBlocks'],
['new_conversations', 'New conversations'],
['popular', 'hasNextBlocks', webSubscriber],
['new_conversations', 'New conversations', webSubscriber],
]);
clearMock.mockClear();
});
@@ -301,7 +301,7 @@ describe('BlockService', () => {
const captured = await botService.processConversationMessage(event);
expect(captured).toBe(true);
expect(triggeredEvents).toEqual([
['existing_conversations', 'Existing conversations'],
['existing_conversations', 'Existing conversations', webSubscriber],
]);
clearMock.mockClear();
});

View File

@@ -9,6 +9,7 @@
import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { BotStatsType } from '@/analytics/schemas/bot-stats.schema';
import EventWrapper from '@/channel/lib/EventWrapper';
import { LoggerService } from '@/logger/logger.service';
import { SettingService } from '@/setting/services/setting.service';
@@ -65,8 +66,18 @@ export class BotService {
.getHandler()
.sendMessage(event, envelope, options, context);
this.eventEmitter.emit('hook:stats:entry', 'outgoing', 'Outgoing');
this.eventEmitter.emit('hook:stats:entry', 'all_messages', 'All Messages');
this.eventEmitter.emit(
'hook:stats:entry',
BotStatsType.outgoing,
'Outgoing',
recipient,
);
this.eventEmitter.emit(
'hook:stats:entry',
BotStatsType.all_messages,
'All Messages',
recipient,
);
// Trigger sent message event
const sentMessage: MessageCreateDto = {
@@ -293,7 +304,12 @@ export class BotService {
if (next) {
// Increment stats about popular blocks
this.eventEmitter.emit('hook:stats:entry', 'popular', next.name);
this.eventEmitter.emit(
'hook:stats:entry',
BotStatsType.popular,
next.name,
convo.sender,
);
// Go next!
this.logger.debug('Respond to nested conversion! Go next ', next.id);
try {
@@ -352,8 +368,9 @@ export class BotService {
this.eventEmitter.emit(
'hook:stats:entry',
'existing_conversations',
BotStatsType.existing_conversations,
'Existing conversations',
subscriber,
);
this.logger.debug('Conversation has been captured! Responding ...');
return await this.handleIncomingMessage(conversation, event);
@@ -373,10 +390,15 @@ export class BotService {
* @param block - Starting block
*/
async startConversation(event: EventWrapper<any, any>, block: BlockFull) {
// Increment popular stats
this.eventEmitter.emit('hook:stats:entry', 'popular', block.name);
// Launching a new conversation
const subscriber = event.getSender();
// Increment popular stats
this.eventEmitter.emit(
'hook:stats:entry',
BotStatsType.popular,
block.name,
subscriber,
);
try {
const convo = await this.conversationService.create({
@@ -384,8 +406,9 @@ export class BotService {
});
this.eventEmitter.emit(
'hook:stats:entry',
'new_conversations',
BotStatsType.new_conversations,
'New conversations',
subscriber,
);
try {

View File

@@ -11,6 +11,7 @@ import { EventEmitter2, OnEvent } from '@nestjs/event-emitter';
import mime from 'mime';
import { v4 as uuidv4 } from 'uuid';
import { BotStatsType } from '@/analytics/schemas/bot-stats.schema';
import { AttachmentService } from '@/attachment/services/attachment.service';
import {
AttachmentAccess,
@@ -149,11 +150,17 @@ export class ChatService {
}
this.websocketGateway.broadcastMessageReceived(populatedMsg, subscriber);
this.eventEmitter.emit('hook:stats:entry', 'incoming', 'Incoming');
this.eventEmitter.emit(
'hook:stats:entry',
'all_messages',
BotStatsType.incoming,
'Incoming',
subscriber,
);
this.eventEmitter.emit(
'hook:stats:entry',
BotStatsType.all_messages,
'All Messages',
subscriber,
);
} catch (err) {
this.logger.error('Unable to log received message.', err, event);
@@ -248,7 +255,7 @@ export class ChatService {
};
this.eventEmitter.emit('hook:chatbot:sent', sentMessage, event);
this.eventEmitter.emit('hook:stats:entry', 'echo', 'Echo');
this.eventEmitter.emit('hook:stats:entry', 'echo', 'Echo', recipient);
} catch (err) {
this.logger.error('Unable to log echo message', err, event);
}

View File

@@ -13,6 +13,7 @@ import { AttachmentRepository } from '@/attachment/repositories/attachment.repos
import { AttachmentModel } from '@/attachment/schemas/attachment.schema';
import { AttachmentService } from '@/attachment/services/attachment.service';
import { BlockService } from '@/chat/services/block.service';
import { FieldType } from '@/setting/schemas/types';
import { NOT_FOUND_ID } from '@/utils/constants/mock';
import { getUpdateOneError } from '@/utils/test/errors/messages';
import { installContentFixtures } from '@/utils/test/fixtures/content';
@@ -100,27 +101,27 @@ describe('ContentTypeController', () => {
{
name: 'address',
label: 'Address',
type: 'text',
type: FieldType.text,
},
{
name: 'image',
label: 'Image',
type: 'file',
type: FieldType.file,
},
{
name: 'description',
label: 'Description',
type: 'html',
type: FieldType.html,
},
{
name: 'rooms',
label: 'Rooms',
type: 'file',
type: FieldType.file,
},
{
name: 'price',
label: 'Price',
type: 'file',
type: FieldType.file,
},
],
};

View File

@@ -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.
@@ -19,11 +19,12 @@ import {
ValidateNested,
} from 'class-validator';
import { FieldType } from '@/setting/schemas/types';
import { DtoConfig } from '@/utils/types/dto.types';
import { ValidateRequiredFields } from '../validators/validate-required-fields.validator';
export class FieldType {
export class ContentField {
@IsString()
@IsNotEmpty()
@Matches(/^[a-z][a-z_0-9]*$/)
@@ -34,11 +35,12 @@ export class FieldType {
label: string;
@IsString()
@IsEnum(['text', 'url', 'textarea', 'checkbox', 'file', 'html'], {
@IsNotEmpty()
@IsEnum(FieldType, {
message:
"type must be one of the following values: 'text', 'url', 'textarea', 'checkbox', 'file', 'html'",
})
type: string;
type: FieldType;
}
export class ContentTypeCreateDto {
@@ -47,13 +49,16 @@ export class ContentTypeCreateDto {
@IsNotEmpty()
name: string;
@ApiPropertyOptional({ description: 'Content type fields', type: FieldType })
@ApiPropertyOptional({
description: 'Content type fields',
type: ContentField,
})
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Validate(ValidateRequiredFields)
@Type(() => FieldType)
fields?: FieldType[];
@Type(() => ContentField)
fields?: ContentField[];
}
export class ContentTypeUpdateDto extends PartialType(ContentTypeCreateDto) {}

View File

@@ -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,9 +9,12 @@
import { ModelDefinition, Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import mongoose from 'mongoose';
import { FieldType } from '@/setting/schemas/types';
import { BaseSchema } from '@/utils/generics/base-schema';
import { LifecycleHookManager } from '@/utils/generics/lifecycle-hook-manager';
import { ContentField } from '../dto/contentType.dto';
@Schema({ timestamps: true })
export class ContentType extends BaseSchema {
/**
@@ -30,20 +33,16 @@ export class ContentType extends BaseSchema {
{
name: 'title',
label: 'Title',
type: 'text',
type: FieldType.text,
},
{
name: 'status',
label: 'Status',
type: 'checkbox',
type: FieldType.checkbox,
},
],
})
fields: {
name: string;
label: string;
type: string;
}[];
fields: ContentField[];
}
export const ContentTypeModel: ModelDefinition = LifecycleHookManager.attach({

View File

@@ -145,7 +145,7 @@ export class ContentService extends BaseService<
...acc,
{
title: String(title),
status: Boolean(status),
status: status.trim().toLowerCase() === 'true',
entity: targetContentType,
dynamicFields: Object.keys(rest)
.filter((key) =>

View File

@@ -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.
@@ -12,24 +12,26 @@ import {
ValidatorConstraintInterface,
} from 'class-validator';
import { FieldType } from '../dto/contentType.dto';
import { FieldType } from '@/setting/schemas/types';
import { ContentField } from '../dto/contentType.dto';
@ValidatorConstraint({ name: 'validateRequiredFields', async: false })
export class ValidateRequiredFields implements ValidatorConstraintInterface {
private readonly REQUIRED_FIELDS: FieldType[] = [
private readonly REQUIRED_FIELDS: ContentField[] = [
{
name: 'title',
label: 'Title',
type: 'text',
type: FieldType.text,
},
{
name: 'status',
label: 'Status',
type: 'checkbox',
type: FieldType.checkbox,
},
];
validate(fields: FieldType[]): boolean {
validate(fields: ContentField[]): boolean {
const errors: string[] = [];
this.REQUIRED_FIELDS.forEach((requiredField, index) => {

View File

@@ -12,6 +12,7 @@ import { OnEvent } from '@nestjs/event-emitter';
import { I18nService } from '@/i18n/services/i18n.service';
import { PluginService } from '@/plugins/plugins.service';
import { PluginType } from '@/plugins/types';
import { SettingType } from '@/setting/schemas/types';
import { SettingService } from '@/setting/services/setting.service';
import { BaseService } from '@/utils/generics/base-service';
@@ -57,21 +58,35 @@ export class TranslationService extends BaseService<Translation> {
PluginType.block,
block.message.plugin,
);
const defaultSettings = await plugin?.getDefaultSettings();
const defaultSettings = (await plugin?.getDefaultSettings()) || [];
const filteredSettings = defaultSettings.filter(
({ translatable, type }) =>
[
SettingType.text,
SettingType.textarea,
SettingType.multiple_text,
].includes(type) &&
(translatable === undefined || translatable === true),
);
const settingTypeMap = new Map(
filteredSettings.map((setting) => [setting.label, setting.type]),
);
// plugin
Object.entries(block.message.args).forEach(([l, arg]) => {
const setting = defaultSettings?.find(({ label }) => label === l);
if (setting?.translatable) {
if (Array.isArray(arg)) {
// array of text
strings = strings.concat(arg);
} else if (typeof arg === 'string') {
// text
strings.push(arg);
}
for (const [key, value] of Object.entries(block.message.args)) {
const settingType = settingTypeMap.get(key);
switch (settingType) {
case SettingType.multiple_text:
strings = strings.concat(value);
break;
case SettingType.text:
case SettingType.textarea:
strings.push(value);
break;
default:
break;
}
});
}
} else if ('text' in block.message && Array.isArray(block.message.text)) {
// array of text
strings = strings.concat(block.message.text);

View File

@@ -50,11 +50,11 @@ async function bootstrap() {
const settingService = app.get<SettingService>(SettingService);
app.enableCors({
origin: (origin, callback) => {
settingService
origin: async (origin, callback) => {
await settingService
.getAllowedOrigins()
.then((allowedOrigins) => {
if (!origin || allowedOrigins.has(origin)) {
if (!origin || allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error('Not allowed by CORS'));

View File

@@ -10,17 +10,14 @@ import { BadRequestException, NotFoundException } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { getUpdateOneError } from '@/utils/test/errors/messages';
import { nlpEntityFixtures } from '@/utils/test/fixtures/nlpentity';
import {
installNlpValueFixtures,
nlpValueFixtures,
} from '@/utils/test/fixtures/nlpvalue';
import { getPageQuery } from '@/utils/test/pagination';
import {
closeInMongodConnection,
rootMongooseTestModule,
} from '@/utils/test/test';
import { TFixtures } from '@/utils/test/types';
import { buildTestingMocks } from '@/utils/test/utils';
import { NlpValueCreateDto } from '../dto/nlp-value.dto';
@@ -29,11 +26,7 @@ import { NlpSampleEntityRepository } from '../repositories/nlp-sample-entity.rep
import { NlpValueRepository } from '../repositories/nlp-value.repository';
import { NlpEntityModel } from '../schemas/nlp-entity.schema';
import { NlpSampleEntityModel } from '../schemas/nlp-sample-entity.schema';
import {
NlpValue,
NlpValueFull,
NlpValueModel,
} from '../schemas/nlp-value.schema';
import { NlpValue, NlpValueModel } from '../schemas/nlp-value.schema';
import { NlpEntityService } from '../services/nlp-entity.service';
import { NlpValueService } from '../services/nlp-value.service';
@@ -80,63 +73,6 @@ describe('NlpValueController', () => {
afterEach(jest.clearAllMocks);
describe('findPage', () => {
it('should find nlp Values, and foreach nlp value populate the corresponding entity', async () => {
const pageQuery = getPageQuery<NlpValue>({
sort: ['value', 'desc'],
});
const result = await nlpValueController.findPage(
pageQuery,
['entity'],
{},
);
const nlpValueFixturesWithEntities = nlpValueFixtures.reduce(
(acc, curr) => {
acc.push({
...curr,
entity: nlpEntityFixtures[
parseInt(curr.entity!)
] as NlpValueFull['entity'],
builtin: curr.builtin!,
expressions: curr.expressions!,
metadata: curr.metadata!,
});
return acc;
},
[] as TFixtures<NlpValueFull>[],
);
expect(result).toEqualPayload(nlpValueFixturesWithEntities);
});
it('should find nlp Values', async () => {
const pageQuery = getPageQuery<NlpValue>({
sort: ['value', 'desc'],
});
const result = await nlpValueController.findPage(
pageQuery,
['invalidCriteria'],
{},
);
const nlpEntities = await nlpEntityService.findAll();
const nlpValueFixturesWithEntities = nlpValueFixtures.reduce(
(acc, curr) => {
const ValueWithEntities = {
...curr,
entity: curr.entity ? nlpEntities[parseInt(curr.entity!)].id : null,
expressions: curr.expressions!,
metadata: curr.metadata!,
builtin: curr.builtin!,
};
acc.push(ValueWithEntities);
return acc;
},
[] as TFixtures<NlpValueCreateDto>[],
);
expect(result).toEqualPayload(nlpValueFixturesWithEntities);
});
});
describe('count', () => {
it('should count the nlp Values', async () => {
const result = await nlpValueController.filterCount();

View File

@@ -30,6 +30,7 @@ import { PageQueryPipe } from '@/utils/pagination/pagination-query.pipe';
import { PopulatePipe } from '@/utils/pipes/populate.pipe';
import { SearchFilterPipe } from '@/utils/pipes/search-filter.pipe';
import { TFilterQuery } from '@/utils/types/filter.types';
import { Format } from '@/utils/types/format.types';
import { NlpValueCreateDto, NlpValueUpdateDto } from '../dto/nlp-value.dto';
import {
@@ -126,7 +127,7 @@ export class NlpValueController extends BaseController<
}
/**
* Retrieves a paginated list of NLP values.
* Retrieves a paginated list of NLP values with NLP Samples count.
*
* Supports filtering, pagination, and optional population of related entities.
*
@@ -134,10 +135,10 @@ export class NlpValueController extends BaseController<
* @param populate - An array of related entities to populate.
* @param filters - Filters to apply when retrieving the NLP values.
*
* @returns A promise resolving to a paginated list of NLP values.
* @returns A promise resolving to a paginated list of NLP values with NLP Samples count.
*/
@Get()
async findPage(
async findWithCount(
@Query(PageQueryPipe) pageQuery: PageQueryDto<NlpValue>,
@Query(PopulatePipe) populate: string[],
@Query(
@@ -147,9 +148,11 @@ export class NlpValueController extends BaseController<
)
filters: TFilterQuery<NlpValue>,
) {
return this.canPopulate(populate)
? await this.nlpValueService.findAndPopulate(filters, pageQuery)
: await this.nlpValueService.find(filters, pageQuery);
return await this.nlpValueService.findWithCount(
this.canPopulate(populate) ? Format.FULL : Format.STUB,
pageQuery,
filters,
);
}
/**

View File

@@ -8,10 +8,20 @@
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Document, Model, Query } from 'mongoose';
import { plainToInstance } from 'class-transformer';
import {
Document,
Model,
PipelineStage,
Query,
SortOrder,
Types,
} from 'mongoose';
import { BaseRepository, DeleteResult } from '@/utils/generics/base-repository';
import { PageQueryDto } from '@/utils/pagination/pagination-query.dto';
import { TFilterQuery } from '@/utils/types/filter.types';
import { Format } from '@/utils/types/format.types';
import { NlpValueDto } from '../dto/nlp-value.dto';
import {
@@ -19,7 +29,10 @@ import {
NlpValue,
NlpValueDocument,
NlpValueFull,
NlpValueFullWithCount,
NlpValuePopulate,
NlpValueWithCount,
TNlpValueCount,
} from '../schemas/nlp-value.schema';
import { NlpSampleEntityRepository } from './nlp-sample-entity.repository';
@@ -106,4 +119,139 @@ export class NlpValueRepository extends BaseRepository<
throw new Error('Attempted to delete a NLP value using unknown criteria');
}
}
private getSortDirection(sortOrder: SortOrder) {
return typeof sortOrder === 'number'
? sortOrder
: sortOrder.toString().toLowerCase() === 'desc'
? -1
: 1;
}
/**
* Performs an aggregation to retrieve NLP values with their sample counts.
*
* @param format - The format can be full or stub
* @param pageQuery - The pagination parameters
* @param filterQuery - The filter criteria
* @returns Aggregated Nlp Value results with sample counts
*/
private async aggregateWithCount<F extends Format>(
format: F,
{
limit = 10,
skip = 0,
sort = ['createdAt', 'desc'],
}: PageQueryDto<NlpValue>,
{ $and = [], ...rest }: TFilterQuery<NlpValue>,
): Promise<TNlpValueCount<F>[]> {
const pipeline: PipelineStage[] = [
{
$match: {
...rest,
...($and.length
? {
$and: $and.map(({ entity, ...rest }) => ({
...rest,
...(entity
? { entity: new Types.ObjectId(String(entity)) }
: {}),
})),
}
: {}),
},
},
{
$skip: skip,
},
{
$limit: limit,
},
{
$lookup: {
from: 'nlpsampleentities',
localField: '_id',
foreignField: 'value',
as: '_sampleEntities',
},
},
{
$unwind: {
path: '$_sampleEntities',
preserveNullAndEmptyArrays: true,
},
},
{
$group: {
_id: '$_id',
_originalDoc: {
$first: {
$unsetField: { input: '$$ROOT', field: 'nlpSamplesCount' },
},
},
nlpSamplesCount: {
$sum: { $cond: [{ $ifNull: ['$_sampleEntities', false] }, 1, 0] },
},
},
},
{
$replaceWith: {
$mergeObjects: [
'$_originalDoc',
{ nlpSamplesCount: '$nlpSamplesCount' },
],
},
},
...(format === Format.FULL
? [
{
$lookup: {
from: 'nlpentities',
localField: 'entity',
foreignField: '_id',
as: 'entity',
},
},
{
$unwind: '$entity',
},
]
: []),
{
$sort: {
[sort[0]]: this.getSortDirection(sort[1]),
_id: this.getSortDirection(sort[1]),
},
},
];
return await this.model.aggregate<TNlpValueCount<F>>(pipeline).exec();
}
async findWithCount<F extends Format>(
format: F,
pageQuery: PageQueryDto<NlpValue>,
filterQuery: TFilterQuery<NlpValue>,
): Promise<TNlpValueCount<F>[]> {
try {
const aggregatedResults = await this.aggregateWithCount(
format,
pageQuery,
filterQuery,
);
if (format === Format.FULL) {
return plainToInstance(NlpValueFullWithCount, aggregatedResults, {
excludePrefixes: ['_'],
}) as TNlpValueCount<F>[];
}
return plainToInstance(NlpValueWithCount, aggregatedResults, {
excludePrefixes: ['_'],
}) as TNlpValueCount<F>[];
} catch (error) {
this.logger.error(`Error in findWithCount: ${error.message}`, error);
throw error;
}
}
}

View File

@@ -16,6 +16,7 @@ import {
TFilterPopulateFields,
THydratedDocument,
} from '@/utils/types/filter.types';
import { TStubOrFull } from '@/utils/types/format.types';
import { NlpEntity, NlpEntityFull } from './nlp-entity.schema';
import { NlpValueMap } from './types';
@@ -106,6 +107,14 @@ export class NlpValueFull extends NlpValueStub {
entity: NlpEntity;
}
export class NlpValueWithCount extends NlpValue {
nlpSamplesCount: number;
}
export class NlpValueFullWithCount extends NlpValueFull {
nlpSamplesCount: number;
}
export type NlpValueDocument = THydratedDocument<NlpValue>;
export const NlpValueModel: ModelDefinition = LifecycleHookManager.attach({
@@ -121,3 +130,9 @@ export type NlpValuePopulate = keyof TFilterPopulateFields<
>;
export const NLP_VALUE_POPULATE: NlpValuePopulate[] = ['entity'];
export type TNlpValueCount<T> = TStubOrFull<
T,
NlpValueWithCount,
NlpValueFullWithCount
>;

View File

@@ -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.
@@ -10,6 +10,9 @@ import { forwardRef, Inject, Injectable } from '@nestjs/common';
import { DeleteResult } from '@/utils/generics/base-repository';
import { BaseService } from '@/utils/generics/base-service';
import { PageQueryDto } from '@/utils/pagination/pagination-query.dto';
import { TFilterQuery } from '@/utils/types/filter.types';
import { Format } from '@/utils/types/format.types';
import { NlpValueCreateDto, NlpValueDto } from '../dto/nlp-value.dto';
import { NlpValueRepository } from '../repositories/nlp-value.repository';
@@ -18,6 +21,7 @@ import {
NlpValue,
NlpValueFull,
NlpValuePopulate,
TNlpValueCount,
} from '../schemas/nlp-value.schema';
import { NlpSampleEntityValue } from '../schemas/types';
@@ -218,4 +222,12 @@ export class NlpValueService extends BaseService<
});
return Promise.all(promises);
}
async findWithCount<F extends Format>(
format: F,
pageQuery: PageQueryDto<NlpValue>,
filters: TFilterQuery<NlpValue>,
): Promise<TNlpValueCount<F>[]> {
return await this.repository.findWithCount(format, pageQuery, filters);
}
}

View File

@@ -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.
@@ -20,6 +20,15 @@ export enum SettingType {
multiple_attachment = 'multiple_attachment',
}
export enum FieldType {
text = 'text',
url = 'url',
textarea = 'textarea',
checkbox = 'checkbox',
file = 'file',
html = 'html',
}
/**
* The following interfaces are declared, and currently not used
* TextSetting

View File

@@ -161,14 +161,12 @@ describe('SettingService', () => {
expect(settingService.find).toHaveBeenCalledWith({
label: 'allowed_domains',
});
expect(result).toEqual(
new Set([
'*',
'https://example.com',
'https://test.com',
'https://another.com',
]),
);
expect(result).toEqual([
'*',
'https://example.com',
'https://test.com',
'https://another.com',
]);
});
it('should return the config allowed cors only if no settings are found', async () => {
@@ -179,7 +177,7 @@ describe('SettingService', () => {
expect(settingService.find).toHaveBeenCalledWith({
label: 'allowed_domains',
});
expect(result).toEqual(new Set(['*']));
expect(result).toEqual(['*']);
});
it('should handle settings with empty values', async () => {
@@ -195,7 +193,7 @@ describe('SettingService', () => {
expect(settingService.find).toHaveBeenCalledWith({
label: 'allowed_domains',
});
expect(result).toEqual(new Set(['*', 'https://example.com']));
expect(result).toEqual(['*', 'https://example.com']);
});
});
});

View File

@@ -135,7 +135,7 @@ export class SettingService extends BaseService<Setting> {
* @returns A promise that resolves to a set of allowed origins
*/
@Cacheable(ALLOWED_ORIGINS_CACHE_KEY)
async getAllowedOrigins() {
async getAllowedOrigins(): Promise<string[]> {
const settings = (await this.find({
label: 'allowed_domains',
})) as TextSetting[];
@@ -150,7 +150,7 @@ export class SettingService extends BaseService<Setting> {
...allowedDomains,
]);
return uniqueOrigins;
return Array.from(uniqueOrigins);
}
/**

View File

@@ -13,6 +13,7 @@ import {
ContentType,
ContentTypeModel,
} from '@/cms/schemas/content-type.schema';
import { FieldType } from '@/setting/schemas/types';
import { getFixturesWithDefaultValues } from '../defaultValues';
import { FixturesTypeBuilder } from '../types';
@@ -27,12 +28,12 @@ export const contentTypeDefaultValues: TContentTypeFixtures['defaultValues'] = {
{
name: 'title',
label: 'Title',
type: 'text',
type: FieldType.text,
},
{
name: 'status',
label: 'Status',
type: 'checkbox',
type: FieldType.checkbox,
},
],
};
@@ -44,27 +45,27 @@ const contentTypes: TContentTypeFixtures['values'][] = [
{
name: 'title',
label: 'Title',
type: 'text',
type: FieldType.text,
},
{
name: 'status',
label: 'Status',
type: 'checkbox',
type: FieldType.checkbox,
},
{
name: 'description',
label: 'Description',
type: 'text',
type: FieldType.text,
},
{
name: 'image',
label: 'Image',
type: 'file',
type: FieldType.file,
},
{
name: 'subtitle',
label: 'Image',
type: 'file',
type: FieldType.file,
},
],
},
@@ -74,22 +75,22 @@ const contentTypes: TContentTypeFixtures['values'][] = [
{
name: 'title',
label: 'Title',
type: 'text',
type: FieldType.text,
},
{
name: 'status',
label: 'Status',
type: 'checkbox',
type: FieldType.checkbox,
},
{
name: 'address',
label: 'Address',
type: 'text',
type: FieldType.text,
},
{
name: 'image',
label: 'Image',
type: 'file',
type: FieldType.file,
},
],
},
@@ -99,22 +100,22 @@ const contentTypes: TContentTypeFixtures['values'][] = [
{
name: 'title',
label: 'Title',
type: 'text',
type: FieldType.text,
},
{
name: 'status',
label: 'Status',
type: 'checkbox',
type: FieldType.checkbox,
},
{
name: 'address',
label: 'Address',
type: 'text',
type: FieldType.text,
},
{
name: 'image',
label: 'Image',
type: 'file',
type: FieldType.file,
},
],
},

View File

@@ -0,0 +1,18 @@
/*
* 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).
*/
export enum Format {
NONE = 0,
STUB = 1,
BASIC = 2,
FULL = 3,
}
export type TStubOrFull<TF, TStub, TFull> = TF extends Format.STUB
? TStub
: TFull;

View File

@@ -54,15 +54,15 @@ export const buildWebSocketGatewayOptions = (): Partial<ServerOptions> => {
...(config.sockets.cookie && { cookie: config.sockets.cookie }),
...(config.sockets.onlyAllowOrigins && {
cors: {
origin: (origin, cb) => {
origin: async (origin, cb) => {
// Retrieve the allowed origins from the settings
const app = AppInstance.getApp();
const settingService = app.get<SettingService>(SettingService);
settingService
await settingService
.getAllowedOrigins()
.then((allowedOrigins) => {
if (origin && allowedOrigins.has(origin)) {
if (origin && allowedOrigins.includes(origin)) {
cb(null, true);
} else {
// eslint-disable-next-line no-console

View File

@@ -28,7 +28,6 @@
"@mui/x-data-grid": "^7.3.2",
"@projectstorm/react-canvas-core": "^7.0.3",
"@projectstorm/react-diagrams": "^7.0.4",
"@types/qs": "^6.9.15",
"axios": "^1.7.7",
"eazychart-css": "^0.2.1-alpha.0",
"eazychart-react": "^0.8.0-alpha.0",
@@ -44,9 +43,11 @@
"react-hook-form": "^7.51.5",
"react-i18next": "^14.1.1",
"react-query": "^3.39.3",
"socket.io-client": "^4.7.5"
"socket.io-client": "^4.7.5",
"random-seed": "^0.3.0"
},
"devDependencies": {
"@types/qs": "^6.9.15",
"@types/node": "20.12.12",
"@types/random-seed": "^0.3.5",
"@types/react": "18.3.2",
@@ -57,7 +58,6 @@
"eslint-import-resolver-typescript": "~3.6.1",
"eslint-plugin-header": "^3.1.1",
"lint-staged": "^15.3.0",
"random-seed": "^0.3.0",
"typescript": "^5.5.3"
},
"engines": {

View File

@@ -334,6 +334,7 @@
"nlp": "NLU",
"nlp_entity": "Entity",
"nlp_entity_value": "Value",
"nlp_samples_count": "Samples count",
"value": "Value",
"synonyms": "Synonyms",
"lookups": "Lookups",

View File

@@ -334,6 +334,7 @@
"nlp": "NLU",
"nlp_entity": "Entité NLU",
"nlp_entity_value": "Valeur NLU",
"nlp_samples_count": "Nombre des échantillons",
"value": "Valeur",
"lookups": "Stratégies",
"lookup_strategies": "Stratégie de recherche",

View File

@@ -20,6 +20,7 @@ import { useConfig } from "@/hooks/useConfig";
import { useTranslate } from "@/hooks/useTranslate";
import { Title } from "@/layout/content/Title";
import { EntityType, RouterType } from "@/services/types";
import { extractQueryParamsUrl } from "@/utils/URL";
import { getAvatarSrc } from "../helpers/mapMessages";
import { useChat } from "../hooks/ChatContext";
@@ -53,7 +54,7 @@ export const SubscribersList = (props: {
<Grid padding={2}>
<Title title={t(props.assignedTo)} icon={InboxIcon} />
</Grid>
{subscribers?.length > 0 && (
{subscribers?.length > 0 ? (
<ConversationList
scrollable
loading={isFetching}
@@ -64,7 +65,10 @@ export const SubscribersList = (props: {
<Conversation
onClick={() => {
chat.setSubscriberId(subscriber.id);
push(`/${RouterType.INBOX}/subscribers/${subscriber.id}`);
push({
pathname: `/${RouterType.INBOX}/subscribers/${subscriber.id}`,
query: extractQueryParamsUrl(window.location.href),
});
}}
className="changeColor"
key={subscriber.id}
@@ -87,6 +91,10 @@ export const SubscribersList = (props: {
</Conversation>
))}
</ConversationList>
) : (
<Grid p={1} color="gray" textAlign="center">
{t("message.no_result_found")}
</Grid>
)}
</>
);

View File

@@ -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.
@@ -26,7 +26,7 @@ import { AssignedTo } from "./types";
export const Inbox = () => {
const { t } = useTranslate();
const { onSearch, searchPayload } = useSearch<ISubscriber>({
const { onSearch, searchPayload, searchText } = useSearch<ISubscriber>({
$or: ["first_name", "last_name"],
});
const [channels, setChannels] = useState<string[]>([]);
@@ -48,6 +48,7 @@ export const Inbox = () => {
<Sidebar position="left">
<Grid paddingX={1} paddingTop={1}>
<Search
value={searchText}
onClearClick={() => onSearch("")}
className="changeColor"
onChange={(v) => onSearch(v)}

View File

@@ -392,6 +392,7 @@ export default function NlpSample() {
`nlpsample/export${type ? `?type=${type}` : ""}`,
)}
startIcon={<DownloadIcon />}
disabled={dataGridProps?.rows?.length === 0}
>
{t("button.export")}
</Button>

View File

@@ -34,7 +34,6 @@ import { useToast } from "@/hooks/useToast";
import { useTranslate } from "@/hooks/useTranslate";
import { PageHeader } from "@/layout/content/PageHeader";
import { EntityType, Format } from "@/services/types";
import { NlpLookups } from "@/types/nlp-entity.types";
import { INlpValue } from "@/types/nlp-value.types";
import { PermissionAction } from "@/types/permission.types";
import { getDateTimeFormatter } from "@/utils/date";
@@ -52,13 +51,12 @@ export const NlpValues = ({ entityId }: { entityId: string }) => {
entity: EntityType.NLP_ENTITY,
format: Format.FULL,
});
const canHaveSynonyms = nlpEntity?.lookups?.[0] === NlpLookups.keywords;
const { onSearch, searchPayload } = useSearch<INlpValue>({
$eq: [{ entity: entityId }],
$or: ["doc", "value"]
$or: ["doc", "value"],
});
const { dataGridProps } = useFind(
{ entity: EntityType.NLP_VALUE },
{ entity: EntityType.NLP_VALUE, format: Format.FULL },
{
params: searchPayload,
},
@@ -88,7 +86,10 @@ export const NlpValues = ({ entityId }: { entityId: string }) => {
{
label: ActionColumnLabel.Edit,
action: (row) =>
dialogs.open(NlpValueFormDialog, { data: row, canHaveSynonyms }),
dialogs.open(NlpValueFormDialog, {
defaultValues: row,
presetValues: nlpEntity,
}),
},
{
label: ActionColumnLabel.Delete,
@@ -103,7 +104,7 @@ export const NlpValues = ({ entityId }: { entityId: string }) => {
],
t("label.operations"),
);
const synonymsColumn = {
const synonymsColumn = {
flex: 3,
field: "synonyms",
headerName: t("label.synonyms"),
@@ -125,6 +126,24 @@ export const NlpValues = ({ entityId }: { entityId: string }) => {
disableColumnMenu: true,
renderHeader,
},
{
flex: 2,
field: "nlpSamplesCount",
align: "center",
headerName: t("label.nlp_samples_count"),
sortable: true,
disableColumnMenu: true,
headerAlign: "center",
renderHeader,
renderCell: ({ row }) => (
<Chip
sx={{ alignContent: "center" }}
id={row.id}
label={row.nlpSamplesCount}
variant="inbox"
/>
),
},
{
flex: 3,
field: "doc",
@@ -220,7 +239,11 @@ export const NlpValues = ({ entityId }: { entityId: string }) => {
startIcon={<AddIcon />}
variant="contained"
sx={{ float: "right" }}
onClick={() => dialogs.open(NlpValueFormDialog, null)}
onClick={() =>
dialogs.open(NlpValueFormDialog, {
presetValues: nlpEntity,
})
}
>
{t("button.add")}
</Button>

View File

@@ -20,19 +20,31 @@ import { useToast } from "@/hooks/useToast";
import { useTranslate } from "@/hooks/useTranslate";
import { EntityType, Format } from "@/services/types";
import { ComponentFormProps } from "@/types/common/dialogs.types";
import { INlpEntity, NlpLookups } from "@/types/nlp-entity.types";
import { INlpValue, INlpValueAttributes } from "@/types/nlp-value.types";
export const NlpValueForm: FC<
ComponentFormProps<{ data: INlpValue; canHaveSynonyms: boolean }>
> = ({ data: props, Wrapper = Fragment, WrapperProps, ...rest }) => {
const { data, canHaveSynonyms } = props || {};
export type NlpValueFormProps = {
defaultValues?: INlpValue;
presetValues: INlpEntity | undefined;
};
export const NlpValueForm: FC<ComponentFormProps<NlpValueFormProps>> = ({
data: props,
Wrapper = Fragment,
WrapperProps,
...rest
}) => {
const { defaultValues: nlpValue, presetValues: nlpEntity } = props || {
defaultValues: null,
presetValues: null,
};
const { t } = useTranslate();
const { toast } = useToast();
const { query } = useRouter();
const { refetch: refetchEntity } = useGet(data?.entity || String(query.id), {
const { refetch: refetchEntity } = useGet(nlpEntity?.id!, {
entity: EntityType.NLP_ENTITY,
format: Format.FULL,
});
const canHaveSynonyms = nlpEntity?.lookups.includes(NlpLookups.keywords);
const { mutate: createNlpValue } = useCreate(EntityType.NLP_VALUE, {
onError: () => {
rest.onError?.();
@@ -54,15 +66,21 @@ export const NlpValueForm: FC<
toast.success(t("message.success_save"));
},
});
const { reset, register, handleSubmit, control } = useForm<
const {
reset,
register,
handleSubmit,
control,
formState: { errors },
} = useForm<
INlpValueAttributes & {
expressions: string[];
}
>({
defaultValues: {
value: data?.value || "",
doc: data?.doc || "",
expressions: data?.expressions || [],
value: nlpValue?.value || "",
doc: nlpValue?.doc || "",
expressions: nlpValue?.expressions || [],
},
});
const validationRules = {
@@ -73,24 +91,24 @@ export const NlpValueForm: FC<
description: {},
};
const onSubmitForm = async (params: INlpValueAttributes) => {
if (data) {
updateNlpValue({ id: data.id, params });
if (nlpValue) {
updateNlpValue({ id: nlpValue.id, params });
} else {
createNlpValue({ ...params, entity: String(query.id) });
}
};
useEffect(() => {
if (data) {
if (nlpValue) {
reset({
value: data.value,
expressions: data.expressions,
doc: data.doc,
value: nlpValue.value,
expressions: nlpValue.expressions,
doc: nlpValue.doc,
});
} else {
reset();
}
}, [data, reset]);
}, [nlpValue, reset]);
return (
<Wrapper onSubmit={handleSubmit(onSubmitForm)} {...WrapperProps}>
@@ -99,8 +117,10 @@ export const NlpValueForm: FC<
<ContentItem>
<Input
label={t("placeholder.nlp_value")}
error={!!errors.value}
required
autoFocus
helperText={errors.value ? errors.value.message : null}
{...register("value", validationRules.value)}
/>
</ContentItem>
@@ -118,7 +138,11 @@ export const NlpValueForm: FC<
name="expressions"
control={control}
render={({ field }) => (
<MultipleInput label="synonyms" {...field} />
<MultipleInput
label={t("label.synonyms")}
{...field}
minInput={1}
/>
)}
/>
</ContentItem>

View File

@@ -8,20 +8,17 @@
import { GenericFormDialog } from "@/app-components/dialogs";
import { ComponentFormDialogProps } from "@/types/common/dialogs.types";
import { INlpValue } from "@/types/nlp-value.types";
import { NlpValueForm } from "./NlpValueForm";
import { NlpValueForm, NlpValueFormProps } from "./NlpValueForm";
export const NlpValueFormDialog = <
T extends { data: INlpValue; canHaveSynonyms: boolean } = {
data: INlpValue;
canHaveSynonyms: boolean;
},
T extends NlpValueFormProps = NlpValueFormProps,
>(
props: ComponentFormDialogProps<T>,
) => (
<GenericFormDialog<T>
Form={NlpValueForm}
rowKey="defaultValues"
addText="title.new_nlp_entity_value"
editText="title.edit_nlp_value"
{...props}

View File

@@ -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,13 +9,21 @@
import { createContext, ReactNode, useContext } from "react";
import { FormProvider, UseFormReturn } from "react-hook-form";
import { IBlockAttributes, IBlock } from "@/types/block.types";
import { IBlock, IBlockAttributes } from "@/types/block.types";
// Create a custom context for the block value
const BlockContext = createContext<IBlock | undefined>(undefined);
// Custom hook to use block context
export const useBlock = () => useContext(BlockContext);
export const useBlock = () => {
const context = useContext(BlockContext);
if (!context) {
throw new Error("useBlock must be used within an BlockContext");
}
return context;
};
// This component wraps FormProvider and adds block to its context
function BlockFormProvider({
@@ -23,7 +31,7 @@ function BlockFormProvider({
methods,
block,
}: {
methods: UseFormReturn<IBlockAttributes, any, undefined>;
methods: UseFormReturn<IBlockAttributes>;
block: IBlock | undefined;
children: ReactNode;
}) {

View File

@@ -8,7 +8,7 @@
import getConfig from "next/config";
import { useRouter } from "next/router";
import { createContext, ReactNode, useEffect, useState } from "react";
import { createContext, ReactNode } from "react";
import {
QueryObserverResult,
RefetchOptions,
@@ -25,7 +25,6 @@ import { useSubscribeBroadcastChannel } from "@/hooks/useSubscribeBroadcastChann
import { useTranslate } from "@/hooks/useTranslate";
import { RouterType } from "@/services/types";
import { IUser } from "@/types/user.types";
import { getFromQuery } from "@/utils/URL";
export interface AuthContextValue {
user: IUser | undefined;
@@ -51,10 +50,8 @@ const { publicRuntimeConfig } = getConfig();
export const AuthProvider = ({ children }: AuthProviderProps): JSX.Element => {
const router = useRouter();
const [search, setSearch] = useState("");
const hasPublicPath = PUBLIC_PATHS.includes(router.pathname);
const { i18n } = useTranslate();
const [isReady, setIsReady] = useState(false);
const queryClient = useQueryClient();
const updateLanguage = (lang: string) => {
i18n.changeLanguage(lang);
@@ -66,11 +63,11 @@ export const AuthProvider = ({ children }: AuthProviderProps): JSX.Element => {
};
const authRedirection = async (isAuthenticated: boolean) => {
if (isAuthenticated) {
const redirect = getFromQuery({ search, key: "redirect" });
const nextPage = redirect && decodeURIComponent(redirect);
if (nextPage?.startsWith("/")) {
await router.push(nextPage);
if (
router.query.redirect &&
router.query.redirect.toString().startsWith("/")
) {
await router.push(router.query.redirect.toString());
} else if (hasPublicPath) {
await router.push(RouterType.HOME);
}
@@ -109,14 +106,9 @@ export const AuthProvider = ({ children }: AuthProviderProps): JSX.Element => {
router.reload();
});
useEffect(() => {
const search = location.search;
setSearch(search);
setIsReady(true);
}, []);
if (!isReady || isLoading) return <Progress />;
if (isLoading) {
return <Progress />;
}
return (
<AuthContext.Provider

View File

@@ -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.
@@ -7,12 +7,13 @@
*/
import { debounce } from "@mui/material";
import { ChangeEvent, useState } from "react";
import { useRouter } from "next/router";
import { ChangeEvent, useCallback, useEffect, useState } from "react";
import {
TParamItem,
TBuildParamProps,
TBuildInitialParamProps,
TBuildParamProps,
TParamItem,
} from "@/types/search.types";
const buildOrParams = <T,>({ params, searchText }: TBuildParamProps<T>) => ({
@@ -52,13 +53,38 @@ const buildNeqInitialParams = <T,>({
);
export const useSearch = <T,>(params: TParamItem<T>) => {
const [searchText, setSearchText] = useState<string>("");
const onSearch = debounce(
(e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement> | string) => {
setSearchText(typeof e === "string" ? e : e.target.value);
},
300,
const router = useRouter();
const [searchText, setSearchText] = useState<string>(
(router.query.search as string) || "",
);
useEffect(() => {
if (router.query.search !== searchText) {
setSearchText((router.query.search as string) || "");
}
}, [router.query.search]);
const updateQueryParams = useCallback(
debounce(async (newSearchText: string) => {
await router.replace(
{
pathname: router.pathname,
query: { ...router.query, search: newSearchText || undefined },
},
undefined,
{ shallow: true },
);
}, 300),
[router],
);
const onSearch = (
e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement> | string,
) => {
const newSearchText = typeof e === "string" ? e : e.target.value;
setSearchText(newSearchText);
updateQueryParams(newSearchText);
};
const {
$eq: eqInitialParams,
$iLike: iLikeParams,
@@ -67,6 +93,7 @@ export const useSearch = <T,>(params: TParamItem<T>) => {
} = params;
return {
searchText,
onSearch,
searchPayload: {
where: {

View File

@@ -19,6 +19,7 @@ export interface INlpValueAttributes {
expressions?: string[];
metadata?: Record<string, any>;
builtin?: boolean;
nlpSamplesCount?: number;
}
export interface INlpValueStub extends IBaseSchema, INlpValueAttributes {}

View File

@@ -6,25 +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).
*/
export const getFromQuery = ({
key,
search,
defaultValue = "",
}: {
key: string;
search?: string;
defaultValue?: string;
}) => {
try {
const paramsString = search || window.location.search;
const searchParams = new URLSearchParams(paramsString);
const loadCampaign = searchParams.get(key) || defaultValue;
return loadCampaign;
} catch (e) {
return defaultValue;
}
};
import qs from "qs";
export const buildURL = (baseUrl: string, relativePath: string): string => {
try {
@@ -57,3 +39,12 @@ export const isAbsoluteUrl = (value: string = ""): boolean => {
return false;
}
};
// todo: in the future we might need to extract this logic into a hook
export const extractQueryParamsUrl = (fullUrl: string): string => {
const extractedQueryParams = qs.parse(new URL(fullUrl).search, {
ignoreQueryPrefix: true,
});
return qs.stringify(extractedQueryParams);
};

6
package-lock.json generated
View File

@@ -59,7 +59,6 @@
"@mui/x-data-grid": "^7.3.2",
"@projectstorm/react-canvas-core": "^7.0.3",
"@projectstorm/react-diagrams": "^7.0.4",
"@types/qs": "^6.9.15",
"axios": "^1.7.7",
"eazychart-css": "^0.2.1-alpha.0",
"eazychart-react": "^0.8.0-alpha.0",
@@ -79,6 +78,7 @@
},
"devDependencies": {
"@types/node": "20.12.12",
"@types/qs": "^6.9.15",
"@types/random-seed": "^0.3.5",
"@types/react": "18.3.2",
"@types/react-dom": "^18",
@@ -2824,6 +2824,7 @@
"version": "3.5.2",
"resolved": "https://registry.npmjs.org/@types/emoji-js/-/emoji-js-3.5.2.tgz",
"integrity": "sha512-qPR85yjSPk2UEbdjYYNHfcOjVod7DCARSrJlPcL+cwaDFwdnmOFhPyYUvP5GaW0YZEy8mU93ZjTNgsVWz1zzlg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": {
@@ -2877,6 +2878,7 @@
"version": "6.9.15",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.15.tgz",
"integrity": "sha512-uXHQKES6DQKKCLh441Xv/dwxOq1TVS3JPUMlEqoEglvlhR6Mxnlew/Xq/LRVHpLyk7iK3zODe1qYHIMltO7XGg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/random-seed": {
@@ -10307,7 +10309,6 @@
"version": "2.2.5",
"license": "AGPL-3.0-only",
"dependencies": {
"@types/emoji-js": "^3.5.2",
"autolinker": "^4.0.0",
"dayjs": "^1.11.12",
"emoji-js": "^3.8.0",
@@ -10317,6 +10318,7 @@
"socket.io-client": "^4.7.5"
},
"devDependencies": {
"@types/emoji-js": "^3.5.2",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@typescript-eslint/eslint-plugin": "^7.15.0",

View File

@@ -20,7 +20,6 @@
"*.{ts,tsx}": "eslint --fix -c \".eslintrc-staged.json\""
},
"dependencies": {
"@types/emoji-js": "^3.5.2",
"autolinker": "^4.0.0",
"dayjs": "^1.11.12",
"emoji-js": "^3.8.0",
@@ -30,6 +29,7 @@
"socket.io-client": "^4.7.5"
},
"devDependencies": {
"@types/emoji-js": "^3.5.2",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@typescript-eslint/eslint-plugin": "^7.15.0",