diff --git a/i18n/en.pot b/i18n/en.pot index 25bc4b0ae..29afc0990 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -1000,6 +1000,45 @@ msgstr "" msgid "Scheduler" msgstr "" +msgid "Sending" +msgstr "" + +msgid "Share error information" +msgstr "" + +msgid "Discard" +msgstr "" + +msgid "Send" +msgstr "" + +msgid "To" +msgstr "" + +msgid "Message" +msgstr "" + +msgid "I have read and accept the " +msgstr "" + +msgid "EyeSeeTea S.L. Privacy Policy" +msgstr "" + +msgid " paying special attention to the section about " +msgstr "" + +msgid "share sesitive data" +msgstr "" + +msgid "Email" +msgstr "" + +msgid "Email sent successfully" +msgstr "" + +msgid "Message sent successfully" +msgstr "" + msgid "The token is empty" msgstr "" @@ -1110,9 +1149,6 @@ msgstr "" msgid "Property" msgstr "" -msgid "Message" -msgstr "" - msgid "Program Indicators / Program Data Elements" msgstr "" diff --git a/i18n/es.po b/i18n/es.po index ef352a08f..ac9167133 100644 --- a/i18n/es.po +++ b/i18n/es.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: i18next-conv\n" -"POT-Creation-Date: 2025-06-02T07:06:35.778Z\n" +"POT-Creation-Date: 2025-06-20T05:31:37.220Z\n" "PO-Revision-Date: 2020-07-10T06:53:30.625Z\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -1001,6 +1001,45 @@ msgstr "" msgid "Scheduler" msgstr "" +msgid "Sending" +msgstr "" + +msgid "Share error information" +msgstr "" + +msgid "Discard" +msgstr "" + +msgid "Send" +msgstr "" + +msgid "To" +msgstr "" + +msgid "Message" +msgstr "" + +msgid "I have read and accept the " +msgstr "" + +msgid "EyeSeeTea S.L. Privacy Policy" +msgstr "" + +msgid " paying special attention to the section about " +msgstr "" + +msgid "share sesitive data" +msgstr "" + +msgid "Email" +msgstr "" + +msgid "Email sent successfully" +msgstr "" + +msgid "Message sent successfully" +msgstr "" + msgid "The token is empty" msgstr "" @@ -1113,9 +1152,6 @@ msgstr "" msgid "Property" msgstr "" -msgid "Message" -msgstr "" - msgid "Program Indicators / Program Data Elements" msgstr "" diff --git a/i18n/fr.po b/i18n/fr.po index 106b25883..716c8f5f1 100644 --- a/i18n/fr.po +++ b/i18n/fr.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: i18next-conv\n" -"POT-Creation-Date: 2025-06-02T07:06:35.778Z\n" +"POT-Creation-Date: 2025-06-20T05:31:37.220Z\n" "PO-Revision-Date: 2020-07-10T06:53:30.625Z\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -1001,6 +1001,45 @@ msgstr "" msgid "Scheduler" msgstr "" +msgid "Sending" +msgstr "" + +msgid "Share error information" +msgstr "" + +msgid "Discard" +msgstr "" + +msgid "Send" +msgstr "" + +msgid "To" +msgstr "" + +msgid "Message" +msgstr "" + +msgid "I have read and accept the " +msgstr "" + +msgid "EyeSeeTea S.L. Privacy Policy" +msgstr "" + +msgid " paying special attention to the section about " +msgstr "" + +msgid "share sesitive data" +msgstr "" + +msgid "Email" +msgstr "" + +msgid "Email sent successfully" +msgstr "" + +msgid "Message sent successfully" +msgstr "" + msgid "The token is empty" msgstr "" @@ -1113,9 +1152,6 @@ msgstr "" msgid "Property" msgstr "" -msgid "Message" -msgstr "" - msgid "Program Indicators / Program Data Elements" msgstr "" diff --git a/i18n/pt.po b/i18n/pt.po index 106b25883..716c8f5f1 100644 --- a/i18n/pt.po +++ b/i18n/pt.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: i18next-conv\n" -"POT-Creation-Date: 2025-06-02T07:06:35.778Z\n" +"POT-Creation-Date: 2025-06-20T05:31:37.220Z\n" "PO-Revision-Date: 2020-07-10T06:53:30.625Z\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -1001,6 +1001,45 @@ msgstr "" msgid "Scheduler" msgstr "" +msgid "Sending" +msgstr "" + +msgid "Share error information" +msgstr "" + +msgid "Discard" +msgstr "" + +msgid "Send" +msgstr "" + +msgid "To" +msgstr "" + +msgid "Message" +msgstr "" + +msgid "I have read and accept the " +msgstr "" + +msgid "EyeSeeTea S.L. Privacy Policy" +msgstr "" + +msgid " paying special attention to the section about " +msgstr "" + +msgid "share sesitive data" +msgstr "" + +msgid "Email" +msgstr "" + +msgid "Email sent successfully" +msgstr "" + +msgid "Message sent successfully" +msgstr "" + msgid "The token is empty" msgstr "" @@ -1113,9 +1152,6 @@ msgstr "" msgid "Property" msgstr "" -msgid "Message" -msgstr "" - msgid "Program Indicators / Program Data Elements" msgstr "" diff --git a/src/data/common/utils/api-futures.ts b/src/data/common/utils/api-futures.ts index 81b7c917d..1cf7777d3 100644 --- a/src/data/common/utils/api-futures.ts +++ b/src/data/common/utils/api-futures.ts @@ -1,6 +1,14 @@ import { Future, FutureData } from "../../../domain/common/entities/Future"; import { CancelableResponse } from "../../../types/d2-api"; +type SpecificError = { + response: { + data: { + message: string; + }; + }; +}; + /** * @description This file is refactored */ @@ -9,13 +17,30 @@ export function apiToFuture(res: CancelableResponse): FutureData { - if (err instanceof Error) { + if (isSpecificError(err)) { + reject(new Error(err.response.data.message)); + } else if (err instanceof Error) { reject(err); } else { console.error("apiToFuture:uncatched", err); + reject(new Error("Unknown error")); } }); return res.cancel; }); } + +const isSpecificError = (err: unknown): err is SpecificError => { + if (!err || typeof err !== "object") return false; + + const specificError = err as { response?: { data?: { message?: unknown } } }; + + return ( + typeof specificError.response === "object" && + specificError.response !== null && + typeof specificError.response.data === "object" && + specificError.response.data !== null && + typeof specificError.response.data.message === "string" + ); +}; diff --git a/src/data/comunications/AttachedFileD2ApiRepository.ts b/src/data/comunications/AttachedFileD2ApiRepository.ts new file mode 100644 index 000000000..21c0604fa --- /dev/null +++ b/src/data/comunications/AttachedFileD2ApiRepository.ts @@ -0,0 +1,33 @@ +import { Future, FutureData } from "../../domain/common/entities/Future"; +import { AttachedFile, AttachedFileInput, ObjectSharing } from "../../domain/comunications/entities/AttachedFile"; +import { AttachedFileRepository } from "../../domain/comunications/repositories/AttachedFileRepository"; + +import { D2Api } from "../../types/d2-api"; +import { apiToFuture } from "../common/utils/api-futures"; + +export class AttachedFileD2ApiRepository implements AttachedFileRepository { + constructor(private api: D2Api) {} + + create(file: AttachedFileInput): FutureData { + return apiToFuture(this.api.files.upload(file)) + .flatMap(({ id }) => { + return Future.joinObj({ + id: Future.success(id), + sharing: this.postSharing(id, file.sharing), + }); + }) + .map(({ id }) => { + const isDev = process.env.NODE_ENV === "development"; + const baseUrl = isDev ? process.env.REACT_APP_DHIS2_BASE_URL : this.api.baseUrl; + return { id, name: file.name, url: `${baseUrl}/api/documents/${id}/data`, sharing: file.sharing }; + }); + } + + updateSharing(file: AttachedFile): FutureData { + return this.postSharing(file.id, file.sharing); + } + + private postSharing(id: string, sharing: ObjectSharing) { + return apiToFuture(this.api.sharing.post({ id, type: "document" }, sharing)).map(() => undefined); + } +} diff --git a/src/data/comunications/EmailD2ApiRepository.ts b/src/data/comunications/EmailD2ApiRepository.ts new file mode 100644 index 000000000..19d59eb9b --- /dev/null +++ b/src/data/comunications/EmailD2ApiRepository.ts @@ -0,0 +1,22 @@ +import { Email } from "../../domain/comunications/entities/Email"; +import { EmailRepository } from "../../domain/comunications/repositories/EmailRepository"; +import { D2Api } from "../../types/d2-api"; +import { FutureData } from "../../domain/common/entities/Future"; +import { OutboundMessage } from "@eyeseetea/d2-api/api/email"; +import { apiToFuture } from "../common/utils/api-futures"; + +export class EmailD2ApiRepository implements EmailRepository { + constructor(private readonly d2Api: D2Api) {} + + send(message: Email): FutureData { + return apiToFuture(this.d2Api.email.sendMessage(this.buildOutboundMessage(message))); + } + + buildOutboundMessage(message: Email): OutboundMessage { + return { + subject: message.subject, + text: message.text, + recipients: message.recipients.map(recipient => recipient.value), + }; + } +} diff --git a/src/data/comunications/MessageD2ApiRepository.ts b/src/data/comunications/MessageD2ApiRepository.ts new file mode 100644 index 000000000..85b8afc07 --- /dev/null +++ b/src/data/comunications/MessageD2ApiRepository.ts @@ -0,0 +1,74 @@ +import { D2Api, MetadataPick } from "../../types/d2-api"; +import { Future, FutureData } from "../../domain/common/entities/Future"; +import { MessageRepository } from "../../domain/comunications/repositories/MessageRepository"; +import { Message } from "../../domain/comunications/entities/Message"; +import { PostMessage } from "@eyeseetea/d2-api/api/messageConversations"; +import { MessageRecipient } from "../../domain/comunications/entities/MessageRecipient"; +import { apiToFuture } from "../common/utils/api-futures"; + +export class MessageD2ApiRepository implements MessageRepository { + constructor(private readonly d2Api: D2Api) {} + + searchRecipients(text: string): FutureData { + return Future.joinObj({ + users: apiToFuture( + this.d2Api.models.users.get({ + fields: fields, + paging: false, + filter: { displayName: { like: text } }, + }) + ).map(response => response.objects.map(this.buildUserRecipient)), + userGroups: apiToFuture( + this.d2Api.models.userGroups.get({ + fields: fields, + paging: false, + filter: { displayName: { like: text } }, + }) + ).map(response => response.objects.map(this.buildUserGroupRecipient)), + }).flatMap(responses => Future.success([...responses.users, ...responses.userGroups])); + } + + send(message: Message): FutureData { + return apiToFuture(this.d2Api.messageConversations.post(this.buildPostMessage(message))); + } + + buildPostMessage(message: Message): PostMessage { + return { + subject: message.subject, + text: message.text, + users: message.recipients + .filter(recipient => recipient.type === "User") + .map(recipient => ({ id: recipient.id })), + userGroups: message.recipients + .filter(recipient => recipient.type === "UserGroup") + .map(recipient => ({ id: recipient.id })), + organisationUnits: [], + }; + } + + buildUserRecipient(user: D2User): MessageRecipient { + return MessageRecipient.create({ + type: "User", + id: user.id, + name: user.displayName, + }).getOrThrow(); + } + + buildUserGroupRecipient(userGroup: D2UserGroup): MessageRecipient { + return MessageRecipient.create({ + type: "UserGroup", + id: userGroup.id, + name: userGroup.displayName, + }).getOrThrow(); + } +} + +const fields = { id: true, displayName: true } as const; + +type D2UserGroup = MetadataPick<{ + userGroups: { fields: typeof fields }; +}>["userGroups"][number]; + +type D2User = MetadataPick<{ + userGroups: { fields: typeof fields }; +}>["userGroups"][number]; diff --git a/src/domain/common/entities/Validations.ts b/src/domain/common/entities/Validations.ts index 2cec6c604..c37537a90 100644 --- a/src/domain/common/entities/Validations.ts +++ b/src/domain/common/entities/Validations.ts @@ -62,3 +62,7 @@ export function validateModel(item: T, validations: ModelValidation[]): Valid return acc; }, [] as ValidationError[]); } + +export function isBlank(value: any): boolean { + return !value || (value.length !== undefined && value.length === 0); +} diff --git a/src/domain/common/entities/ValueObject.ts b/src/domain/common/entities/ValueObject.ts new file mode 100644 index 000000000..51adccd96 --- /dev/null +++ b/src/domain/common/entities/ValueObject.ts @@ -0,0 +1,15 @@ +export abstract class ValueObject { + constructor(protected props: T) {} + + equals(vo?: ValueObject): boolean { + if (vo === null || vo === undefined) { + return false; + } + + if (vo.props === undefined) { + return false; + } + + return JSON.stringify(this.props) === JSON.stringify(vo.props); + } +} diff --git a/src/domain/comunications/entities/AttachedFile.ts b/src/domain/comunications/entities/AttachedFile.ts new file mode 100644 index 000000000..9100d045d --- /dev/null +++ b/src/domain/comunications/entities/AttachedFile.ts @@ -0,0 +1,22 @@ +import { PartialBy } from "../../../types/utils"; +import { SharingSetting } from "../../common/entities/SharingSetting"; + +export type AttachedFile = { + id: string; + name: string; + url: string; + sharing: ObjectSharing; +}; + +export interface AttachedFileInput { + name: string; + data: Blob; + sharing: ObjectSharing; +} + +export type ObjectSharing = { + publicAccess?: string; + externalAccess?: boolean; + userAccesses?: PartialBy[]; + userGroupAccesses?: PartialBy[]; +}; diff --git a/src/domain/comunications/entities/Email.ts b/src/domain/comunications/entities/Email.ts new file mode 100644 index 000000000..f3a7a52b7 --- /dev/null +++ b/src/domain/comunications/entities/Email.ts @@ -0,0 +1,56 @@ +import _ from "lodash"; +import { Either } from "../../common/entities/Either"; +import { ValidationError } from "../../common/entities/Validations"; +import { ValueObject } from "../../common/entities/ValueObject"; +import { EmailAddress } from "./EmailAddress"; + +export type EmailData = { + recipients: string[]; + subject: string; + text: string; +}; + +export type EmailProps = Pick & { + recipients: EmailAddress[]; +}; + +export class Email extends ValueObject { + public readonly recipients: EmailAddress[]; + public readonly subject: string; + public readonly text: string; + + private constructor(props: EmailProps) { + super(props); + + this.recipients = props.recipients; + this.subject = props.subject; + this.text = props.text; + } + + public static create(data: EmailData): Either { + const recipientsError = data.recipients.length === 0; + + if (recipientsError) + return Either.error([ + { + property: "recipients", + error: "invalid_recipients", + description: "Email must have at least one recipient", + }, + ]); + + const emailResponses = data.recipients.map(email => EmailAddress.create(email)); + + const emailErrors = _.compact( + emailResponses.filter(response => response.isError).map(response => response.value.error) + ); + + if (emailErrors.length > 0) { + return Either.error(emailErrors); + } else { + const emails = _.compact(emailResponses.map(response => response.value.data)); + + return Either.success(new Email({ ...data, recipients: emails })); + } + } +} diff --git a/src/domain/comunications/entities/EmailAddress.ts b/src/domain/comunications/entities/EmailAddress.ts new file mode 100644 index 000000000..50f913322 --- /dev/null +++ b/src/domain/comunications/entities/EmailAddress.ts @@ -0,0 +1,41 @@ +import { Either } from "../../common/entities/Either"; +import { ValidationError } from "../../common/entities/Validations"; +import { ValueObject } from "../../common/entities/ValueObject"; + +export interface EmailAddressProps { + value: string; +} + +const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +export class EmailAddress extends ValueObject { + public readonly value: string; + + private constructor(props: EmailAddressProps) { + super(props); + + this.value = props.value; + } + + public static create(email: string): Either { + const error = this.validate(email); + + if (error) { + return Either.error(error); + } else { + return Either.success(new EmailAddress({ value: this.format(email) })); + } + } + + private static validate(email: string): ValidationError | undefined { + const isValid = EMAIL_PATTERN.test(email); + + return isValid + ? undefined + : { property: "value", error: "invalid_email", description: "Email must be a valid email address" }; + } + + private static format(email: string): string { + return email.trim().toLowerCase(); + } +} diff --git a/src/domain/comunications/entities/Message.ts b/src/domain/comunications/entities/Message.ts new file mode 100644 index 000000000..bbe555d19 --- /dev/null +++ b/src/domain/comunications/entities/Message.ts @@ -0,0 +1,56 @@ +import _ from "lodash"; +import { Either } from "../../common/entities/Either"; +import { ValidationError } from "../../common/entities/Validations"; +import { ValueObject } from "../../common/entities/ValueObject"; +import { MessageRecipient, MessageRecipientProps } from "./MessageRecipient"; + +export type MessageData = { + recipients: MessageRecipientProps[]; + subject: string; + text: string; +}; + +export type MessageProps = Pick & { + recipients: MessageRecipient[]; +}; + +export class Message extends ValueObject { + public readonly recipients: MessageRecipient[]; + public readonly subject: string; + public readonly text: string; + + private constructor(props: MessageProps) { + super(props); + + this.recipients = props.recipients; + this.subject = props.subject; + this.text = props.text; + } + + public static create(data: MessageData): Either { + const recipientsError = data.recipients.length === 0; + + if (recipientsError) + return Either.error([ + { + property: "recipients", + error: "invalid_recipients", + description: "Message must have at least one recipient", + }, + ]); + + const recipientResponses = data.recipients.map(recipient => MessageRecipient.create(recipient)); + + const recipientErrors = _.compact( + recipientResponses.filter(response => response.isError).map(response => response.value.error) + ).flat(); + + if (recipientErrors.length > 0) { + return Either.error(recipientErrors); + } else { + const recipients = _.compact(recipientResponses.map(response => response.value.data)); + + return Either.success(new Message({ ...data, recipients: recipients })); + } + } +} diff --git a/src/domain/comunications/entities/MessageRecipient.ts b/src/domain/comunications/entities/MessageRecipient.ts new file mode 100644 index 000000000..98ae87812 --- /dev/null +++ b/src/domain/comunications/entities/MessageRecipient.ts @@ -0,0 +1,48 @@ +import _ from "lodash"; +import { Either } from "../../common/entities/Either"; +import { isBlank, ValidationError } from "../../common/entities/Validations"; +import { ValueObject } from "../../common/entities/ValueObject"; + +export interface MessageRecipientProps { + id: string; + name: string; + type: "User" | "UserGroup"; +} + +export class MessageRecipient extends ValueObject { + public readonly id: string; + public readonly name: string; + public readonly type: "User" | "UserGroup"; + + private constructor(props: MessageRecipientProps) { + super(props); + + this.id = props.id; + this.name = props.name; + this.type = props.type; + } + + public static create(data: MessageRecipientProps): Either { + const error = this.validate(data); + + if (error) { + return Either.error(error); + } else { + return Either.success(new MessageRecipient(data)); + } + } + + private static validate(data: MessageRecipientProps): ValidationError[] | undefined { + const isMissingId = isBlank(data.id); + const isMissingName = isBlank(data.name); + const isMissingType = isBlank(data.type); + + const errors = _.compact([ + isMissingId ? { property: "id", error: "missing_id", description: "Id is required" } : undefined, + isMissingName ? { property: "name", error: "missing_name", description: "Name is required" } : undefined, + isMissingType ? { property: "type", error: "missing_type", description: "Type is required" } : undefined, + ]); + + return errors.length > 0 ? errors : undefined; + } +} diff --git a/src/domain/comunications/repositories/AttachedFileRepository.ts b/src/domain/comunications/repositories/AttachedFileRepository.ts new file mode 100644 index 000000000..cc7388748 --- /dev/null +++ b/src/domain/comunications/repositories/AttachedFileRepository.ts @@ -0,0 +1,7 @@ +import { FutureData } from "../../common/entities/Future"; +import { AttachedFile, AttachedFileInput } from "../entities/AttachedFile"; + +export interface AttachedFileRepository { + create(file: AttachedFileInput): FutureData; + updateSharing(file: AttachedFile): FutureData; +} diff --git a/src/domain/comunications/repositories/EmailRepository.ts b/src/domain/comunications/repositories/EmailRepository.ts new file mode 100644 index 000000000..8f5be9bac --- /dev/null +++ b/src/domain/comunications/repositories/EmailRepository.ts @@ -0,0 +1,6 @@ +import { FutureData } from "../../common/entities/Future"; +import { Email } from "../entities/Email"; + +export interface EmailRepository { + send(message: Email): FutureData; +} diff --git a/src/domain/comunications/repositories/MessageRepository.ts b/src/domain/comunications/repositories/MessageRepository.ts new file mode 100644 index 000000000..cc5ce3ce2 --- /dev/null +++ b/src/domain/comunications/repositories/MessageRepository.ts @@ -0,0 +1,8 @@ +import { FutureData } from "../../common/entities/Future"; +import { Message } from "../entities/Message"; +import { MessageRecipient } from "../entities/MessageRecipient"; + +export interface MessageRepository { + searchRecipients(text: string): FutureData; + send(message: Message): FutureData; +} diff --git a/src/domain/comunications/usecases/AttachFileUseCase.ts b/src/domain/comunications/usecases/AttachFileUseCase.ts new file mode 100644 index 000000000..de58e4aca --- /dev/null +++ b/src/domain/comunications/usecases/AttachFileUseCase.ts @@ -0,0 +1,11 @@ +import { FutureData } from "../../common/entities/Future"; +import { AttachedFile, AttachedFileInput } from "../entities/AttachedFile"; +import { AttachedFileRepository } from "../repositories/AttachedFileRepository"; + +export class AttachFileUseCase { + constructor(private repository: AttachedFileRepository) {} + + execute(file: AttachedFileInput): FutureData { + return this.repository.create(file); + } +} diff --git a/src/domain/comunications/usecases/SearchMessageRecipientsUseCase.ts b/src/domain/comunications/usecases/SearchMessageRecipientsUseCase.ts new file mode 100644 index 000000000..3f80342ed --- /dev/null +++ b/src/domain/comunications/usecases/SearchMessageRecipientsUseCase.ts @@ -0,0 +1,11 @@ +import { FutureData } from "../../common/entities/Future"; +import { MessageRecipient } from "../entities/MessageRecipient"; +import { MessageRepository } from "../repositories/MessageRepository"; + +export class SearchMessageRecipientsUseCase { + constructor(private messageRepository: MessageRepository) {} + + execute(text: string): FutureData { + return this.messageRepository.searchRecipients(text); + } +} diff --git a/src/domain/comunications/usecases/SendEmailUseCase.ts b/src/domain/comunications/usecases/SendEmailUseCase.ts new file mode 100644 index 000000000..1dd285a4f --- /dev/null +++ b/src/domain/comunications/usecases/SendEmailUseCase.ts @@ -0,0 +1,26 @@ +import { Future, FutureData } from "../../common/entities/Future"; +import { AttachedFile } from "../entities/AttachedFile"; +import { Email } from "../entities/Email"; +import { AttachedFileRepository } from "../repositories/AttachedFileRepository"; +import { EmailRepository } from "../repositories/EmailRepository"; + +export class SendEmailUseCase { + constructor(private emailRepository: EmailRepository, private attacchedFileRepository: AttachedFileRepository) {} + + execute(email: Email, attachedFiles: AttachedFile[]): FutureData { + return this.updateSharingInFiles(attachedFiles).flatMap(() => { + return this.emailRepository.send(email); + }); + } + private updateSharingInFiles(attachedFiles: AttachedFile[]): FutureData { + const files = attachedFiles.map(file => { + return { ...file, sharing: { publicAccess: "rw------", externalAccess: true } }; + }); + + const futures = files.map(file => { + return this.attacchedFileRepository.updateSharing(file); + }); + + return Future.parallel(futures, { concurrency: 5 }).map(() => undefined); + } +} diff --git a/src/domain/comunications/usecases/SendMessageUseCase.ts b/src/domain/comunications/usecases/SendMessageUseCase.ts new file mode 100644 index 000000000..d82d9a50b --- /dev/null +++ b/src/domain/comunications/usecases/SendMessageUseCase.ts @@ -0,0 +1,42 @@ +import { Future, FutureData } from "../../common/entities/Future"; +import { AttachedFile } from "../entities/AttachedFile"; +import { Message } from "../entities/Message"; +import { AttachedFileRepository } from "../repositories/AttachedFileRepository"; +import { MessageRepository } from "../repositories/MessageRepository"; + +export class SendMessageUseCase { + constructor( + private messageRepository: MessageRepository, + private attacchedFileRepository: AttachedFileRepository + ) {} + + execute(message: Message, attachedFiles: AttachedFile[]): FutureData { + return this.updateSharingInFiles(message, attachedFiles).flatMap(() => { + return this.messageRepository.send(message); + }); + } + + private updateSharingInFiles(message: Message, attachedFiles: AttachedFile[]): FutureData { + const userAccesses = message.recipients + .filter(recipient => recipient.type === "User") + .map(recipient => { + return { id: recipient.id, name: recipient.name, access: "rw------" }; + }); + + const userGroupAccesses = message.recipients + .filter(recipient => recipient.type === "UserGroup") + .map(recipient => { + return { id: recipient.id, name: recipient.name, access: "rw------" }; + }); + + const files = attachedFiles.map(file => { + return { ...file, sharing: { ...file.sharing, userAccesses, userGroupAccesses } }; + }); + + const futures = files.map(file => { + return this.attacchedFileRepository.updateSharing(file); + }); + + return Future.parallel(futures, { concurrency: 5 }).map(() => undefined); + } +} diff --git a/src/domain/reports/entities/SynchronizationResult.ts b/src/domain/reports/entities/SynchronizationResult.ts index b9d921fc8..6123efde6 100644 --- a/src/domain/reports/entities/SynchronizationResult.ts +++ b/src/domain/reports/entities/SynchronizationResult.ts @@ -22,10 +22,15 @@ export interface ErrorMessage { property?: string; } +export type ResultInstance = { + version?: string; + url?: string; +} & NamedRef; + export interface SynchronizationResult { status: SynchronizationStatus; - origin?: NamedRef | Store; // TODO: Create union - instance: NamedRef; + origin?: ResultInstance | Store; // TODO: Create union + instance: ResultInstance; originPackage?: NamedRef; date: Date; type: SynchronizationResultType; diff --git a/src/presentation/CompositionRoot.ts b/src/presentation/CompositionRoot.ts index 802b76764..bf16515fe 100644 --- a/src/presentation/CompositionRoot.ts +++ b/src/presentation/CompositionRoot.ts @@ -124,6 +124,14 @@ import { TableColumnsDataStoreRepository } from "../data/table-columns/TableColu import { getD2APiFromInstance } from "../utils/d2-utils"; import { RoleD2ApiRepository } from "../data/role/RoleD2ApiRepository"; import { ValidateRolesUseCase } from "../domain/role/ValidateRolesUseCase"; +import { D2Api } from "@eyeseetea/d2-api/2.36"; +import { SendEmailUseCase } from "../domain/comunications/usecases/SendEmailUseCase"; +import { EmailD2ApiRepository } from "../data/comunications/EmailD2ApiRepository"; +import { AttachFileUseCase } from "../domain/comunications/usecases/AttachFileUseCase"; +import { AttachedFileD2ApiRepository } from "../data/comunications/AttachedFileD2ApiRepository"; +import { SendMessageUseCase } from "../domain/comunications/usecases/SendMessageUseCase"; +import { MessageD2ApiRepository } from "../data/comunications/MessageD2ApiRepository"; +import { SearchMessageRecipientsUseCase } from "../domain/comunications/usecases/SearchMessageRecipientsUseCase"; import { StorageDataStoreClient } from "../data/storage/StorageDataStoreClient"; import { MetadataPayloadBuilder } from "../domain/metadata/builders/MetadataPayloadBuilder"; import { GitHubRepository } from "../domain/packages/repositories/GitHubRepository"; @@ -146,6 +154,7 @@ export class CompositionRoot { private gitHubRepository: GitHubRepository; private downloadRepository: DownloadRepository; private transformationRepository: TransformationRepository; + private api: D2Api; constructor(public readonly localInstance: Instance, encryptionKey: string) { this.repositoryFactory = new DynamicRepositoryFactory(); @@ -158,6 +167,8 @@ export class CompositionRoot { this.metadataPayloadBuilder = new MetadataPayloadBuilder(this.repositoryFactory, this.localInstance); this.eventsPayloadBuilder = new EventsPayloadBuilder(this.repositoryFactory, this.localInstance); this.aggregatedPayloadBuilder = new AggregatedPayloadBuilder(this.repositoryFactory, this.localInstance); + + this.api = getD2APiFromInstance(this.localInstance); } @cache() @@ -477,10 +488,20 @@ export class CompositionRoot { @cache() public get roles() { - const api = getD2APiFromInstance(this.localInstance); + return getExecute({ + validate: new ValidateRolesUseCase(new RoleD2ApiRepository(this.api)), + }); + } + @cache() + public get comunications() { + const messageRepository = new MessageD2ApiRepository(this.api); + const attachedFileRepository = new AttachedFileD2ApiRepository(this.api); return getExecute({ - validate: new ValidateRolesUseCase(new RoleD2ApiRepository(api)), + sendEmail: new SendEmailUseCase(new EmailD2ApiRepository(this.api), attachedFileRepository), + sendMessage: new SendMessageUseCase(messageRepository, attachedFileRepository), + searchMessageRecipients: new SearchMessageRecipientsUseCase(messageRepository), + attachFile: new AttachFileUseCase(attachedFileRepository), }); } diff --git a/src/presentation/react/core/components/email-input/EmailInput.tsx b/src/presentation/react/core/components/email-input/EmailInput.tsx new file mode 100644 index 000000000..b5d2e4e36 --- /dev/null +++ b/src/presentation/react/core/components/email-input/EmailInput.tsx @@ -0,0 +1,77 @@ +import React, { KeyboardEvent, ChangeEvent, useCallback } from "react"; +import styled from "styled-components"; +import { TextField, Chip, TextFieldProps } from "@material-ui/core"; +import { useEmailInput } from "./useEmailInput"; + +interface EmailInputProps extends Omit { + emails?: string[]; + text?: string; + onEmailsChange?: (emails: string[]) => void; + onTextChange?: (text: string) => void; +} + +export const EmailInput: React.FC = ({ + emails: propEmails, + text: propText, + onEmailsChange, + onTextChange, + ...textFieldProps +}) => { + const { internalText, internalEmails, handleInternalEmailInputChange, tryAddEmail, handleDelete } = useEmailInput( + propEmails, + propText, + onEmailsChange, + onTextChange + ); + + const handleKeyDown = (event: KeyboardEvent) => { + if ((event.key === "Enter" || event.key === "Tab") && internalText.trim()) { + tryAddEmail(internalText); + event.preventDefault(); + } + }; + + const handleInputChange = useCallback( + (e: ChangeEvent) => { + handleInternalEmailInputChange(e.target.value); + }, + [handleInternalEmailInputChange] + ); + + return ( + + {internalEmails.map((email, index) => { + return ( + handleDelete(email)} + /> + ); + })} + + ), + }} + /> + ); +}; + +const ChipContainer = styled.div` + display: flex; + flex-wrap: wrap; + align-items: center; + margin: 8px; +`; + +export const SelectedChip = styled(Chip)` + border-radius: 20px; + border: none; + margin: 1px; +`; diff --git a/src/presentation/react/core/components/email-input/useEmailInput.ts b/src/presentation/react/core/components/email-input/useEmailInput.ts new file mode 100644 index 000000000..af6cecfc2 --- /dev/null +++ b/src/presentation/react/core/components/email-input/useEmailInput.ts @@ -0,0 +1,72 @@ +import { useState, useCallback, useEffect } from "react"; +import { EmailAddress } from "../../../../../domain/comunications/entities/EmailAddress"; + +export function useEmailInput( + emails?: string[], + text?: string, + onEmailsChange?: (emails: string[]) => void, + onTextChange?: (text: string) => void +) { + const [internalText, setInternalText] = useState(text || ""); + const [internalEmails, setInternalEmails] = useState(emails || []); + + useEffect(() => { + setInternalEmails(emails || []); + }, [emails]); + + useEffect(() => { + setInternalText(text || ""); + }, [text]); + + const tryAddEmail = useCallback( + (email: string) => { + const emailAddress = EmailAddress.create(email); + + if (emailAddress.isSuccess()) { + const emails = [...internalEmails, email.trim()]; + setInternalEmails(emails); + setInternalText(""); + + if (onEmailsChange) { + onEmailsChange(emails); + } + + if (onTextChange) { + onTextChange(internalText); + } + } + }, + [internalEmails, internalText, onEmailsChange, onTextChange] + ); + + const handleDelete = useCallback( + (emailToDelete: string) => { + const emails = internalEmails.filter(email => email !== emailToDelete); + setInternalEmails(emails); + + if (onEmailsChange) { + onEmailsChange(emails); + } + }, + [internalEmails, onEmailsChange] + ); + + const handleInternalEmailInputChange = useCallback( + (text: string) => { + setInternalText(text); + + if (onTextChange) { + onTextChange(internalText); + } + }, + [internalText, onTextChange] + ); + + return { + internalText, + internalEmails, + tryAddEmail, + handleDelete, + handleInternalEmailInputChange, + }; +} diff --git a/src/presentation/react/core/components/message-recipients/MessageRecipients.tsx b/src/presentation/react/core/components/message-recipients/MessageRecipients.tsx new file mode 100644 index 000000000..2207903d3 --- /dev/null +++ b/src/presentation/react/core/components/message-recipients/MessageRecipients.tsx @@ -0,0 +1,105 @@ +import React, { ChangeEvent, useCallback, useEffect, useRef, useState } from "react"; +import styled from "styled-components"; +import { TextField, Chip, TextFieldProps, Menu, MenuItem } from "@material-ui/core"; +import { useMessageRecipients } from "./useMessageRecipients"; +import { MessageRecipientProps } from "../../../../../domain/comunications/entities/MessageRecipient"; + +interface MessageRecipientsProps extends Omit { + recipients?: MessageRecipientProps[]; + text?: string; + onRecipientsChange?: (recipients: MessageRecipientProps[]) => void; + onTextChange?: (text: string) => void; +} + +export const MessageRecipients: React.FC = ({ + recipients: propRecipients, + text: propText, + onRecipientsChange, + onTextChange, + ...textFieldProps +}) => { + const { + internalText, + internalRecipients, + recipientCandidates, + handleInternalTextChange, + handleDelete, + onSelectCantidate, + } = useMessageRecipients(propRecipients, propText, onRecipientsChange, onTextChange); + + const [anchorEl, setAnchorEl] = useState(null); + const ref = useRef(null); + + useEffect(() => { + if (recipientCandidates.length > 0) { + setAnchorEl(ref.current); + } else { + setAnchorEl(null); + } + }, [internalText, recipientCandidates.length]); + + const handleInputChange = useCallback( + (e: ChangeEvent) => { + handleInternalTextChange(e.target.value); + }, + [handleInternalTextChange] + ); + + const handleCloseOptionsMenu = useCallback(() => { + setAnchorEl(null); + }, []); + + return ( + <> + + {internalRecipients.map((recipient, index) => { + return ( + handleDelete(recipient.id)} + /> + ); + })} + + ), + }} + /> + + {recipientCandidates.map((candidate, index) => { + return ( + onSelectCantidate(candidate.id)}> + {candidate.name} + + ); + })} + + + ); +}; + +const ChipContainer = styled.div` + display: flex; + flex-wrap: wrap; + align-items: center; + margin: 8px; +`; + +export const SelectedChip = styled(Chip)` + border-radius: 20px; + border: none; + margin: 1px; +`; diff --git a/src/presentation/react/core/components/message-recipients/useMessageRecipients.ts b/src/presentation/react/core/components/message-recipients/useMessageRecipients.ts new file mode 100644 index 000000000..50f31cbeb --- /dev/null +++ b/src/presentation/react/core/components/message-recipients/useMessageRecipients.ts @@ -0,0 +1,92 @@ +import { useState, useCallback, useEffect } from "react"; +import { MessageRecipientProps } from "../../../../../domain/comunications/entities/MessageRecipient"; + +import { useAppContext } from "../../contexts/AppContext"; + +export function useMessageRecipients( + recipients?: MessageRecipientProps[], + text?: string, + onRecipientsChange?: (recipients: MessageRecipientProps[]) => void, + onTextChange?: (text: string) => void +) { + const { compositionRoot } = useAppContext(); + const [internalText, setInternalText] = useState(text || ""); + const [internalRecipients, setInternalRecipients] = useState(recipients || []); + const [recipientCandidates, setRecipientCandidates] = useState([]); + + useEffect(() => { + setInternalRecipients(recipients || []); + }, [recipients]); + + useEffect(() => { + setInternalText(text || ""); + }, [text]); + + const handleDelete = useCallback( + (recipientId: string) => { + const emails = internalRecipients.filter(recipient => recipient.id !== recipientId); + setInternalRecipients(emails); + + if (onRecipientsChange) { + onRecipientsChange(emails); + } + }, + [internalRecipients, onRecipientsChange] + ); + + const onSelectCantidate = useCallback( + (cantidateId: string) => { + const candidate = recipientCandidates.find(recipient => recipient.id === cantidateId); + + if (candidate) { + const recipìents = [...internalRecipients, candidate]; + + setInternalRecipients(recipìents); + setRecipientCandidates([]); + setInternalText(""); + + if (onRecipientsChange) { + onRecipientsChange(recipìents); + } + } + }, + [internalRecipients, onRecipientsChange, recipientCandidates] + ); + + useEffect(() => { + if (internalText.length <= 1) return; + + setRecipientCandidates([]); + + const cancel = compositionRoot.comunications.searchMessageRecipients(internalText).run( + recipients => { + setRecipientCandidates(recipients); + }, + error => { + console.error(error); + } + ); + + return cancel; + }, [compositionRoot.comunications, internalText, text]); + + const handleInternalTextChange = useCallback( + (text: string) => { + setInternalText(text); + + if (onTextChange) { + onTextChange(internalText); + } + }, + [internalText, onTextChange] + ); + + return { + internalText, + internalRecipients, + recipientCandidates, + handleDelete, + handleInternalTextChange, + onSelectCantidate, + }; +} diff --git a/src/presentation/react/core/components/share-sync-error/ShareSyncError.tsx b/src/presentation/react/core/components/share-sync-error/ShareSyncError.tsx new file mode 100644 index 000000000..fb902ca21 --- /dev/null +++ b/src/presentation/react/core/components/share-sync-error/ShareSyncError.tsx @@ -0,0 +1,154 @@ +import { ConfirmationDialog, useLoading, useSnackbar } from "@eyeseetea/d2-ui-components"; +import { Checkbox, DialogContent, FormControlLabel, Link, TextField } from "@material-ui/core"; +import { SynchronizationResult } from "../../../../../domain/reports/entities/SynchronizationResult"; +import i18n from "../../../../../locales"; +import React, { useEffect } from "react"; +import styled from "styled-components"; +import { ShareSyncType, useShareSyncError } from "./useShareSyncError"; +import { EmailInput } from "../email-input/EmailInput"; +import RadioButtonGroup from "../radio-button-group/RadioButtonGroup"; +import { MessageRecipients } from "../message-recipients/MessageRecipients"; + +interface SyncSummaryProps { + errorResults: SynchronizationResult[]; + onClose: () => void; +} + +export const ShareSyncError = ({ errorResults, onClose }: SyncSummaryProps) => { + const state = useShareSyncError(errorResults); + const snackbar = useSnackbar(); + const loading = useLoading(); + + useEffect(() => { + if (state.messageToUser?.type === "success") { + snackbar.success(state.messageToUser.message); + } else if (state.messageToUser?.type === "error") { + snackbar.error(state.messageToUser.message); + } + }, [state.messageToUser, snackbar]); + + useEffect(() => { + if (state.sending) { + loading.show(true, i18n.t("Sending")); + } else { + loading.hide(); + } + }, [state.sending, loading]); + + useEffect(() => { + if (state.attachingFiles) { + loading.show(); + } else { + loading.hide(); + } + }, [state.attachingFiles, loading]); + + const handleSubjectChange = (event: React.ChangeEvent) => { + const { value } = event.target; + + state.changeSubject(value); + }; + + const handleMessageChange = (event: React.ChangeEvent) => { + const { value } = event.target; + + state.changeText(value); + }; + + const handleChangeType = (type: string) => { + state.changeType(type as ShareSyncType); + }; + + const handleAgreementChange = (event: React.ChangeEvent) => { + state.onAggreementChange(event.target.checked); + }; + + return ( + + + + + {state.type === "Email" ? ( + + ) : ( + + )} + + + + + } + label={ + <> + {i18n.t("I have read and accept the ")} + + {i18n.t("EyeSeeTea S.L. Privacy Policy")} + + {i18n.t(" paying special attention to the section about ")} + + {i18n.t("share sesitive data")} + + + } + /> + + + + ); +}; + +const StyledForm = styled.form` + display: flex; + flex-direction: column; + gap: 20px; + margin-top: 20px; +`; + +const StyledTextField = styled(TextField)` + width: 100%; +`; diff --git a/src/presentation/react/core/components/share-sync-error/useShareSyncError.ts b/src/presentation/react/core/components/share-sync-error/useShareSyncError.ts new file mode 100644 index 000000000..d0f832733 --- /dev/null +++ b/src/presentation/react/core/components/share-sync-error/useShareSyncError.ts @@ -0,0 +1,236 @@ +import i18n from "@eyeseetea/feedback-component/locales"; +import { useCallback, useEffect, useState } from "react"; +import { Future } from "../../../../../domain/common/entities/Future"; +import { AttachedFile } from "../../../../../domain/comunications/entities/AttachedFile"; +import { Email } from "../../../../../domain/comunications/entities/Email"; +import { Message } from "../../../../../domain/comunications/entities/Message"; +import { MessageRecipientProps } from "../../../../../domain/comunications/entities/MessageRecipient"; +import { ResultInstance, SynchronizationResult } from "../../../../../domain/reports/entities/SynchronizationResult"; +import { Store } from "../../../../../domain/stores/entities/Store"; +import { useAppContext } from "../../contexts/AppContext"; + +type ShareSyncState = { + type: ShareSyncType; + toEmail: string[]; + toMessageRecipients: MessageRecipientProps[]; + subject: string; + text: string; + messageToUser: MessageToUser | undefined; + sending: boolean; + attachingFiles: boolean; + changeType: (type: ShareSyncType) => void; + changeToEmail: (to: string[]) => void; + changeToMessageRecipients: (to: MessageRecipientProps[]) => void; + changeSubject: (subject: string) => void; + changeText: (message: string) => void; + send: () => void; + typeOptions: TypeOption[]; + agreementAccepted: boolean; + onAggreementChange: (accepted: boolean) => void; +}; + +const typeOptions: TypeOption[] = [ + { id: "Email", name: i18n.t("Email") }, + { id: "Message", name: i18n.t("Message") }, +]; + +type TypeOption = { id: string; name: string }; + +export type ShareSyncType = "Email" | "Message"; + +type MessageToUser = { + message: string; + type: "error" | "success"; +}; + +export function useShareSyncError(errorResults: SynchronizationResult[]): ShareSyncState { + const [type, setType] = useState("Email"); + const [toEmail, setToEmail] = useState([]); + const [toMessageRecipients, setToMessageRecipients] = useState([]); + const [subject, setSubject] = useState("Error encountered when trying a migration in MetaData Sync"); + const [text, setText] = useState(""); + const [messageToUser, setMessageToUser] = useState(); + const [sending, setSending] = useState(false); + const [attachingFiles, setAttachingFiles] = useState(false); + const [attachedFiles, setAttachedFiles] = useState([]); + const [agreementAccepted, setAgreementAccepted] = useState(false); + + const { compositionRoot } = useAppContext(); + + useEffect(() => { + const initialText = errorResults + .map(result => { + return `Error encountered when trying a ${ + result.type + } migration in MetaData Sync between servers origin: ${getServerInfo( + result.origin + )} and destination: ${getServerInfo(result.instance)}. See the payload and summary below.\n`; + }) + .join("\n"); + + const attachesFileTexts = attachedFiles.map(file => { + return `- ${file.name}: ${file.url}`; + }); + + setText(`${initialText}\n${attachesFileTexts.join("\n")}`); + }, [attachedFiles, errorResults]); + + useEffect(() => { + setAttachingFiles(true); + + const futures = errorResults + .map(result => { + return [ + compositionRoot.comunications.attachFile({ + name: `${result.type}-payload.json`, + data: createJsonBobByObject(result.payload), + sharing: { publicAccess: "--------", externalAccess: false }, + }), + compositionRoot.comunications.attachFile({ + name: `${result.type}-summary.json`, + data: createJsonBobByObject(result.response), + sharing: { publicAccess: "--------", externalAccess: false }, + }), + ]; + }) + .flat(); + + Future.parallel(futures, { concurrency: 5 }).run( + files => { + setAttachingFiles(false); + setAttachedFiles(files); + }, + error => { + setAttachingFiles(false); + setMessageToUser({ message: error.message, type: "error" }); + } + ); + }, [compositionRoot.comunications, errorResults]); + + const changeType = useCallback((type: ShareSyncType) => { + setType(type); + setToEmail([]); + }, []); + + const changeToEmail = useCallback((to: string[]) => { + setToEmail(to); + }, []); + + const changeToMessageRecipients = useCallback((recipients: MessageRecipientProps[]) => { + setToMessageRecipients(recipients); + }, []); + + const onSubjectChange = useCallback((subject: string) => { + setSubject(subject); + }, []); + + const changeText = useCallback((message: string) => { + setText(message); + }, []); + + const sendEmail = useCallback(async () => { + const emailValidation = Email.create({ + recipients: toEmail, + subject, + text, + }); + + emailValidation.match({ + success: email => { + setSending(true); + + compositionRoot.comunications.sendEmail(email, attachedFiles).run( + () => { + setSending(false); + setMessageToUser({ message: i18n.t("Email sent successfully"), type: "success" }); + }, + error => { + setSending(false); + setMessageToUser({ message: error.message, type: "error" }); + } + ); + }, + error: errors => { + setMessageToUser({ message: errors.map(error => error.description).join("\n"), type: "error" }); + }, + }); + }, [toEmail, subject, text, compositionRoot.comunications, attachedFiles]); + + const sendMessage = useCallback(async () => { + const messageValidation = Message.create({ + recipients: toMessageRecipients, + subject, + text, + }); + + messageValidation.match({ + success: message => { + setSending(true); + + compositionRoot.comunications.sendMessage(message, attachedFiles).run( + () => { + setSending(false); + setMessageToUser({ message: i18n.t("Message sent successfully"), type: "success" }); + }, + error => { + setSending(false); + setMessageToUser({ message: error.message, type: "error" }); + } + ); + }, + error: errors => { + setMessageToUser({ message: errors.join("\n"), type: "error" }); + }, + }); + }, [toMessageRecipients, subject, text, compositionRoot.comunications, attachedFiles]); + + const send = useCallback(async () => { + if (type === "Email") { + sendEmail(); + } else { + sendMessage(); + } + }, [type, sendEmail, sendMessage]); + + const onAggreementChange = useCallback((accepted: boolean) => { + setAgreementAccepted(accepted); + }, []); + + return { + type, + toEmail, + toMessageRecipients, + subject, + text, + messageToUser, + sending, + attachingFiles, + changeType, + changeToEmail, + changeToMessageRecipients, + changeSubject: onSubjectChange, + changeText, + send, + typeOptions, + agreementAccepted, + onAggreementChange, + }; +} + +function createJsonBobByObject(object: unknown): Blob { + const json = JSON.stringify(object); + return new Blob([json], { type: "application/json" }); +} +function getServerInfo(origin: ResultInstance | Store | undefined): string { + if (!origin) return "Unknown server"; + + if ("version" in origin && "url" in origin) { + return `${origin.url} (v${origin.version})`; + } + + if ("repository" in origin) { + return origin.repository; + } + + return "Unknown server"; +} diff --git a/src/presentation/react/core/components/sync-summary/SyncSummary.tsx b/src/presentation/react/core/components/sync-summary/SyncSummary.tsx index 1d5c28bc6..612f2020e 100644 --- a/src/presentation/react/core/components/sync-summary/SyncSummary.tsx +++ b/src/presentation/react/core/components/sync-summary/SyncSummary.tsx @@ -5,6 +5,8 @@ import { AccordionSummary, DialogContent, makeStyles, + Menu, + MenuItem, Table, TableBody, TableCell, @@ -15,7 +17,7 @@ import { } from "@material-ui/core"; import ExpandMoreIcon from "@material-ui/icons/ExpandMore"; import _ from "lodash"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { JsonView, Props, defaultStyles } from "react-json-view-lite"; import { SynchronizationReport } from "../../../../../domain/reports/entities/SynchronizationReport"; import { ErrorMessage, SynchronizationResult } from "../../../../../domain/reports/entities/SynchronizationResult"; @@ -30,6 +32,7 @@ import { NamedRef } from "../../../../../domain/common/entities/Ref"; import { SummaryTable } from "./SummaryTable"; import "react-json-view-lite/dist/index.css"; +import { ShareSyncError } from "../share-sync-error/ShareSyncError"; const useStyles = makeStyles(theme => ({ accordionHeading1: { @@ -165,111 +168,155 @@ const SyncSummary = ({ report, onClose }: SyncSummaryProps) => { const loading = useLoading(); const [results, setResults] = useState(report.getResults()); + + const [anchorEl, setAnchorEl] = useState(null); + + const [shareErrorOpen, setShareErrorOpen] = useState(false); + const payloads = _.compact(report.getResults().map(({ payload }) => payload)); + const errorResults = useMemo(() => report.getResults().filter(result => result.status === "ERROR"), [report]); + const downloadJSON = async () => { loading.show(true, i18n.t("Generating JSON")); await compositionRoot.reports.downloadPayloads([report]); loading.reset(); }; + const handleOpenOptionsMenu = useCallback((event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }, []); + + const handleCloseOptionsMenu = useCallback(() => { + setAnchorEl(null); + }, []); + + const handleCloseShareError = useCallback(() => { + setShareErrorOpen(false); + }, []); + + const shareError = useCallback(() => { + setShareErrorOpen(true); + setAnchorEl(null); + }, []); + useEffect(() => { if (report.getResults().length > 0) return; compositionRoot.reports.getSyncResults(report.id).then(setResults); }, [compositionRoot, report]); return ( - 0 ? downloadJSON : undefined} - cancelText={i18n.t("Ok")} - maxWidth={"lg"} - fullWidth={true} - infoActionText={i18n.t("Download JSON Payload")} - > - - {results.map( - ({ origin, instance, status, typeStats = [], stats, message, errors, type, originPackage }, i) => ( - - }> - - {`Type: ${getTypeName(type, report.type)}`} -
- {origin && `${i18n.t("Origin")}: ${getOriginName(origin)}`} - {origin &&
} - {originPackage && `${i18n.t("Origin package")}: ${originPackage.name}`} - {originPackage &&
} - {`${i18n.t("Destination instance")}: ${instance.name}`} -
- - {`${i18n.t("Status")}: `} - {formatStatusTag(status)} - -
- - - {i18n.t("Summary")} - - - {message && ( - - {message} - - )} + <> + 0 ? handleOpenOptionsMenu : payloads.length > 0 ? downloadJSON : undefined + } + cancelText={i18n.t("Ok")} + maxWidth={"lg"} + fullWidth={true} + infoActionText={errorResults.length > 0 ? i18n.t("Options") : i18n.t("Download JSON Payload")} + > + + {results.map( + ( + { origin, instance, status, typeStats = [], stats, message, errors, type, originPackage }, + i + ) => ( + + }> + + {`Type: ${getTypeName(type, report.type)}`} +
+ {origin && `${i18n.t("Origin")}: ${getOriginName(origin)}`} + {origin &&
} + {originPackage && `${i18n.t("Origin package")}: ${originPackage.name}`} + {originPackage &&
} + {`${i18n.t("Destination instance")}: ${instance.name}`} +
+ + {`${i18n.t("Status")}: `} + {formatStatusTag(status)} + +
- {stats && ( - + {i18n.t("Summary")} - )} - {errors && errors.length > 0 && ( -
+ {message && ( - {i18n.t("Messages")} + {message} + )} + + {stats && ( - {buildMessageTable(_.take(errors, 10))} + -
- )} + )} + + {errors && errors.length > 0 && ( +
+ + {i18n.t("Messages")} + + + {buildMessageTable(_.take(errors, 10))} + +
+ )} +
+ ) + )} + + {report.dataStats && ( + + }> + + {i18n.t("Data Statistics")} + + + + + {buildDataStatsTable(report.type, report.dataStats, classes)} + - ) - )} + )} - {report.dataStats && ( }> - {i18n.t("Data Statistics")} + {i18n.t("JSON Response")} - {buildDataStatsTable(report.type, report.dataStats, classes)} + - )} - - - }> - {i18n.t("JSON Response")} - - - - - - -
-
+
+
+ + {i18n.t("Download JSON Payload")} + {i18n.t("Share error information")} + + {shareErrorOpen && } + ); };