diff --git a/src/app/drive/types/index.ts b/src/app/drive/types/index.ts index 64f0be9bd3..34dd3b9ebb 100644 --- a/src/app/drive/types/index.ts +++ b/src/app/drive/types/index.ts @@ -30,6 +30,7 @@ export interface DriveFolderData { uuid: string; type?: string; user?: UserResumeData; + caducityDate?: string; } export interface DriveFolderMetadataPayload { @@ -63,6 +64,7 @@ export interface DriveFileData { sharings?: { type: string; id: string }[]; uuid: string; user?: UserResumeData; + caducityDate?: string; } interface Thumbnail { diff --git a/src/app/i18n/locales/de.json b/src/app/i18n/locales/de.json index 3576fd6721..22ef4311b3 100644 --- a/src/app/i18n/locales/de.json +++ b/src/app/i18n/locales/de.json @@ -1164,6 +1164,10 @@ "restore": "Wiederherstellen", "delete-permanently": "Lösche dauerhaft" }, + "autoDelete": { + "inDays_one": "In {{count}} Tag", + "inDays_other": "In {{count}} Tagen" + }, "automaticDisposal": { "badge": "Neues Update", "title": "Automatische Papierkorb-Entsorgung", @@ -1454,7 +1458,8 @@ "name": "Name", "modified": "Modifiziert", "size": "Größe", - "actions": "Aktionen" + "actions": "Aktionen", + "autoDelete": "Automatisches Löschen" } }, "viewMode": { diff --git a/src/app/i18n/locales/en.json b/src/app/i18n/locales/en.json index 40c2e4f912..e1844a2e8c 100644 --- a/src/app/i18n/locales/en.json +++ b/src/app/i18n/locales/en.json @@ -1249,6 +1249,10 @@ "restore": "Restore", "delete-permanently": "Delete permanently" }, + "autoDelete": { + "inDays_one": "In {{count}} day", + "inDays_other": "In {{count}} days" + }, "automaticDisposal": { "badge": "New update", "title": "Automatic Trash disposal", @@ -1536,7 +1540,8 @@ "name": "Name", "modified": "Modified", "size": "Size", - "actions": "Actions" + "actions": "Actions", + "autoDelete": "Auto-delete" } }, "viewMode": { diff --git a/src/app/i18n/locales/es.json b/src/app/i18n/locales/es.json index 914c273228..ee2eb3d0ff 100644 --- a/src/app/i18n/locales/es.json +++ b/src/app/i18n/locales/es.json @@ -1226,6 +1226,10 @@ "restore": "Restaurar", "delete-permanently": "Eliminar permanentemente" }, + "autoDelete": { + "inDays_one": "En {{count}} día", + "inDays_other": "En {{count}} días" + }, "automaticDisposal": { "badge": "Nueva actualización", "title": "Eliminación automática de la Papelera", @@ -1514,7 +1518,8 @@ "name": "Nombre", "modified": "Modificado", "size": "Tamaño", - "actions": "Acciones" + "actions": "Acciones", + "autoDelete": "Eliminación automática" } }, "viewMode": { diff --git a/src/app/i18n/locales/fr.json b/src/app/i18n/locales/fr.json index 96bfef8e97..3709a48908 100644 --- a/src/app/i18n/locales/fr.json +++ b/src/app/i18n/locales/fr.json @@ -1177,6 +1177,10 @@ "restore": "Restaurer", "delete-permanently": "Supprimer définitivement" }, + "autoDelete": { + "inDays_one": "Dans {{count}} jour", + "inDays_other": "Dans {{count}} jours" + }, "automaticDisposal": { "badge": "Nouvelle mise à jour", "title": "Élimination automatique de la Corbeille", @@ -1460,7 +1464,8 @@ "name": "Nom", "modified": "Modifié", "size": "Taille", - "actions": "Actions" + "actions": "Actions", + "autoDelete": "Suppression automatique" } }, "viewMode": { diff --git a/src/app/i18n/locales/it.json b/src/app/i18n/locales/it.json index 0969174182..9d37c04d25 100644 --- a/src/app/i18n/locales/it.json +++ b/src/app/i18n/locales/it.json @@ -1284,6 +1284,10 @@ "restore": "Ripristino", "delete-permanently": "Elimina definitivamente" }, + "autoDelete": { + "inDays_one": "Tra {{count}} giorno", + "inDays_other": "Tra {{count}} giorni" + }, "automaticDisposal": { "badge": "Nuovo aggiornamento", "title": "Eliminazione automatica del Cestino", @@ -1567,7 +1571,8 @@ "name": "Nome", "modified": "Ultima modifica", "size": "Dimensione", - "actions": "Azioni" + "actions": "Azioni", + "autoDelete": "Eliminazione automatica" } }, "viewMode": { diff --git a/src/app/i18n/locales/ru.json b/src/app/i18n/locales/ru.json index 5e549f2f51..91db029fbc 100644 --- a/src/app/i18n/locales/ru.json +++ b/src/app/i18n/locales/ru.json @@ -1190,6 +1190,10 @@ "restore": "Восстановить", "delete-permanently": "Удалить навсегда" }, + "autoDelete": { + "inDays_one": "Через {{count}} день", + "inDays_other": "Через {{count}} дней" + }, "automaticDisposal": { "badge": "Новое обновление", "title": "Автоматическая очистка Корзины", @@ -1473,7 +1477,8 @@ "name": "Имя", "modified": "Изменено", "size": "Размер", - "actions": "Действия" + "actions": "Действия", + "autoDelete": "Автоматическое удаление" } }, "viewMode": { diff --git a/src/app/i18n/locales/tw.json b/src/app/i18n/locales/tw.json index da9c8fe92e..eabbd975e4 100644 --- a/src/app/i18n/locales/tw.json +++ b/src/app/i18n/locales/tw.json @@ -1179,6 +1179,10 @@ "restore": "還原", "delete-permanently": "永久刪除" }, + "autoDelete": { + "inDays_one": "{{count}} 天後", + "inDays_other": "{{count}} 天後" + }, "automaticDisposal": { "badge": "新更新", "title": "自動清空垃圾桶", @@ -1466,7 +1470,8 @@ "name": "名稱", "modified": "修改日期", "size": "大小", - "actions": "操作" + "actions": "操作", + "autoDelete": "自動刪除" } }, "viewMode": { diff --git a/src/app/i18n/locales/zh.json b/src/app/i18n/locales/zh.json index be90f57298..8bec6dc6e1 100644 --- a/src/app/i18n/locales/zh.json +++ b/src/app/i18n/locales/zh.json @@ -1214,6 +1214,10 @@ "restore": "恢复", "delete-permanently": "永久删除" }, + "autoDelete": { + "inDays_one": "{{count}} 天后", + "inDays_other": "{{count}} 天后" + }, "automaticDisposal": { "badge": "新更新", "title": "自动清空垃圾箱", @@ -1501,7 +1505,8 @@ "name": "名称", "modified": "修改日期", "size": "大小", - "actions": "操作" + "actions": "操作", + "autoDelete": "自动删除" } }, "viewMode": { diff --git a/src/components/Skeleton.tsx b/src/components/Skeleton.tsx index 76110e6c73..56bdbd421d 100644 --- a/src/components/Skeleton.tsx +++ b/src/components/Skeleton.tsx @@ -7,3 +7,13 @@ export const skinSkeleton = [
,
, ]; + +export const skinSkeletonTrash = [ +
+
+
+
, +
, +
, +
, +]; diff --git a/src/services/date.service.test.ts b/src/services/date.service.test.ts index d29e6215ca..b1526e9d1a 100644 --- a/src/services/date.service.test.ts +++ b/src/services/date.service.test.ts @@ -35,4 +35,35 @@ describe('dateService', () => { expect(isBefore).toBe(false); }); + + describe('calculateDaysUntilDate', () => { + test('when target date is undefined, then returns 0', () => { + const result = dateService.calculateDaysUntilDate(); + expect(result).toBe(0); + }); + + test('when target date is in the past, then returns 0', () => { + const pastDate = dayjs().subtract(5, 'day').toISOString(); + const result = dateService.calculateDaysUntilDate(pastDate); + expect(result).toBe(0); + }); + + test('when target date is 10 days in the future, then returns 10', () => { + const futureDate = dayjs().add(10, 'day').toISOString(); + const result = dateService.calculateDaysUntilDate(futureDate); + expect(result).toBe(10); + }); + + test('when target date is tomorrow, then returns 1', () => { + const tomorrow = dayjs().add(1, 'day').toISOString(); + const result = dateService.calculateDaysUntilDate(tomorrow); + expect(result).toBe(1); + }); + + test('when target date is a Date object 30 days ahead, then returns 30', () => { + const futureDate = dayjs().add(30, 'day').toDate(); + const result = dateService.calculateDaysUntilDate(futureDate); + expect(result).toBe(30); + }); + }); }); diff --git a/src/services/date.service.ts b/src/services/date.service.ts index 518395ed7f..75a7599988 100644 --- a/src/services/date.service.ts +++ b/src/services/date.service.ts @@ -28,6 +28,15 @@ export const formatDefaultDate = (date: Date | string | number, translate: (key: return dayjs(date).format(`D MMM, YYYY [${translatedAt}] HH:mm`); }; +function calculateDaysUntilDate(targetDate?: string | Date): number { + if (!targetDate) return 0; + + const target = dayjs(targetDate); + const now = dayjs(); + const diffDays = target.diff(now, 'day'); + return Math.max(diffDays, 0); +} + const dateService = { format, fromNow, @@ -35,6 +44,7 @@ const dateService = { getCurrentDate, getExpirationDate, formatDefaultDate, + calculateDaysUntilDate, }; export default dateService; diff --git a/src/views/Drive/components/DriveExplorer/components/DriveExplorerList.tsx b/src/views/Drive/components/DriveExplorer/components/DriveExplorerList/DriveExplorerList.tsx similarity index 93% rename from src/views/Drive/components/DriveExplorer/components/DriveExplorerList.tsx rename to src/views/Drive/components/DriveExplorer/components/DriveExplorerList/DriveExplorerList.tsx index b3749e34ba..574be21919 100644 --- a/src/views/Drive/components/DriveExplorer/components/DriveExplorerList.tsx +++ b/src/views/Drive/components/DriveExplorer/components/DriveExplorerList/DriveExplorerList.tsx @@ -3,12 +3,13 @@ import storageSelectors from 'app/store/slices/storage/storage.selectors'; import { fetchSortedFolderContentThunk } from 'app/store/slices/storage/storage.thunks/fetchSortedFolderContentThunk'; import React, { memo, useCallback, useState } from 'react'; import { connect, useSelector } from 'react-redux'; +import { getListHeaders } from './getListHeaders'; import { ListShareLinksItem, Role } from '@internxt/sdk/dist/drive/share/types'; import navigationService from 'services/navigation.service'; import { useTranslationContext } from 'app/i18n/provider/TranslationProvider'; -import { skinSkeleton } from 'components/Skeleton'; -import { moveItemsToTrash } from '../../../../../views/Trash/services'; +import { skinSkeleton, skinSkeletonTrash } from 'components/Skeleton'; +import { moveItemsToTrash } from 'views/Trash/services'; import { OrderDirection, OrderSettings } from 'app/core/types'; import shareService from 'app/share/services/share.service'; import { AppDispatch, RootState } from 'app/store'; @@ -31,7 +32,7 @@ import { contextMenuTrashItems, contextMenuWorkspaceFile, contextMenuWorkspaceFolder, -} from './DriveItemContextMenu'; +} from '../DriveItemContextMenu'; import { List } from '@internxt/ui'; import { DownloadManager } from 'app/network/DownloadManager'; @@ -57,7 +58,7 @@ interface DriveExplorerListProps { type ObjectWithId = { id: string | number }; -type SortField = 'type' | 'name' | 'updatedAt' | 'size'; +type SortField = 'type' | 'name' | 'updatedAt' | 'size' | 'caducityDate'; type ContextMenuDriveItem = DriveItemData | Pick | (ListShareLinksItem & { code: string }); @@ -139,6 +140,7 @@ const DriveExplorerList: React.FC = memo((props) => { const currentFolderId = useAppSelector(storageSelectors.currentFolderId); const isRecents = props.title === translate('views.recents.head'); const isTrash = props.title === translate('trash.trash'); + const skeleton = isTrash ? skinSkeletonTrash : skinSkeleton; const sortBy = (value: { field: SortField; direction: 'ASC' | 'DESC' }) => { let direction = OrderDirection.Asc; @@ -167,6 +169,12 @@ const DriveExplorerList: React.FC = memo((props) => { if (value.field === 'size') { resetDriveOrder({ dispatch, orderType: 'size', direction, currentFolderId }); } + + if (value.field === 'caducityDate') { + if (isTrash) { + props.resetPaginationState(); + } + } }; function handleMouseEnter() { @@ -451,32 +459,8 @@ const DriveExplorerList: React.FC = memo((props) => { /> )} - - header={[ - { - label: translate('drive.list.columns.name'), - width: 'flex grow items-center min-w-driveNameHeader', - name: 'name', - orderable: !isRecents, - defaultDirection: 'ASC', - buttonDataCy: 'driveListHeaderNameButton', - textDataCy: 'driveListHeaderNameButtonText', - }, - { - label: translate('drive.list.columns.modified'), - width: 'w-date', - name: 'updatedAt', - orderable: !isRecents, - defaultDirection: 'ASC', - }, - { - label: translate('drive.list.columns.size'), - orderable: !isRecents && !isTrash, - defaultDirection: 'ASC', - width: 'w-size', - name: 'size', - }, - ]} + + header={getListHeaders(translate, isRecents, isTrash)} checkboxDataCy="driveListHeaderCheckbox" disableKeyboardShortcuts={props.disableKeyboardShortcuts || props.showStopSharingConfirmation} items={props.items} @@ -485,7 +469,7 @@ const DriveExplorerList: React.FC = memo((props) => { itemComposition={[(item) => createDriveListItem(item, props.isTrash)]} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} - skinSkeleton={skinSkeleton} + skinSkeleton={skeleton} emptyState={<>} onNextPage={onEndOfScroll} onEnterPressed={(driveItem) => { diff --git a/src/views/Drive/components/DriveExplorer/components/DriveExplorerListItem.scss b/src/views/Drive/components/DriveExplorer/components/DriveExplorerList/DriveExplorerListItem.scss similarity index 100% rename from src/views/Drive/components/DriveExplorer/components/DriveExplorerListItem.scss rename to src/views/Drive/components/DriveExplorer/components/DriveExplorerList/DriveExplorerListItem.scss diff --git a/src/views/Drive/components/DriveExplorer/components/DriveExplorerListItem.tsx b/src/views/Drive/components/DriveExplorer/components/DriveExplorerList/DriveExplorerListItem.tsx similarity index 76% rename from src/views/Drive/components/DriveExplorer/components/DriveExplorerListItem.tsx rename to src/views/Drive/components/DriveExplorer/components/DriveExplorerList/DriveExplorerListItem.tsx index 8ce573bc36..85973f7b24 100644 --- a/src/views/Drive/components/DriveExplorer/components/DriveExplorerListItem.tsx +++ b/src/views/Drive/components/DriveExplorer/components/DriveExplorerList/DriveExplorerListItem.tsx @@ -1,14 +1,15 @@ import { items } from '@internxt/lib'; import usersIcon from 'assets/icons/users.svg'; -import { useEffect } from 'react'; -import { DriveExplorerItemProps } from '../types'; +import { useEffect, useMemo } from 'react'; +import { DriveExplorerItemProps } from '../../types'; import dateService from 'services/date.service'; import transformItemService from 'app/drive/services/item-transform.service'; import sizeService from 'app/drive/services/size.service'; import iconService from 'app/drive/services/icon.service'; -import { useDriveItemActions, useDriveItemDrag, useDriveItemDrop, useDriveItemStoreProps } from '../../../hooks'; +import { useDriveItemActions, useDriveItemDrag, useDriveItemDrop, useDriveItemStoreProps } from '../../../../hooks'; import './DriveExplorerListItem.scss'; -import { t } from 'i18next'; +import { useTranslationContext } from 'app/i18n/provider/TranslationProvider'; +import { WarningCircle } from '@phosphor-icons/react'; const getItemClassNames = (isSelected: boolean, isDraggingOver: boolean, isDragging: boolean): string => { const selectedClass = isSelected ? 'selected' : ''; @@ -21,7 +22,21 @@ const isItemInteractive = (item: DriveExplorerItemProps['item']): boolean => { return (item.isFolder && !item.deleted) || (!item.isFolder && item.status === 'EXISTS'); }; -const DriveExplorerListItem = ({ item }: DriveExplorerItemProps): JSX.Element => { +const URGENT_AUTO_DELETE_THRESHOLD_DAYS = 2; + +const getAutoDeleteStatusInfo = ( + days: number, + translate: (key: string, options?: { count?: number }) => string, +): { text: string; isUrgent: boolean } => { + const isUrgent = days <= URGENT_AUTO_DELETE_THRESHOLD_DAYS; + return { + text: translate('trash.autoDelete.inDays', { count: days }), + isUrgent, + }; +}; + +const DriveExplorerListItem = ({ item, isTrash }: DriveExplorerItemProps): JSX.Element => { + const { translate } = useTranslationContext(); const { isItemSelected, isEditingName } = useDriveItemStoreProps(); const { nameInputRef, onNameClicked, onItemClicked, onItemDoubleClicked, downloadAndSetThumbnail } = useDriveItemActions(item); @@ -30,6 +45,12 @@ const DriveExplorerListItem = ({ item }: DriveExplorerItemProps): JSX.Element => const { connectDropTarget, isDraggingOverThisItem } = useDriveItemDrop(item); const ItemIconComponent = iconService.getItemIcon(item.isFolder, item.type); + const daysUntilDelete = isTrash ? dateService.calculateDaysUntilDate(item.caducityDate) : 0; + const autoDeleteStatusInfo = useMemo( + () => (isTrash && daysUntilDelete > 0 ? getAutoDeleteStatusInfo(daysUntilDelete, translate) : null), + [isTrash, daysUntilDelete, translate], + ); + useEffect(() => { if (isEditingName(item)) { const current = nameInputRef.current; @@ -111,9 +132,19 @@ const DriveExplorerListItem = ({ item }: DriveExplorerItemProps): JSX.Element => isInteractive && connectDropTarget(
) } + {/* AUTO-DELETE (only for trash) */} + {isTrash && autoDeleteStatusInfo && ( +
+
+ + {autoDeleteStatusInfo.text} +
+
+ )} + {/* DATE */}
- {dateService.formatDefaultDate(item.updatedAt, t)} + {dateService.formatDefaultDate(item.updatedAt, translate)}
{/* SIZE */} diff --git a/src/views/Drive/components/DriveExplorer/components/DriveExplorerList/getListHeaders.test.ts b/src/views/Drive/components/DriveExplorer/components/DriveExplorerList/getListHeaders.test.ts new file mode 100644 index 0000000000..dfd6e7311b --- /dev/null +++ b/src/views/Drive/components/DriveExplorer/components/DriveExplorerList/getListHeaders.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test, vi } from 'vitest'; +import { getListHeaders } from './getListHeaders'; + +describe('List column headers', () => { + const mockTranslate = vi.fn((key: string) => key); + + test('when viewing the regular drive, then it returns name, modified date, and size columns', () => { + const headers = getListHeaders(mockTranslate, false, false); + + expect(headers).toHaveLength(3); + expect(headers[0].name).toBe('name'); + expect(headers[1].name).toBe('updatedAt'); + expect(headers[2].name).toBe('size'); + }); + + test('when viewing the trash, then it includes a sortable auto-delete column and disables size sorting', () => { + const headers = getListHeaders(mockTranslate, false, true); + + expect(headers).toHaveLength(4); + + const caducityHeader = headers.find((h) => h.name === 'caducityDate'); + expect(caducityHeader?.label).toBe('drive.list.columns.autoDelete'); + expect(caducityHeader?.orderable).toBe(true); + + const sizeHeader = headers.find((h) => h.name === 'size'); + expect(sizeHeader?.orderable).toBe(false); + }); + + test('when viewing recent files, then all columns cannot be sorted', () => { + const headers = getListHeaders(mockTranslate, true, false); + + headers.forEach((header) => { + expect(header.orderable).toBe(false); + }); + }); +}); diff --git a/src/views/Drive/components/DriveExplorer/components/DriveExplorerList/getListHeaders.ts b/src/views/Drive/components/DriveExplorer/components/DriveExplorerList/getListHeaders.ts new file mode 100644 index 0000000000..00a60f2997 --- /dev/null +++ b/src/views/Drive/components/DriveExplorer/components/DriveExplorerList/getListHeaders.ts @@ -0,0 +1,56 @@ +interface ListHeaderItem { + label: string; + width: string; + name: 'type' | 'name' | 'updatedAt' | 'size' | 'caducityDate'; + orderable: boolean; + defaultDirection: 'ASC' | 'DESC'; + buttonDataCy?: string; + textDataCy?: string; +} + +export const getListHeaders = ( + translate: (key: string) => string, + isRecents: boolean, + isTrash: boolean, +): ListHeaderItem[] => { + const headers: ListHeaderItem[] = [ + { + label: translate('drive.list.columns.name'), + width: 'flex grow items-center min-w-driveNameHeader', + name: 'name', + orderable: !isRecents, + defaultDirection: 'ASC', + buttonDataCy: 'driveListHeaderNameButton', + textDataCy: 'driveListHeaderNameButtonText', + }, + ]; + + if (isTrash) { + headers.push({ + label: translate('drive.list.columns.autoDelete'), + width: 'w-date', + name: 'caducityDate', + orderable: true, + defaultDirection: 'ASC', + }); + } + + headers.push( + { + label: translate('drive.list.columns.modified'), + width: 'w-date', + name: 'updatedAt', + orderable: !isRecents, + defaultDirection: 'ASC', + }, + { + label: translate('drive.list.columns.size'), + orderable: !isRecents && !isTrash, + defaultDirection: 'ASC', + width: 'w-size', + name: 'size', + }, + ); + + return headers; +}; diff --git a/src/views/Drive/components/DriveExplorer/components/DriveExplorerList/index.ts b/src/views/Drive/components/DriveExplorer/components/DriveExplorerList/index.ts new file mode 100644 index 0000000000..048c45e3b8 --- /dev/null +++ b/src/views/Drive/components/DriveExplorer/components/DriveExplorerList/index.ts @@ -0,0 +1 @@ +export { default } from './DriveExplorerList';