diff --git a/README.md b/README.md index 69a0faf4c..daff0092d 100644 --- a/README.md +++ b/README.md @@ -188,5 +188,9 @@ The tests are colocated in their respective feature. For example, if we have a ` ## TODO - [ ] Switch to Vitest -- [ ] Review commented out lint rules - [ ] Review use of any +- [ ] Review use of `as` +- [ ] Review use of `!` +- [ ] Review use of `@ts-ignore` +- [ ] Review use of `eslint-disable` +- [ ] Switch to Zod diff --git a/src/app-providers.tsx b/src/app-providers.tsx index 2a1cec7b9..4ec58ab13 100644 --- a/src/app-providers.tsx +++ b/src/app-providers.tsx @@ -4,13 +4,15 @@ import { GlobalErrorBoundary } from './shared/components/global-error-boundary' import { TntChakraProvider } from './shared/providers/tnt-chakra-provider' import { AuthProvider } from './shared/contexts/auth-context' import { PropsWithChildren } from 'react' +import { useTranslation } from 'react-i18next' export const AppProviders: FC = (props) => { + const { t } = useTranslation() return ( - {props.children} + {props.children} diff --git a/src/app-routes.tsx b/src/app-routes.tsx index 3bef39d91..578ee78c9 100644 --- a/src/app-routes.tsx +++ b/src/app-routes.tsx @@ -1,4 +1,4 @@ -import { LogoutCmd } from './features/auth/application/logout-cmd' +import { LogoutCmd } from './features/auth/application/logout.cmd' import { LazyLoginPage } from './features/auth/ui/login-page.lazy' import { LazyCalendarDesktop } from './features/binnacle/features/activity/ui/calendar-desktop/calendar-desktop.lazy' import { LazyCalendarMobile } from './features/binnacle/features/activity/ui/calendar-mobile/calendar-mobile.lazy' diff --git a/src/features/administration/features/project/application/block-project-cmd.test.ts b/src/features/administration/features/project/application/block-project.cmd.test.ts similarity index 92% rename from src/features/administration/features/project/application/block-project-cmd.test.ts rename to src/features/administration/features/project/application/block-project.cmd.test.ts index d1e157c3f..771ceb584 100644 --- a/src/features/administration/features/project/application/block-project-cmd.test.ts +++ b/src/features/administration/features/project/application/block-project.cmd.test.ts @@ -1,5 +1,5 @@ import { mock } from 'jest-mock-extended' -import { BlockProjectCmd } from './block-project-cmd' +import { BlockProjectCmd } from './block-project.cmd' import { ProjectRepository } from '../../../../shared/project/domain/project-repository' describe('BlockProjectCmd', () => { diff --git a/src/features/administration/features/project/application/block-project-cmd.ts b/src/features/administration/features/project/application/block-project.cmd.ts similarity index 100% rename from src/features/administration/features/project/application/block-project-cmd.ts rename to src/features/administration/features/project/application/block-project.cmd.ts diff --git a/src/features/administration/features/project/application/unblock-project-cmd.test.ts b/src/features/administration/features/project/application/unblock-project.cmd.test.ts similarity index 91% rename from src/features/administration/features/project/application/unblock-project-cmd.test.ts rename to src/features/administration/features/project/application/unblock-project.cmd.test.ts index 5cee99b67..1a7dfb2b2 100644 --- a/src/features/administration/features/project/application/unblock-project-cmd.test.ts +++ b/src/features/administration/features/project/application/unblock-project.cmd.test.ts @@ -1,6 +1,6 @@ import { mock } from 'jest-mock-extended' -import { UnblockProjectCmd } from './unblock-project-cmd' import { ProjectRepository } from '../../../../shared/project/domain/project-repository' +import { UnblockProjectCmd } from './unblock-project.cmd' describe('UnblockProjectCmd', () => { it('should unblock a project', async () => { diff --git a/src/features/administration/features/project/application/unblock-project-cmd.ts b/src/features/administration/features/project/application/unblock-project.cmd.ts similarity index 100% rename from src/features/administration/features/project/application/unblock-project-cmd.ts rename to src/features/administration/features/project/application/unblock-project.cmd.ts diff --git a/src/features/administration/features/project/ui/components/block-project-modal.tsx b/src/features/administration/features/project/ui/components/block-project-modal.tsx index d0f8748a2..4f26e3dcc 100644 --- a/src/features/administration/features/project/ui/components/block-project-modal.tsx +++ b/src/features/administration/features/project/ui/components/block-project-modal.tsx @@ -18,7 +18,7 @@ import { SubmitButton } from '../../../../../../shared/components/form-fields/su import { useResolve } from '../../../../../../shared/di/use-resolve' import { DateField } from '../../../../../../shared/components/form-fields/date-field' import { chrono, parseISO } from '../../../../../../shared/utils/chrono' -import { BlockProjectCmd } from '../../application/block-project-cmd' +import { BlockProjectCmd } from '../../application/block-project.cmd' import { ProjectModalFormSchema, ProjectModalFormValidationSchema } from './project-modal.schema' import { useIsMobile } from '../../../../../../shared/hooks/use-is-mobile' import { Project } from '../../../../../shared/project/domain/project' diff --git a/src/features/administration/features/project/ui/components/projects-table.tsx b/src/features/administration/features/project/ui/components/projects-table.tsx index 8bcae882c..ca1ed2ce6 100644 --- a/src/features/administration/features/project/ui/components/projects-table.tsx +++ b/src/features/administration/features/project/ui/components/projects-table.tsx @@ -6,9 +6,9 @@ import { useExecuteUseCaseOnMount } from '../../../../../../shared/arch/hooks/us import { useSubscribeToUseCase } from '../../../../../../shared/arch/hooks/use-subscribe-to-use-case' import { Table } from '../../../../../../shared/components/table/table' import { ColumnsProps } from '../../../../../../shared/components/table/table.types' -import { BlockProjectCmd } from '../../application/block-project-cmd' +import { BlockProjectCmd } from '../../application/block-project.cmd' import { GetProjectsWithBlockerUserName } from '../../application/get-projects-with-blocker-user-name' -import { UnblockProjectCmd } from '../../application/unblock-project-cmd' +import { UnblockProjectCmd } from '../../application/unblock-project.cmd' import { ProjectStatus } from '../../domain/project-status' import { AdaptedProjects, adaptProjectsToTable } from '../projects-page-utils' import { ProjectsFilterFormCombos } from './combos/projects-combos' diff --git a/src/features/administration/features/project/ui/components/unblock-project-modal.tsx b/src/features/administration/features/project/ui/components/unblock-project-modal.tsx index 4fc18307d..da372580d 100644 --- a/src/features/administration/features/project/ui/components/unblock-project-modal.tsx +++ b/src/features/administration/features/project/ui/components/unblock-project-modal.tsx @@ -14,7 +14,7 @@ import { FC } from 'react' import { useGetUseCase } from '../../../../../../shared/arch/hooks/use-get-use-case' import { useResolve } from '../../../../../../shared/di/use-resolve' import { useIsMobile } from '../../../../../../shared/hooks/use-is-mobile' -import { UnblockProjectCmd } from '../../application/unblock-project-cmd' +import { UnblockProjectCmd } from '../../application/unblock-project.cmd' import { Project } from '../../../../../shared/project/domain/project' import { ProjectErrorMessage } from '../../../../../shared/project/domain/services/project-error-message' diff --git a/src/features/auth/application/logout-cmd.test.ts b/src/features/auth/application/logout.cmd.test.ts similarity index 92% rename from src/features/auth/application/logout-cmd.test.ts rename to src/features/auth/application/logout.cmd.test.ts index 12b09b692..c6163d0ca 100644 --- a/src/features/auth/application/logout-cmd.test.ts +++ b/src/features/auth/application/logout.cmd.test.ts @@ -1,6 +1,6 @@ import { mock } from 'jest-mock-extended' import { AuthRepository } from '../domain/auth-repository' -import { LogoutCmd } from './logout-cmd' +import { LogoutCmd } from './logout.cmd' describe('LogoutCmd', () => { it('should execute logout using the repository', async () => { diff --git a/src/features/auth/application/logout-cmd.ts b/src/features/auth/application/logout.cmd.ts similarity index 100% rename from src/features/auth/application/logout-cmd.ts rename to src/features/auth/application/logout.cmd.ts diff --git a/src/features/binnacle/features/activity/application/approve-activity-cmd.test.ts b/src/features/binnacle/features/activity/application/approve-activity.cmd.test.ts similarity index 90% rename from src/features/binnacle/features/activity/application/approve-activity-cmd.test.ts rename to src/features/binnacle/features/activity/application/approve-activity.cmd.test.ts index fd363fc04..f9ca40d17 100644 --- a/src/features/binnacle/features/activity/application/approve-activity-cmd.test.ts +++ b/src/features/binnacle/features/activity/application/approve-activity.cmd.test.ts @@ -1,6 +1,6 @@ import { mock } from 'jest-mock-extended' import { ActivityRepository } from '../domain/activity-repository' -import { ApproveActivityCmd } from './approve-activity-cmd' +import { ApproveActivityCmd } from './approve-activity.cmd' describe('ApproveActivityCmd', () => { it('should approve an activity', async () => { diff --git a/src/features/binnacle/features/activity/application/approve-activity-cmd.ts b/src/features/binnacle/features/activity/application/approve-activity.cmd.ts similarity index 100% rename from src/features/binnacle/features/activity/application/approve-activity-cmd.ts rename to src/features/binnacle/features/activity/application/approve-activity.cmd.ts diff --git a/src/features/binnacle/features/activity/application/create-activity-cmd.test.ts b/src/features/binnacle/features/activity/application/create-activity.cmd.test.ts similarity index 87% rename from src/features/binnacle/features/activity/application/create-activity-cmd.test.ts rename to src/features/binnacle/features/activity/application/create-activity.cmd.test.ts index 9c1f0a36a..c84686a48 100644 --- a/src/features/binnacle/features/activity/application/create-activity-cmd.test.ts +++ b/src/features/binnacle/features/activity/application/create-activity.cmd.test.ts @@ -1,7 +1,7 @@ import { mock } from 'jest-mock-extended' import { ActivityRepository } from '../domain/activity-repository' import { NewActivity } from '../domain/new-activity' -import { CreateActivityCmd } from './create-activity-cmd' +import { CreateActivityCmd } from './create-activity.cmd' describe('CreateActivityCmd', () => { it('should create a new activity', async () => { @@ -18,14 +18,13 @@ function setup() { const newActivity: NewActivity = { description: 'any-description', + evidences: ['foo'], billable: true, interval: { start: new Date('2000-03-01T09:00:00.000Z'), end: new Date('2000-03-01T13:00:00.000Z') }, - projectRoleId: 1, - evidence: undefined, - hasEvidences: false + projectRoleId: 1 } return { diff --git a/src/features/binnacle/features/activity/application/create-activity-cmd.ts b/src/features/binnacle/features/activity/application/create-activity.cmd.ts similarity index 100% rename from src/features/binnacle/features/activity/application/create-activity-cmd.ts rename to src/features/binnacle/features/activity/application/create-activity.cmd.ts diff --git a/src/features/binnacle/features/activity/application/delete-activity-cmd.test.ts b/src/features/binnacle/features/activity/application/delete-activity.cmd.test.ts similarity index 90% rename from src/features/binnacle/features/activity/application/delete-activity-cmd.test.ts rename to src/features/binnacle/features/activity/application/delete-activity.cmd.test.ts index ec7c32119..481d6ccb2 100644 --- a/src/features/binnacle/features/activity/application/delete-activity-cmd.test.ts +++ b/src/features/binnacle/features/activity/application/delete-activity.cmd.test.ts @@ -1,6 +1,6 @@ import { mock } from 'jest-mock-extended' import { ActivityRepository } from '../domain/activity-repository' -import { DeleteActivityCmd } from './delete-activity-cmd' +import { DeleteActivityCmd } from './delete-activity.cmd' describe('DeleteActivityCmd', () => { it('should delete an activity by id', async () => { diff --git a/src/features/binnacle/features/activity/application/delete-activity-cmd.ts b/src/features/binnacle/features/activity/application/delete-activity.cmd.ts similarity index 100% rename from src/features/binnacle/features/activity/application/delete-activity-cmd.ts rename to src/features/binnacle/features/activity/application/delete-activity.cmd.ts diff --git a/src/features/binnacle/features/activity/application/get-activity-evidence-qry.test.ts b/src/features/binnacle/features/activity/application/get-activity-evidence-qry.test.ts new file mode 100644 index 000000000..0c2a2953a --- /dev/null +++ b/src/features/binnacle/features/activity/application/get-activity-evidence-qry.test.ts @@ -0,0 +1,23 @@ +import { mock } from 'jest-mock-extended' +import { GetActivityEvidenceQry } from './get-activity-evidence-qry' +import { HttpAttachmentRepository } from '../../attachments/infrastructure/http-attachment-repository' + +describe('GetActivityEvidenceQry', () => { + it('should get an activity image by id', async () => { + const { getActivityImageQry, urlToFileConverter } = setup() + const id = 'foo' + + await getActivityImageQry.internalExecute(id) + + expect(urlToFileConverter.getAttachment).toBeCalledWith(id) + }) +}) + +function setup() { + const httpAttachmentRepository = mock() + + return { + getActivityImageQry: new GetActivityEvidenceQry(httpAttachmentRepository), + urlToFileConverter: httpAttachmentRepository + } +} diff --git a/src/features/binnacle/features/activity/application/get-activity-evidence-qry.ts b/src/features/binnacle/features/activity/application/get-activity-evidence-qry.ts new file mode 100644 index 000000000..9287f2721 --- /dev/null +++ b/src/features/binnacle/features/activity/application/get-activity-evidence-qry.ts @@ -0,0 +1,16 @@ +import { Query, UseCaseKey } from '@archimedes/arch' +import { singleton } from 'tsyringe' +import { HttpAttachmentRepository } from '../../attachments/infrastructure/http-attachment-repository' +import { Uuid } from '../../../../../shared/types/uuid' + +@UseCaseKey('GetActivityEvidenceQry') +@singleton() +export class GetActivityEvidenceQry extends Query { + constructor(private readonly httpAttachmentRepository: HttpAttachmentRepository) { + super() + } + + async internalExecute(uuid: Uuid): Promise { + return this.httpAttachmentRepository.getAttachment(uuid) + } +} diff --git a/src/features/binnacle/features/activity/application/get-activity-image-qry.test.ts b/src/features/binnacle/features/activity/application/get-activity-image-qry.test.ts deleted file mode 100644 index 23ae2702f..000000000 --- a/src/features/binnacle/features/activity/application/get-activity-image-qry.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { mock } from 'jest-mock-extended' -import { ActivityRepository } from '../domain/activity-repository' -import { GetActivityEvidenceQry } from './get-activity-image-qry' - -describe('GetActivityEvidenceQry', () => { - it('should get an activity image by id', async () => { - const { getActivityImageQry, activityRepository } = setup() - const id = 1 - - await getActivityImageQry.internalExecute(id) - - expect(activityRepository.getActivityEvidence).toBeCalledWith(id) - }) -}) - -function setup() { - const activityRepository = mock() - - return { - getActivityImageQry: new GetActivityEvidenceQry(activityRepository), - activityRepository - } -} diff --git a/src/features/binnacle/features/activity/application/get-activity-image-qry.ts b/src/features/binnacle/features/activity/application/get-activity-image-qry.ts deleted file mode 100644 index 693eb2da0..000000000 --- a/src/features/binnacle/features/activity/application/get-activity-image-qry.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Query, UseCaseKey } from '@archimedes/arch' -import { ACTIVITY_REPOSITORY } from '../../../../../shared/di/container-tokens' -import { Id } from '../../../../../shared/types/id' -import { inject, singleton } from 'tsyringe' -import type { ActivityRepository } from '../domain/activity-repository' - -@UseCaseKey('GetActivityEvidenceQry') -@singleton() -export class GetActivityEvidenceQry extends Query { - constructor(@inject(ACTIVITY_REPOSITORY) private activityRepository: ActivityRepository) { - super() - } - - async internalExecute(id: Id): Promise { - return this.activityRepository.getActivityEvidence(id) - } -} diff --git a/src/features/binnacle/features/activity/application/update-activity-cmd.test.ts b/src/features/binnacle/features/activity/application/update-activity.cmd.test.ts similarity index 87% rename from src/features/binnacle/features/activity/application/update-activity-cmd.test.ts rename to src/features/binnacle/features/activity/application/update-activity.cmd.test.ts index fb2b08d7f..183d15cbb 100644 --- a/src/features/binnacle/features/activity/application/update-activity-cmd.test.ts +++ b/src/features/binnacle/features/activity/application/update-activity.cmd.test.ts @@ -1,7 +1,7 @@ import { mock } from 'jest-mock-extended' import { ActivityRepository } from '../domain/activity-repository' import { UpdateActivity } from '../domain/update-activity' -import { UpdateActivityCmd } from './update-activity-cmd' +import { UpdateActivityCmd } from './update-activity.cmd' describe('UpdateActivityCmd', () => { it('should update the activity correctly', async () => { @@ -19,14 +19,13 @@ function setup() { const updateActivity: UpdateActivity = { id: 1, description: 'Minutes activity', + evidences: ['foo'], billable: true, interval: { start: new Date('2023-03-01T09:00:00.000Z'), end: new Date('2023-03-01T13:00:00.000Z') }, - projectRoleId: 1, - evidence: undefined, - hasEvidences: false + projectRoleId: 1 } return { diff --git a/src/features/binnacle/features/activity/application/update-activity-cmd.ts b/src/features/binnacle/features/activity/application/update-activity.cmd.ts similarity index 100% rename from src/features/binnacle/features/activity/application/update-activity-cmd.ts rename to src/features/binnacle/features/activity/application/update-activity.cmd.ts diff --git a/src/features/binnacle/features/activity/domain/activity-code-errors.ts b/src/features/binnacle/features/activity/domain/activity-code-errors.ts index 60da0ad39..ad8228c76 100644 --- a/src/features/binnacle/features/activity/domain/activity-code-errors.ts +++ b/src/features/binnacle/features/activity/domain/activity-code-errors.ts @@ -8,6 +8,5 @@ export const ActivityCodeErrors = { MAX_REGISTRABLE_TIME_PER_ACTIVITY_LIMIT_EXCEEDED: 'MAX_REGISTRABLE_TIME_PER_ACTIVITY_LIMIT_EXCEEDED', INVALID_ACTIVITY_APPROVAL_STATE: 'INVALID_ACTIVITY_APPROVAL_STATE', - BLOCKED_PROJECT: 'BLOCKED_PROJECT', - ILLEGAL_ARGUMENT: 'ILLEGAL_ARGUMENT' + BLOCKED_PROJECT: 'BLOCKED_PROJECT' } as const diff --git a/src/features/binnacle/features/activity/domain/activity-repository.ts b/src/features/binnacle/features/activity/domain/activity-repository.ts index d26114805..1a0db91d4 100644 --- a/src/features/binnacle/features/activity/domain/activity-repository.ts +++ b/src/features/binnacle/features/activity/domain/activity-repository.ts @@ -14,8 +14,6 @@ export interface ActivityRepository { queryParams: GetActivitiesQueryParams ): Promise - getActivityEvidence(activityId: Id): Promise - getActivitySummary(interval: DateInterval): Promise create(newActivity: NewActivity): Promise diff --git a/src/features/binnacle/features/activity/domain/activity.ts b/src/features/binnacle/features/activity/domain/activity.ts index a539280d8..8bf682d21 100644 --- a/src/features/binnacle/features/activity/domain/activity.ts +++ b/src/features/binnacle/features/activity/domain/activity.ts @@ -4,13 +4,14 @@ import { LiteProjectRoleWithProjectId } from '../../search/domain/lite-project-r import { LiteProjectWithOrganizationId } from '../../search/domain/lite-project-with-organization-id' import { ActivityInterval } from './activity-interval' import { ActivityApproval } from './activity-approval' +import { Uuid } from '../../../../../shared/types/uuid' export interface Activity { id: Id description: string userId: Id billable: boolean - hasEvidences: boolean + evidences: Uuid[] organization: Organization project: LiteProjectWithOrganizationId projectRole: LiteProjectRoleWithProjectId @@ -18,3 +19,7 @@ export interface Activity { interval: ActivityInterval userName?: string } + +export function hasEvidence(activity: Activity): boolean { + return activity.evidences.length > 0 +} diff --git a/src/features/binnacle/features/activity/domain/new-activity.ts b/src/features/binnacle/features/activity/domain/new-activity.ts index 68b35a601..64b6be8f0 100644 --- a/src/features/binnacle/features/activity/domain/new-activity.ts +++ b/src/features/binnacle/features/activity/domain/new-activity.ts @@ -3,8 +3,7 @@ import { ActivityWithProjectRoleId } from './activity-with-project-role-id' export type NewActivity = Pick< ActivityWithProjectRoleId, - 'description' | 'billable' | 'projectRoleId' | 'hasEvidences' + 'description' | 'billable' | 'projectRoleId' | 'evidences' > & { interval: DateInterval - evidence?: File } diff --git a/src/features/binnacle/features/activity/domain/services/activity-error-message.ts b/src/features/binnacle/features/activity/domain/services/activity-error-message.ts index b132c8c7d..1f3f5becf 100644 --- a/src/features/binnacle/features/activity/domain/services/activity-error-message.ts +++ b/src/features/binnacle/features/activity/domain/services/activity-error-message.ts @@ -20,8 +20,7 @@ const ActivityErrorTitles: Record = { MAX_REGISTRABLE_TIME_PER_ACTIVITY_LIMIT_EXCEEDED: 'activity_api_errors.max_registrable_time_per_activity_limit_title', INVALID_ACTIVITY_APPROVAL_STATE: 'activity_api_errors.invalid_activity_approval_state_title', - BLOCKED_PROJECT: 'activity_api_errors.blocked_project', - ILLEGAL_ARGUMENT: 'activity_api_errors.invalid_file_format_title' + BLOCKED_PROJECT: 'activity_api_errors.blocked_project' } const ActivityErrorDescriptions: Record = { @@ -36,8 +35,7 @@ const ActivityErrorDescriptions: Record = { 'activity_api_errors.max_registrable_time_per_activity_limit_description', INVALID_ACTIVITY_APPROVAL_STATE: 'activity_api_errors.invalid_activity_approval_state_description', - BLOCKED_PROJECT: 'activity_api_errors.blocked_project_description', - ILLEGAL_ARGUMENT: 'activity_api_errors.invalid_file_format_description' + BLOCKED_PROJECT: 'activity_api_errors.blocked_project_description' } @injectable() diff --git a/src/features/binnacle/features/activity/domain/update-activity.ts b/src/features/binnacle/features/activity/domain/update-activity.ts index 7ab805e9e..9ed40bdb5 100644 --- a/src/features/binnacle/features/activity/domain/update-activity.ts +++ b/src/features/binnacle/features/activity/domain/update-activity.ts @@ -3,8 +3,7 @@ import { ActivityWithProjectRoleId } from './activity-with-project-role-id' export type UpdateActivity = Pick< ActivityWithProjectRoleId, - 'id' | 'description' | 'billable' | 'projectRoleId' | 'hasEvidences' + 'id' | 'description' | 'billable' | 'projectRoleId' | 'evidences' > & { interval: DateInterval - evidence?: File } diff --git a/src/features/binnacle/features/activity/infrastructure/fake-activity-repository.ts b/src/features/binnacle/features/activity/infrastructure/fake-activity-repository.ts index f9e686f04..9245b4e31 100644 --- a/src/features/binnacle/features/activity/infrastructure/fake-activity-repository.ts +++ b/src/features/binnacle/features/activity/infrastructure/fake-activity-repository.ts @@ -25,10 +25,6 @@ export class FakeActivityRepository implements ActivityRepository { return this.activities } - async getActivityEvidence(): Promise { - return new File([''], 'filename') - } - async getActivitySummary(): Promise { return ActivityMother.marchActivitySummary() } diff --git a/src/features/binnacle/features/activity/infrastructure/http-activity-repository.test.ts b/src/features/binnacle/features/activity/infrastructure/http-activity-repository.test.ts index 4a7a20104..1a3e8fc84 100644 --- a/src/features/binnacle/features/activity/infrastructure/http-activity-repository.test.ts +++ b/src/features/binnacle/features/activity/infrastructure/http-activity-repository.test.ts @@ -1,6 +1,5 @@ import { mock } from 'jest-mock-extended' import { HttpClient } from '../../../../../shared/http/http-client' -import { Base64Converter } from '../../../../../shared/base64/base64-converter' import { DateInterval } from '../../../../../shared/types/date-interval' import { chrono, parseISO } from '../../../../../shared/utils/chrono' import { ActivityMother } from '../../../../../test-utils/mothers/activity-mother' @@ -89,21 +88,8 @@ describe('HttpActivityRepository', () => { expect(result).toEqual(response) }) - it('should call http client to get an activity evidence', async () => { - const { httpClient, httpActivityRepository, base64Converter } = setup() - const id = 1 - const anyHash = 'data:image/jpeg;base64,R0lGODlhAQABAAAAACw=' - httpClient.get.mockResolvedValue(anyHash) - - await httpActivityRepository.getActivityEvidence(id) - - expect(httpClient.get).toHaveBeenCalledWith('/api/activity/1/evidence') - expect(base64Converter.toFile).toHaveBeenCalledWith(anyHash, '') - }) - it('should call http client to create an activity', async () => { - const { httpClient, httpActivityRepository, base64Converter } = setup() - const anyHash = 'R0lGODlhAQABAAAAACw=' + const { httpClient, httpActivityRepository } = setup() const newActivity = ActivityMother.newActivity() const response = ActivityMother.daysActivityWithoutEvidencePendingWithProjectRoleId() const serializedActivity: NewActivityDto = { @@ -111,22 +97,18 @@ describe('HttpActivityRepository', () => { interval: { start: chrono(newActivity.interval.start).getLocaleDateString(), end: chrono(newActivity.interval.end).getLocaleDateString() - }, - evidence: `data:undefined;base64,${anyHash}` + } } - base64Converter.toBase64.mockResolvedValue(anyHash) httpClient.post.mockResolvedValue(response) const result = await httpActivityRepository.create(newActivity) - expect(base64Converter.toBase64).toHaveBeenCalledWith('file') expect(httpClient.post).toHaveBeenCalledWith('/api/activity', serializedActivity) expect(result).toEqual(response) }) it('should call http client to update an activity', async () => { - const { httpClient, httpActivityRepository, base64Converter } = setup() - const anyHash = 'R0lGODlhAQABAAAAACw=' + const { httpClient, httpActivityRepository } = setup() const updateActivity = ActivityMother.updateActivity() const response = ActivityMother.daysActivityWithoutEvidencePendingWithProjectRoleId() const serializedActivity: NewActivityDto = { @@ -134,15 +116,12 @@ describe('HttpActivityRepository', () => { interval: { start: chrono(updateActivity.interval.start).getLocaleDateString(), end: chrono(updateActivity.interval.end).getLocaleDateString() - }, - evidence: `data:undefined;base64,${anyHash}` + } } - base64Converter.toBase64.mockResolvedValue(anyHash) httpClient.put.mockResolvedValue(response) const result = await httpActivityRepository.update(updateActivity) - expect(base64Converter.toBase64).toHaveBeenCalledWith('file') expect(httpClient.put).toHaveBeenCalledWith('/api/activity', serializedActivity) expect(result).toEqual(response) }) @@ -198,11 +177,9 @@ describe('HttpActivityRepository', () => { function setup() { const httpClient = mock() - const base64Converter = mock() return { httpClient, - base64Converter, - httpActivityRepository: new HttpActivityRepository(httpClient, base64Converter) + httpActivityRepository: new HttpActivityRepository(httpClient) } } diff --git a/src/features/binnacle/features/activity/infrastructure/http-activity-repository.ts b/src/features/binnacle/features/activity/infrastructure/http-activity-repository.ts index 8d9333271..78d0d97fc 100644 --- a/src/features/binnacle/features/activity/infrastructure/http-activity-repository.ts +++ b/src/features/binnacle/features/activity/infrastructure/http-activity-repository.ts @@ -1,4 +1,3 @@ -import { Base64Converter } from '../../../../../shared/base64/base64-converter' import { HttpClient } from '../../../../../shared/http/http-client' import { DateInterval } from '../../../../../shared/types/date-interval' import { Id } from '../../../../../shared/types/id' @@ -30,10 +29,7 @@ export class HttpActivityRepository implements ActivityRepository { protected static activityDaysPath = '/api/calendar/workable-days/count' protected static activityNaturalDaysPath = '/api/calendar/days/count' - constructor( - private httpClient: HttpClient, - private base64Converter: Base64Converter - ) {} + constructor(private httpClient: HttpClient) {} async getAll({ start, end }: DateInterval, userId: Id): Promise { const data = await this.httpClient.get( @@ -50,14 +46,6 @@ export class HttpActivityRepository implements ActivityRepository { return data.map((x) => ActivityWithProjectRoleIdMapper.toDomain(x)) } - async getActivityEvidence(activityId: Id): Promise { - const response = await this.httpClient.get( - HttpActivityRepository.activityEvidencePath(activityId) - ) - - return this.base64Converter.toFile(response, '') - } - async getActivitySummary({ start, end }: DateInterval): Promise { const data = await this.httpClient.get>( HttpActivityRepository.activitySummaryPath, @@ -78,19 +66,12 @@ export class HttpActivityRepository implements ActivityRepository { } async create(newActivity: NewActivity): Promise { - const { evidence } = newActivity const serializedActivity: NewActivityDto = { ...newActivity, interval: { start: chrono(newActivity.interval.start).getLocaleDateString(), end: chrono(newActivity.interval.end).getLocaleDateString() - }, - evidence: undefined - } - - if (evidence) { - const evidenceConverted = await this.base64Converter.toBase64(evidence) - serializedActivity.evidence = `data:${evidence.type};base64,${evidenceConverted}` + } } return this.httpClient.post( @@ -100,7 +81,6 @@ export class HttpActivityRepository implements ActivityRepository { } async update(activity: UpdateActivity): Promise { - const { evidence } = activity const serializedActivity: UpdateActivityDto = { ...activity, interval: { @@ -110,11 +90,6 @@ export class HttpActivityRepository implements ActivityRepository { evidence: undefined } - if (evidence) { - const evidenceConverted = await this.base64Converter.toBase64(evidence) - serializedActivity.evidence = `data:${evidence.type};base64,${evidenceConverted}` - } - return this.httpClient.put( HttpActivityRepository.activityPath, serializedActivity diff --git a/src/features/binnacle/features/activity/infrastructure/new-activity-dto.ts b/src/features/binnacle/features/activity/infrastructure/new-activity-dto.ts index ce3e7a728..c87721479 100644 --- a/src/features/binnacle/features/activity/infrastructure/new-activity-dto.ts +++ b/src/features/binnacle/features/activity/infrastructure/new-activity-dto.ts @@ -1,6 +1,4 @@ import { Serialized } from '../../../../../shared/types/serialized' import { NewActivity } from '../domain/new-activity' -export type NewActivityDto = Omit, 'evidence'> & { - evidence?: string -} +export type NewActivityDto = Serialized diff --git a/src/features/binnacle/features/activity/ui/activities-page.test.tsx b/src/features/binnacle/features/activity/ui/activities-page.test.tsx index 4686da6bb..194812cdf 100644 --- a/src/features/binnacle/features/activity/ui/activities-page.test.tsx +++ b/src/features/binnacle/features/activity/ui/activities-page.test.tsx @@ -58,6 +58,7 @@ function setup(activities: Activity[]) { const getActivityImageQryMock = jest.fn() const createActivityCmdMock = jest.fn() const updateActivityCmdMock = jest.fn() + const uploadAttachmentCmdMock = jest.fn() ;(useExecuteUseCaseOnMount as jest.Mock).mockImplementation((arg) => { if (arg.prototype.key === 'GetActivitiesQry') { @@ -88,6 +89,11 @@ function setup(activities: Activity[]) { useCase: deleteActivityCmdMock } } + if (arg.prototype.key === 'UploadAttachmentCmd') { + return { + useCase: uploadAttachmentCmdMock + } + } if (arg.prototype.key === 'GetActivityEvidenceQry') { return { useCase: getActivityImageQryMock diff --git a/src/features/binnacle/features/activity/ui/calendar-desktop/activities-calendar/calendar-cell/cell-activity-button/activity-preview.tsx b/src/features/binnacle/features/activity/ui/calendar-desktop/activities-calendar/calendar-cell/cell-activity-button/activity-preview.tsx index 22aa5224b..24076e11a 100644 --- a/src/features/binnacle/features/activity/ui/calendar-desktop/activities-calendar/calendar-cell/cell-activity-button/activity-preview.tsx +++ b/src/features/binnacle/features/activity/ui/calendar-desktop/activities-calendar/calendar-cell/cell-activity-button/activity-preview.tsx @@ -15,6 +15,7 @@ import { useTranslation } from 'react-i18next' import { getHumanizedDuration } from '../../../../../../../../../shared/utils/chrono' import { ActivityWithRenderDays } from '../../../../../domain/activity-with-render-days' import { TimeUnits } from '../../../../../../../../../shared/types/time-unit' +import { hasEvidence } from '../../../../../domain/activity' interface Props { activity: ActivityWithRenderDays @@ -43,7 +44,7 @@ export const ActivityPreview = (props: Props) => { ${t('activity_form.role')}: ${activity.projectRole.name}, ${t('activity_form.duration')}: ${humanizedDuration}, ${activity.billable ? t('activity_form.billable') + ',' : ''} - ${activity.hasEvidences ? t('activity_form.image') + ',' : ''} + ${hasEvidence(activity) ? t('activity_form.image') + ',' : ''} ` const bg = useColorModeValue('white', 'gray.800') @@ -92,7 +93,7 @@ export const ActivityPreview = (props: Props) => { {t('activity_form.billable')} )} - {activity.hasEvidences && ( + {hasEvidence(activity) && ( {t('activity_form.image')} diff --git a/src/features/binnacle/features/activity/ui/calendar-desktop/activities-calendar/calendar-cell/cell-header/cell-header.tsx b/src/features/binnacle/features/activity/ui/calendar-desktop/activities-calendar/calendar-cell/cell-header/cell-header.tsx index d67f05a4c..62571d217 100644 --- a/src/features/binnacle/features/activity/ui/calendar-desktop/activities-calendar/calendar-cell/cell-header/cell-header.tsx +++ b/src/features/binnacle/features/activity/ui/calendar-desktop/activities-calendar/calendar-cell/cell-header/cell-header.tsx @@ -3,7 +3,7 @@ import { CameraIcon } from '@heroicons/react/24/outline' import type { ReactNode } from 'react' import { forwardRef, Fragment } from 'react' import { useCalendarContext } from '../../../../contexts/calendar-context' -import { Activity } from '../../../../../domain/activity' +import { Activity, hasEvidence } from '../../../../../domain/activity' import { getDurationByHours } from '../../../../../utils/get-duration' import { chrono, @@ -68,7 +68,7 @@ const ProjectsWithEvidences = ({ activities }: { activities: Activity[] }) => { const bgIconColor = useColorModeValue('#727272', 'whiteAlpha.900') const verifications = activities - .filter((a) => a.hasEvidences) + .filter((a) => hasEvidence(a)) .map((a) => a.project.name) .join(', ') diff --git a/src/features/binnacle/features/activity/ui/calendar-desktop/calendar-desktop.tsx b/src/features/binnacle/features/activity/ui/calendar-desktop/calendar-desktop.tsx index 3879de6ab..3eb1d69b9 100644 --- a/src/features/binnacle/features/activity/ui/calendar-desktop/calendar-desktop.tsx +++ b/src/features/binnacle/features/activity/ui/calendar-desktop/calendar-desktop.tsx @@ -3,11 +3,11 @@ import { FC, Fragment, useMemo } from 'react' import { SkipNavLink } from '../../../../../../shared/components/navbar/skip-nav-link' import { useExecuteUseCaseOnMount } from '../../../../../../shared/arch/hooks/use-execute-use-case-on-mount' import { useSubscribeToUseCase } from '../../../../../../shared/arch/hooks/use-subscribe-to-use-case' -import { ApproveActivityCmd } from '../../application/approve-activity-cmd' -import { CreateActivityCmd } from '../../application/create-activity-cmd' -import { DeleteActivityCmd } from '../../application/delete-activity-cmd' +import { ApproveActivityCmd } from '../../application/approve-activity.cmd' +import { CreateActivityCmd } from '../../application/create-activity.cmd' +import { DeleteActivityCmd } from '../../application/delete-activity.cmd' import { GetCalendarDataQry } from '../../application/get-calendar-data-qry' -import { UpdateActivityCmd } from '../../application/update-activity-cmd' +import { UpdateActivityCmd } from '../../application/update-activity.cmd' import { firstDayOfFirstWeekOfMonth } from '../../utils/first-day-of-first-week-of-month' import { lastDayOfLastWeekOfMonth } from '../../utils/last-day-of-last-week-of-month' import { TimeSummary } from '../components/time-summary/time-summary' diff --git a/src/features/binnacle/features/activity/ui/calendar-mobile/activities-list/activities-section.tsx b/src/features/binnacle/features/activity/ui/calendar-mobile/activities-list/activities-section.tsx index c8c33139c..f3ab19f96 100644 --- a/src/features/binnacle/features/activity/ui/calendar-mobile/activities-list/activities-section.tsx +++ b/src/features/binnacle/features/activity/ui/calendar-mobile/activities-list/activities-section.tsx @@ -9,12 +9,12 @@ import { useExecuteUseCaseOnMount } from '../../../../../../../shared/arch/hooks import { useSubscribeToUseCase } from '../../../../../../../shared/arch/hooks/use-subscribe-to-use-case' import { chrono } from '../../../../../../../shared/utils/chrono' import { SubmitButton } from '../../../../../../../shared/components/form-fields/submit-button' -import { ApproveActivityCmd } from '../../../application/approve-activity-cmd' -import { CreateActivityCmd } from '../../../application/create-activity-cmd' -import { DeleteActivityCmd } from '../../../application/delete-activity-cmd' +import { ApproveActivityCmd } from '../../../application/approve-activity.cmd' +import { CreateActivityCmd } from '../../../application/create-activity.cmd' +import { DeleteActivityCmd } from '../../../application/delete-activity.cmd' import { GetActivitiesQry } from '../../../application/get-activities-qry' import { GetActivitySummaryQry } from '../../../application/get-activity-summary-qry' -import { UpdateActivityCmd } from '../../../application/update-activity-cmd' +import { UpdateActivityCmd } from '../../../application/update-activity.cmd' import { Activity } from '../../../domain/activity' import { firstDayOfFirstWeekOfMonth } from '../../../utils/first-day-of-first-week-of-month' import { getDurationByHours } from '../../../utils/get-duration' diff --git a/src/features/binnacle/features/activity/ui/components/activities-list/activities-list-adapter.test.tsx b/src/features/binnacle/features/activity/ui/components/activities-list/activities-list-adapter.test.tsx index 35a0ff7cd..3ac3302e9 100644 --- a/src/features/binnacle/features/activity/ui/components/activities-list/activities-list-adapter.test.tsx +++ b/src/features/binnacle/features/activity/ui/components/activities-list/activities-list-adapter.test.tsx @@ -24,7 +24,7 @@ describe('ActivitiesListAdapter', () => { }, billable: false, description: 'Pending activity in days', - hasEvidences: false, + evidences: [], id: 4, interval: { duration: 6, diff --git a/src/features/binnacle/features/activity/ui/components/activities-list/activities-list-adapter.tsx b/src/features/binnacle/features/activity/ui/components/activities-list/activities-list-adapter.tsx index fe2718b72..eb455abf1 100644 --- a/src/features/binnacle/features/activity/ui/components/activities-list/activities-list-adapter.tsx +++ b/src/features/binnacle/features/activity/ui/components/activities-list/activities-list-adapter.tsx @@ -3,7 +3,7 @@ import { PaperClipIcon } from '@heroicons/react/24/outline' import { t } from 'i18next' import { TimeUnits } from '../../../../../../../shared/types/time-unit' import { chrono, getHumanizedDuration } from '../../../../../../../shared/utils/chrono' -import { Activity } from '../../../domain/activity' +import { Activity, hasEvidence } from '../../../domain/activity' import { getDurationByMinutes } from '../../../utils/get-duration' import { ReactNode } from 'react' @@ -66,7 +66,7 @@ export const activitiesListAdapter = (activities: Activity[]): AdaptedActivity[] ) } })(), - attachment: activity.hasEvidences && , + attachment: hasEvidence(activity) && , action: activity } }) diff --git a/src/features/binnacle/features/activity/ui/components/activities-list/activities-list.tsx b/src/features/binnacle/features/activity/ui/components/activities-list/activities-list.tsx index aea6ecc21..b526bbd6a 100644 --- a/src/features/binnacle/features/activity/ui/components/activities-list/activities-list.tsx +++ b/src/features/binnacle/features/activity/ui/components/activities-list/activities-list.tsx @@ -13,10 +13,10 @@ import { RemoveActivityButton } from '../activity-form/components/remove-activit import { ActivityModal } from '../activity-modal/activity-modal' import { ActivitiesListTable } from './activities-list-table' import { ActivityFilterForm } from './components/activity-filter/activity-filter-form' -import { CreateActivityCmd } from '../../../application/create-activity-cmd' -import { UpdateActivityCmd } from '../../../application/update-activity-cmd' -import { DeleteActivityCmd } from '../../../application/delete-activity-cmd' -import { ApproveActivityCmd } from '../../../application/approve-activity-cmd' +import { CreateActivityCmd } from '../../../application/create-activity.cmd' +import { UpdateActivityCmd } from '../../../application/update-activity.cmd' +import { DeleteActivityCmd } from '../../../application/delete-activity.cmd' +import { ApproveActivityCmd } from '../../../application/approve-activity.cmd' import { DateInterval } from '../../../../../../../shared/types/date-interval' import { useQueryParams } from '../../../../../../../shared/router/use-query-params' import { TimeUnits } from '../../../../../../../shared/types/time-unit' diff --git a/src/features/binnacle/features/activity/ui/components/activity-form/activity-form.test.tsx b/src/features/binnacle/features/activity/ui/components/activity-form/activity-form.test.tsx index 69d6f32ad..84834fec3 100644 --- a/src/features/binnacle/features/activity/ui/components/activity-form/activity-form.test.tsx +++ b/src/features/binnacle/features/activity/ui/components/activity-form/activity-form.test.tsx @@ -45,8 +45,7 @@ describe.skip('ActivityForm', () => { { billable: true, description: 'Lorem ipsum', - hasEvidences: false, - evidence: undefined, + evidences: [], interval: { end: new Date(today.getFullYear(), today.getMonth(), today.getDate(), 13, 0, 0), start: new Date(today.getFullYear(), today.getMonth(), today.getDate(), 9, 0, 0) @@ -165,9 +164,8 @@ describe.skip('ActivityForm', () => { { id: 1, description: 'Minutes activity Description changed', - evidence: undefined, + evidences: [], billable: true, - hasEvidences: false, interval: { end: new Date('2023-06-07T13:00:00.000Z'), start: new Date('2023-06-07T09:00:00.000Z') diff --git a/src/features/binnacle/features/activity/ui/components/activity-form/activity-form.tsx b/src/features/binnacle/features/activity/ui/components/activity-form/activity-form.tsx index da33b2618..86d9caaee 100644 --- a/src/features/binnacle/features/activity/ui/components/activity-form/activity-form.tsx +++ b/src/features/binnacle/features/activity/ui/components/activity-form/activity-form.tsx @@ -6,16 +6,14 @@ import { FC, useEffect, useMemo, useState } from 'react' import { Controller, useForm, useWatch } from 'react-hook-form' import { useTranslation } from 'react-i18next' import { useGetUseCase } from '../../../../../../../shared/arch/hooks/use-get-use-case' -import { FileField } from '../../../../../../../shared/components/file-field' import { DateField } from '../../../../../../../shared/components/form-fields/date-field' import { TimeFieldWithSelector } from '../../../../../../../shared/components/form-fields/time-field-with-selector' import { useResolve } from '../../../../../../../shared/di/use-resolve' import { DateInterval } from '../../../../../../../shared/types/date-interval' import { chrono, parse } from '../../../../../../../shared/utils/chrono' import { TextField } from '../../../../../../../shared/components/form-fields/text-field' -import { CreateActivityCmd } from '../../../application/create-activity-cmd' -import { GetActivityEvidenceQry } from '../../../application/get-activity-image-qry' -import { UpdateActivityCmd } from '../../../application/update-activity-cmd' +import { CreateActivityCmd } from '../../../application/create-activity.cmd' +import { UpdateActivityCmd } from '../../../application/update-activity.cmd' import { Activity } from '../../../domain/activity' import { NewActivity } from '../../../domain/new-activity' import { ActivityErrorMessage } from '../../../domain/services/activity-error-message' @@ -29,6 +27,11 @@ import { GetAutofillHours } from './utils/get-autofill-hours' import { GetInitialActivityFormValues } from './utils/get-initial-activity-form-values' import { TimeUnits } from '../../../../../../../shared/types/time-unit' import { NonHydratedProjectRole } from '../../../../project-role/domain/non-hydrated-project-role' +import { UploadAttachmentCmd } from '../../../../attachments/application/upload-attachment.cmd' +import { AttachmentErrorMessage } from '../../../../attachments/domain/services/attachment-error-message' +import { ActivityEvidence } from './components/activity-evidence' +import { RemoteFile } from '../../../../attachments/domain/remote-file' +import { isFileType } from '../../../../attachments/domain/services/file-utils' export const ACTIVITY_FORM_ID = 'activity-form-id' @@ -79,10 +82,10 @@ export const ActivityForm: FC = (props) => { } = props const { t } = useTranslation() const activityErrorMessage = useResolve(ActivityErrorMessage) - const [isLoadingEvidences, setIsLoadingEvidences] = useState(true) - const { useCase: getActivityEvidenceQry } = useGetUseCase(GetActivityEvidenceQry) + const attachmentErrorMessage = useResolve(AttachmentErrorMessage) const { useCase: createActivityCmd } = useGetUseCase(CreateActivityCmd) const { useCase: updateActivityCmd } = useGetUseCase(UpdateActivityCmd) + const { useCase: uploadAttachmentCmd } = useGetUseCase(UploadAttachmentCmd) const initialFormValues = useMemo(() => { if (!settings) return @@ -134,31 +137,45 @@ export const ActivityForm: FC = (props) => { ] }) + const [fileChanged, setFileChanged] = useState(false) + const [remoteFiles, setRemoteFiles] = useState([]) + useEffect(() => { - if (activity?.hasEvidences) { - getActivityEvidenceQry.execute(activity.id).then((evidence) => { - setValue('file', evidence) - setIsLoadingEvidences(false) - }) - return + if (activity !== undefined) { + const remoteFilesFromActivity = activity.evidences.map((evidence) => ({ id: evidence })) + setRemoteFiles(remoteFilesFromActivity) } - - setIsLoadingEvidences(false) - }, [activity, getActivityEvidenceQry, setValue]) + }, [activity]) const onSubmit = async (data: ActivityFormSchema) => { const projectRoleId = data.showRecentRole ? data.recentProjectRole!.id : data.projectRole!.id const isNewActivity = activity?.id === undefined onActivityFormSubmit() + let attachment: RemoteFile | undefined + + if (data.file !== undefined && fileChanged) { + try { + attachment = await uploadAttachmentCmd.execute(data.file, { + showToastError: true, + errorMessage: attachmentErrorMessage.get + }) + } catch { + onSubmitError() + return + } + } else if (remoteFiles.length > 0) { + attachment = remoteFiles[0] + } + if (isNewActivity) { + const id = attachment?.id const newActivity: NewActivity = { description: data.description, billable: data.billable, interval, projectRoleId: projectRoleId, - evidence: data.file, - hasEvidences: Boolean(data.file) + ...(id !== undefined ? { evidences: [id] } : { evidences: [] }) } await createActivityCmd @@ -170,14 +187,14 @@ export const ActivityForm: FC = (props) => { .then(onAfterSubmit) .catch(onSubmitError) } else { + const id = attachment?.id const updateActivity: UpdateActivity = { id: activity!.id, description: data.description, billable: data.billable, interval, projectRoleId: projectRoleId, - evidence: data.file, - hasEvidences: Boolean(data.file) + ...(id !== undefined ? { evidences: [id] } : { evidences: [] }) } await updateActivityCmd @@ -240,12 +257,15 @@ export const ActivityForm: FC = (props) => { setBillableProjectOnChange() }, [activity, showRecentRole, project, setValue, recentProjectRole]) - const onFileChanged = async (files: File[]) => { + const onFileChanged = async (files: (File | RemoteFile)[]) => { + setFileChanged(true) + if (!files || files.length === 0) { + setRemoteFiles([]) return setValue('file', undefined) } - setValue('file', files[0]) + if (isFileType(files[0])) setValue('file', files[0]) } return ( @@ -380,14 +400,11 @@ export const ActivityForm: FC = (props) => { isDisabled={isReadOnly} /> - + > ) } diff --git a/src/features/binnacle/features/activity/ui/components/activity-form/components/activity-evidence.tsx b/src/features/binnacle/features/activity/ui/components/activity-form/components/activity-evidence.tsx new file mode 100644 index 000000000..029281ef0 --- /dev/null +++ b/src/features/binnacle/features/activity/ui/components/activity-form/components/activity-evidence.tsx @@ -0,0 +1,38 @@ +import { FileField } from '../../../../../../../../shared/components/file-field' +import { useTranslation } from 'react-i18next' +import { FC } from 'react' +import { useGetUseCase } from '../../../../../../../../shared/arch/hooks/use-get-use-case' +import { GetActivityEvidenceQry } from '../../../../application/get-activity-evidence-qry' +import { openFilePreview } from '../../../../../../../../shared/utils/open-file-preview' +import { RemoteFile } from '../../../../../attachments/domain/remote-file' +import { Uuid } from '../../../../../../../../shared/types/uuid' + +interface Props { + files?: (File | RemoteFile)[] + onChange: (file: (File | RemoteFile)[]) => void + isReadOnly?: boolean +} + +export const ActivityEvidence: FC = (props) => { + const { t } = useTranslation() + const { executeUseCase: getActivityEvidenceQry, isLoading } = + useGetUseCase(GetActivityEvidenceQry) + + const handlePreviewClick = (uuid: Uuid) => { + getActivityEvidenceQry(uuid).then(async (evidence) => { + openFilePreview(evidence) + }) + } + + return ( + + ) +} diff --git a/src/features/binnacle/features/activity/ui/components/activity-form/components/remove-activity-button.tsx b/src/features/binnacle/features/activity/ui/components/activity-form/components/remove-activity-button.tsx index 8c61eb1ae..29383d22b 100644 --- a/src/features/binnacle/features/activity/ui/components/activity-form/components/remove-activity-button.tsx +++ b/src/features/binnacle/features/activity/ui/components/activity-form/components/remove-activity-button.tsx @@ -8,7 +8,7 @@ import { Button } from '@chakra-ui/react' import { TrashIcon } from '@heroicons/react/24/outline' -import { DeleteActivityCmd } from '../../../../application/delete-activity-cmd' +import { DeleteActivityCmd } from '../../../../application/delete-activity.cmd' import { Activity } from '../../../../domain/activity' import type { FC } from 'react' import { Fragment, useRef, useState } from 'react' diff --git a/src/features/binnacle/features/activity/ui/components/pending-activities/evidence-icon/evidence-icon.tsx b/src/features/binnacle/features/activity/ui/components/pending-activities/evidence-icon/evidence-icon.tsx index b5c7d18a3..dcc5f81b5 100644 --- a/src/features/binnacle/features/activity/ui/components/pending-activities/evidence-icon/evidence-icon.tsx +++ b/src/features/binnacle/features/activity/ui/components/pending-activities/evidence-icon/evidence-icon.tsx @@ -1,20 +1,20 @@ import { PaperClipIcon } from '@heroicons/react/24/outline' import { FC, useState } from 'react' -import { Id } from '@archimedes/arch' import { useGetUseCase } from '../../../../../../../../shared/arch/hooks/use-get-use-case' -import { GetActivityEvidenceQry } from '../../../../application/get-activity-image-qry' import { Spinner } from '@chakra-ui/react' import { useResolve } from '../../../../../../../../shared/di/use-resolve' import { ActivityErrorMessage } from '../../../../domain/services/activity-error-message' import { openFilePreview } from '../../../../../../../../shared/utils/open-file-preview' +import { GetActivityEvidenceQry } from '../../../../application/get-activity-evidence-qry' +import { Uuid } from '../../../../../../../../shared/types/uuid' interface Props { - activityId: Id + evidenceUuid: Uuid evidenceKey: number } export const EvidenceIcon: FC = (props) => { - const { activityId, evidenceKey } = props + const { evidenceUuid, evidenceKey } = props const activityErrorMessage = useResolve(ActivityErrorMessage) const { useCase: getActivityEvidenceQry } = useGetUseCase(GetActivityEvidenceQry) @@ -22,7 +22,7 @@ export const EvidenceIcon: FC = (props) => { const handlePreview = () => { getActivityEvidenceQry - .execute(activityId, { showToastError: true, errorMessage: activityErrorMessage.get }) + .execute(evidenceUuid, { showToastError: true, errorMessage: activityErrorMessage.get }) .then((file) => { openFilePreview(file) }) diff --git a/src/features/binnacle/features/activity/ui/components/time-summary/time-summary.tsx b/src/features/binnacle/features/activity/ui/components/time-summary/time-summary.tsx index 375c39cd7..d30288779 100644 --- a/src/features/binnacle/features/activity/ui/components/time-summary/time-summary.tsx +++ b/src/features/binnacle/features/activity/ui/components/time-summary/time-summary.tsx @@ -5,11 +5,11 @@ import { useTranslation } from 'react-i18next' import { useExecuteUseCaseOnMount } from '../../../../../../../shared/arch/hooks/use-execute-use-case-on-mount' import { useSubscribeToUseCase } from '../../../../../../../shared/arch/hooks/use-subscribe-to-use-case' import { chrono } from '../../../../../../../shared/utils/chrono' -import { ApproveActivityCmd } from '../../../application/approve-activity-cmd' -import { CreateActivityCmd } from '../../../application/create-activity-cmd' -import { DeleteActivityCmd } from '../../../application/delete-activity-cmd' +import { ApproveActivityCmd } from '../../../application/approve-activity.cmd' +import { CreateActivityCmd } from '../../../application/create-activity.cmd' +import { DeleteActivityCmd } from '../../../application/delete-activity.cmd' import { GetTimeSummaryQry } from '../../../application/get-time-summary-qry' -import { UpdateActivityCmd } from '../../../application/update-activity-cmd' +import { UpdateActivityCmd } from '../../../application/update-activity.cmd' import { TimeSummaryMode } from '../../../domain/selected-time-summary-mode' import { getDurationByHours } from '../../../utils/get-duration' import { useCalendarContext } from '../../contexts/calendar-context' diff --git a/src/features/binnacle/features/activity/ui/pending-activities-page-utils.tsx b/src/features/binnacle/features/activity/ui/pending-activities-page-utils.tsx index d26988eb3..756562e9e 100644 --- a/src/features/binnacle/features/activity/ui/pending-activities-page-utils.tsx +++ b/src/features/binnacle/features/activity/ui/pending-activities-page-utils.tsx @@ -48,8 +48,8 @@ export const adaptActivitiesToTable = (activities: Activity[]): AdaptedActivity[ role: ( ), - attachment: activity.hasEvidences && ( - + attachment: activity.evidences.length > 0 && ( + ), action: activity, approvalDate: activity.approval, diff --git a/src/features/binnacle/features/activity/ui/pending-activities-page.tsx b/src/features/binnacle/features/activity/ui/pending-activities-page.tsx index 077229d31..c19601aee 100644 --- a/src/features/binnacle/features/activity/ui/pending-activities-page.tsx +++ b/src/features/binnacle/features/activity/ui/pending-activities-page.tsx @@ -9,7 +9,7 @@ import { PageWithTitle } from '../../../../../shared/components/page-with-title/ import { Table } from '../../../../../shared/components/table/table' import { ColumnsProps } from '../../../../../shared/components/table/table.types' import { useResolve } from '../../../../../shared/di/use-resolve' -import { ApproveActivityCmd } from '../application/approve-activity-cmd' +import { ApproveActivityCmd } from '../application/approve-activity.cmd' import { GetActivitiesByFiltersQry } from '../application/get-activities-by-filters-qry' import { Activity } from '../domain/activity' import { ActivityErrorMessage } from '../domain/services/activity-error-message' @@ -19,7 +19,7 @@ import { useIsMobile } from '../../../../../shared/hooks/use-is-mobile' import { GetActivitiesQueryParams } from '../domain/get-activities-query-params' import { ActivityFilters } from './components/activity-filters/activity-filters' import { RemoveActivityButton } from './components/activity-form/components/remove-activity-button' -import { DeleteActivityCmd } from '../application/delete-activity-cmd' +import { DeleteActivityCmd } from '../application/delete-activity.cmd' import { ActivityApprovalState } from '../domain/activity-approval-state' import { chrono } from '../../../../../shared/utils/chrono' import { ActivityApproval } from '../domain/activity-approval' diff --git a/src/features/binnacle/features/attachments/application/upload-attachment.cmd.ts b/src/features/binnacle/features/attachments/application/upload-attachment.cmd.ts new file mode 100644 index 000000000..a82a374e1 --- /dev/null +++ b/src/features/binnacle/features/attachments/application/upload-attachment.cmd.ts @@ -0,0 +1,18 @@ +import { Command } from '@archimedes/arch' +import { inject, injectable } from 'tsyringe' +import type { AttachmentRepository } from '../domain/attachment-repository' +import { ATTACHMENT_REPOSITORY } from '../../../../../shared/di/container-tokens' +import { Uuid } from '../../../../../shared/types/uuid' + +@injectable() +export class UploadAttachmentCmd extends Command { + constructor( + @inject(ATTACHMENT_REPOSITORY) private readonly attachmentRepository: AttachmentRepository + ) { + super() + } + + internalExecute(file: File): Promise<{ id: Uuid }> { + return this.attachmentRepository.uploadAttachment(file) + } +} diff --git a/src/features/binnacle/features/attachments/domain/attachment-code-errors.ts b/src/features/binnacle/features/attachments/domain/attachment-code-errors.ts new file mode 100644 index 000000000..4b68cc628 --- /dev/null +++ b/src/features/binnacle/features/attachments/domain/attachment-code-errors.ts @@ -0,0 +1,3 @@ +export const AttachmentCodeErrors = { + ATTACHMENT_MIMETYPE_NOT_SUPPORTED: 'ATTACHMENT_MIMETYPE_NOT_SUPPORTED' +} diff --git a/src/features/binnacle/features/attachments/domain/attachment-repository.ts b/src/features/binnacle/features/attachments/domain/attachment-repository.ts new file mode 100644 index 000000000..7959ee40b --- /dev/null +++ b/src/features/binnacle/features/attachments/domain/attachment-repository.ts @@ -0,0 +1,7 @@ +import { Uuid } from '../../../../../shared/types/uuid' + +export interface AttachmentRepository { + uploadAttachment(attachment: File): Promise<{ id: Uuid }> + + getAttachment(uuid: Uuid): Promise +} diff --git a/src/features/binnacle/features/attachments/domain/remote-file.ts b/src/features/binnacle/features/attachments/domain/remote-file.ts new file mode 100644 index 000000000..cc5890c1f --- /dev/null +++ b/src/features/binnacle/features/attachments/domain/remote-file.ts @@ -0,0 +1,5 @@ +import { Uuid } from '../../../../../shared/types/uuid' + +export interface RemoteFile { + id: Uuid +} diff --git a/src/features/binnacle/features/attachments/domain/services/attachment-error-message.ts b/src/features/binnacle/features/attachments/domain/services/attachment-error-message.ts new file mode 100644 index 000000000..b8974b6f4 --- /dev/null +++ b/src/features/binnacle/features/attachments/domain/services/attachment-error-message.ts @@ -0,0 +1,27 @@ +import { NotificationMessage } from '../../../../../../shared/notification/notification-message' +import { i18n } from '../../../../../../shared/i18n/i18n' +import { AttachmentCodeErrors } from '../attachment-code-errors' + +type AttachmentCodeError = keyof typeof AttachmentCodeErrors + +const AttachmentErrorTitles: Record = { + ATTACHMENT_MIMETYPE_NOT_SUPPORTED: 'activity_api_errors.invalid_file_format_title' +} +const AttachmentErrorDescription: Record = { + ATTACHMENT_MIMETYPE_NOT_SUPPORTED: 'activity_api_errors.invalid_file_format_description' +} + +export class AttachmentErrorMessage { + get(code: string): NotificationMessage { + return { + title: i18n.t( + AttachmentErrorTitles[code as AttachmentCodeError] || + 'activity_api_errors.unknown_error_title' + ), + description: i18n.t( + AttachmentErrorDescription[code as AttachmentCodeError] || + 'activity_api_errors.unknown_error_description' + ) + } + } +} diff --git a/src/features/binnacle/features/attachments/domain/services/file-utils.ts b/src/features/binnacle/features/attachments/domain/services/file-utils.ts new file mode 100644 index 000000000..a2a5dc243 --- /dev/null +++ b/src/features/binnacle/features/attachments/domain/services/file-utils.ts @@ -0,0 +1,5 @@ +import { RemoteFile } from '../remote-file' + +export const isFileType = (file: File | RemoteFile): file is File => { + return file instanceof File +} diff --git a/src/features/binnacle/features/attachments/infrastructure/fake-attachment-repository.ts b/src/features/binnacle/features/attachments/infrastructure/fake-attachment-repository.ts new file mode 100644 index 000000000..4aab47a6c --- /dev/null +++ b/src/features/binnacle/features/attachments/infrastructure/fake-attachment-repository.ts @@ -0,0 +1,16 @@ +import { AttachmentRepository } from '../domain/attachment-repository' +import { injectable } from 'tsyringe' +import { Uuid } from '../../../../../shared/types/uuid' + +@injectable() +export class FakeAttachmentRepository implements AttachmentRepository { + async uploadAttachment(): Promise<{ id: Uuid }> { + return { + id: 'uuid' + } + } + + async getAttachment(uuid: Uuid): Promise { + return new File([uuid], 'image/jpeg') + } +} diff --git a/src/features/binnacle/features/attachments/infrastructure/http-attachment-repository.test.ts b/src/features/binnacle/features/attachments/infrastructure/http-attachment-repository.test.ts new file mode 100644 index 000000000..354f95c43 --- /dev/null +++ b/src/features/binnacle/features/attachments/infrastructure/http-attachment-repository.test.ts @@ -0,0 +1,37 @@ +import { mock } from 'jest-mock-extended' +import { HttpClient } from '../../../../../shared/http/http-client' +import { HttpAttachmentRepository } from './http-attachment-repository' + +describe('HttpAttachmentRepository', () => { + it('should upload attachment', async () => { + const { httpAttachmentRepository, httpClient } = setup() + + const file = new File([], 'image/jpeg') + + const formData = new FormData() + formData.append('attachmentFile', file) + await httpAttachmentRepository.uploadAttachment(file) + + expect(httpClient.post).toHaveBeenCalledWith('/api/attachment', formData, { + headers: { + 'Content-Type': 'multipart/form-data' + } + }) + }) + + it('should get attachment', async () => { + const { httpAttachmentRepository, httpClient } = setup() + + await httpAttachmentRepository.getAttachment('foo') + + expect(httpClient.get).toHaveBeenCalledWith('/api/attachment/foo', { responseType: 'blob' }) + }) +}) + +function setup() { + const httpClient = mock() + return { + httpClient, + httpAttachmentRepository: new HttpAttachmentRepository(httpClient) + } +} diff --git a/src/features/binnacle/features/attachments/infrastructure/http-attachment-repository.ts b/src/features/binnacle/features/attachments/infrastructure/http-attachment-repository.ts new file mode 100644 index 000000000..5ffc6a031 --- /dev/null +++ b/src/features/binnacle/features/attachments/infrastructure/http-attachment-repository.ts @@ -0,0 +1,27 @@ +import { AttachmentRepository } from '../domain/attachment-repository' +import { injectable } from 'tsyringe' +import { HttpClient } from '../../../../../shared/http/http-client' +import { Uuid } from '../../../../../shared/types/uuid' + +@injectable() +export class HttpAttachmentRepository implements AttachmentRepository { + protected static attachmentPath = '/api/attachment' + + constructor(private readonly httpClient: HttpClient) {} + + async uploadAttachment(attachment: File): Promise<{ id: Uuid }> { + const formData = new FormData() + formData.append('attachmentFile', attachment) + return this.httpClient.post(HttpAttachmentRepository.attachmentPath, formData, { + headers: { + 'Content-Type': 'multipart/form-data' + } + }) + } + + async getAttachment(uuid: Uuid): Promise { + return this.httpClient.get(`${HttpAttachmentRepository.attachmentPath}/${uuid}`, { + responseType: 'blob' + }) + } +} diff --git a/src/features/binnacle/features/vacation/application/create-vacation-cmd.test.ts b/src/features/binnacle/features/vacation/application/create-vacation-cmd.test.ts index 592faa5b9..efc01d20c 100644 --- a/src/features/binnacle/features/vacation/application/create-vacation-cmd.test.ts +++ b/src/features/binnacle/features/vacation/application/create-vacation-cmd.test.ts @@ -1,7 +1,7 @@ import { mock } from 'jest-mock-extended' import { VacationMother } from '../../../../../test-utils/mothers/vacation-mother' import { VacationRepository } from '../domain/vacation-repository' -import { CreateVacationCmd } from './create-vacation-cmd' +import { CreateVacationCmd } from './create-vacation.cmd' import { VacationGenerated } from '../domain/vacation-generated' import { NewVacation } from '../domain/new-vacation' import { i18n } from '../../../../../shared/i18n/i18n' diff --git a/src/features/binnacle/features/vacation/application/create-vacation-cmd.ts b/src/features/binnacle/features/vacation/application/create-vacation.cmd.ts similarity index 100% rename from src/features/binnacle/features/vacation/application/create-vacation-cmd.ts rename to src/features/binnacle/features/vacation/application/create-vacation.cmd.ts diff --git a/src/features/binnacle/features/vacation/application/delete-vacation-cmd.ts b/src/features/binnacle/features/vacation/application/delete-vacation.cmd.ts similarity index 100% rename from src/features/binnacle/features/vacation/application/delete-vacation-cmd.ts rename to src/features/binnacle/features/vacation/application/delete-vacation.cmd.ts diff --git a/src/features/binnacle/features/vacation/application/update-vacation-cmd.ts b/src/features/binnacle/features/vacation/application/update-vacation.cmd.ts similarity index 100% rename from src/features/binnacle/features/vacation/application/update-vacation-cmd.ts rename to src/features/binnacle/features/vacation/application/update-vacation.cmd.ts diff --git a/src/features/binnacle/features/vacation/ui/components/vacation-details.tsx b/src/features/binnacle/features/vacation/ui/components/vacation-details.tsx index 5ea2fec79..f37f16169 100644 --- a/src/features/binnacle/features/vacation/ui/components/vacation-details.tsx +++ b/src/features/binnacle/features/vacation/ui/components/vacation-details.tsx @@ -2,10 +2,10 @@ import { Grid, SkeletonText, Text } from '@chakra-ui/react' import { useTranslation } from 'react-i18next' import { useExecuteUseCaseOnMount } from '../../../../../../shared/arch/hooks/use-execute-use-case-on-mount' import { useSubscribeToUseCase } from '../../../../../../shared/arch/hooks/use-subscribe-to-use-case' -import { CreateVacationCmd } from '../../application/create-vacation-cmd' -import { DeleteVacationCmd } from '../../application/delete-vacation-cmd' +import { CreateVacationCmd } from '../../application/create-vacation.cmd' +import { DeleteVacationCmd } from '../../application/delete-vacation.cmd' import { GetVacationSummaryQry } from '../../application/get-vacation-summary-qry' -import { UpdateVacationCmd } from '../../application/update-vacation-cmd' +import { UpdateVacationCmd } from '../../application/update-vacation.cmd' import { FC } from 'react' interface Props { diff --git a/src/features/binnacle/features/vacation/ui/components/vacation-form-modal/vacation-form-modal.tsx b/src/features/binnacle/features/vacation/ui/components/vacation-form-modal/vacation-form-modal.tsx index 2dec0531c..c745eae9f 100644 --- a/src/features/binnacle/features/vacation/ui/components/vacation-form-modal/vacation-form-modal.tsx +++ b/src/features/binnacle/features/vacation/ui/components/vacation-form-modal/vacation-form-modal.tsx @@ -12,8 +12,8 @@ import { useTranslation } from 'react-i18next' import { useGetUseCase } from '../../../../../../../shared/arch/hooks/use-get-use-case' import { SubmitButton } from '../../../../../../../shared/components/form-fields/submit-button' import { useResolve } from '../../../../../../../shared/di/use-resolve' -import { CreateVacationCmd } from '../../../application/create-vacation-cmd' -import { UpdateVacationCmd } from '../../../application/update-vacation-cmd' +import { CreateVacationCmd } from '../../../application/create-vacation.cmd' +import { UpdateVacationCmd } from '../../../application/update-vacation.cmd' import { NewVacation } from '../../../domain/new-vacation' import { VacationErrorMessage } from '../../../domain/services/vacation-error-message' import { UpdateVacation } from '../../../domain/update-vacation' diff --git a/src/features/binnacle/features/vacation/ui/components/vacation-table/remove-vacation-button/remove-vacation-button.tsx b/src/features/binnacle/features/vacation/ui/components/vacation-table/remove-vacation-button/remove-vacation-button.tsx index 79ded42fe..ada3bfe3b 100644 --- a/src/features/binnacle/features/vacation/ui/components/vacation-table/remove-vacation-button/remove-vacation-button.tsx +++ b/src/features/binnacle/features/vacation/ui/components/vacation-table/remove-vacation-button/remove-vacation-button.tsx @@ -1,6 +1,6 @@ import { ExecutionOptions } from '@archimedes/arch' import { Button } from '@chakra-ui/react' -import { DeleteVacationCmd } from '../../../../application/delete-vacation-cmd' +import { DeleteVacationCmd } from '../../../../application/delete-vacation.cmd' import type { FC } from 'react' import { useRef, useState } from 'react' import { useTranslation } from 'react-i18next' diff --git a/src/features/binnacle/features/vacation/ui/components/vacation-table/vacation-table.tsx b/src/features/binnacle/features/vacation/ui/components/vacation-table/vacation-table.tsx index 0f62e9bf7..37d617aaa 100644 --- a/src/features/binnacle/features/vacation/ui/components/vacation-table/vacation-table.tsx +++ b/src/features/binnacle/features/vacation/ui/components/vacation-table/vacation-table.tsx @@ -1,10 +1,10 @@ import { Skeleton, Stack } from '@chakra-ui/react' import { useExecuteUseCaseOnMount } from '../../../../../../../shared/arch/hooks/use-execute-use-case-on-mount' import { useSubscribeToUseCase } from '../../../../../../../shared/arch/hooks/use-subscribe-to-use-case' -import { CreateVacationCmd } from '../../../application/create-vacation-cmd' -import { DeleteVacationCmd } from '../../../application/delete-vacation-cmd' +import { CreateVacationCmd } from '../../../application/create-vacation.cmd' +import { DeleteVacationCmd } from '../../../application/delete-vacation.cmd' import { GetAllVacationsQry } from '../../../application/get-all-vacations-qry' -import { UpdateVacationCmd } from '../../../application/update-vacation-cmd' +import { UpdateVacationCmd } from '../../../application/update-vacation.cmd' import { Vacation } from '../../../domain/vacation' import { LazyVacationTableDesktop } from './vacation-table-desktop/vacation-table.desktop.lazy' import { LazyVacationTableMobile } from './vacation-table-mobile/vacation-table.mobile.lazy' diff --git a/src/features/shared/user/features/settings/application/save-user-settings-cmd.test.ts b/src/features/shared/user/features/settings/application/save-user-settings-cmd.test.ts index d79a9a423..a040bfc4d 100644 --- a/src/features/shared/user/features/settings/application/save-user-settings-cmd.test.ts +++ b/src/features/shared/user/features/settings/application/save-user-settings-cmd.test.ts @@ -1,7 +1,7 @@ import { mock } from 'jest-mock-extended' import { UserSettingsMother } from '../../../../../../test-utils/mothers/user-settings-mother' import { UserSettingsRepository } from '../domain/user-settings-repository' -import { SaveUserSettingsCmd } from './save-user-settings-cmd' +import { SaveUserSettingsCmd } from './save-user-settings.cmd' describe('SaveUserSettingsCmd', () => { it('should save the user settings to the repository', async () => { diff --git a/src/features/shared/user/features/settings/application/save-user-settings-cmd.ts b/src/features/shared/user/features/settings/application/save-user-settings.cmd.ts similarity index 100% rename from src/features/shared/user/features/settings/application/save-user-settings-cmd.ts rename to src/features/shared/user/features/settings/application/save-user-settings.cmd.ts diff --git a/src/features/shared/user/features/settings/ui/settings-page.tsx b/src/features/shared/user/features/settings/ui/settings-page.tsx index 9c76490e1..4c23a8b20 100644 --- a/src/features/shared/user/features/settings/ui/settings-page.tsx +++ b/src/features/shared/user/features/settings/ui/settings-page.tsx @@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next' import { PageWithTitle } from '../../../../../../shared/components/page-with-title/page-with-title' import { useResolve } from '../../../../../../shared/di/use-resolve' import { GetUserSettingsQry } from '../application/get-user-settings-qry' -import { SaveUserSettingsCmd } from '../application/save-user-settings-cmd' +import { SaveUserSettingsCmd } from '../application/save-user-settings.cmd' import { UserSettings } from '../domain/user-settings' import { SettingsForm } from './components/settings-form/settings-form' diff --git a/src/shared/archimedes/archimedes.ts b/src/shared/archimedes/archimedes.ts index 0917d2c54..5ba2453e6 100644 --- a/src/shared/archimedes/archimedes.ts +++ b/src/shared/archimedes/archimedes.ts @@ -6,38 +6,38 @@ import { ExecutorLink, InvalidationPolicy } from '@archimedes/arch' -import { LogoutCmd } from '../../features/auth/application/logout-cmd' -import { ApproveActivityCmd } from '../../features/binnacle/features/activity/application/approve-activity-cmd' -import { CreateActivityCmd } from '../../features/binnacle/features/activity/application/create-activity-cmd' -import { DeleteActivityCmd } from '../../features/binnacle/features/activity/application/delete-activity-cmd' +import { LogoutCmd } from '../../features/auth/application/logout.cmd' +import { ApproveActivityCmd } from '../../features/binnacle/features/activity/application/approve-activity.cmd' +import { CreateActivityCmd } from '../../features/binnacle/features/activity/application/create-activity.cmd' +import { DeleteActivityCmd } from '../../features/binnacle/features/activity/application/delete-activity.cmd' import { GetActivitiesQry } from '../../features/binnacle/features/activity/application/get-activities-qry' -import { GetActivityEvidenceQry } from '../../features/binnacle/features/activity/application/get-activity-image-qry' import { GetActivitySummaryQry } from '../../features/binnacle/features/activity/application/get-activity-summary-qry' import { GetCalendarDataQry } from '../../features/binnacle/features/activity/application/get-calendar-data-qry' import { GetDaysForActivityDaysPeriodQry } from '../../features/binnacle/features/activity/application/get-days-for-activity-days-period-qry' import { GetActivitiesByFiltersQry } from '../../features/binnacle/features/activity/application/get-activities-by-filters-qry' import { GetTimeSummaryQry } from '../../features/binnacle/features/activity/application/get-time-summary-qry' import { GetYearBalanceQry } from '../../features/binnacle/features/activity/application/get-year-balance-qry' -import { UpdateActivityCmd } from '../../features/binnacle/features/activity/application/update-activity-cmd' +import { UpdateActivityCmd } from '../../features/binnacle/features/activity/application/update-activity.cmd' import { GetProjectRolesQry } from '../../features/binnacle/features/project-role/application/get-project-roles-qry' import { GetRecentProjectRolesQry } from '../../features/binnacle/features/project-role/application/get-recent-project-roles-qry' import { SearchProjectRolesQry } from '../../features/binnacle/features/search/application/search-project-roles-qry' -import { CreateVacationCmd } from '../../features/binnacle/features/vacation/application/create-vacation-cmd' -import { DeleteVacationCmd } from '../../features/binnacle/features/vacation/application/delete-vacation-cmd' +import { CreateVacationCmd } from '../../features/binnacle/features/vacation/application/create-vacation.cmd' +import { DeleteVacationCmd } from '../../features/binnacle/features/vacation/application/delete-vacation.cmd' import { GetAllVacationsForDateIntervalQry } from '../../features/binnacle/features/vacation/application/get-all-vacations-for-date-interval-qry' import { GetAllVacationsQry } from '../../features/binnacle/features/vacation/application/get-all-vacations-qry' import { GetDaysForVacationPeriodQry } from '../../features/binnacle/features/vacation/application/get-days-for-vacation-period-qry' import { GetVacationSummaryQry } from '../../features/binnacle/features/vacation/application/get-vacation-summary-qry' -import { UpdateVacationCmd } from '../../features/binnacle/features/vacation/application/update-vacation-cmd' +import { UpdateVacationCmd } from '../../features/binnacle/features/vacation/application/update-vacation.cmd' import { GetUserSettingsQry } from '../../features/shared/user/features/settings/application/get-user-settings-qry' -import { SaveUserSettingsCmd } from '../../features/shared/user/features/settings/application/save-user-settings-cmd' +import { SaveUserSettingsCmd } from '../../features/shared/user/features/settings/application/save-user-settings.cmd' import { TOAST } from '../di/container-tokens' import { container } from 'tsyringe' -import { BlockProjectCmd } from '../../features/administration/features/project/application/block-project-cmd' +import { BlockProjectCmd } from '../../features/administration/features/project/application/block-project.cmd' import { GetProjectsWithBlockerUserName } from '../../features/administration/features/project/application/get-projects-with-blocker-user-name' -import { UnblockProjectCmd } from '../../features/administration/features/project/application/unblock-project-cmd' +import { UnblockProjectCmd } from '../../features/administration/features/project/application/unblock-project.cmd' import { ToastNotificationLink } from './links/toast-notification-link' import { ToastType } from '../notification/toast' +import { GetActivityEvidenceQry } from '../../features/binnacle/features/activity/application/get-activity-evidence-qry' import { GetAbsencesQry } from '../../features/binnacle/features/availability/application/get-absences-qry' const toast = container.resolve(TOAST) diff --git a/src/shared/base64/base64-converter.ts b/src/shared/base64/base64-converter.ts deleted file mode 100644 index 2ad6d7d03..000000000 --- a/src/shared/base64/base64-converter.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { injectable } from 'tsyringe' - -@injectable() -export class Base64Converter { - async toFile(base64: string, filename: ''): Promise { - const base64Response = await fetch(base64) - const blob = await base64Response.blob() - const fileTypeRegex = /^data:(.+);base64,/ - const match = base64.match(fileTypeRegex) - const fileType = match ? match[1] : '' - - return new File([blob], filename, { - type: fileType - }) - } - - toBase64(file: File): Promise { - return new Promise((resolve, reject) => { - try { - const reader = new FileReader() - reader.onloadend = () => { - const base64 = (reader.result as string).split('base64,')[1] - resolve(base64) - } - - reader.readAsDataURL(file!) - } catch (error) { - reject(error) - } - }) - } -} diff --git a/src/shared/components/file-field.tsx b/src/shared/components/file-field.tsx index e01d6b328..1515042a4 100644 --- a/src/shared/components/file-field.tsx +++ b/src/shared/components/file-field.tsx @@ -13,15 +13,19 @@ import { FC, useCallback } from 'react' import { useDropzone } from 'react-dropzone' import { useTranslation } from 'react-i18next' import { openFilePreview } from '../utils/open-file-preview' +import { RemoteFile } from '../../features/binnacle/features/attachments/domain/remote-file' +import { isFileType } from '../../features/binnacle/features/attachments/domain/services/file-utils' +import { Uuid } from '../types/uuid' interface Props { gridArea: string - onChange: (files: File[]) => void + onChange: (files: (File | RemoteFile)[]) => void label: string maxFiles?: number - files?: File[] + files?: (File | RemoteFile)[] isLoading?: boolean isReadOnly?: boolean + handlePreviewClick: (uuid: Uuid) => void } const compressionOptions = { @@ -43,7 +47,8 @@ export const FileField: FC = (props) => { label = t('files.attachments'), files = [], isLoading = false, - isReadOnly = false + isReadOnly = false, + handlePreviewClick } = props const onDrop = useCallback( @@ -136,15 +141,15 @@ export const FileField: FC = (props) => {
    {files.map((file, i) => ( -
  • +
  • <> - {file.name} + {isFileType(file) && file.name} { event.stopPropagation() - handlePreview(file) + isFileType(file) ? handlePreview(file) : handlePreviewClick(file.id) }} variant="ghost" isRound={true} diff --git a/src/shared/components/global-error-boundary.tsx b/src/shared/components/global-error-boundary.tsx index 3a06d016a..414172fe1 100644 --- a/src/shared/components/global-error-boundary.tsx +++ b/src/shared/components/global-error-boundary.tsx @@ -1,9 +1,12 @@ import type { ErrorInfo, ReactNode } from 'react' import { Component } from 'react' -import { Box, Heading, StackDivider, Text, VStack } from '@chakra-ui/react' +import { Box, Heading, StackDivider, Text, VStack, Link } from '@chakra-ui/react' +import { TFunction } from 'i18next' +import { PageWithTitle } from './page-with-title/page-with-title' interface Props { children: ReactNode + t: TFunction<'translation'> } interface State { @@ -29,24 +32,27 @@ export class GlobalErrorBoundary extends Component { public render() { if (this.state.error !== null) { return ( - - - 😱 - - - Oops! Something went wrong. - - This could be a cache issue, please clean up your cache and try again. - If the problem persists, contact us. - -
    - Error details - } spacing={2} align="left"> - {this.state.error && this.state.error.toString()} - {this.state.errorInfo?.componentStack} - -
    -
    + + + + + + {this.props.t('global_error.description_1')} + {this.props.t('global_error.description_2')} + {this.props.t('global_error.cta_1')} + + {this.props.t('global_error.cta_2')} + + +
    + {this.props.t('global_error.error_details')} + } spacing={2} align="left"> + {this.state.error && this.state.error.toString()} + {this.state.errorInfo?.componentStack} + +
    +
    +
    ) } diff --git a/src/shared/components/navbar/nav-menu.tsx b/src/shared/components/navbar/nav-menu.tsx index 1b9aad697..c57e480a7 100644 --- a/src/shared/components/navbar/nav-menu.tsx +++ b/src/shared/components/navbar/nav-menu.tsx @@ -7,7 +7,7 @@ import { CalendarIcon, CogIcon } from '@heroicons/react/20/solid' -import { LogoutCmd } from '../../../features/auth/application/logout-cmd' +import { LogoutCmd } from '../../../features/auth/application/logout.cmd' import { useTranslation } from 'react-i18next' import { useNavigate } from 'react-router-dom' import { NavItemButton } from './nav-item-button' diff --git a/src/shared/di/container-tokens.ts b/src/shared/di/container-tokens.ts index c58bcfe70..e2cfb1cf3 100644 --- a/src/shared/di/container-tokens.ts +++ b/src/shared/di/container-tokens.ts @@ -2,6 +2,7 @@ export const STORAGE = Symbol('STORAGE') export const TOAST = Symbol('TOAST') export const ACTIVITY_REPOSITORY = Symbol('ACTIVITY_REPOSITORY') export const VACATION_REPOSITORY = Symbol('VACATION_REPOSITORY') +export const ATTACHMENT_REPOSITORY = Symbol('ATTACHMENT_REPOSITORY') export const HOLIDAY_REPOSITORY = Symbol('HOLIDAY_REPOSITORY') export const SEARCH_REPOSITORY = Symbol('SEARCH_REPOSITORY') export const AUTH_REPOSITORY = Symbol('AUTH_REPOSITORY') diff --git a/src/shared/di/container.ts b/src/shared/di/container.ts index 551987b66..112190f0a 100644 --- a/src/shared/di/container.ts +++ b/src/shared/di/container.ts @@ -12,6 +12,7 @@ import { container } from 'tsyringe' import { ABSENCE_REPOSITORY, ACTIVITY_REPOSITORY, + ATTACHMENT_REPOSITORY, AUTH_REPOSITORY, HOLIDAY_REPOSITORY, ORGANIZATION_REPOSITORY, @@ -28,6 +29,7 @@ import { import { toast, ToastType } from '../notification/toast' import { HttpProjectRepository } from '../../features/shared/project/infrastructure/http-project-repository' import { HttpAbsenceRepository } from '../../features/binnacle/features/availability/infrastructure/http-absence-repository' +import { HttpAttachmentRepository } from '../../features/binnacle/features/attachments/infrastructure/http-attachment-repository' container.register(STORAGE, { useValue: localStorage }) container.register(TOAST, { useValue: toast }) @@ -37,6 +39,7 @@ container.registerSingleton(USER_REPOSITORY, HttpUserRepository) container.registerSingleton(USER_SETTINGS_REPOSITORY, LocalStorageUserSettingsRepository) container.registerSingleton(VACATION_REPOSITORY, HttpVacationRepository) container.registerSingleton(HOLIDAY_REPOSITORY, HttpHolidayRepository) +container.registerSingleton(ATTACHMENT_REPOSITORY, HttpAttachmentRepository) container.registerSingleton(SEARCH_REPOSITORY, HttpSearchRepository) container.registerSingleton(PROJECT_ROLE_REPOSITORY, HttpProjectRoleRepository) container.registerSingleton(PROJECT_REPOSITORY, HttpProjectRepository) diff --git a/src/shared/http/get-params-serializer.ts b/src/shared/http/get-params-serializer.ts index 351f26367..75a991ca1 100644 --- a/src/shared/http/get-params-serializer.ts +++ b/src/shared/http/get-params-serializer.ts @@ -1,4 +1,3 @@ export function getParamsSerializer(params: Record) { - // JavaScript automatically converts the value toString() return new URLSearchParams(params as Record).toString() } diff --git a/src/shared/http/http-client.ts b/src/shared/http/http-client.ts index 68fcec146..cc6ba8ace 100644 --- a/src/shared/http/http-client.ts +++ b/src/shared/http/http-client.ts @@ -43,7 +43,7 @@ export class HttpClient { return data } - async post(path: string, data?: DataType, config?: AxiosRequestConfig): Promise { + async post(path: string, data?: DataType | FormData, config?: AxiosRequestConfig): Promise { const response = await this.httpInstance.post(path, data, config) return response.data } diff --git a/src/shared/i18n/en.json b/src/shared/i18n/en.json index a16e1ee81..c6480d9d7 100644 --- a/src/shared/i18n/en.json +++ b/src/shared/i18n/en.json @@ -14,6 +14,14 @@ "logo": "Logo", "goHome": "Go to the home page" }, + "global_error": { + "title": "Sorry, something went wrong.", + "description_1": "We don't know exactly what caused the error, but we'll try to fix it as soon as possible.", + "description_2": "In the meantime you can try by clearing your cache and reloading the page.", + "cta_1": "If the problem persists, ", + "cta_2": "contact support.", + "error_details": "Error details" + }, "api_errors": { "general_description": "Please try again later", "unauthorized": "Unauthorized", diff --git a/src/shared/i18n/es.json b/src/shared/i18n/es.json index d4c6a3122..1b79ee625 100644 --- a/src/shared/i18n/es.json +++ b/src/shared/i18n/es.json @@ -14,6 +14,14 @@ "logo": "Logo", "goHome": "Ir a la página principal" }, + "global_error": { + "title": "Lo sentimos, algo salió mal.", + "description_1": "No sabemos exactamente qué causó el error, pero trataremos de solucionarlo lo antes posible.", + "description_2": "Mientras tanto, puedes intentar borrando tu caché y recargando la página.", + "cta_1": "Si el problema persiste, ", + "cta_2": "contacta al soporte.", + "error_details": "Detalles del error" + }, "api_errors": { "general_description": "Por favor, inténtalo más tarde", "unauthorized": "Acceso no autorizado", diff --git a/src/shared/router/require-auth.tsx b/src/shared/router/require-auth.tsx index 0dac12b7b..64ab553ca 100644 --- a/src/shared/router/require-auth.tsx +++ b/src/shared/router/require-auth.tsx @@ -7,10 +7,13 @@ export const RequireAuth: FC = ({ children }) => { const { isLoggedIn } = useAuthContext() const location = useLocation() - if (isLoggedIn === undefined) return null + if (isLoggedIn === undefined) { + return null + } + if (!isLoggedIn) { return } - return children as any + return children } diff --git a/src/shared/types/url.ts b/src/shared/types/url.ts new file mode 100644 index 000000000..41175d51a --- /dev/null +++ b/src/shared/types/url.ts @@ -0,0 +1 @@ +export type Url = string diff --git a/src/shared/types/uuid.ts b/src/shared/types/uuid.ts new file mode 100644 index 000000000..3bf240f9d --- /dev/null +++ b/src/shared/types/uuid.ts @@ -0,0 +1 @@ +export type Uuid = string diff --git a/src/test-utils/di/integration-di.ts b/src/test-utils/di/integration-di.ts index 5ddbb365d..3c403dda9 100644 --- a/src/test-utils/di/integration-di.ts +++ b/src/test-utils/di/integration-di.ts @@ -3,6 +3,7 @@ import { container } from 'tsyringe' import { ABSENCE_REPOSITORY, ACTIVITY_REPOSITORY, + ATTACHMENT_REPOSITORY, AUTH_REPOSITORY, HOLIDAY_REPOSITORY, ORGANIZATION_REPOSITORY, @@ -27,6 +28,7 @@ import { FakeOrganizationRepository } from '../../features/binnacle/features/org import { FakeActivityRepository } from '../../features/binnacle/features/activity/infrastructure/fake-activity-repository' import { toast, ToastType } from '../../shared/notification/toast' import { FakeUserSettingsRepository } from '../../features/shared/user/features/settings/infrastructure/fake-user-settings-repository' +import { FakeAttachmentRepository } from '../../features/binnacle/features/attachments/infrastructure/fake-attachment-repository' import { FakeProjectRepository } from '../../features/shared/project/infrastructure/fake-project-repository' import { FakeAbsenceRepository } from '../../features/binnacle/features/availability/infrastructure/fake-absence-repository' @@ -40,6 +42,7 @@ container.registerSingleton(VACATION_REPOSITORY, FakeVacationRepository) container.registerSingleton(HOLIDAY_REPOSITORY, FakeHolidayRepository) container.registerSingleton(SEARCH_REPOSITORY, FakeSearchRepository) container.registerSingleton(PROJECT_ROLE_REPOSITORY, FakeProjectRoleRepository) +container.registerSingleton(ATTACHMENT_REPOSITORY, FakeAttachmentRepository) container.registerSingleton(PROJECT_REPOSITORY, FakeProjectRepository) container.registerSingleton(ORGANIZATION_REPOSITORY, FakeOrganizationRepository) container.registerSingleton(ACTIVITY_REPOSITORY, FakeActivityRepository) diff --git a/src/test-utils/mothers/activity-mother.ts b/src/test-utils/mothers/activity-mother.ts index 8d3044ba6..4f2d28e02 100644 --- a/src/test-utils/mothers/activity-mother.ts +++ b/src/test-utils/mothers/activity-mother.ts @@ -89,7 +89,7 @@ export class ActivityMother { id: 1, description: 'Minutes activity', billable: true, - hasEvidences: false, + evidences: [], organization: OrganizationMother.organization(), project: LiteProjectMother.billableLiteProjectWithOrganizationId(), projectRole: ProjectRoleMother.liteProjectRoleInMinutes(), @@ -176,8 +176,8 @@ export class ActivityMother { id: 1, description: 'Accepted activity in days', billable: false, - hasEvidences: true, organization: OrganizationMother.organization(), + evidences: [], project: LiteProjectMother.billableLiteProjectWithOrganizationId(), projectRole: ProjectRoleMother.liteProjectRoleInDaysRequireApproval(), approval: { @@ -201,8 +201,8 @@ export class ActivityMother { return { id: 4, description: 'Pending activity in days', + evidences: [], billable: false, - hasEvidences: false, organization: OrganizationMother.organization(), project: LiteProjectMother.billableLiteProjectWithOrganizationId(), projectRole: ProjectRoleMother.liteProjectRoleInDaysRequireApproval(), @@ -724,13 +724,12 @@ export class ActivityMother { return { description: 'any-description', billable: true, + evidences: [], interval: { start: new Date('2000-03-01T09:00:00.000Z'), end: new Date('2000-03-01T13:00:00.000Z') }, - projectRoleId: 1, - evidence: 'file' as any, - hasEvidences: false + projectRoleId: 1 } } @@ -738,14 +737,13 @@ export class ActivityMother { return { id: 1, description: 'any-description', + evidences: [], billable: true, interval: { start: new Date('2000-03-01T09:00:00.000Z'), end: new Date('2000-03-01T13:00:00.000Z') }, - projectRoleId: 1, - evidence: 'file' as any, - hasEvidences: false + projectRoleId: 1 } } }