diff --git a/api/package-lock.json b/api/package-lock.json index c9982881..404f3eaf 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -57,7 +57,8 @@ "sanitize-filename": "^1.6.3", "slug": "^8.2.2", "ts-migrate-mongoose": "^3.8.4", - "uuid": "^9.0.1" + "uuid": "^9.0.1", + "zod": "^3.24.1" }, "devDependencies": { "@compodoc/compodoc": "^1.1.24", @@ -20280,6 +20281,14 @@ "resolved": "https://registry.npmjs.org/zepto/-/zepto-1.2.0.tgz", "integrity": "sha512-C1x6lfvBICFTQIMgbt3JqMOno3VOtkWat/xEakLTOurskYIHPmzJrzd1e8BnmtdDVJlGuk5D+FxyCA8MPmkIyA==", "dev": true + }, + "node_modules/zod": { + "version": "3.24.1", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.1.tgz", + "integrity": "sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/api/package.json b/api/package.json index cb1d9a61..2cd82dbe 100644 --- a/api/package.json +++ b/api/package.json @@ -92,7 +92,8 @@ "sanitize-filename": "^1.6.3", "slug": "^8.2.2", "ts-migrate-mongoose": "^3.8.4", - "uuid": "^9.0.1" + "uuid": "^9.0.1", + "zod": "^3.24.1" }, "devDependencies": { "@compodoc/compodoc": "^1.1.24", @@ -144,8 +145,8 @@ }, "optionalDependencies": { "@css-inline/css-inline-linux-arm64-musl": "^0.14.1", - "@resvg/resvg-js-linux-arm64-musl": "^2.6.2", - "@resvg/resvg-js-darwin-arm64": "^2.6.2" + "@resvg/resvg-js-darwin-arm64": "^2.6.2", + "@resvg/resvg-js-linux-arm64-musl": "^2.6.2" }, "overrides": { "mjml": "5.0.0-alpha.4" diff --git a/api/src/chat/repositories/subscriber.repository.spec.ts b/api/src/chat/repositories/subscriber.repository.spec.ts index fd553e5f..1e101057 100644 --- a/api/src/chat/repositories/subscriber.repository.spec.ts +++ b/api/src/chat/repositories/subscriber.repository.spec.ts @@ -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. @@ -52,6 +52,7 @@ describe('SubscriberRepository', () => { let allSubscribers: Subscriber[]; let allAttachments: Attachment[]; let subscribersWithPopulatedFields: SubscriberFull[]; + let eventEmitter: EventEmitter2; beforeAll(async () => { const module = await Test.createTestingModule({ @@ -94,6 +95,7 @@ describe('SubscriberRepository', () => { allUsers.find(({ id }) => subscriber.assignedTo === id) || null, avatar: allAttachments.find(({ id }) => subscriber.avatar === id) || null, })); + eventEmitter = module.get(EventEmitter2); }); afterEach(jest.clearAllMocks); @@ -155,4 +157,73 @@ describe('SubscriberRepository', () => { ); }); }); + + describe('updateOne', () => { + it('should execute preUpdate hook and emit events on assignedTo change', async () => { + // Arrange: Set up a mock subscriber + const oldSubscriber = { + ...subscriberFixtures[0], // Mocked existing subscriber + assignedTo: null, + } as Subscriber; + + const updates = { assignedTo: '9'.repeat(24) }; // Change assigned user; + + jest + .spyOn(subscriberRepository, 'findOne') + .mockResolvedValue(oldSubscriber); + jest.spyOn(eventEmitter, 'emit'); + + await subscriberRepository.updateOne(oldSubscriber.id, updates); + + expect(eventEmitter.emit).toHaveBeenNthCalledWith( + 3, + 'hook:subscriber:assign', + expect.anything(), + expect.anything(), + ); + expect(eventEmitter.emit).toHaveBeenNthCalledWith( + 4, + 'hook:analytics:passation', + expect.anything(), + true, // Because assignedTo has changed + ); + }); + + it('should not emit events if assignedTo remains unchanged', async () => { + const oldSubscriber = { + ...subscriberFixtures[0], + assignedTo: '8'.repeat(24), + } as Subscriber; + + const updates = { assignedTo: '8'.repeat(24) }; // Same user; + + jest + .spyOn(subscriberRepository, 'findOne') + .mockResolvedValue(oldSubscriber); + jest.spyOn(eventEmitter, 'emit'); + + await subscriberRepository.updateOne(oldSubscriber.id, updates); + + expect(eventEmitter.emit).not.toHaveBeenCalledWith( + 'hook:subscriber:assign', + expect.anything(), + expect.anything(), + ); + expect(eventEmitter.emit).not.toHaveBeenCalledWith( + 'hook:analytics:passation', + expect.anything(), + expect.anything(), + ); + }); + + it('should throw an error if the subscriber does not exist', async () => { + jest.spyOn(subscriberRepository, 'findOne').mockResolvedValue(null); + + await expect( + subscriberRepository.updateOne('0'.repeat(24), { + $set: { assignedTo: 'user-456' }, + }), + ).rejects.toThrow(); + }); + }); }); diff --git a/api/src/chat/repositories/subscriber.repository.ts b/api/src/chat/repositories/subscriber.repository.ts index fa59ee70..02d0ad66 100644 --- a/api/src/chat/repositories/subscriber.repository.ts +++ b/api/src/chat/repositories/subscriber.repository.ts @@ -80,26 +80,29 @@ export class SubscriberRepository extends BaseRepository< ): Promise { const subscriberUpdates: SubscriberUpdateDto = updates?.['$set']; - const oldSubscriber = await this.findOne(criteria); + if ('assignedTo' in subscriberUpdates) { + // In case of a handover or handback, emit events + const oldSubscriber = await this.findOne(criteria); - if (!oldSubscriber) { - throw new Error('Something went wrong: subscriber does not exist'); - } + if (!oldSubscriber) { + throw new Error('Something went wrong: subscriber does not exist'); + } - if (subscriberUpdates.assignedTo !== oldSubscriber?.assignedTo) { - this.eventEmitter.emit( - 'hook:subscriber:assign', - subscriberUpdates, - oldSubscriber, - ); - - if (!(subscriberUpdates.assignedTo && oldSubscriber?.assignedTo)) { + if (subscriberUpdates.assignedTo !== oldSubscriber?.assignedTo) { this.eventEmitter.emit( - 'hook:analytics:passation', + 'hook:subscriber:assign', + subscriberUpdates, oldSubscriber, - !!subscriberUpdates?.assignedTo, ); - subscriberUpdates.assignedAt = new Date(); + + if (!(subscriberUpdates.assignedTo && oldSubscriber?.assignedTo)) { + this.eventEmitter.emit( + 'hook:analytics:passation', + oldSubscriber, + !!subscriberUpdates?.assignedTo, + ); + subscriberUpdates.assignedAt = new Date(); + } } } } diff --git a/api/src/chat/schemas/types/attachment.ts b/api/src/chat/schemas/types/attachment.ts index bb52b303..d4bac205 100644 --- a/api/src/chat/schemas/types/attachment.ts +++ b/api/src/chat/schemas/types/attachment.ts @@ -6,6 +6,8 @@ * 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 { z } from 'zod'; + export enum FileType { image = 'image', video = 'video', @@ -14,6 +16,8 @@ export enum FileType { unknown = 'unknown', } +export const fileTypeSchema = z.nativeEnum(FileType); + /** * The `AttachmentRef` type defines two possible ways to reference an attachment: * 1. By `id`: This is used when the attachment is uploaded and stored in the Hexabot system. @@ -22,20 +26,24 @@ export enum FileType { * the content is generated or retrieved by a plugin that consumes a third-party API. * In this case, the `url` field contains the direct link to the external resource. */ -export type AttachmentRef = - | { - id: string | null; - } - | { - /** @deprecated To be used only for external URLs (plugins), for stored attachments use "id" instead */ - url: string; - }; -/** IMPORTANT: No need to use generic type here */ -export interface AttachmentPayload { - type: FileType; - payload: T; -} +export const attachmentRefSchema = z.union([ + z.object({ + id: z.string().nullable(), + }), + z.object({ + url: z.string(), + }), +]); + +export type AttachmentRef = z.infer; + +export const attachmentPayloadSchema = z.object({ + type: fileTypeSchema, + payload: attachmentRefSchema, +}); + +export type AttachmentPayload = z.infer; /** @deprecated */ export type WithUrl = A & { url?: string }; diff --git a/api/src/chat/schemas/types/button.ts b/api/src/chat/schemas/types/button.ts index c38cfe7b..37bf74ee 100644 --- a/api/src/chat/schemas/types/button.ts +++ b/api/src/chat/schemas/types/button.ts @@ -1,28 +1,36 @@ /* - * 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 { z } from 'zod'; + export enum ButtonType { postback = 'postback', web_url = 'web_url', } -export type PostBackButton = { - type: ButtonType.postback; - title: string; - payload: string; -}; +const postBackButtonSchema = z.object({ + type: z.literal(ButtonType.postback), + title: z.string(), + payload: z.string(), +}); -export type WebUrlButton = { - type: ButtonType.web_url; - title: string; - url: string; - messenger_extensions?: boolean; - webview_height_ratio?: 'compact' | 'tall' | 'full'; -}; +const webUrlButtonSchema = z.object({ + type: z.literal(ButtonType.web_url), + title: z.string(), + url: z.string().url(), + messenger_extensions: z.boolean().optional(), + webview_height_ratio: z.enum(['compact', 'tall', 'full']).optional(), +}); -export type Button = PostBackButton | WebUrlButton; +export const buttonSchema = z.union([postBackButtonSchema, webUrlButtonSchema]); + +export type PostBackButton = z.infer; + +export type WebUrlButton = z.infer; + +export type Button = z.infer; diff --git a/api/src/chat/schemas/types/capture-var.ts b/api/src/chat/schemas/types/capture-var.ts index 002340ab..54a821e6 100644 --- a/api/src/chat/schemas/types/capture-var.ts +++ b/api/src/chat/schemas/types/capture-var.ts @@ -1,15 +1,19 @@ /* - * 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). */ -export interface CaptureVar { - // entity=`-1` to match text message - // entity=`-2` for postback payload - // entity is `String` for NLP entities - entity: number | string; - context_var: string; -} +import { z } from 'zod'; + +// entity=`-1` to match text message +// entity=`-2` for postback payload +// entity is `String` for NLP entities +export const captureVarSchema = z.object({ + entity: z.union([z.number().min(-2).max(-1), z.string()]), + context_var: z.string().regex(/^[a-z][a-z_0-9]*$/), +}); + +export type CaptureVar = z.infer; diff --git a/api/src/chat/schemas/types/channel.ts b/api/src/chat/schemas/types/channel.ts index 06359493..dee41f1c 100644 --- a/api/src/chat/schemas/types/channel.ts +++ b/api/src/chat/schemas/types/channel.ts @@ -1,11 +1,13 @@ /* - * 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 { z } from 'zod'; + import { ChannelName } from '@/channel/types'; export type SubscriberChannelData = @@ -17,3 +19,11 @@ export type SubscriberChannelData = // Channel's specific attributes [P in keyof SubscriberChannelDict[C]]: SubscriberChannelDict[C][P]; }; + +export const channelDataSchema = z + .object({ + name: z.string().regex(/-channel$/) as z.ZodType, + }) + .passthrough(); + +export type Channel = z.infer; diff --git a/api/src/chat/schemas/types/pattern.ts b/api/src/chat/schemas/types/pattern.ts index 8b0ceab1..7c43cf11 100644 --- a/api/src/chat/schemas/types/pattern.ts +++ b/api/src/chat/schemas/types/pattern.ts @@ -1,29 +1,42 @@ /* - * 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 { z } from 'zod'; + import { PayloadType } from './message'; -export interface PayloadPattern { - label: string; - value: string; - // @todo : rename 'attachment' to 'attachments' - type?: PayloadType; -} +export const payloadPatternSchema = z.object({ + label: z.string(), + value: z.string(), + type: z.nativeEnum(PayloadType).optional(), +}); -export type NlpPattern = - | { - entity: string; - match: 'entity'; - } - | { - entity: string; - match: 'value'; - value: string; - }; +export type PayloadPattern = z.infer; -export type Pattern = string | RegExp | PayloadPattern | NlpPattern[]; +export const nlpPatternSchema = z.discriminatedUnion('match', [ + z.object({ + entity: z.string(), + match: z.literal('entity'), + }), + z.object({ + entity: z.string(), + match: z.literal('value'), + value: z.string(), + }), +]); + +export type NlpPattern = z.infer; + +export const patternSchema = z.union([ + z.string(), + z.instanceof(RegExp), + payloadPatternSchema, + z.array(nlpPatternSchema), +]); + +export type Pattern = z.infer; diff --git a/api/src/chat/schemas/types/position.ts b/api/src/chat/schemas/types/position.ts index b8440f0a..7ff3545a 100644 --- a/api/src/chat/schemas/types/position.ts +++ b/api/src/chat/schemas/types/position.ts @@ -1,12 +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). */ -export type Position = { - x: number; - y: number; -}; +import { z } from 'zod'; + +export const positionSchema = z.object({ + x: z.number(), + y: z.number(), +}); + +export type Position = z.infer; diff --git a/api/src/chat/schemas/types/quick-reply.ts b/api/src/chat/schemas/types/quick-reply.ts index 29ef17cd..e58cea15 100644 --- a/api/src/chat/schemas/types/quick-reply.ts +++ b/api/src/chat/schemas/types/quick-reply.ts @@ -6,21 +6,10 @@ * 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 { AttachmentPayload } from './attachment'; -import { PayloadType } from './message'; +import { z } from 'zod'; -export type Payload = - | { - type: PayloadType.location; - coordinates: { - lat: number; - lon: number; - }; - } - | { - type: PayloadType.attachments; - attachment: AttachmentPayload; - }; +import { attachmentPayloadSchema } from './attachment'; +import { PayloadType } from './message'; export enum QuickReplyType { text = 'text', @@ -29,8 +18,28 @@ export enum QuickReplyType { user_email = 'user_email', } -export interface StdQuickReply { - content_type: QuickReplyType; - title: string; - payload: string; -} +export const cordinatesSchema = z.object({ + lat: z.number(), + lon: z.number(), +}); + +export const payloadSchema = z.discriminatedUnion('type', [ + z.object({ + type: z.literal(PayloadType.location), + coordinates: cordinatesSchema, + }), + z.object({ + type: z.literal(PayloadType.attachments), + attachment: attachmentPayloadSchema, + }), +]); + +export const stdQuickReplySchema = z.object({ + content_type: z.nativeEnum(QuickReplyType), + title: z.string(), + payload: z.string(), +}); + +export type Payload = z.infer; + +export type StdQuickReply = z.infer; diff --git a/api/src/chat/schemas/types/subscriberContext.ts b/api/src/chat/schemas/types/subscriberContext.ts index 9eebff43..100c054d 100644 --- a/api/src/chat/schemas/types/subscriberContext.ts +++ b/api/src/chat/schemas/types/subscriberContext.ts @@ -1,11 +1,15 @@ /* - * 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). */ -export interface SubscriberContext { - vars?: { [key: string]: any }; -} +import { z } from 'zod'; + +export const subscriberContextSchema = z.object({ + vars: z.record(z.any()).optional(), +}); + +export type SubscriberContext = z.infer; diff --git a/api/src/chat/validation-rules/is-channel-data.ts b/api/src/chat/validation-rules/is-channel-data.ts index 3dbd4c2d..5bb6082e 100644 --- a/api/src/chat/validation-rules/is-channel-data.ts +++ b/api/src/chat/validation-rules/is-channel-data.ts @@ -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. @@ -13,17 +13,15 @@ import { ValidatorConstraintInterface, } from 'class-validator'; -export function isChannelData(channel: any) { - return ( - typeof channel === 'object' && - channel.name && - typeof channel.name === 'string' - ); +import { Channel, channelDataSchema } from '../schemas/types/channel'; + +export function isChannelData(channel: Channel) { + return channelDataSchema.safeParse(channel).success; } @ValidatorConstraint({ async: false }) export class ChannelDataValidator implements ValidatorConstraintInterface { - validate(channel: any) { + validate(channel: Channel) { return isChannelData(channel); } } diff --git a/api/src/chat/validation-rules/is-pattern-list.ts b/api/src/chat/validation-rules/is-pattern-list.ts index 528144ee..c64b2d08 100644 --- a/api/src/chat/validation-rules/is-pattern-list.ts +++ b/api/src/chat/validation-rules/is-pattern-list.ts @@ -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,62 +12,15 @@ import { ValidatorConstraint, ValidatorConstraintInterface, } from 'class-validator'; -import Joi from 'joi'; -import { Pattern } from '../schemas/types/pattern'; +import { Pattern, patternSchema } from '../schemas/types/pattern'; export function isPatternList(patterns: Pattern[]) { - return ( - Array.isArray(patterns) && - patterns.every((pattern) => { - if (typeof pattern === 'string') { - // Check if valid regex - if (pattern.endsWith('/') && pattern.startsWith('/')) { - try { - new RegExp(pattern.slice(1, -1), 'gi'); - } catch (err) { - return false; - } - return true; - } - // Check if valid string (Equals/Like) - return pattern !== ''; - } else if (Array.isArray(pattern)) { - // Check if valid NLP pattern - const nlpSchema = Joi.array() - .items( - Joi.object().keys({ - entity: Joi.string().required(), - match: Joi.string().valid('entity', 'value').required(), - value: Joi.string().required(), - }), - ) - .min(1); - const nlpCheck = nlpSchema.validate(pattern); - if (nlpCheck.error) { - // console.log('Message validation failed! ', nlpCheck); - } - return !nlpCheck.error; - } else if (typeof pattern === 'object') { - // Invalid structure? - const payloadSchema = Joi.object().keys({ - label: Joi.string().required(), - value: Joi.any().required(), - type: Joi.string(), - }); - const payloadCheck = payloadSchema.validate(pattern); - if (payloadCheck.error) { - // console.log( - // 'Message validation failed! ', - // payloadCheck, - // ); - } - return !payloadCheck.error; - } else { - return false; - } - }) - ); + if (!Array.isArray(patterns)) { + return false; + } + + return patterns.every((pattern) => patternSchema.safeParse(pattern).success); } @ValidatorConstraint({ async: false }) diff --git a/api/src/chat/validation-rules/is-position.ts b/api/src/chat/validation-rules/is-position.ts index c00ba57b..bbf6e95c 100644 --- a/api/src/chat/validation-rules/is-position.ts +++ b/api/src/chat/validation-rules/is-position.ts @@ -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. @@ -13,18 +13,10 @@ import { ValidatorConstraintInterface, } from 'class-validator'; -import { Position } from '../schemas/types/position'; +import { Position, positionSchema } from '../schemas/types/position'; export function isPosition(position: Position) { - return ( - typeof position === 'object' && - !isNaN(position.x) && - !isNaN(position.y) && - position.x !== Infinity && - position.x !== -Infinity && - position.y !== Infinity && - position.y !== -Infinity - ); + return positionSchema.safeParse(position).success; } @ValidatorConstraint({ async: false }) diff --git a/api/src/chat/validation-rules/is-valid-capture.ts b/api/src/chat/validation-rules/is-valid-capture.ts index 664280ea..3956af1e 100644 --- a/api/src/chat/validation-rules/is-valid-capture.ts +++ b/api/src/chat/validation-rules/is-valid-capture.ts @@ -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,43 +12,17 @@ import { ValidatorConstraint, ValidatorConstraintInterface, } from 'class-validator'; -import Joi from 'joi'; -type Tentity = -1 | -2; - -export interface CaptureVar { - // entity=`-1` to match text message - // entity=`-2` for postback payload - // entity is `String` for NLP entities - entity: Tentity | string; - context_var: string; -} - -const allowedEntityValues: Tentity[] = [-1, -2]; +import { CaptureVar, captureVarSchema } from '../schemas/types/capture-var'; export function isValidVarCapture(vars: CaptureVar[]) { - const captureSchema = Joi.array().items( - Joi.object().keys({ - entity: Joi.alternatives().try( - // `-1` to match text message & `-2` for postback payload - Joi.number() - .valid(...allowedEntityValues) - .required(), - // String for NLP entities - Joi.string().required(), - ), - context_var: Joi.string() - .regex(/^[a-z][a-z_0-9]*$/) - .required(), - }), - ); - - const captureCheck = captureSchema.validate(vars); - if (captureCheck.error) { - // eslint-disable-next-line - console.log('Capture vars validation failed!', captureCheck.error); + if (!Array.isArray(vars)) { + return false; } - return !captureCheck.error; + + return vars.every( + (captureVar) => captureVarSchema.safeParse(captureVar).success, + ); } @ValidatorConstraint({ async: false }) diff --git a/api/src/utils/test/mocks/block.ts b/api/src/utils/test/mocks/block.ts index 556d0399..7562e327 100644 --- a/api/src/utils/test/mocks/block.ts +++ b/api/src/utils/test/mocks/block.ts @@ -13,6 +13,7 @@ import { import { BlockFull } from '@/chat/schemas/block.schema'; import { FileType } from '@/chat/schemas/types/attachment'; import { ButtonType } from '@/chat/schemas/types/button'; +import { CaptureVar } from '@/chat/schemas/types/capture-var'; import { OutgoingMessageFormat, PayloadType, @@ -20,7 +21,6 @@ import { import { BlockOptions, ContentOptions } from '@/chat/schemas/types/options'; import { Pattern } from '@/chat/schemas/types/pattern'; import { QuickReplyType } from '@/chat/schemas/types/quick-reply'; -import { CaptureVar } from '@/chat/validation-rules/is-valid-capture'; import { modelInstance } from './misc'; diff --git a/frontend/public/locales/en/translation.json b/frontend/public/locales/en/translation.json index c094ede1..7474b471 100644 --- a/frontend/public/locales/en/translation.json +++ b/frontend/public/locales/en/translation.json @@ -44,6 +44,8 @@ "account_disabled": "Your account has been either disabled or is pending confirmation.", "success_invitation_sent": "Invitation to join has been successfully sent.", "item_delete_confirm": "Are you sure you want to delete this item?", + "item_selected_delete_confirm": "Are you sure you want to delete this selected item?", + "items_selected_delete_confirm": "Are you sure you want to delete those {{0}} selected items?", "item_delete_success": "Item has been deleted successfully", "success_save": "Changes has been saved!", "no_result_found": "No result found", diff --git a/frontend/public/locales/fr/translation.json b/frontend/public/locales/fr/translation.json index 881393a0..2bb48ec8 100644 --- a/frontend/public/locales/fr/translation.json +++ b/frontend/public/locales/fr/translation.json @@ -44,6 +44,8 @@ "account_disabled": "Votre compte a été désactivé ou est en attente de confirmation.", "success_invitation_sent": "L'invitation a été envoyée avec succès.", "item_delete_confirm": "Êtes-vous sûr de bien vouloir supprimer cet élément?", + "item_selected_delete_confirm": "Êtes-vous sûr de bien vouloir supprimer cet élément sélectionné?", + "items_selected_delete_confirm": "Êtes-vous sûr de bien vouloir supprimer ces {{0}} éléments sélectionnés?", "item_delete_success": "L'élément a été supprimé avec succès", "success_save": "Les modifications ont été enregistrées!", "no_result_found": "Aucun résultat trouvé", diff --git a/frontend/src/app-components/buttons/FormButtons.tsx b/frontend/src/app-components/buttons/FormButtons.tsx new file mode 100644 index 00000000..469b9cf5 --- /dev/null +++ b/frontend/src/app-components/buttons/FormButtons.tsx @@ -0,0 +1,44 @@ +/* + * 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 CheckIcon from "@mui/icons-material/Check"; +import CloseIcon from "@mui/icons-material/Close"; +import { Button, Grid } from "@mui/material"; + +import { useTranslate } from "@/hooks/useTranslate"; +import { FormButtonsProps } from "@/types/common/dialogs.types"; + +export const DialogFormButtons = ({ onCancel, onSubmit }: FormButtonsProps) => { + const { t } = useTranslate(); + + return ( + + + + + ); +}; diff --git a/frontend/src/app-components/dialogs/DialogTitle.tsx b/frontend/src/app-components/dialogs/DialogTitle.tsx index 5bc70726..ba816660 100644 --- a/frontend/src/app-components/dialogs/DialogTitle.tsx +++ b/frontend/src/app-components/dialogs/DialogTitle.tsx @@ -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. @@ -8,10 +8,10 @@ import CloseIcon from "@mui/icons-material/Close"; import { + IconButton, + DialogTitle as MuiDialogTitle, Typography, styled, - DialogTitle as MuiDialogTitle, - IconButton, } from "@mui/material"; const StyledDialogTitle = styled(Typography)(() => ({ @@ -28,8 +28,10 @@ export const DialogTitle = ({ }) => ( {children} - - - + {onClose ? ( + + + + ) : null} ); diff --git a/frontend/src/app-components/dialogs/FormDialog.tsx b/frontend/src/app-components/dialogs/FormDialog.tsx new file mode 100644 index 00000000..8a5bfe24 --- /dev/null +++ b/frontend/src/app-components/dialogs/FormDialog.tsx @@ -0,0 +1,33 @@ +/* + * 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 { Dialog, DialogActions, DialogContent } from "@mui/material"; + +import { DialogTitle } from "@/app-components/dialogs"; +import { FormDialogProps } from "@/types/common/dialogs.types"; + +import { DialogFormButtons } from "../buttons/FormButtons"; + +export const FormDialog = ({ + title, + children, + onSubmit, + ...rest +}: FormDialogProps) => { + const handleClose = () => rest.onClose?.({}, "backdropClick"); + + return ( + + {title} + {children} + + + + + ); +}; diff --git a/frontend/src/app-components/dialogs/confirm/ConfirmDialog.tsx b/frontend/src/app-components/dialogs/confirm/ConfirmDialog.tsx new file mode 100644 index 00000000..f6b2f30b --- /dev/null +++ b/frontend/src/app-components/dialogs/confirm/ConfirmDialog.tsx @@ -0,0 +1,74 @@ +/* + * 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 { + Button, + Dialog, + DialogActions, + DialogContent, + Grid, +} from "@mui/material"; +import { cloneElement, FC, ReactNode } from "react"; + +import { useTranslate } from "@/hooks/useTranslate"; +import { ConfirmOptions, DialogProps } from "@/types/common/dialogs.types"; + +import { DialogTitle } from "../DialogTitle"; + +import { useDialogLoadingButton } from "./hooks/useDialogLoadingButton"; + +export interface ConfirmDialogPayload extends ConfirmOptions { + msg: ReactNode; +} + +export interface ConfirmDialogProps + extends DialogProps { + mode?: "selection" | "click"; + count?: number; +} + +export const ConfirmDialog: FC = ({ payload, ...rest }) => { + const { t } = useTranslate(); + const cancelButtonProps = useDialogLoadingButton(() => rest.onClose(false)); + const okButtonProps = useDialogLoadingButton(() => rest.onClose(true)); + // @ts-ignore + const messageReactNode = cloneElement(payload.msg, { + mode: rest.mode, + count: rest.count, + }); + + return ( + rest.onClose(false)} + > + rest.onClose(false)}> + {payload.title || t("title.warning")} + + {messageReactNode} + + + + + + + + ); +}; diff --git a/frontend/src/app-components/dialogs/confirm/ConfirmDialogBody.tsx b/frontend/src/app-components/dialogs/confirm/ConfirmDialogBody.tsx new file mode 100644 index 00000000..8396a397 --- /dev/null +++ b/frontend/src/app-components/dialogs/confirm/ConfirmDialogBody.tsx @@ -0,0 +1,41 @@ +/* + * 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 ErrorIcon from "@mui/icons-material/Error"; +import { Grid, Typography } from "@mui/material"; + +import { useTranslate } from "@/hooks/useTranslate"; + +export const ConfirmDialogBody = ({ + mode = "click", + count = 1, +}: { + mode?: "selection" | "click"; + count?: number; +}) => { + const { t } = useTranslate(); + const dialogBodyText = + mode === "selection" + ? count === 1 + ? t("message.item_selected_delete_confirm") + : t("message.items_selected_delete_confirm", { + "0": count.toString(), + }) + : t("message.item_delete_confirm"); + + return ( + + + + + + {dialogBodyText} + + + ); +}; diff --git a/frontend/src/app-components/dialogs/confirm/hooks/useDialogLoadingButton.ts b/frontend/src/app-components/dialogs/confirm/hooks/useDialogLoadingButton.ts new file mode 100644 index 00000000..915f9d9c --- /dev/null +++ b/frontend/src/app-components/dialogs/confirm/hooks/useDialogLoadingButton.ts @@ -0,0 +1,26 @@ +/* + * 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 { useState } from "react"; + +export const useDialogLoadingButton = (onClose: () => Promise) => { + const [loading, setLoading] = useState(false); + const handleClick = async () => { + try { + setLoading(true); + await onClose(); + } finally { + setLoading(false); + } + }; + + return { + onClick: handleClick, + loading, + }; +}; diff --git a/frontend/src/app-components/dialogs/index.ts b/frontend/src/app-components/dialogs/index.ts index 6948629d..f56c6c22 100644 --- a/frontend/src/app-components/dialogs/index.ts +++ b/frontend/src/app-components/dialogs/index.ts @@ -6,7 +6,10 @@ * 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 * from "./confirm/ConfirmDialog"; +export * from "./confirm/ConfirmDialogBody"; export * from "./DeleteDialog"; export * from "./DialogTitle"; +export * from "./FormDialog"; export * from "./layouts/ContentContainer"; export * from "./layouts/ContentItem"; diff --git a/frontend/src/components/categories/CategoryDialog.tsx b/frontend/src/components/categories/CategoryDialog.tsx deleted file mode 100644 index 8a2b69ec..00000000 --- a/frontend/src/components/categories/CategoryDialog.tsx +++ /dev/null @@ -1,113 +0,0 @@ -/* - * Copyright © 2024 Hexastack. All rights reserved. - * - * Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms: - * 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission. - * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). - */ - -import { Dialog, DialogActions, DialogContent } from "@mui/material"; -import { FC, useEffect } from "react"; -import { useForm } from "react-hook-form"; - -import DialogButtons from "@/app-components/buttons/DialogButtons"; -import { DialogTitle } from "@/app-components/dialogs/DialogTitle"; -import { ContentContainer } from "@/app-components/dialogs/layouts/ContentContainer"; -import { ContentItem } from "@/app-components/dialogs/layouts/ContentItem"; -import { Input } from "@/app-components/inputs/Input"; -import { useCreate } from "@/hooks/crud/useCreate"; -import { useUpdate } from "@/hooks/crud/useUpdate"; -import { DialogControlProps } from "@/hooks/useDialog"; -import { useToast } from "@/hooks/useToast"; -import { useTranslate } from "@/hooks/useTranslate"; -import { EntityType } from "@/services/types"; -import { ICategory, ICategoryAttributes } from "@/types/category.types"; - -export type CategoryDialogProps = DialogControlProps; - -export const CategoryDialog: FC = ({ - open, - data, - closeDialog, - ...rest -}) => { - const { t } = useTranslate(); - const { toast } = useToast(); - const { mutateAsync: createCategory } = useCreate(EntityType.CATEGORY, { - onError: (error) => { - toast.error(error); - }, - onSuccess: () => { - closeDialog(); - toast.success(t("message.success_save")); - }, - }); - const { mutateAsync: updateCategory } = useUpdate(EntityType.CATEGORY, { - onError: () => { - toast.error(t("message.internal_server_error")); - }, - onSuccess: () => { - closeDialog(); - toast.success(t("message.success_save")); - }, - }); - const { - reset, - register, - formState: { errors }, - handleSubmit, - } = useForm({ - defaultValues: { label: data?.label || "" }, - }); - const validationRules = { - label: { - required: t("message.label_is_required"), - }, - }; - const onSubmitForm = async (params: ICategoryAttributes) => { - if (data) { - updateCategory({ id: data.id, params }); - } else { - createCategory(params); - } - }; - - useEffect(() => { - if (open) reset(); - }, [open, reset]); - - useEffect(() => { - if (data) { - reset({ - label: data.label, - }); - } else { - reset(); - } - }, [data, reset]); - - return ( - -
- - {data ? t("title.edit_category") : t("title.new_category")} - - - - - - - - - - - -
-
- ); -}; diff --git a/frontend/src/components/categories/CategoryForm.tsx b/frontend/src/components/categories/CategoryForm.tsx new file mode 100644 index 00000000..abeb522c --- /dev/null +++ b/frontend/src/components/categories/CategoryForm.tsx @@ -0,0 +1,97 @@ +/* + * 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, useEffect } from "react"; +import { useForm } from "react-hook-form"; + +import { ContentContainer, ContentItem } from "@/app-components/dialogs/"; +import { Input } from "@/app-components/inputs/Input"; +import { useCreate } from "@/hooks/crud/useCreate"; +import { useUpdate } from "@/hooks/crud/useUpdate"; +import { useToast } from "@/hooks/useToast"; +import { useTranslate } from "@/hooks/useTranslate"; +import { EntityType } from "@/services/types"; +import { ICategory, ICategoryAttributes } from "@/types/category.types"; +import { ComponentFormProps } from "@/types/common/dialogs.types"; + +export const CategoryForm: FC> = ({ + data, + Wrapper = Fragment, + WrapperProps, + ...rest +}) => { + const { t } = useTranslate(); + const { toast } = useToast(); + const options = { + onError: (error: Error) => { + rest.onError?.(); + toast.error(error || t("message.internal_server_error")); + }, + onSuccess: () => { + rest.onSuccess?.(); + toast.success(t("message.success_save")); + }, + }; + const { mutate: createCategory } = useCreate(EntityType.CATEGORY, options); + const { mutate: updateCategory } = useUpdate(EntityType.CATEGORY, options); + const { + reset, + register, + formState: { errors }, + handleSubmit, + } = useForm({ + defaultValues: { label: data?.label || "" }, + }); + const validationRules = { + label: { + required: t("message.label_is_required"), + }, + }; + const onSubmitForm = (params: ICategoryAttributes) => { + if (data) { + updateCategory({ id: data.id, params }); + } else { + createCategory(params); + } + }; + + useEffect(() => { + if (data) { + reset({ + label: data.label, + }); + } else { + reset(); + } + }, [data, reset]); + + return ( + +
+ + + + + +
+
+ ); +}; + +CategoryForm.displayName = "CategoryForm"; diff --git a/frontend/src/components/categories/CategoryFormDialog.tsx b/frontend/src/components/categories/CategoryFormDialog.tsx new file mode 100644 index 00000000..74112ea3 --- /dev/null +++ b/frontend/src/components/categories/CategoryFormDialog.tsx @@ -0,0 +1,37 @@ +/* + * 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 } from "react"; + +import { FormDialog } from "@/app-components/dialogs"; +import { useTranslate } from "@/hooks/useTranslate"; +import { ICategory } from "@/types/category.types"; +import { ComponentFormDialogProps } from "@/types/common/dialogs.types"; + +import { CategoryForm } from "./CategoryForm"; + +export const CategoryFormDialog: FC> = ({ + payload, + ...rest +}) => { + const { t } = useTranslate(); + + return ( + { + rest.onClose(true); + }} + Wrapper={FormDialog} + WrapperProps={{ + title: payload ? t("title.edit_category") : t("title.new_category"), + ...rest, + }} + /> + ); +}; diff --git a/frontend/src/components/categories/index.tsx b/frontend/src/components/categories/index.tsx index d9976536..5c805b54 100644 --- a/frontend/src/components/categories/index.tsx +++ b/frontend/src/components/categories/index.tsx @@ -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. @@ -13,7 +13,7 @@ import { Button, Grid, Paper } from "@mui/material"; import { GridColDef, GridRowSelectionModel } from "@mui/x-data-grid"; import { useState } from "react"; -import { DeleteDialog } from "@/app-components/dialogs/DeleteDialog"; +import { ConfirmDialogBody } from "@/app-components/dialogs"; import { FilterTextfield } from "@/app-components/inputs/FilterTextfield"; import { ActionColumnLabel, @@ -24,7 +24,7 @@ import { DataGrid } from "@/app-components/tables/DataGrid"; import { useDelete } from "@/hooks/crud/useDelete"; import { useDeleteMany } from "@/hooks/crud/useDeleteMany"; import { useFind } from "@/hooks/crud/useFind"; -import { getDisplayDialogs, useDialog } from "@/hooks/useDialog"; +import { useDialogs } from "@/hooks/useDialogs"; import { useHasPermission } from "@/hooks/useHasPermission"; import { useSearch } from "@/hooks/useSearch"; import { useToast } from "@/hooks/useToast"; @@ -36,14 +36,12 @@ import { getDateTimeFormatter } from "@/utils/date"; import { ICategory } from "../../types/category.types"; -import { CategoryDialog } from "./CategoryDialog"; +import { CategoryFormDialog } from "./CategoryFormDialog"; export const Categories = () => { const { t } = useTranslate(); const { toast } = useToast(); - const addDialogCtl = useDialog(false); - const editDialogCtl = useDialog(false); - const deleteDialogCtl = useDialog(false); + const dialogs = useDialogs(); const hasPermission = useHasPermission(); const { onSearch, searchPayload } = useSearch({ $iLike: ["label"], @@ -54,38 +52,40 @@ export const Categories = () => { params: searchPayload, }, ); - const { mutateAsync: deleteCategory } = useDelete(EntityType.CATEGORY, { - onError: (error) => { + const options = { + onError: (error: Error) => { toast.error(error.message || t("message.internal_server_error")); }, onSuccess: () => { - deleteDialogCtl.closeDialog(); setSelectedCategories([]); toast.success(t("message.item_delete_success")); }, - }); - const { mutateAsync: deleteCategories } = useDeleteMany(EntityType.CATEGORY, { - onError: (error) => { - toast.error(error.message || t("message.internal_server_error")); - }, - onSuccess: () => { - deleteDialogCtl.closeDialog(); - setSelectedCategories([]); - toast.success(t("message.item_delete_success")); - }, - }); + }; + const { mutate: deleteCategory } = useDelete(EntityType.CATEGORY, options); + const { mutate: deleteCategories } = useDeleteMany( + EntityType.CATEGORY, + options, + ); const [selectedCategories, setSelectedCategories] = useState([]); const actionColumns = useActionColumns( EntityType.CATEGORY, [ { label: ActionColumnLabel.Edit, - action: (row) => editDialogCtl.openDialog(row), + action: async (row) => { + await dialogs.open(CategoryFormDialog, row); + }, requires: [PermissionAction.UPDATE], }, { label: ActionColumnLabel.Delete, - action: (row) => deleteDialogCtl.openDialog(row.id), + action: async ({ id }) => { + const isConfirmed = await dialogs.confirm(ConfirmDialogBody); + + if (isConfirmed) { + deleteCategory(id); + } + }, requires: [PermissionAction.DELETE], }, ], @@ -131,22 +131,6 @@ export const Categories = () => { return ( - - - { - if (selectedCategories.length > 0) { - deleteCategories(selectedCategories), setSelectedCategories([]); - deleteDialogCtl.closeDialog(); - } else if (deleteDialogCtl?.data) { - { - deleteCategory(deleteDialogCtl.data); - deleteDialogCtl.closeDialog(); - } - } - }} - /> { startIcon={} variant="contained" sx={{ float: "right" }} - onClick={() => addDialogCtl.openDialog()} + onClick={() => dialogs.open(CategoryFormDialog, null)} > {t("button.add")} ) : null} - {selectedCategories.length > 0 && ( - - - - )} + diff --git a/frontend/src/components/context-vars/ContextVarDialog.tsx b/frontend/src/components/context-vars/ContextVarDialog.tsx deleted file mode 100644 index b894eae1..00000000 --- a/frontend/src/components/context-vars/ContextVarDialog.tsx +++ /dev/null @@ -1,165 +0,0 @@ -/* - * Copyright © 2024 Hexastack. All rights reserved. - * - * Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms: - * 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission. - * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). - */ - -import { - Dialog, - DialogActions, - DialogContent, - FormControlLabel, - FormHelperText, - Switch, -} from "@mui/material"; -import { FC, useEffect } from "react"; -import { Controller, useForm } from "react-hook-form"; - -import DialogButtons from "@/app-components/buttons/DialogButtons"; -import { DialogTitle } from "@/app-components/dialogs/DialogTitle"; -import { ContentContainer } from "@/app-components/dialogs/layouts/ContentContainer"; -import { ContentItem } from "@/app-components/dialogs/layouts/ContentItem"; -import { Input } from "@/app-components/inputs/Input"; -import { useCreate } from "@/hooks/crud/useCreate"; -import { useUpdate } from "@/hooks/crud/useUpdate"; -import { DialogControlProps } from "@/hooks/useDialog"; -import { useToast } from "@/hooks/useToast"; -import { useTranslate } from "@/hooks/useTranslate"; -import { EntityType } from "@/services/types"; -import { IContextVar, IContextVarAttributes } from "@/types/context-var.types"; -import { slugify } from "@/utils/string"; - -export type ContextVarDialogProps = DialogControlProps; -export const ContextVarDialog: FC = ({ - open, - data, - closeDialog, - ...rest -}) => { - const { t } = useTranslate(); - const { toast } = useToast(); - const { mutateAsync: createContextVar } = useCreate(EntityType.CONTEXT_VAR, { - onError: (error) => { - toast.error(error); - }, - onSuccess() { - closeDialog(); - toast.success(t("message.success_save")); - }, - }); - const { mutateAsync: updateContextVar } = useUpdate(EntityType.CONTEXT_VAR, { - onError: () => { - toast.error(t("message.internal_server_error")); - }, - onSuccess() { - closeDialog(); - toast.success(t("message.success_save")); - }, - }); - const { - reset, - register, - setValue, - handleSubmit, - formState: { errors }, - control, - } = useForm({ - defaultValues: { - name: data?.name || "", - label: data?.label || "", - permanent: data?.permanent || false, - }, - }); - const validationRules = { - label: { - required: t("message.label_is_required"), - }, - name: { - pattern: { - value: /^[a-z_0-9]+$/, - message: t("message.context_vars_name_is_invalid"), - }, - }, - }; - const onSubmitForm = async (params: IContextVarAttributes) => { - if (data) { - updateContextVar({ id: data.id, params }); - } else { - createContextVar(params); - } - }; - - useEffect(() => { - if (open) reset(); - }, [open, reset]); - - useEffect(() => { - if (data) { - reset({ - label: data.label, - name: data.name, - permanent: data.permanent, - }); - } else { - reset(); - } - }, [data, reset]); - - return ( - -
- - {data ? t("title.edit_context_var") : t("title.new_context_var")} - - - - - { - setValue("label", value); - setValue("name", slugify(value)); - }, - }} - helperText={errors.label ? errors.label.message : null} - /> - - - - - - ( - } - label={t("label.permanent")} - /> - )} - /> - {t("help.permanent")} - - - - - - -
-
- ); -}; diff --git a/frontend/src/components/context-vars/ContextVarForm.tsx b/frontend/src/components/context-vars/ContextVarForm.tsx new file mode 100644 index 00000000..6250daea --- /dev/null +++ b/frontend/src/components/context-vars/ContextVarForm.tsx @@ -0,0 +1,146 @@ +/* + * 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 { FormControlLabel, FormHelperText, Switch } from "@mui/material"; +import { FC, Fragment, useEffect } from "react"; +import { Controller, useForm } from "react-hook-form"; + +import { ContentContainer, ContentItem } from "@/app-components/dialogs/"; +import { Input } from "@/app-components/inputs/Input"; +import { useCreate } from "@/hooks/crud/useCreate"; +import { useUpdate } from "@/hooks/crud/useUpdate"; +import { useToast } from "@/hooks/useToast"; +import { useTranslate } from "@/hooks/useTranslate"; +import { EntityType } from "@/services/types"; +import { ComponentFormProps } from "@/types/common/dialogs.types"; +import { IContextVar, IContextVarAttributes } from "@/types/context-var.types"; +import { slugify } from "@/utils/string"; + +export const ContextVarForm: FC> = ({ + data, + Wrapper = Fragment, + WrapperProps, + ...rest +}) => { + const { t } = useTranslate(); + const { toast } = useToast(); + const options = { + onError: (error: Error) => { + rest.onError?.(); + toast.error(error.message || t("message.internal_server_error")); + }, + onSuccess: () => { + rest.onSuccess?.(); + toast.success(t("message.success_save")); + }, + }; + const { mutateAsync: createContextVar } = useCreate( + EntityType.CONTEXT_VAR, + options, + ); + const { mutateAsync: updateContextVar } = useUpdate( + EntityType.CONTEXT_VAR, + options, + ); + const { + reset, + control, + register, + setValue, + formState: { errors }, + handleSubmit, + } = useForm({ + defaultValues: { + name: data?.name || "", + label: data?.label || "", + permanent: data?.permanent || false, + }, + }); + const validationRules = { + name: { + pattern: { + value: /^[a-z_0-9]+$/, + message: t("message.context_vars_name_is_invalid"), + }, + }, + label: { + required: t("message.label_is_required"), + }, + }; + const onSubmitForm = async (params: IContextVarAttributes) => { + if (data) { + updateContextVar({ id: data.id, params }); + } else { + createContextVar(params); + } + }; + + useEffect(() => { + if (data) { + reset({ + name: data.name, + label: data.label, + permanent: data.permanent, + }); + } else { + reset(); + } + }, [data, reset]); + + return ( + +
+ + + { + setValue("label", value); + setValue("name", slugify(value)); + }, + }} + helperText={errors.label ? errors.label.message : null} + /> + + + + + + ( + } + label={t("label.permanent")} + /> + )} + /> + {t("help.permanent")} + + +
+
+ ); +}; diff --git a/frontend/src/components/context-vars/ContextVarFormDialog.tsx b/frontend/src/components/context-vars/ContextVarFormDialog.tsx new file mode 100644 index 00000000..d33d71e1 --- /dev/null +++ b/frontend/src/components/context-vars/ContextVarFormDialog.tsx @@ -0,0 +1,38 @@ +/* + * 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 } from "react"; + +import { FormDialog } from "@/app-components/dialogs"; +import { useTranslate } from "@/hooks/useTranslate"; +import { ComponentFormDialogProps } from "@/types/common/dialogs.types"; +import { IContextVar } from "@/types/context-var.types"; + +import { ContextVarForm } from "./ContextVarForm"; + +export const ContextVarFormDialog: FC< + ComponentFormDialogProps +> = ({ payload, ...rest }) => { + const { t } = useTranslate(); + + return ( + { + rest.onClose(true); + }} + Wrapper={FormDialog} + WrapperProps={{ + title: payload + ? t("title.edit_context_var") + : t("title.new_context_var"), + ...rest, + }} + /> + ); +}; diff --git a/frontend/src/components/context-vars/index.tsx b/frontend/src/components/context-vars/index.tsx index 502dc580..fdee1011 100644 --- a/frontend/src/components/context-vars/index.tsx +++ b/frontend/src/components/context-vars/index.tsx @@ -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. @@ -11,9 +11,9 @@ import AddIcon from "@mui/icons-material/Add"; import DeleteIcon from "@mui/icons-material/Delete"; import { Button, Grid, Paper, Switch } from "@mui/material"; import { GridColDef, GridRowSelectionModel } from "@mui/x-data-grid"; -import React, { useState } from "react"; +import { useState } from "react"; -import { DeleteDialog } from "@/app-components/dialogs/DeleteDialog"; +import { ConfirmDialogBody } from "@/app-components/dialogs"; import { FilterTextfield } from "@/app-components/inputs/FilterTextfield"; import { ActionColumnLabel, @@ -25,7 +25,7 @@ import { useDelete } from "@/hooks/crud/useDelete"; import { useDeleteMany } from "@/hooks/crud/useDeleteMany"; import { useFind } from "@/hooks/crud/useFind"; import { useUpdate } from "@/hooks/crud/useUpdate"; -import { getDisplayDialogs, useDialog } from "@/hooks/useDialog"; +import { useDialogs } from "@/hooks/useDialogs"; import { useHasPermission } from "@/hooks/useHasPermission"; import { useSearch } from "@/hooks/useSearch"; import { useToast } from "@/hooks/useToast"; @@ -36,14 +36,12 @@ import { IContextVar } from "@/types/context-var.types"; import { PermissionAction } from "@/types/permission.types"; import { getDateTimeFormatter } from "@/utils/date"; -import { ContextVarDialog } from "./ContextVarDialog"; +import { ContextVarFormDialog } from "./ContextVarFormDialog"; export const ContextVars = () => { const { t } = useTranslate(); const { toast } = useToast(); - const addDialogCtl = useDialog(false); - const editDialogCtl = useDialog(false); - const deleteDialogCtl = useDialog(false); + const dialogs = useDialogs(); const hasPermission = useHasPermission(); const { onSearch, searchPayload } = useSearch({ $iLike: ["label"], @@ -54,7 +52,7 @@ export const ContextVars = () => { params: searchPayload, }, ); - const { mutateAsync: updateContextVar } = useUpdate(EntityType.CONTEXT_VAR, { + const { mutate: updateContextVar } = useUpdate(EntityType.CONTEXT_VAR, { onError: () => { toast.error(t("message.internal_server_error")); }, @@ -62,28 +60,22 @@ export const ContextVars = () => { toast.success(t("message.success_save")); }, }); - const { mutateAsync: deleteContextVar } = useDelete(EntityType.CONTEXT_VAR, { - onError: (error) => { + const options = { + onError: (error: Error) => { toast.error(error); }, onSuccess() { - deleteDialogCtl.closeDialog(); setSelectedContextVars([]); toast.success(t("message.item_delete_success")); }, - }); - const { mutateAsync: deleteContextVars } = useDeleteMany( + }; + const { mutate: deleteContextVar } = useDelete( EntityType.CONTEXT_VAR, - { - onError: (error) => { - toast.error(error); - }, - onSuccess: () => { - deleteDialogCtl.closeDialog(); - setSelectedContextVars([]); - toast.success(t("message.item_delete_success")); - }, - }, + options, + ); + const { mutate: deleteContextVars } = useDeleteMany( + EntityType.CONTEXT_VAR, + options, ); const [selectedContextVars, setSelectedContextVars] = useState([]); const actionColumns = useActionColumns( @@ -91,12 +83,18 @@ export const ContextVars = () => { [ { label: ActionColumnLabel.Edit, - action: (row) => editDialogCtl.openDialog(row), + action: (row) => dialogs.open(ContextVarFormDialog, row), requires: [PermissionAction.UPDATE], }, { label: ActionColumnLabel.Delete, - action: (row) => deleteDialogCtl.openDialog(row.id), + action: async ({ id }) => { + const isConfirmed = await dialogs.confirm(ConfirmDialogBody); + + if (isConfirmed) { + deleteContextVar(id); + } + }, requires: [PermissionAction.DELETE], }, ], @@ -119,9 +117,9 @@ export const ContextVars = () => { disableColumnMenu: true, renderHeader, headerAlign: "left", - renderCell: (params) => ( + renderCell: ({ row, value }) => ( { } onChange={() => { updateContextVar({ - id: params.row.id, - params: { permanent: !params.value }, + id: row.id, + params: { permanent: !value }, }); }} /> @@ -166,21 +164,6 @@ export const ContextVars = () => { return ( - - - - { - if (selectedContextVars.length > 0) { - deleteContextVars(selectedContextVars); - setSelectedContextVars([]); - deleteDialogCtl.closeDialog(); - } else if (deleteDialogCtl?.data) { - deleteContextVar(deleteDialogCtl.data); - } - }} - /> { startIcon={} variant="contained" sx={{ float: "right" }} - onClick={() => addDialogCtl.openDialog()} + onClick={() => dialogs.open(ContextVarFormDialog, null)} > {t("button.add")} ) : null} - {selectedContextVars.length > 0 && ( - - - - )} + + + diff --git a/frontend/src/components/labels/LabelDialog.tsx b/frontend/src/components/labels/LabelDialog.tsx deleted file mode 100644 index 3c9b50a5..00000000 --- a/frontend/src/components/labels/LabelDialog.tsx +++ /dev/null @@ -1,150 +0,0 @@ -/* - * Copyright © 2024 Hexastack. All rights reserved. - * - * Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms: - * 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission. - * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). - */ - -import { Dialog, DialogActions, DialogContent } from "@mui/material"; -import { FC, useEffect } from "react"; -import { useForm } from "react-hook-form"; - -import DialogButtons from "@/app-components/buttons/DialogButtons"; -import { DialogTitle } from "@/app-components/dialogs/DialogTitle"; -import { ContentContainer } from "@/app-components/dialogs/layouts/ContentContainer"; -import { ContentItem } from "@/app-components/dialogs/layouts/ContentItem"; -import { Input } from "@/app-components/inputs/Input"; -import { useCreate } from "@/hooks/crud/useCreate"; -import { useUpdate } from "@/hooks/crud/useUpdate"; -import { DialogControlProps } from "@/hooks/useDialog"; -import { useToast } from "@/hooks/useToast"; -import { useTranslate } from "@/hooks/useTranslate"; -import { EntityType } from "@/services/types"; -import { ILabel, ILabelAttributes } from "@/types/label.types"; -import { slugify } from "@/utils/string"; - -export type LabelDialogProps = DialogControlProps; -export const LabelDialog: FC = ({ - open, - data, - closeDialog, - ...rest -}) => { - const { t } = useTranslate(); - const { toast } = useToast(); - const { mutateAsync: createLabel } = useCreate(EntityType.LABEL, { - onError: () => { - toast.error(t("message.internal_server_error")); - }, - onSuccess() { - closeDialog(); - toast.success(t("message.success_save")); - }, - }); - const { mutateAsync: updateLabel } = useUpdate(EntityType.LABEL, { - onError: () => { - toast.error(t("message.internal_server_error")); - }, - onSuccess() { - closeDialog(); - toast.success(t("message.success_save")); - }, - }); - const { - reset, - register, - setValue, - formState: { errors }, - handleSubmit, - } = useForm({ - defaultValues: { - name: data?.name || "", - title: data?.title || "", - description: data?.description || "", - }, - }); - const validationRules = { - title: { - required: t("message.title_is_required"), - }, - name: {}, - description: {}, - }; - const onSubmitForm = async (params: ILabelAttributes) => { - if (data) { - updateLabel({ id: data.id, params }); - } else { - createLabel(params); - } - }; - - useEffect(() => { - if (open) reset(); - }, [open, reset]); - - useEffect(() => { - if (data) { - reset({ - name: data.name, - title: data.title, - description: data.description, - }); - } else { - reset(); - } - }, [data, reset]); - - return ( - -
- - {data ? t("title.edit_label") : t("title.new_label")} - - - - - { - setValue("title", value); - setValue("name", slugify(value).toUpperCase()); - }, - }} - helperText={errors.title ? errors.title.message : null} - /> - - - - - - - - - - - - -
-
- ); -}; diff --git a/frontend/src/components/labels/LabelForm.tsx b/frontend/src/components/labels/LabelForm.tsx new file mode 100644 index 00000000..f4f713c0 --- /dev/null +++ b/frontend/src/components/labels/LabelForm.tsx @@ -0,0 +1,131 @@ +/* + * 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, useEffect } from "react"; +import { useForm } from "react-hook-form"; + +import { ContentContainer, ContentItem } from "@/app-components/dialogs/"; +import { Input } from "@/app-components/inputs/Input"; +import { useCreate } from "@/hooks/crud/useCreate"; +import { useUpdate } from "@/hooks/crud/useUpdate"; +import { useToast } from "@/hooks/useToast"; +import { useTranslate } from "@/hooks/useTranslate"; +import { EntityType } from "@/services/types"; +import { ComponentFormProps } from "@/types/common/dialogs.types"; +import { ILabel, ILabelAttributes } from "@/types/label.types"; +import { slugify } from "@/utils/string"; + +export const LabelForm: FC> = ({ + data, + Wrapper = Fragment, + WrapperProps, + ...rest +}) => { + const { t } = useTranslate(); + const { toast } = useToast(); + const options = { + onError: (error: Error) => { + rest.onError?.(); + toast.error(error.message || t("message.internal_server_error")); + }, + onSuccess: () => { + rest.onSuccess?.(); + toast.success(t("message.success_save")); + }, + }; + const { mutate: createLabel } = useCreate(EntityType.LABEL, options); + const { mutate: updateLabel } = useUpdate(EntityType.LABEL, options); + const { + reset, + register, + setValue, + formState: { errors }, + handleSubmit, + } = useForm({ + defaultValues: { + name: data?.name || "", + title: data?.title || "", + description: data?.description || "", + }, + }); + const validationRules = { + title: { + required: t("message.title_is_required"), + }, + name: {}, + description: {}, + }; + const onSubmitForm = (params: ILabelAttributes) => { + if (data) { + updateLabel({ id: data.id, params }); + } else { + createLabel(params); + } + }; + + useEffect(() => { + if (data) { + reset({ + name: data.name, + title: data.title, + description: data.description, + }); + } else { + reset(); + } + }, [data, reset]); + + return ( + +
+ + + { + setValue("title", value); + setValue("name", slugify(value).toUpperCase()); + }, + }} + helperText={errors.title ? errors.title.message : null} + /> + + + + + + + + +
+
+ ); +}; diff --git a/frontend/src/components/labels/LabelFormDialog.tsx b/frontend/src/components/labels/LabelFormDialog.tsx new file mode 100644 index 00000000..16f692b7 --- /dev/null +++ b/frontend/src/components/labels/LabelFormDialog.tsx @@ -0,0 +1,37 @@ +/* + * 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 } from "react"; + +import { FormDialog } from "@/app-components/dialogs"; +import { useTranslate } from "@/hooks/useTranslate"; +import { ComponentFormDialogProps } from "@/types/common/dialogs.types"; +import { ILabel } from "@/types/label.types"; + +import { LabelForm } from "./LabelForm"; + +export const LabelFormDialog: FC> = ({ + payload, + ...rest +}) => { + const { t } = useTranslate(); + + return ( + { + rest.onClose(true); + }} + Wrapper={FormDialog} + WrapperProps={{ + title: payload ? t("title.edit_label") : t("title.new_label"), + ...rest, + }} + /> + ); +}; diff --git a/frontend/src/components/labels/index.tsx b/frontend/src/components/labels/index.tsx index 2f529d96..152f12cb 100644 --- a/frontend/src/components/labels/index.tsx +++ b/frontend/src/components/labels/index.tsx @@ -1,18 +1,18 @@ /* - * 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 { faTags } from "@fortawesome/free-solid-svg-icons"; import AddIcon from "@mui/icons-material/Add"; import { Button, Grid, Paper } from "@mui/material"; import { GridColDef } from "@mui/x-data-grid"; -import React from "react"; -import { DeleteDialog } from "@/app-components/dialogs/DeleteDialog"; +import { ConfirmDialogBody } from "@/app-components/dialogs"; import { FilterTextfield } from "@/app-components/inputs/FilterTextfield"; import { ActionColumnLabel, @@ -22,7 +22,7 @@ import { renderHeader } from "@/app-components/tables/columns/renderHeader"; import { DataGrid } from "@/app-components/tables/DataGrid"; import { useDelete } from "@/hooks/crud/useDelete"; import { useFind } from "@/hooks/crud/useFind"; -import { getDisplayDialogs, useDialog } from "@/hooks/useDialog"; +import { useDialogs } from "@/hooks/useDialogs"; import { useHasPermission } from "@/hooks/useHasPermission"; import { useSearch } from "@/hooks/useSearch"; import { useToast } from "@/hooks/useToast"; @@ -33,14 +33,12 @@ import { ILabel } from "@/types/label.types"; import { PermissionAction } from "@/types/permission.types"; import { getDateTimeFormatter } from "@/utils/date"; -import { LabelDialog } from "./LabelDialog"; +import { LabelFormDialog } from "./LabelFormDialog"; export const Labels = () => { const { t } = useTranslate(); const { toast } = useToast(); - const addDialogCtl = useDialog(false); - const editDialogCtl = useDialog(false); - const deleteDialogCtl = useDialog(false); + const dialogs = useDialogs(); const hasPermission = useHasPermission(); const { onSearch, searchPayload } = useSearch({ $or: ["name", "title"], @@ -51,12 +49,11 @@ export const Labels = () => { params: searchPayload, }, ); - const { mutateAsync: deleteLabel } = useDelete(EntityType.LABEL, { + const { mutate: deleteLabel } = useDelete(EntityType.LABEL, { onError: () => { toast.error(t("message.internal_server_error")); }, onSuccess() { - deleteDialogCtl.closeDialog(); toast.success(t("message.item_delete_success")); }, }); @@ -65,12 +62,18 @@ export const Labels = () => { [ { label: ActionColumnLabel.Edit, - action: (row) => editDialogCtl.openDialog(row), + action: (row) => dialogs.open(LabelFormDialog, row), requires: [PermissionAction.UPDATE], }, { label: ActionColumnLabel.Delete, - action: (row) => deleteDialogCtl.openDialog(row.id), + action: async ({ id }) => { + const isConfirmed = await dialogs.confirm(ConfirmDialogBody); + + if (isConfirmed) { + deleteLabel(id); + } + }, requires: [PermissionAction.DELETE], }, ], @@ -149,14 +152,6 @@ export const Labels = () => { return ( - - - { - if (deleteDialogCtl?.data) deleteLabel(deleteDialogCtl.data); - }} - /> { startIcon={} variant="contained" sx={{ float: "right" }} - onClick={() => addDialogCtl.openDialog()} + onClick={() => dialogs.open(LabelFormDialog, null)} > {t("button.add")} diff --git a/frontend/src/components/translations/EditTranslationDialog.tsx b/frontend/src/components/translations/EditTranslationDialog.tsx deleted file mode 100644 index f3d3fcbe..00000000 --- a/frontend/src/components/translations/EditTranslationDialog.tsx +++ /dev/null @@ -1,107 +0,0 @@ -/* - * Copyright © 2024 Hexastack. All rights reserved. - * - * Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms: - * 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission. - * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). - */ - -import { - Dialog, - DialogActions, - DialogContent, - FormLabel, - Typography, -} from "@mui/material"; -import { FC, useEffect } from "react"; -import { Controller, useForm } from "react-hook-form"; - -import DialogButtons from "@/app-components/buttons/DialogButtons"; -import { DialogTitle } from "@/app-components/dialogs/DialogTitle"; -import { ContentContainer } from "@/app-components/dialogs/layouts/ContentContainer"; -import { ContentItem } from "@/app-components/dialogs/layouts/ContentItem"; -import { useFind } from "@/hooks/crud/useFind"; -import { useUpdate } from "@/hooks/crud/useUpdate"; -import { DialogControlProps } from "@/hooks/useDialog"; -import { useToast } from "@/hooks/useToast"; -import { useTranslate } from "@/hooks/useTranslate"; -import { EntityType } from "@/services/types"; -import { - ITranslation, - ITranslationAttributes, - ITranslations, -} from "@/types/translation.types"; - -import TranslationInput from "./TranslationInput"; - -export type EditTranslationDialogProps = DialogControlProps; -export const EditTranslationDialog: FC = ({ - open, - data, - closeDialog, - ...rest -}) => { - const { data: languages } = useFind( - { entity: EntityType.LANGUAGE }, - { - hasCount: false, - }, - ); - const { t } = useTranslate(); - const { toast } = useToast(); - const { mutateAsync: updateTranslation } = useUpdate(EntityType.TRANSLATION, { - onError: () => { - toast.error(t("message.internal_server_error")); - }, - onSuccess() { - closeDialog(); - toast.success(t("message.success_save")); - }, - }); - const { reset, control, handleSubmit } = useForm({ - defaultValues: data, - }); - const onSubmitForm = async (params: ITranslationAttributes) => { - if (data?.id) updateTranslation({ id: data.id, params }); - }; - - useEffect(() => { - if (open) reset(data); - }, [open, reset, data]); - - return ( - -
- - {t("title.update_translation")} - - - - {t("label.original_text")} - {data?.str} - - - {languages - .filter(({ isDefault }) => !isDefault) - .map((language) => ( - - ( - - )} - /> - - ))} - - - - - -
-
- ); -}; diff --git a/frontend/src/components/translations/TranslationForm.tsx b/frontend/src/components/translations/TranslationForm.tsx new file mode 100644 index 00000000..a796c94b --- /dev/null +++ b/frontend/src/components/translations/TranslationForm.tsx @@ -0,0 +1,115 @@ +/* + * 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 { FormLabel, Grid, Typography } from "@mui/material"; +import { FC, Fragment } from "react"; +import { Controller, ControllerRenderProps, useForm } from "react-hook-form"; + +import { ContentContainer, ContentItem } from "@/app-components/dialogs"; +import { Input } from "@/app-components/inputs/Input"; +import { useFind } from "@/hooks/crud/useFind"; +import { useUpdate } from "@/hooks/crud/useUpdate"; +import { useToast } from "@/hooks/useToast"; +import { useTranslate } from "@/hooks/useTranslate"; +import { EntityType } from "@/services/types"; +import { ComponentFormProps } from "@/types/common/dialogs.types"; +import { ILanguage } from "@/types/language.types"; +import { + ITranslation, + ITranslationAttributes, + ITranslations, +} from "@/types/translation.types"; + +interface TranslationInputProps { + field: ControllerRenderProps; + language: ILanguage; +} + +const TranslationInput: React.FC = ({ + field, + language: { isRTL, title }, +}) => ( + + {title} +
+ } + minRows={3} + inputRef={field.ref} + multiline={true} + {...field} + /> +); + +export const TranslationForm: FC> = ({ + data, + Wrapper = Fragment, + WrapperProps, + ...rest +}) => { + const { t } = useTranslate(); + const { toast } = useToast(); + const { data: languages } = useFind( + { entity: EntityType.LANGUAGE }, + { + hasCount: false, + }, + ); + const { mutate: updateTranslation } = useUpdate(EntityType.TRANSLATION, { + onError: (error: Error) => { + rest.onError?.(); + toast.error(error.message || t("message.internal_server_error")); + }, + onSuccess() { + rest.onSuccess?.(); + toast.success(t("message.success_save")); + }, + }); + const { control, handleSubmit } = useForm({ + defaultValues: { + translations: data?.translations, + }, + }); + const onSubmitForm = (params: ITranslationAttributes) => { + if (data?.id) updateTranslation({ id: data.id, params }); + }; + + return ( + +
+ + {t("label.original_text")} + {data?.str} + + + {languages + .filter(({ isDefault }) => !isDefault) + .map((language) => ( + + ( + + )} + /> + + ))} + +
+
+ ); +}; + +TranslationForm.displayName = TranslationForm.name; diff --git a/frontend/src/components/translations/TranslationFormDialog.tsx b/frontend/src/components/translations/TranslationFormDialog.tsx new file mode 100644 index 00000000..aa1c75ef --- /dev/null +++ b/frontend/src/components/translations/TranslationFormDialog.tsx @@ -0,0 +1,36 @@ +/* + * 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 } from "react"; + +import { FormDialog } from "@/app-components/dialogs"; +import { useTranslate } from "@/hooks/useTranslate"; +import { ComponentFormDialogProps } from "@/types/common/dialogs.types"; +import { ITranslation } from "@/types/translation.types"; + +import { TranslationForm } from "./TranslationForm"; + +export const TranslationFormDialog: FC< + ComponentFormDialogProps +> = ({ payload, ...rest }) => { + const { t } = useTranslate(); + + return ( + { + rest.onClose(true); + }} + Wrapper={FormDialog} + WrapperProps={{ + title: t("title.update_translation"), + ...rest, + }} + /> + ); +}; diff --git a/frontend/src/components/translations/TranslationInput.tsx b/frontend/src/components/translations/TranslationInput.tsx deleted file mode 100644 index 06009845..00000000 --- a/frontend/src/components/translations/TranslationInput.tsx +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright © 2024 Hexastack. All rights reserved. - * - * Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms: - * 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission. - * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). - */ - -import { Grid } from "@mui/material"; -import React from "react"; -import { ControllerRenderProps } from "react-hook-form"; - -import { Input } from "@/app-components/inputs/Input"; -import { ILanguage } from "@/types/language.types"; -import { ITranslationAttributes } from "@/types/translation.types"; - -interface RenderTranslationInputProps { - language: ILanguage; - field: ControllerRenderProps; -} - -const TranslationInput: React.FC = ({ - language, - field, -}) => ( - - {language.title} -
- } - multiline={true} - minRows={3} - {...field} - /> -); - -TranslationInput.displayName = "TranslationInput"; - -export default TranslationInput; diff --git a/frontend/src/components/translations/index.tsx b/frontend/src/components/translations/index.tsx index 08e6cca4..d190e8b3 100644 --- a/frontend/src/components/translations/index.tsx +++ b/frontend/src/components/translations/index.tsx @@ -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. @@ -11,7 +11,7 @@ import AutorenewIcon from "@mui/icons-material/Autorenew"; import { Button, Chip, Grid, Paper, Stack } from "@mui/material"; import { GridColDef } from "@mui/x-data-grid"; -import { DeleteDialog } from "@/app-components/dialogs"; +import { ConfirmDialogBody } from "@/app-components/dialogs"; import { FilterTextfield } from "@/app-components/inputs/FilterTextfield"; import { ActionColumnLabel, @@ -21,7 +21,7 @@ import { DataGrid } from "@/app-components/tables/DataGrid"; import { useDelete } from "@/hooks/crud/useDelete"; import { useFind } from "@/hooks/crud/useFind"; import { useRefreshTranslations } from "@/hooks/entities/translation-hooks"; -import { getDisplayDialogs, useDialog } from "@/hooks/useDialog"; +import { useDialogs } from "@/hooks/useDialogs"; import { useSearch } from "@/hooks/useSearch"; import { useToast } from "@/hooks/useToast"; import { useTranslate } from "@/hooks/useTranslate"; @@ -32,19 +32,18 @@ import { PermissionAction } from "@/types/permission.types"; import { ITranslation } from "@/types/translation.types"; import { getDateTimeFormatter } from "@/utils/date"; -import { EditTranslationDialog } from "./EditTranslationDialog"; +import { TranslationFormDialog } from "./TranslationFormDialog"; export const Translations = () => { const { t } = useTranslate(); const { toast } = useToast(); + const dialogs = useDialogs(); const { data: languages } = useFind( { entity: EntityType.LANGUAGE }, { hasCount: false, }, ); - const editDialogCtl = useDialog(false); - const deleteDialogCtl = useDialog(false); const { onSearch, searchPayload } = useSearch({ $iLike: ["str"], }); @@ -54,16 +53,15 @@ export const Translations = () => { params: searchPayload, }, ); - const { mutateAsync: deleteTranslation } = useDelete(EntityType.TRANSLATION, { + const { mutate: deleteTranslation } = useDelete(EntityType.TRANSLATION, { onError: (error) => { toast.error(error); }, onSuccess() { - deleteDialogCtl.closeDialog(); toast.success(t("message.item_delete_success")); }, }); - const { mutateAsync: checkRefreshTranslations, isLoading } = + const { mutate: checkRefreshTranslations, isLoading } = useRefreshTranslations({ onError: () => { toast.error(t("message.internal_server_error")); @@ -78,12 +76,18 @@ export const Translations = () => { [ { label: ActionColumnLabel.Edit, - action: (row) => editDialogCtl.openDialog(row), + action: (row) => dialogs.open(TranslationFormDialog, row), requires: [PermissionAction.UPDATE], }, { label: ActionColumnLabel.Delete, - action: (row) => deleteDialogCtl.openDialog(row.id), + action: async ({ id }) => { + const isConfirmed = await dialogs.confirm(ConfirmDialogBody); + + if (isConfirmed) { + deleteTranslation(id); + } + }, requires: [PermissionAction.DELETE], }, ], @@ -164,14 +168,6 @@ export const Translations = () => { - - { - if (deleteDialogCtl?.data) - deleteTranslation(deleteDialogCtl.data); - }} - /> diff --git a/frontend/src/components/visual-editor/v2/Diagrams.tsx b/frontend/src/components/visual-editor/v2/Diagrams.tsx index 4fa0a934..7e6237b0 100644 --- a/frontend/src/components/visual-editor/v2/Diagrams.tsx +++ b/frontend/src/components/visual-editor/v2/Diagrams.tsx @@ -41,7 +41,7 @@ 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 { CategoryFormDialog } from "@/components/categories/CategoryFormDialog"; import { isSameEntity } from "@/hooks/crud/helpers"; import { useDeleteFromCache } from "@/hooks/crud/useDelete"; import { useDeleteMany } from "@/hooks/crud/useDeleteMany"; @@ -51,11 +51,11 @@ 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 { useDialogs } from "@/hooks/useDialogs"; import { useSearch } from "@/hooks/useSearch"; import { useTranslate } from "@/hooks/useTranslate"; import { EntityType, Format, QueryType, RouterType } from "@/services/types"; import { IBlock } from "@/types/block.types"; -import { ICategory } from "@/types/category.types"; import { BlockPorts } from "@/types/visual-editor.types"; import BlockDialog from "../BlockDialog"; @@ -74,9 +74,9 @@ const Diagrams = () => { const [engine, setEngine] = useState(); const [canvas, setCanvas] = useState(); const [selectedBlockId, setSelectedBlockId] = useState(); + const dialogs = useDialogs(); const deleteDialogCtl = useDialog(false); const moveDialogCtl = useDialog(false); - const addCategoryDialogCtl = useDialog(false); const { mutateAsync: updateBlocks } = useUpdateMany(EntityType.BLOCK); const { buildDiagram, @@ -528,7 +528,6 @@ const Diagrams = () => { }} > - {...deleteDialogCtl} callback={onDelete} /> { width: "42px", minWidth: "42px", }} - onClick={(e) => { - addCategoryDialogCtl.openDialog(); + onClick={async (e) => { + await dialogs.open(CategoryFormDialog, null); e.preventDefault(); }} > diff --git a/frontend/src/contexts/dialogs.context.tsx b/frontend/src/contexts/dialogs.context.tsx new file mode 100644 index 00000000..7daa9027 --- /dev/null +++ b/frontend/src/contexts/dialogs.context.tsx @@ -0,0 +1,147 @@ +/* + * 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 { + createContext, + useCallback, + useId, + useMemo, + useRef, + useState, +} from "react"; + +import { + CloseDialog, + DialogComponent, + DialogProviderProps, + DialogStackEntry, + OpenDialog, + OpenDialogOptions, +} from "@/types/common/dialogs.types"; + +export const DialogsContext = createContext< + | { + open: OpenDialog; + close: CloseDialog; + } + | undefined +>(undefined); + +/** + * Provider for Dialog stacks. The subtree of this component can use the `useDialogs` hook to + * access the dialogs API. The dialogs are rendered in the order they are requested. + * + * Demos: + * + * - [useDialogs](https://mui.com/toolpad/core/react-use-dialogs/) + * + * API: + * + * - [DialogsProvider API](https://mui.com/toolpad/core/api/dialogs-provider) + */ +function DialogsProvider(props: DialogProviderProps) { + const { children, unmountAfter = 1000 } = props; + const [stack, setStack] = useState[]>([]); + const keyPrefix = useId(); + const nextId = useRef(0); + const requestDialog = useCallback( + function open( + Component: DialogComponent, + payload: P, + options: OpenDialogOptions = {}, + ) { + const { onClose = async () => {} } = options; + let resolve: ((result: R) => void) | undefined; + const promise = new Promise((resolveImpl) => { + resolve = resolveImpl; + }); + + if (!resolve) { + throw new Error("resolve not set"); + } + + const key = `${keyPrefix}-${nextId.current}`; + + nextId.current += 1; + + const newEntry: DialogStackEntry = { + key, + open: true, + promise, + Component, + payload, + onClose, + resolve, + msgProps: { count: options.count, mode: options.mode }, + }; + + setStack((prevStack) => [...prevStack, newEntry]); + + return promise; + }, + [keyPrefix], + ); + const closeDialogUi = useCallback( + function closeDialogUi(dialog: Promise) { + setStack((prevStack) => + prevStack.map((entry) => + entry.promise === dialog ? { ...entry, open: false } : entry, + ), + ); + setTimeout(() => { + // wait for closing animation + setStack((prevStack) => + prevStack.filter((entry) => entry.promise !== dialog), + ); + }, unmountAfter); + }, + [unmountAfter], + ); + const closeDialog = useCallback( + async function closeDialog(dialog: Promise, result: R) { + const entryToClose = stack.find((entry) => entry.promise === dialog); + + if (!entryToClose) { + throw new Error("dialog not found"); + } + + await entryToClose.onClose(result); + entryToClose.resolve(result); + closeDialogUi(dialog); + + return dialog; + }, + [stack, closeDialogUi], + ); + const contextValue = useMemo( + () => ({ + open: requestDialog, + close: closeDialog, + }), + [requestDialog, closeDialog], + ); + + return ( + + {children} + {stack.map(({ key, open, Component, payload, promise, msgProps }) => ( + { + await closeDialog(promise, result); + }} + {...msgProps} + /> + ))} + + ); +} + +export { DialogsProvider }; diff --git a/frontend/src/hooks/useDialogs.ts b/frontend/src/hooks/useDialogs.ts new file mode 100644 index 00000000..4b6ef29c --- /dev/null +++ b/frontend/src/hooks/useDialogs.ts @@ -0,0 +1,58 @@ +/* + * 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 React, { useContext } from "react"; + +import { ConfirmDialog } from "@/app-components/dialogs"; +import { DialogsContext } from "@/contexts/dialogs.context"; +import { + CloseDialog, + OpenConfirmDialog, + OpenDialog, +} from "@/types/common/dialogs.types"; + +export interface DialogHook { + open: OpenDialog; + close: CloseDialog; + confirm: OpenConfirmDialog; +} + +export const useDialogs = (): DialogHook => { + const context = useContext(DialogsContext); + + if (!context) { + throw new Error("useDialogs must be used within a DialogsProvider"); + } + + const { open, close } = context; + const confirm = React.useCallback( + async (msg, { onClose, ...options } = {}) => { + const { count, mode, ...rest } = options; + + return open( + ConfirmDialog, + { + ...rest, + msg: React.createElement(msg), + }, + { + mode, + count, + onClose, + }, + ); + }, + [open], + ); + + return { + open, + close, + confirm, + }; +}; diff --git a/frontend/src/pages/_app.tsx b/frontend/src/pages/_app.tsx index 583f91f1..fcff228c 100644 --- a/frontend/src/pages/_app.tsx +++ b/frontend/src/pages/_app.tsx @@ -21,6 +21,7 @@ import { ApiClientProvider } from "@/contexts/apiClient.context"; import { AuthProvider } from "@/contexts/auth.context"; import BroadcastChannelProvider from "@/contexts/broadcast-channel.context"; import { ConfigProvider } from "@/contexts/config.context"; +import { DialogsProvider } from "@/contexts/dialogs.context"; import { PermissionProvider } from "@/contexts/permission.context"; import { SettingsProvider } from "@/contexts/setting.context"; import { ToastProvider } from "@/hooks/useToast"; @@ -73,33 +74,37 @@ const App = ({ Component, pageProps }: TAppPropsWithLayout) => {
- ( - - )} - > - - - - - - - - - - {getLayout()} - - - - - - - - - - + + ( + + )} + > + + + + + + + + + + + {getLayout()} + + + + + + + + + + + +
diff --git a/frontend/src/types/common/dialogs.types.ts b/frontend/src/types/common/dialogs.types.ts new file mode 100644 index 00000000..9c884f15 --- /dev/null +++ b/frontend/src/types/common/dialogs.types.ts @@ -0,0 +1,165 @@ +/* + * 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 { DialogProps as MuiDialogProps } from "@mui/material"; +import { BaseSyntheticEvent } from "react"; + +interface ConfirmDialogExtraOptions { + mode?: "click" | "selection"; + count?: number; +} +// context +export interface OpenDialogOptions extends ConfirmDialogExtraOptions { + /** + * A function that is called before closing the dialog closes. The dialog + * stays open as long as the returned promise is not resolved. Use this if + * you want to perform an async action on close and show a loading state. + * + * @param result The result that the dialog will return after closing. + * @returns A promise that resolves when the dialog can be closed. + */ + onClose?: (result: R) => Promise; +} + +/** + * The props that are passed to a dialog component. + */ +export interface DialogProps

{ + /** + * The payload that was passed when the dialog was opened. + */ + payload: P; + /** + * Whether the dialog is open. + */ + open: boolean; + /** + * A function to call when the dialog should be closed. If the dialog has a return + * value, it should be passed as an argument to this function. You should use the promise + * that is returned to show a loading state while the dialog is performing async actions + * on close. + * @param result The result to return from the dialog. + * @returns A promise that resolves when the dialog can be fully closed. + */ + onClose: (result: R) => Promise; +} + +export type DialogComponent = React.ComponentType>; + +export interface OpenDialog { + /** + * Open a dialog without payload. + * @param Component The dialog component to open. + * @param options Additional options for the dialog. + */ +

( + Component: DialogComponent, + payload?: P, + options?: OpenDialogOptions, + ): Promise; + /** + * Open a dialog and pass a payload. + * @param Component The dialog component to open. + * @param payload The payload to pass to the dialog. + * @param options Additional options for the dialog. + */ + ( + Component: DialogComponent, + payload: P, + options?: OpenDialogOptions, + ): Promise; +} + +export interface CloseDialog { + /** + * Close a dialog and return a result. + * @param dialog The dialog to close. The promise returned by `open`. + * @param result The result to return from the dialog. + * @returns A promise that resolves when the dialog is fully closed. + */ + (dialog: Promise, result: R): Promise; +} + +export interface ConfirmOptions extends OpenDialogOptions { + /** + * A title for the dialog. Defaults to `'Confirm'`. + */ + title?: React.ReactNode; + /** + * The text to show in the "Ok" button. Defaults to `'Ok'`. + */ + okText?: React.ReactNode; + /** + * Denotes the purpose of the dialog. This will affect the color of the + * "Ok" button. Defaults to `undefined`. + */ + severity?: "error" | "info" | "success" | "warning"; + /** + * The text to show in the "Cancel" button. Defaults to `'Cancel'`. + */ + cancelText?: React.ReactNode; +} + +export interface OpenConfirmDialog { + /** + * Open a confirmation dialog. Returns a promise that resolves to true if + * the user confirms, false if the user cancels. + * + * @param msg The message to show in the dialog. + * @param options Additional options for the dialog. + * @returns A promise that resolves to true if the user confirms, false if the user cancels. + */ + (msg: React.ComponentType, options?: ConfirmOptions): Promise; +} + +export interface DialogHook { + // alert: OpenAlertDialog; + confirm: OpenConfirmDialog; + // prompt: OpenPromptDialog; + open: OpenDialog; + close: CloseDialog; +} + +export interface DialogStackEntry { + key: string; + open: boolean; + promise: Promise; + Component: DialogComponent; + payload: P; + onClose: (result: R) => Promise; + resolve: (result: R) => void; + msgProps: ConfirmDialogExtraOptions; +} + +export interface DialogProviderProps { + children?: React.ReactNode; + unmountAfter?: number; +} + +// form dialog +export interface FormDialogProps extends MuiDialogProps { + title?: string; + children?: React.ReactNode; + onSubmit: (e: BaseSyntheticEvent) => void; +} + +// form +export type ComponentFormProps = { + data: T | null; + onError?: () => void; + onSuccess?: () => void; + Wrapper?: React.FC; + WrapperProps?: Partial; +}; + +export interface FormButtonsProps { + onCancel?: () => void; + onSubmit: (e: BaseSyntheticEvent) => void; +} + +export type ComponentFormDialogProps = DialogProps; diff --git a/widget/src/ChatWidget.tsx b/widget/src/ChatWidget.tsx index 9c7966c8..ac8853f8 100644 --- a/widget/src/ChatWidget.tsx +++ b/widget/src/ChatWidget.tsx @@ -1,15 +1,17 @@ /* - * 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 "normalize.css"; import "./ChatWidget.css"; import Launcher from "./components/Launcher"; import UserSubscription from "./components/UserSubscription"; +import BroadcastChannelProvider from "./providers/BroadcastChannelProvider"; import ChatProvider from "./providers/ChatProvider"; import { ColorProvider } from "./providers/ColorProvider"; import { ConfigProvider } from "./providers/ConfigProvider"; @@ -29,9 +31,11 @@ function ChatWidget(props: Partial) { - - - + + + + + diff --git a/widget/src/UiChatWidget.tsx b/widget/src/UiChatWidget.tsx index 9dab8432..32290344 100644 --- a/widget/src/UiChatWidget.tsx +++ b/widget/src/UiChatWidget.tsx @@ -1,11 +1,12 @@ /* - * 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 { PropsWithChildren } from "react"; import Launcher from "./components/Launcher";