@scality/data-browser-library 1.1.5 → 1.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,11 +3,31 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
3
3
  import user_event from "@testing-library/user-event";
4
4
  import { createTestWrapper } from "../../test/testUtils.js";
5
5
  import { UploadButton } from "../objects/UploadButton.js";
6
+ const mockMutateAsync = jest.fn();
7
+ jest.mock('../../hooks', ()=>({
8
+ ...jest.requireActual('../../hooks'),
9
+ useUploadObjects: ()=>({
10
+ mutate: jest.fn(),
11
+ mutateAsync: mockMutateAsync,
12
+ reset: jest.fn(),
13
+ isPending: false,
14
+ isIdle: true,
15
+ isError: false,
16
+ isSuccess: false,
17
+ status: 'idle',
18
+ data: void 0,
19
+ error: null
20
+ })
21
+ }));
6
22
  describe('UploadButton - Core Functionality', ()=>{
7
23
  const defaultProps = {
8
24
  bucket: 'test-bucket',
9
25
  prefix: 'test-prefix'
10
26
  };
27
+ beforeEach(()=>{
28
+ mockMutateAsync.mockClear();
29
+ mockMutateAsync.mockResolvedValue({});
30
+ });
11
31
  const renderUploadButton = (props = {})=>{
12
32
  const Wrapper = createTestWrapper();
13
33
  return render(/*#__PURE__*/ jsx(Wrapper, {
@@ -141,4 +161,64 @@ describe('UploadButton - Core Functionality', ()=>{
141
161
  expect(screen.getByText('Drag and drop files and folders here')).toBeInTheDocument();
142
162
  });
143
163
  });
164
+ const addFileAndUpload = async ()=>{
165
+ const fileInput = screen.getByRole('presentation').querySelector('input[type="file"]');
166
+ const testFile = new File([
167
+ 'test content'
168
+ ], 'test.txt', {
169
+ type: 'text/plain'
170
+ });
171
+ await user_event.upload(fileInput, testFile);
172
+ await waitFor(()=>expect(screen.getByText('test.txt')).toBeInTheDocument());
173
+ const uploadButtons = screen.getAllByRole('button', {
174
+ name: 'Upload'
175
+ });
176
+ const modalUploadButton = uploadButtons.find((button)=>!button.querySelector('svg'));
177
+ fireEvent.click(modalUploadButton);
178
+ };
179
+ it('constructs correct key when prefix has trailing slash', async ()=>{
180
+ renderUploadButton({
181
+ prefix: 'documents/'
182
+ });
183
+ fireEvent.click(screen.getByRole('button', {
184
+ name: /upload/i
185
+ }));
186
+ await waitFor(()=>expect(screen.getByText('Upload Files')).toBeInTheDocument());
187
+ await addFileAndUpload();
188
+ await waitFor(()=>{
189
+ expect(mockMutateAsync).toHaveBeenCalledWith(expect.objectContaining({
190
+ Key: 'documents/test.txt'
191
+ }));
192
+ });
193
+ });
194
+ it('constructs correct key when prefix has no trailing slash', async ()=>{
195
+ renderUploadButton({
196
+ prefix: 'documents'
197
+ });
198
+ fireEvent.click(screen.getByRole('button', {
199
+ name: /upload/i
200
+ }));
201
+ await waitFor(()=>expect(screen.getByText('Upload Files')).toBeInTheDocument());
202
+ await addFileAndUpload();
203
+ await waitFor(()=>{
204
+ expect(mockMutateAsync).toHaveBeenCalledWith(expect.objectContaining({
205
+ Key: 'documents/test.txt'
206
+ }));
207
+ });
208
+ });
209
+ it('constructs correct key when prefix is empty', async ()=>{
210
+ renderUploadButton({
211
+ prefix: ''
212
+ });
213
+ fireEvent.click(screen.getByRole('button', {
214
+ name: /upload/i
215
+ }));
216
+ await waitFor(()=>expect(screen.getByText('Upload Files')).toBeInTheDocument());
217
+ await addFileAndUpload();
218
+ await waitFor(()=>{
219
+ expect(mockMutateAsync).toHaveBeenCalledWith(expect.objectContaining({
220
+ Key: 'test.txt'
221
+ }));
222
+ });
223
+ });
144
224
  });
@@ -21,7 +21,8 @@ const CreateFolderButton = ({ bucket, prefix = '', label = 'Folder', variant = '
21
21
  }, []);
22
22
  const handleSave = useCallback(()=>{
23
23
  if (!folderName.trim()) return;
24
- const folderKey = prefix ? `${prefix}/${folderName}/` : `${folderName}/`;
24
+ const normalizedPrefix = prefix.endsWith('/') ? prefix.slice(0, -1) : prefix;
25
+ const folderKey = normalizedPrefix ? `${normalizedPrefix}/${folderName}/` : `${folderName}/`;
25
26
  createFolderMutation.mutate({
26
27
  Bucket: bucket,
27
28
  Key: folderKey,
@@ -1,9 +1,11 @@
1
1
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
2
- import { Banner, Icon, Modal, PrettyBytes, Stack, Text, Wrap, spacing, useToast } from "@scality/core-ui";
2
+ import { Banner, Checkbox, Icon, Modal, PrettyBytes, Stack, Text, Wrap, spacing, useToast } from "@scality/core-ui";
3
3
  import { Box, Button } from "@scality/core-ui/dist/next";
4
4
  import { useCallback, useEffect, useState } from "react";
5
5
  import { useDeleteObjects, useGetBucketVersioning } from "../../hooks/index.js";
6
+ import { useDeleteFolder } from "../../hooks/useDeleteFolder.js";
6
7
  import { getDeletionMessages } from "../../utils/deletion/index.js";
8
+ import { useInvalidateQueries } from "../providers/DataBrowserProvider.js";
7
9
  import { DeleteObjectModalContent } from "../ui/DeleteObjectModalContent.js";
8
10
  const Title = ({ objects, isCurrentSelectionPermanentlyDeleted })=>{
9
11
  const foldersSize = objects.filter((object)=>'folder' === object.type).length;
@@ -43,14 +45,18 @@ const Title = ({ objects, isCurrentSelectionPermanentlyDeleted })=>{
43
45
  const DeleteObjectButton = ({ objects, bucketName, onDeleteSuccess })=>{
44
46
  const [isModalOpen, setIsModalOpen] = useState(false);
45
47
  const [selectedObjects, setSelectedObjects] = useState(objects);
48
+ const [isDeleting, setIsDeleting] = useState(false);
46
49
  const { data: versioningData } = useGetBucketVersioning({
47
50
  Bucket: bucketName
48
51
  });
49
- const { mutate: deleteObjects, status: deleteObjectsStatus } = useDeleteObjects();
52
+ const { mutateAsync: deleteObjects } = useDeleteObjects();
53
+ const { mutateAsync: deleteFolder } = useDeleteFolder();
54
+ const invalidateQueries = useInvalidateQueries();
50
55
  const { showToast } = useToast();
51
56
  const isVersioningEnabled = versioningData?.Status === 'Enabled';
52
57
  const isCurrentSelectionPermanentlyDeleted = !isVersioningEnabled || selectedObjects.some((object)=>!!object.VersionId);
53
- const { info: notificationText } = getDeletionMessages({
58
+ const [isCheckboxToggled, setIsCheckboxToggled] = useState(false);
59
+ const { info: notificationText, checkboxRequired } = getDeletionMessages({
54
60
  numberOfObjects: selectedObjects.length,
55
61
  selectedObjectsAreSpecificVersions: isCurrentSelectionPermanentlyDeleted,
56
62
  isBucketVersioned: isVersioningEnabled
@@ -61,51 +67,82 @@ const DeleteObjectButton = ({ objects, bucketName, onDeleteSuccess })=>{
61
67
  const cancel = useCallback(()=>{
62
68
  setIsModalOpen(false);
63
69
  setSelectedObjects(objects);
70
+ setIsCheckboxToggled(false);
64
71
  }, [
65
72
  objects
66
73
  ]);
67
- const deleteSelectedFiles = useCallback(()=>{
68
- const objectsToDelete = selectedObjects.filter((object)=>'folder' !== object.type && object.Key).map((object)=>{
69
- const deleteItem = {
70
- Key: object.Key
71
- };
72
- if (object.VersionId && 'string' == typeof object.VersionId) deleteItem.VersionId = object.VersionId;
73
- return deleteItem;
74
- });
75
- const foldersToDelete = selectedObjects.filter((object)=>'folder' === object.type && object.Key).map((object)=>({
76
- Key: object.Key
77
- }));
78
- deleteObjects({
79
- Bucket: bucketName,
80
- Delete: {
81
- Objects: [
82
- ...objectsToDelete,
83
- ...foldersToDelete
84
- ]
85
- }
86
- }, {
87
- onSuccess: ()=>{
88
- showToast({
89
- open: true,
90
- message: 'Objects deleted successfully',
91
- status: 'success'
92
- });
93
- setIsModalOpen(false);
94
- onDeleteSuccess?.();
95
- },
96
- onError: (error)=>{
97
- showToast({
98
- open: true,
99
- message: error instanceof Error ? error.message : 'Objects deleted failed',
100
- status: 'error'
74
+ const deleteSelectedFiles = useCallback(async ()=>{
75
+ setIsDeleting(true);
76
+ try {
77
+ const folderItems = selectedObjects.filter((object)=>'folder' === object.type && object.Key);
78
+ const nonFolderItems = selectedObjects.filter((object)=>'folder' !== object.type && object.Key);
79
+ if (nonFolderItems.length > 0) {
80
+ const result = await deleteObjects({
81
+ Bucket: bucketName,
82
+ Delete: {
83
+ Objects: nonFolderItems.map((object)=>{
84
+ const deleteItem = {
85
+ Key: object.Key
86
+ };
87
+ if (object.VersionId && 'string' == typeof object.VersionId) deleteItem.VersionId = object.VersionId;
88
+ return deleteItem;
89
+ })
90
+ }
101
91
  });
102
- setIsModalOpen(false);
92
+ if (result.Errors && result.Errors.length > 0) {
93
+ const firstError = result.Errors[0];
94
+ throw new Error(firstError.Message ?? 'Failed to delete objects');
95
+ }
103
96
  }
104
- });
97
+ for (const folder of folderItems)await deleteFolder({
98
+ Bucket: bucketName,
99
+ FolderKey: folder.Key
100
+ });
101
+ const hasFolders = folderItems.length > 0;
102
+ const hasObjects = nonFolderItems.length > 0;
103
+ const message = hasFolders && hasObjects ? 'Objects and folders deleted successfully' : hasFolders ? 'Folders deleted successfully' : 'Objects deleted successfully';
104
+ showToast({
105
+ open: true,
106
+ message,
107
+ status: 'success'
108
+ });
109
+ onDeleteSuccess?.();
110
+ } catch (error) {
111
+ showToast({
112
+ open: true,
113
+ message: error instanceof Error ? error.message : 'Failed to delete objects',
114
+ status: 'error'
115
+ });
116
+ } finally{
117
+ invalidateQueries({
118
+ queryKey: [
119
+ 'ListObjects'
120
+ ]
121
+ });
122
+ invalidateQueries({
123
+ queryKey: [
124
+ 'ListObjectVersions'
125
+ ]
126
+ });
127
+ invalidateQueries({
128
+ queryKey: [
129
+ 'SearchObjects'
130
+ ]
131
+ });
132
+ invalidateQueries({
133
+ queryKey: [
134
+ 'SearchObjectsVersions'
135
+ ]
136
+ });
137
+ setIsModalOpen(false);
138
+ setIsDeleting(false);
139
+ }
105
140
  }, [
106
141
  bucketName,
107
142
  selectedObjects,
108
143
  deleteObjects,
144
+ deleteFolder,
145
+ invalidateQueries,
109
146
  showToast,
110
147
  onDeleteSuccess
111
148
  ]);
@@ -142,11 +179,11 @@ const DeleteObjectButton = ({ objects, bucketName, onDeleteSuccess })=>{
142
179
  label: "Cancel"
143
180
  }),
144
181
  /*#__PURE__*/ jsx(Button, {
145
- isLoading: 'pending' === deleteObjectsStatus,
182
+ isLoading: isDeleting,
146
183
  id: "object-delete-delete-button",
147
184
  variant: "danger",
148
185
  onClick: deleteSelectedFiles,
149
- disabled: 0 === selectedObjects.length,
186
+ disabled: 0 === selectedObjects.length || checkboxRequired && !isCheckboxToggled,
150
187
  label: "Delete"
151
188
  })
152
189
  ]
@@ -164,8 +201,8 @@ const DeleteObjectButton = ({ objects, bucketName, onDeleteSuccess })=>{
164
201
  }),
165
202
  /*#__PURE__*/ jsx(DeleteObjectModalContent, {
166
203
  objects: selectedObjects,
167
- onRemove: (key)=>{
168
- setSelectedObjects(selectedObjects.filter((object)=>object.Key !== key));
204
+ onRemove: (item)=>{
205
+ setSelectedObjects(selectedObjects.filter((object)=>!(object.Key === item.Key && object.VersionId === item.VersionId)));
169
206
  }
170
207
  }),
171
208
  /*#__PURE__*/ jsxs(Box, {
@@ -185,6 +222,15 @@ const DeleteObjectButton = ({ objects, bucketName, onDeleteSuccess })=>{
185
222
  children: /*#__PURE__*/ jsx("span", {
186
223
  children: notificationText
187
224
  })
225
+ }),
226
+ checkboxRequired && selectedObjects.length > 0 && /*#__PURE__*/ jsx(Box, {
227
+ mt: spacing.r12,
228
+ children: /*#__PURE__*/ jsx(Checkbox, {
229
+ id: "confirm-deletion-checkbox",
230
+ label: "Confirm the deletion",
231
+ checked: isCheckboxToggled,
232
+ onChange: ()=>setIsCheckboxToggled((prev)=>!prev)
233
+ })
188
234
  })
189
235
  ]
190
236
  })
@@ -275,7 +275,27 @@ function createOverrideMap(customItems) {
275
275
  const ObjectList = ({ bucketName, prefix, onObjectSelect, onPrefixChange, onSelectedObjectsChange })=>{
276
276
  const { extraObjectListColumns, extraObjectListActions } = useDataBrowserUICustomization();
277
277
  const invalidateQueries = useInvalidateQueries();
278
+ const versionCheck = useListObjectVersions({
279
+ Bucket: bucketName,
280
+ MaxKeys: 10
281
+ }, {
282
+ enabled: Boolean(bucketName)
283
+ });
284
+ const hasObjectVersions = useMemo(()=>{
285
+ const firstPage = versionCheck.data?.pages?.[0];
286
+ if (!firstPage) return false;
287
+ const versions = firstPage.Versions || [];
288
+ const deleteMarkers = firstPage.DeleteMarkers || [];
289
+ return versions.some((v)=>v.VersionId && 'null' !== v.VersionId) || deleteMarkers.length > 0;
290
+ }, [
291
+ versionCheck.data
292
+ ]);
278
293
  const [showVersions, setShowVersions] = useState(false);
294
+ useEffect(()=>{
295
+ if (!hasObjectVersions) setShowVersions(false);
296
+ }, [
297
+ hasObjectVersions
298
+ ]);
279
299
  const isMetadataSearchEnabled = useFeatures('metadatasearch');
280
300
  const queryParams = useQueryParams();
281
301
  const metadataSearchQuery = queryParams.get('metadatasearch');
@@ -787,6 +807,7 @@ const ObjectList = ({ bucketName, prefix, onObjectSelect, onPrefixChange, onSele
787
807
  toggle: showVersions,
788
808
  onChange: (e)=>setShowVersions(e.target.checked),
789
809
  label: "List Versions",
810
+ disabled: !hasObjectVersions,
790
811
  "aria-label": showVersions ? 'Hide object versions' : 'Show object versions',
791
812
  "aria-pressed": showVersions
792
813
  })
@@ -155,9 +155,10 @@ const UploadButton = ({ bucket, prefix = '', uploadOptions = {}, onUploadSuccess
155
155
  const failedFiles = [];
156
156
  for (const file of acceptedFiles)try {
157
157
  const fileBuffer = await file.arrayBuffer();
158
+ const normalizedPrefix = prefix.endsWith('/') ? prefix.slice(0, -1) : prefix;
158
159
  await uploadMutation.mutateAsync({
159
160
  Bucket: bucket,
160
- Key: prefix ? `${prefix}/${file.name}` : file.name,
161
+ Key: normalizedPrefix ? `${normalizedPrefix}/${file.name}` : file.name,
161
162
  Body: new Uint8Array(fileBuffer),
162
163
  ContentType: file.type,
163
164
  ...uploadOptions
@@ -1,5 +1,8 @@
1
1
  import type { Objects } from '../objects/DeleteObjectButton';
2
2
  export declare const DeleteObjectModalContent: ({ objects, onRemove, }: {
3
3
  objects: Objects;
4
- onRemove: (key: string) => void;
4
+ onRemove: (item: {
5
+ Key: string;
6
+ VersionId?: string;
7
+ }) => void;
5
8
  }) => import("react/jsx-runtime").JSX.Element;
@@ -1,46 +1,89 @@
1
- import { jsx } from "react/jsx-runtime";
1
+ import { jsx, jsxs } from "react/jsx-runtime";
2
2
  import { ConstrainedText, Icon, PrettyBytes, spacing } from "@scality/core-ui";
3
- import { tableRowHeight } from "@scality/core-ui/dist/components/tablev2/TableUtils";
4
3
  import { Box, Table } from "@scality/core-ui/dist/next";
5
4
  import { useMemo } from "react";
6
5
  import styled_components from "styled-components";
6
+ const NAME_COLUMN_FLEX = '3';
7
+ const SIZE_COLUMN_FLEX = '1';
7
8
  const Container = styled_components(Box)`
8
- height: ${({ height })=>height};
9
- min-height: 15.63rem;
9
+ width: 31.25rem;
10
+ height: 15.63rem;
11
+ overflow-y: auto;
12
+ border: 1px solid ${({ theme })=>theme.border};
10
13
  margin: ${spacing.r8} 0rem;
11
14
  `;
15
+ const VersionId = styled_components.div`
16
+ font-size: 0.75rem;
17
+ color: ${({ theme })=>theme.textSecondary};
18
+ `;
12
19
  const DeleteObjectModalContent = ({ objects, onRemove })=>{
13
20
  const columns = useMemo(()=>[
14
21
  {
15
22
  Header: 'Name',
16
23
  accessor: 'Key',
17
- Cell: ({ value })=>/*#__PURE__*/ jsx(ConstrainedText, {
18
- text: value,
19
- lineClamp: 1
20
- }),
24
+ cellStyle: {
25
+ flex: NAME_COLUMN_FLEX,
26
+ minWidth: 0
27
+ },
28
+ Cell: ({ value, row })=>{
29
+ const versionId = 'VersionId' in row.original ? row.original.VersionId : void 0;
30
+ return /*#__PURE__*/ jsxs("div", {
31
+ style: {
32
+ minWidth: 0
33
+ },
34
+ children: [
35
+ /*#__PURE__*/ jsx(ConstrainedText, {
36
+ text: value,
37
+ lineClamp: 1
38
+ }),
39
+ versionId && /*#__PURE__*/ jsx(VersionId, {
40
+ children: versionId
41
+ })
42
+ ]
43
+ });
44
+ },
21
45
  id: 'name'
22
46
  },
23
47
  {
24
48
  Header: 'Size',
25
49
  accessor: 'Size',
26
50
  cellStyle: {
51
+ flex: SIZE_COLUMN_FLEX,
27
52
  textAlign: 'right'
28
53
  },
29
- Cell: ({ value })=>/*#__PURE__*/ jsx(PrettyBytes, {
30
- bytes: Number(value)
31
- }),
54
+ Cell: ({ value, row })=>{
55
+ const isLegalHoldEnabled = 'isLegalHoldEnabled' in row.original && row.original.isLegalHoldEnabled;
56
+ return /*#__PURE__*/ jsxs(Box, {
57
+ display: "flex",
58
+ alignItems: "center",
59
+ gap: spacing.r4,
60
+ justifyContent: "flex-end",
61
+ children: [
62
+ void 0 !== value && /*#__PURE__*/ jsx(PrettyBytes, {
63
+ bytes: Number(value)
64
+ }),
65
+ isLegalHoldEnabled && /*#__PURE__*/ jsx(Icon, {
66
+ name: "Rebalance",
67
+ size: "sm"
68
+ })
69
+ ]
70
+ });
71
+ },
32
72
  id: 'size'
33
73
  },
34
74
  {
35
75
  Header: '',
36
76
  accessor: 'type',
37
77
  cellStyle: {
38
- width: '0.625rem'
78
+ flex: '0 0 2rem'
39
79
  },
40
80
  Cell: (row)=>{
41
- const objectKey = row.row.original.Key;
81
+ const item = row.row.original;
42
82
  return /*#__PURE__*/ jsx("div", {
43
- onClick: ()=>onRemove(objectKey),
83
+ onClick: ()=>onRemove({
84
+ Key: item.Key,
85
+ VersionId: 'VersionId' in item ? item.VersionId : void 0
86
+ }),
44
87
  children: /*#__PURE__*/ jsx(Icon, {
45
88
  name: "Close",
46
89
  color: "buttonSecondary"
@@ -51,11 +94,7 @@ const DeleteObjectModalContent = ({ objects, onRemove })=>{
51
94
  ], [
52
95
  onRemove
53
96
  ]);
54
- const HEADER_AND_SPACING_ROWS = 3;
55
- const rowHeight = 'h40';
56
- const tableRowHeightInRem = tableRowHeight[rowHeight];
57
97
  return /*#__PURE__*/ jsx(Container, {
58
- height: `calc(${objects.length + HEADER_AND_SPACING_ROWS} * (${tableRowHeightInRem}rem + 1px))`,
59
98
  children: /*#__PURE__*/ jsx(Table, {
60
99
  columns: columns,
61
100
  data: objects,
@@ -0,0 +1 @@
1
+ export {};