@scality/data-browser-library 1.1.11 → 1.1.12

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.
@@ -709,6 +709,110 @@ describe('BucketLifecycleFormPage', ()=>{
709
709
  });
710
710
  });
711
711
  });
712
+ describe('editing immediate transition rules (0 days)', ()=>{
713
+ it('preserves 0 days when editing a current version transition', async ()=>{
714
+ mockUseGetBucketLifecycle.mockReturnValue({
715
+ data: {
716
+ Rules: [
717
+ {
718
+ ID: 'immediate-glacier',
719
+ Status: 'Enabled',
720
+ Filter: {},
721
+ Transitions: [
722
+ {
723
+ Days: 0,
724
+ StorageClass: 'GLACIER'
725
+ }
726
+ ]
727
+ }
728
+ ]
729
+ },
730
+ status: 'success'
731
+ });
732
+ renderBucketLifecycleFormPage('test-bucket', 'immediate-glacier');
733
+ await waitFor(()=>{
734
+ const transitionToggle = findToggleByLabel('Transition current version');
735
+ expect(transitionToggle).toBeChecked();
736
+ const daysInput = document.getElementById('transition-days-0');
737
+ expect(daysInput).toHaveValue(0);
738
+ });
739
+ });
740
+ it('preserves 0 days when editing a noncurrent version transition', async ()=>{
741
+ mockUseGetBucketLifecycle.mockReturnValue({
742
+ data: {
743
+ Rules: [
744
+ {
745
+ ID: 'immediate-noncurrent',
746
+ Status: 'Enabled',
747
+ Filter: {},
748
+ NoncurrentVersionTransitions: [
749
+ {
750
+ NoncurrentDays: 0,
751
+ StorageClass: 'GLACIER'
752
+ }
753
+ ]
754
+ }
755
+ ]
756
+ },
757
+ status: 'success'
758
+ });
759
+ renderBucketLifecycleFormPage('test-bucket', 'immediate-noncurrent');
760
+ await waitFor(()=>{
761
+ const noncurrentToggle = findToggleByLabel('Transition noncurrent version');
762
+ expect(noncurrentToggle).toBeChecked();
763
+ const daysInput = document.getElementById('noncurrent-transition-days-0');
764
+ expect(daysInput).toHaveValue(0);
765
+ });
766
+ });
767
+ it('preserves 0 days when editing a current version expiration', async ()=>{
768
+ mockUseGetBucketLifecycle.mockReturnValue({
769
+ data: {
770
+ Rules: [
771
+ {
772
+ ID: 'immediate-expire',
773
+ Status: 'Enabled',
774
+ Filter: {},
775
+ Expiration: {
776
+ Days: 0
777
+ }
778
+ }
779
+ ]
780
+ },
781
+ status: 'success'
782
+ });
783
+ renderBucketLifecycleFormPage('test-bucket', 'immediate-expire');
784
+ await waitFor(()=>{
785
+ const expirationToggle = findToggleByLabel('Expiration current version');
786
+ expect(expirationToggle).toBeChecked();
787
+ const daysInput = screen.getByLabelText(/^days$/i);
788
+ expect(daysInput).toHaveValue(0);
789
+ });
790
+ });
791
+ it('preserves 0 days when editing a noncurrent version expiration', async ()=>{
792
+ mockUseGetBucketLifecycle.mockReturnValue({
793
+ data: {
794
+ Rules: [
795
+ {
796
+ ID: 'immediate-noncurrent-expire',
797
+ Status: 'Enabled',
798
+ Filter: {},
799
+ NoncurrentVersionExpiration: {
800
+ NoncurrentDays: 0
801
+ }
802
+ }
803
+ ]
804
+ },
805
+ status: 'success'
806
+ });
807
+ renderBucketLifecycleFormPage('test-bucket', 'immediate-noncurrent-expire');
808
+ await waitFor(()=>{
809
+ const noncurrentExpirationToggle = findToggleByLabel('Expiration noncurrent version');
810
+ expect(noncurrentExpirationToggle).toBeChecked();
811
+ const noncurrentDaysInput = screen.getByLabelText(/noncurrent days/i);
812
+ expect(noncurrentDaysInput).toHaveValue(0);
813
+ });
814
+ });
815
+ });
712
816
  describe('Transition Auto-Add in Create Mode', ()=>{
713
817
  it('auto-adds a transition row when enabling transitions', async ()=>{
714
818
  renderBucketLifecycleFormPage();
@@ -314,6 +314,84 @@ describe('BucketLifecycleList', ()=>{
314
314
  });
315
315
  expect(screen.getByText('Delete expired markers')).toBeInTheDocument();
316
316
  });
317
+ describe('immediate transition rules (0 days)', ()=>{
318
+ it("shows current version transition description for immediate transition", ()=>{
319
+ renderBucketLifecycleList({
320
+ lifecycleRules: [
321
+ {
322
+ ID: 'immediate-glacier',
323
+ Status: 'Enabled',
324
+ Transitions: [
325
+ {
326
+ Days: 0,
327
+ StorageClass: 'GLACIER'
328
+ }
329
+ ]
330
+ }
331
+ ]
332
+ });
333
+ expect(screen.getByText(/Transition current \(0 days\)/)).toBeInTheDocument();
334
+ });
335
+ it("shows noncurrent version transition description for immediate transition", ()=>{
336
+ renderBucketLifecycleList({
337
+ lifecycleRules: [
338
+ {
339
+ ID: 'immediate-noncurrent',
340
+ Status: 'Enabled',
341
+ NoncurrentVersionTransitions: [
342
+ {
343
+ NoncurrentDays: 0,
344
+ StorageClass: 'GLACIER'
345
+ }
346
+ ]
347
+ }
348
+ ]
349
+ });
350
+ expect(screen.getByText(/Transition noncurrent \(0 days\)/)).toBeInTheDocument();
351
+ });
352
+ it("shows expiration description for immediate expiration", ()=>{
353
+ renderBucketLifecycleList({
354
+ lifecycleRules: [
355
+ {
356
+ ID: 'immediate-expire',
357
+ Status: 'Enabled',
358
+ Expiration: {
359
+ Days: 0
360
+ }
361
+ }
362
+ ]
363
+ });
364
+ expect(screen.getByText(/Expire current \(0 days\)/)).toBeInTheDocument();
365
+ });
366
+ it("shows noncurrent expiration description for immediate expiration", ()=>{
367
+ renderBucketLifecycleList({
368
+ lifecycleRules: [
369
+ {
370
+ ID: 'immediate-noncurrent-expire',
371
+ Status: 'Enabled',
372
+ NoncurrentVersionExpiration: {
373
+ NoncurrentDays: 0
374
+ }
375
+ }
376
+ ]
377
+ });
378
+ expect(screen.getByText(/Expire noncurrent \(0 days\)/)).toBeInTheDocument();
379
+ });
380
+ it("shows abort MPU description for immediate abort", ()=>{
381
+ renderBucketLifecycleList({
382
+ lifecycleRules: [
383
+ {
384
+ ID: 'immediate-mpu',
385
+ Status: 'Enabled',
386
+ AbortIncompleteMultipartUpload: {
387
+ DaysAfterInitiation: 0
388
+ }
389
+ }
390
+ ]
391
+ });
392
+ expect(screen.getByText(/Abort Incomplete MPU \(0 days\)/)).toBeInTheDocument();
393
+ });
394
+ });
317
395
  it('shows info icon for multiple actions', ()=>{
318
396
  renderBucketLifecycleList({
319
397
  lifecycleRules: mockLifecycleRules
@@ -226,13 +226,13 @@ const ruleToFormValues = (rule)=>{
226
226
  formValues.transitionsEnabled = true;
227
227
  formValues.transitions = rule.Transitions.map((t)=>({
228
228
  timeType: void 0 !== t.Days ? 'days' : 'date',
229
- days: t.Days || 30,
229
+ days: t.Days ?? 30,
230
230
  date: t.Date ? new Date(t.Date).toISOString().split('T')[0] : '',
231
231
  storageClass: t.StorageClass || 'GLACIER'
232
232
  }));
233
233
  }
234
234
  if (rule.Expiration) {
235
- if (rule.Expiration.Days) {
235
+ if (null != rule.Expiration.Days) {
236
236
  formValues.expirationEnabled = true;
237
237
  formValues.expirationType = 'days';
238
238
  formValues.expirationDays = rule.Expiration.Days;
@@ -246,19 +246,19 @@ const ruleToFormValues = (rule)=>{
246
246
  if (rule.NoncurrentVersionTransitions && rule.NoncurrentVersionTransitions.length > 0) {
247
247
  formValues.noncurrentTransitionsEnabled = true;
248
248
  formValues.noncurrentTransitions = rule.NoncurrentVersionTransitions.map((t)=>({
249
- noncurrentDays: t.NoncurrentDays || 30,
249
+ noncurrentDays: t.NoncurrentDays ?? 30,
250
250
  storageClass: t.StorageClass || 'GLACIER',
251
251
  newerNoncurrentVersions: t.NewerNoncurrentVersions || 0
252
252
  }));
253
253
  }
254
254
  if (rule.NoncurrentVersionExpiration) {
255
255
  formValues.noncurrentExpirationEnabled = true;
256
- formValues.noncurrentExpirationDays = rule.NoncurrentVersionExpiration.NoncurrentDays || 30;
256
+ formValues.noncurrentExpirationDays = rule.NoncurrentVersionExpiration.NoncurrentDays ?? 30;
257
257
  formValues.noncurrentNewerVersions = rule.NoncurrentVersionExpiration.NewerNoncurrentVersions || 0;
258
258
  }
259
259
  if (rule.AbortIncompleteMultipartUpload) {
260
260
  formValues.abortMpuEnabled = true;
261
- formValues.abortMpuDays = rule.AbortIncompleteMultipartUpload.DaysAfterInitiation || 7;
261
+ formValues.abortMpuDays = rule.AbortIncompleteMultipartUpload.DaysAfterInitiation ?? 7;
262
262
  }
263
263
  return formValues;
264
264
  };
@@ -9,7 +9,7 @@ const pluralizeDays = (days)=>1 === days ? `${days} day` : `${days} days`;
9
9
  const formatActions = (rule)=>{
10
10
  const actions = [];
11
11
  if (rule.Expiration) {
12
- if (rule.Expiration.Days) actions.push({
12
+ if (null != rule.Expiration.Days) actions.push({
13
13
  text: `Expire current (${pluralizeDays(rule.Expiration.Days)})`,
14
14
  description: `Expire current versions of objects after ${pluralizeDays(rule.Expiration.Days)}`
15
15
  });
@@ -23,7 +23,7 @@ const formatActions = (rule)=>{
23
23
  description: 'Delete expired object delete markers'
24
24
  });
25
25
  }
26
- if (rule.NoncurrentVersionExpiration?.NoncurrentDays) {
26
+ if (rule.NoncurrentVersionExpiration?.NoncurrentDays != null) {
27
27
  const keepText = rule.NoncurrentVersionExpiration.NewerNoncurrentVersions ? `, keep ${rule.NoncurrentVersionExpiration.NewerNoncurrentVersions}` : '';
28
28
  const keepDescription = rule.NoncurrentVersionExpiration.NewerNoncurrentVersions ? `, keep ${rule.NoncurrentVersionExpiration.NewerNoncurrentVersions} newest versions` : '';
29
29
  actions.push({
@@ -32,7 +32,7 @@ const formatActions = (rule)=>{
32
32
  });
33
33
  }
34
34
  if (rule.Transitions && rule.Transitions.length > 0) rule.Transitions.forEach((transition)=>{
35
- if (transition.Days) actions.push({
35
+ if (null != transition.Days) actions.push({
36
36
  text: `Transition current (${pluralizeDays(transition.Days)})`,
37
37
  description: `Transition current versions of objects after ${pluralizeDays(transition.Days)}`
38
38
  });
@@ -43,7 +43,7 @@ const formatActions = (rule)=>{
43
43
  });
44
44
  });
45
45
  if (rule.NoncurrentVersionTransitions && rule.NoncurrentVersionTransitions.length > 0) rule.NoncurrentVersionTransitions.forEach((transition)=>{
46
- if (transition.NoncurrentDays) {
46
+ if (null != transition.NoncurrentDays) {
47
47
  const keepText = transition.NewerNoncurrentVersions ? `, keep ${transition.NewerNoncurrentVersions}` : '';
48
48
  const keepDescription = transition.NewerNoncurrentVersions ? `, keep ${transition.NewerNoncurrentVersions} newest versions` : '';
49
49
  actions.push({
@@ -52,7 +52,7 @@ const formatActions = (rule)=>{
52
52
  });
53
53
  }
54
54
  });
55
- if (rule.AbortIncompleteMultipartUpload?.DaysAfterInitiation) actions.push({
55
+ if (rule.AbortIncompleteMultipartUpload?.DaysAfterInitiation != null) actions.push({
56
56
  text: `Abort Incomplete MPU (${pluralizeDays(rule.AbortIncompleteMultipartUpload.DaysAfterInitiation)})`,
57
57
  description: `Abort incomplete multipart uploads after ${pluralizeDays(rule.AbortIncompleteMultipartUpload.DaysAfterInitiation)}`
58
58
  });
@@ -1,10 +1,11 @@
1
1
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
2
2
  import { ConstrainedText, FormattedDateTime, Icon, Loader, PrettyBytes, Text, Toggle, Tooltip, spacing, useToast } from "@scality/core-ui";
3
3
  import { CopyButton } from "@scality/core-ui/dist/components/buttonv2/CopyButton.component";
4
- import { Box } from "@scality/core-ui/dist/next";
4
+ import { Box, Button } from "@scality/core-ui/dist/next";
5
5
  import { Fragment as external_react_Fragment, useCallback, useMemo } from "react";
6
6
  import { useDataBrowserUICustomization } from "../../../contexts/DataBrowserUICustomizationContext.js";
7
- import { useGetPublicAccessBlock, useObjectAcl, useObjectLegalHold, useObjectMetadata, useObjectRetention, useSetObjectAcl, useSetObjectLegalHold } from "../../../hooks/index.js";
7
+ import { useGetPublicAccessBlock, useObjectAcl, useObjectLegalHold, useObjectMetadata, useObjectRetention, useRestoreObject, useSetObjectAcl, useSetObjectLegalHold } from "../../../hooks/index.js";
8
+ import { COLD_STORAGE_TOOLTIP, COLD_STORAGE_TOOLTIP_STYLE, parseRestoreStatus } from "../../../utils/coldStorage.js";
8
9
  import { useDataBrowserConfig } from "../../providers/DataBrowserProvider.js";
9
10
  import { Body, ExtraCell, Group, GroupContent, GroupName, GroupValues, Key, Row, Table, TableContainer, Value } from "../../ui/Table.elements.js";
10
11
  import { GetPresignedUrlButton } from "../GetPresignedUrlButton.js";
@@ -24,7 +25,7 @@ const buildPublicUrl = (endpoint, bucket, key, forcePathStyle)=>{
24
25
  };
25
26
  const ObjectSummary = ({ bucketName, objectKey, versionId })=>{
26
27
  const s3Config = useDataBrowserConfig();
27
- const { extraObjectSummaryInformation, extraObjectSummaryDataProtection } = useDataBrowserUICustomization();
28
+ const { extraObjectSummaryInformation, extraObjectSummaryDataProtection, isLocationCold, coldStorageRestoreDays } = useDataBrowserUICustomization();
28
29
  const { data: metadata, status: metadataStatus } = useObjectMetadata({
29
30
  Bucket: bucketName,
30
31
  Key: objectKey,
@@ -48,6 +49,11 @@ const ObjectSummary = ({ bucketName, objectKey, versionId })=>{
48
49
  });
49
50
  const { mutate: setLegalHold, isPending: isUpdatingLegalHold } = useSetObjectLegalHold();
50
51
  const { showToast } = useToast();
52
+ const storageClass = metadata?.StorageClass ?? 'STANDARD';
53
+ const isCold = isLocationCold?.(storageClass) ?? false;
54
+ const isColdMetadataLoading = isCold && 'pending' === metadataStatus;
55
+ const restoreStatus = isCold && 'success' === metadataStatus ? parseRestoreStatus(metadata?.Restore) : null;
56
+ const { mutate: restoreObject, isPending: isRestoring } = useRestoreObject();
51
57
  const { data: aclData, status: aclStatus } = useObjectAcl({
52
58
  Bucket: bucketName,
53
59
  Key: objectKey,
@@ -288,7 +294,7 @@ const ObjectSummary = ({ bucketName, objectKey, versionId })=>{
288
294
  children: "Location"
289
295
  }),
290
296
  /*#__PURE__*/ jsx(Value, {
291
- children: "default"
297
+ children: 'pending' === metadataStatus ? /*#__PURE__*/ jsx(Loader, {}) : 'error' === metadataStatus ? 'Error' : metadata?.StorageClass !== 'STANDARD' && metadata?.StorageClass ? metadata.StorageClass : 'default'
292
298
  })
293
299
  ]
294
300
  }, "location")
@@ -316,6 +322,99 @@ const ObjectSummary = ({ bucketName, objectKey, versionId })=>{
316
322
  if (defaultFields[fieldConfig.id]) defaultFields[fieldConfig.id] = fieldItem;
317
323
  else extraFields.push(fieldItem);
318
324
  });
325
+ const temperatureField = isCold && (restoreStatus || isColdMetadataLoading) ? {
326
+ id: 'temperature',
327
+ label: 'Temperature',
328
+ node: /*#__PURE__*/ jsxs(Row, {
329
+ children: [
330
+ /*#__PURE__*/ jsx(Key, {
331
+ children: "Temperature"
332
+ }),
333
+ /*#__PURE__*/ jsx(Value, {
334
+ children: isColdMetadataLoading ? /*#__PURE__*/ jsx(Loader, {}) : restoreStatus ? /*#__PURE__*/ jsxs(GroupValues, {
335
+ children: [
336
+ /*#__PURE__*/ jsx(Box, {
337
+ display: "flex",
338
+ alignItems: "center",
339
+ gap: spacing.r8,
340
+ children: 'cold' === restoreStatus.status ? /*#__PURE__*/ jsxs(Fragment, {
341
+ children: [
342
+ /*#__PURE__*/ jsx(Icon, {
343
+ name: "Snowflake"
344
+ }),
345
+ /*#__PURE__*/ jsx(Text, {
346
+ children: "Cold"
347
+ }),
348
+ /*#__PURE__*/ jsx(Tooltip, {
349
+ overlay: COLD_STORAGE_TOOLTIP,
350
+ overlayStyle: COLD_STORAGE_TOOLTIP_STYLE,
351
+ children: /*#__PURE__*/ jsx(Icon, {
352
+ name: "Info",
353
+ color: "infoPrimary"
354
+ })
355
+ })
356
+ ]
357
+ }) : 'restoring' === restoreStatus.status ? /*#__PURE__*/ jsxs(Fragment, {
358
+ children: [
359
+ /*#__PURE__*/ jsx(Loader, {
360
+ size: "smaller"
361
+ }),
362
+ /*#__PURE__*/ jsx(Text, {
363
+ children: "Restoring..."
364
+ })
365
+ ]
366
+ }) : /*#__PURE__*/ jsxs(Text, {
367
+ children: [
368
+ "Available until",
369
+ ' ',
370
+ restoreStatus.expiryDate ? /*#__PURE__*/ jsx(FormattedDateTime, {
371
+ format: "date-time-second",
372
+ value: restoreStatus.expiryDate
373
+ }) : 'N/A'
374
+ ]
375
+ })
376
+ }),
377
+ 'cold' === restoreStatus.status && /*#__PURE__*/ jsx(Button, {
378
+ variant: "outline",
379
+ label: "Restore",
380
+ icon: /*#__PURE__*/ jsx(Icon, {
381
+ name: "Redo"
382
+ }),
383
+ disabled: isRestoring,
384
+ onClick: ()=>{
385
+ restoreObject({
386
+ Bucket: bucketName,
387
+ Key: objectKey,
388
+ ...versionId && {
389
+ VersionId: versionId
390
+ },
391
+ RestoreRequest: {
392
+ Days: coldStorageRestoreDays ?? 5
393
+ }
394
+ }, {
395
+ onSuccess: ()=>{
396
+ showToast({
397
+ open: true,
398
+ message: 'Restore initiated',
399
+ status: 'success'
400
+ });
401
+ },
402
+ onError: (error)=>{
403
+ showToast({
404
+ open: true,
405
+ message: error.message,
406
+ status: 'error'
407
+ });
408
+ }
409
+ });
410
+ }
411
+ })
412
+ ]
413
+ }) : null
414
+ })
415
+ ]
416
+ }, "temperature")
417
+ } : null;
319
418
  return [
320
419
  defaultFields.name,
321
420
  defaultFields.versionId,
@@ -323,6 +422,9 @@ const ObjectSummary = ({ bucketName, objectKey, versionId })=>{
323
422
  defaultFields.lastModified,
324
423
  defaultFields.etag,
325
424
  defaultFields.location,
425
+ ...temperatureField ? [
426
+ temperatureField
427
+ ] : [],
326
428
  ...extraFields
327
429
  ].filter((field)=>null !== field.node);
328
430
  }, [
@@ -332,8 +434,17 @@ const ObjectSummary = ({ bucketName, objectKey, versionId })=>{
332
434
  metadata?.ContentLength,
333
435
  metadata?.LastModified,
334
436
  metadata?.ETag,
437
+ metadata?.StorageClass,
335
438
  metadataStatus,
336
- extraObjectSummaryInformation
439
+ extraObjectSummaryInformation,
440
+ isCold,
441
+ isColdMetadataLoading,
442
+ restoreStatus,
443
+ isRestoring,
444
+ bucketName,
445
+ restoreObject,
446
+ showToast,
447
+ coldStorageRestoreDays
337
448
  ]);
338
449
  const accessFields = useMemo(()=>{
339
450
  const fields = [];
@@ -4,7 +4,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
4
4
  import { cleanup, render, screen, waitFor } from "@testing-library/react";
5
5
  import user_event from "@testing-library/user-event";
6
6
  import { DataBrowserUICustomizationProvider } from "../../../../contexts/DataBrowserUICustomizationContext.js";
7
- import { useGetPresignedDownload, useGetPublicAccessBlock, useObjectAcl, useObjectLegalHold, useObjectMetadata, useObjectRetention, useSetObjectAcl, useSetObjectLegalHold } from "../../../../hooks/index.js";
7
+ import { useGetPresignedDownload, useGetPublicAccessBlock, useObjectAcl, useObjectLegalHold, useObjectMetadata, useObjectRetention, useRestoreObject, useSetObjectAcl, useSetObjectLegalHold } from "../../../../hooks/index.js";
8
8
  import { findToggleByLabel } from "../../../../test/testUtils.js";
9
9
  import { EnhancedS3Error, ErrorCategory } from "../../../../utils/errorHandling.js";
10
10
  import { ObjectSummary } from "../ObjectSummary.js";
@@ -16,6 +16,7 @@ jest.mock('@scality/core-ui', ()=>{
16
16
  useToast: jest.fn()
17
17
  };
18
18
  });
19
+ const mockIsLocationCold = jest.fn(()=>false);
19
20
  jest.mock('../../../providers/DataBrowserProvider', ()=>({
20
21
  ...jest.requireActual('../../../providers/DataBrowserProvider'),
21
22
  useDataBrowserConfig: jest.fn(()=>({
@@ -31,6 +32,7 @@ const mockUseObjectAcl = jest.mocked(useObjectAcl);
31
32
  const mockUseSetObjectAcl = jest.mocked(useSetObjectAcl);
32
33
  const mockUseGetPublicAccessBlock = jest.mocked(useGetPublicAccessBlock);
33
34
  const mockUseGetPresignedDownload = jest.mocked(useGetPresignedDownload);
35
+ const mockUseRestoreObject = jest.mocked(useRestoreObject);
34
36
  const mockUseToast = jest.mocked(useToast);
35
37
  const mockObjectMetadata = {
36
38
  VersionId: 'test-version-id',
@@ -99,6 +101,11 @@ const setupMockDefaults = ()=>{
99
101
  mutateAsync: jest.fn(),
100
102
  isPending: false
101
103
  });
104
+ mockUseRestoreObject.mockReturnValue({
105
+ mutate: jest.fn(),
106
+ isPending: false
107
+ });
108
+ mockIsLocationCold.mockReturnValue(false);
102
109
  mockUseToast.mockReturnValue({
103
110
  showToast: jest.fn()
104
111
  });
@@ -126,7 +133,10 @@ const renderWithProviders = (ui, { customizationConfig = {} } = {})=>{
126
133
  return render(/*#__PURE__*/ jsx(QueryClientProvider, {
127
134
  client: queryClient,
128
135
  children: /*#__PURE__*/ jsx(DataBrowserUICustomizationProvider, {
129
- config: customizationConfig,
136
+ config: {
137
+ isLocationCold: mockIsLocationCold,
138
+ ...customizationConfig
139
+ },
130
140
  children: ui
131
141
  })
132
142
  }));
@@ -1097,4 +1107,97 @@ describe('ObjectSummary', ()=>{
1097
1107
  await expectTooltipOnHover(toggle, 'Unable to check public access block settings');
1098
1108
  });
1099
1109
  });
1110
+ describe('Cold Storage - Temperature Row', ()=>{
1111
+ it('should not show Temperature row for non-cold objects', ()=>{
1112
+ mockIsLocationCold.mockReturnValue(false);
1113
+ renderWithProviders(/*#__PURE__*/ jsx(ObjectSummary, {
1114
+ ...defaultProps
1115
+ }));
1116
+ expect(screen.queryByText('Cold')).not.toBeInTheDocument();
1117
+ });
1118
+ it('should show Temperature row with "Cold" status and restore button for cold objects', ()=>{
1119
+ mockIsLocationCold.mockReturnValue(true);
1120
+ mockUseObjectMetadata.mockReturnValue({
1121
+ data: {
1122
+ ...mockObjectMetadata,
1123
+ StorageClass: 'cold-location',
1124
+ Restore: void 0
1125
+ },
1126
+ status: 'success'
1127
+ });
1128
+ renderWithProviders(/*#__PURE__*/ jsx(ObjectSummary, {
1129
+ ...defaultProps
1130
+ }));
1131
+ expect(screen.getByText('Cold')).toBeInTheDocument();
1132
+ expect(screen.getByRole('button', {
1133
+ name: /restore/i
1134
+ })).toBeInTheDocument();
1135
+ });
1136
+ it('should show "Restoring..." status without restore button', ()=>{
1137
+ mockIsLocationCold.mockReturnValue(true);
1138
+ mockUseObjectMetadata.mockReturnValue({
1139
+ data: {
1140
+ ...mockObjectMetadata,
1141
+ StorageClass: 'cold-location',
1142
+ Restore: 'ongoing-request="true"'
1143
+ },
1144
+ status: 'success'
1145
+ });
1146
+ renderWithProviders(/*#__PURE__*/ jsx(ObjectSummary, {
1147
+ ...defaultProps
1148
+ }));
1149
+ expect(screen.getByText('Restoring...')).toBeInTheDocument();
1150
+ expect(screen.queryByRole('button', {
1151
+ name: /restore/i
1152
+ })).not.toBeInTheDocument();
1153
+ });
1154
+ it('should show expiry date when object is restored', ()=>{
1155
+ mockIsLocationCold.mockReturnValue(true);
1156
+ mockUseObjectMetadata.mockReturnValue({
1157
+ data: {
1158
+ ...mockObjectMetadata,
1159
+ StorageClass: 'cold-location',
1160
+ Restore: 'ongoing-request="false", expiry-date="Fri, 02 May 2026 00:00:00 GMT"'
1161
+ },
1162
+ status: 'success'
1163
+ });
1164
+ renderWithProviders(/*#__PURE__*/ jsx(ObjectSummary, {
1165
+ ...defaultProps
1166
+ }));
1167
+ expect(screen.getByText(/Available until/)).toBeInTheDocument();
1168
+ expect(screen.queryByRole('button', {
1169
+ name: /restore/i
1170
+ })).not.toBeInTheDocument();
1171
+ });
1172
+ it('should call restoreObject when restore button is clicked', async ()=>{
1173
+ const mockRestore = jest.fn();
1174
+ mockIsLocationCold.mockReturnValue(true);
1175
+ mockUseObjectMetadata.mockReturnValue({
1176
+ data: {
1177
+ ...mockObjectMetadata,
1178
+ StorageClass: 'cold-location',
1179
+ Restore: void 0
1180
+ },
1181
+ status: 'success'
1182
+ });
1183
+ mockUseRestoreObject.mockReturnValue({
1184
+ mutate: mockRestore,
1185
+ isPending: false
1186
+ });
1187
+ renderWithProviders(/*#__PURE__*/ jsx(ObjectSummary, {
1188
+ ...defaultProps
1189
+ }));
1190
+ const user = user_event.setup();
1191
+ await user.click(screen.getByRole('button', {
1192
+ name: /restore/i
1193
+ }));
1194
+ expect(mockRestore).toHaveBeenCalledWith(expect.objectContaining({
1195
+ Bucket: 'test-bucket',
1196
+ Key: 'test-object.txt',
1197
+ RestoreRequest: {
1198
+ Days: 5
1199
+ }
1200
+ }), expect.any(Object));
1201
+ });
1202
+ });
1100
1203
  });
@@ -1,7 +1,7 @@
1
- import { jsx, jsxs } from "react/jsx-runtime";
2
- import { Banner, ConstrainedText, FormattedDateTime, Icon, Link, PrettyBytes, SearchInput, Text, Toggle, spacing, useToast } from "@scality/core-ui";
1
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
2
+ import { Banner, ConstrainedText, FormattedDateTime, Icon, Link, PrettyBytes, SearchInput, Text, Toggle, Tooltip, spacing, useToast } from "@scality/core-ui";
3
3
  import { Box, Table } from "@scality/core-ui/dist/next";
4
- import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
4
+ import { Fragment as external_react_Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
5
5
  import { useLocation, useNavigate } from "react-router";
6
6
  import styled_components from "styled-components";
7
7
  import { useDataBrowserUICustomization } from "../../contexts/DataBrowserUICustomizationContext.js";
@@ -9,6 +9,7 @@ import { useListObjectVersions, useListObjects, useSearchObjects, useSearchObjec
9
9
  import { useDownloadObject } from "../../hooks/useDownloadObject.js";
10
10
  import { useBatchObjectLegalHold } from "../../hooks/useBatchObjectLegalHold.js";
11
11
  import { useFeatures } from "../../hooks/useFeatures.js";
12
+ import { COLD_STORAGE_TOOLTIP, COLD_STORAGE_TOOLTIP_STYLE } from "../../utils/coldStorage.js";
12
13
  import { useQueryParams } from "../../utils/hooks.js";
13
14
  import { useInvalidateQueries } from "../providers/DataBrowserProvider.js";
14
15
  import MetadataSearch from "../search/MetadataSearch.js";
@@ -62,7 +63,7 @@ const removePrefix = (path, prefix)=>{
62
63
  return path;
63
64
  };
64
65
  const createLegalHoldKey = (key, versionId)=>`${key}:${versionId ?? 'null'}`;
65
- const createNameColumn = (onPrefixChange, onDownload)=>({
66
+ const createNameColumn = (onPrefixChange, onDownload, isLocationCold)=>({
66
67
  Header: 'Name',
67
68
  accessor: 'displayName',
68
69
  id: 'name',
@@ -90,6 +91,7 @@ const createNameColumn = (onPrefixChange, onDownload)=>({
90
91
  iconName = isFolder ? 'Folder' : isDeleteMarker ? 'Deletion-marker' : 'File';
91
92
  const shouldIndent = isVersion && !isLatest;
92
93
  const isLegalHoldEnabled = isObjectLike(row.original) && Boolean(row.original.isLegalHoldEnabled);
94
+ const isCold = !isFolder && !isDeleteMarker && 'StorageClass' in row.original && null != row.original.StorageClass && isLocationCold?.(row.original.StorageClass);
93
95
  return /*#__PURE__*/ jsxs(Box, {
94
96
  display: "flex",
95
97
  alignItems: "center",
@@ -108,7 +110,7 @@ const createNameColumn = (onPrefixChange, onDownload)=>({
108
110
  color: getVersionTextColor(row)
109
111
  }),
110
112
  /*#__PURE__*/ jsx(TruncatedName, {
111
- children: isDeleteMarker ? /*#__PURE__*/ jsx(Text, {
113
+ children: isDeleteMarker || isCold ? /*#__PURE__*/ jsx(Text, {
112
114
  color: getVersionTextColor(row),
113
115
  children: value
114
116
  }) : /*#__PURE__*/ jsx(Text, {
@@ -227,7 +229,7 @@ const createSizeColumn = ()=>({
227
229
  width: 'unset'
228
230
  }
229
231
  });
230
- const createStorageClassColumn = ()=>({
232
+ const createStorageClassColumn = (isLocationCold)=>({
231
233
  Header: 'Storage Location',
232
234
  accessor: 'StorageClass',
233
235
  id: 'storageClass',
@@ -235,13 +237,34 @@ const createStorageClassColumn = ()=>({
235
237
  if (null == value) return /*#__PURE__*/ jsx(Text, {
236
238
  children: "-"
237
239
  });
238
- return /*#__PURE__*/ jsx(Box, {
240
+ const isCold = isObjectLike(row.original) && 'deleteMarker' !== row.original.type && null != value && isLocationCold?.(value);
241
+ const displayValue = 'STANDARD' === value ? 'default' : value;
242
+ return /*#__PURE__*/ jsxs(Box, {
239
243
  display: "flex",
240
244
  justifyContent: "flex-end",
241
- children: /*#__PURE__*/ jsx(Text, {
242
- color: getVersionTextColor(row),
243
- children: 'STANDARD' === value ? 'default' : value
244
- })
245
+ alignItems: "center",
246
+ gap: spacing.r4,
247
+ children: [
248
+ isCold && /*#__PURE__*/ jsxs(Fragment, {
249
+ children: [
250
+ /*#__PURE__*/ jsx(Icon, {
251
+ name: "Snowflake"
252
+ }),
253
+ /*#__PURE__*/ jsx(Tooltip, {
254
+ overlay: COLD_STORAGE_TOOLTIP,
255
+ overlayStyle: COLD_STORAGE_TOOLTIP_STYLE,
256
+ children: /*#__PURE__*/ jsx(Icon, {
257
+ name: "Info",
258
+ color: "infoPrimary"
259
+ })
260
+ })
261
+ ]
262
+ }),
263
+ /*#__PURE__*/ jsx(Text, {
264
+ color: getVersionTextColor(row),
265
+ children: displayValue
266
+ })
267
+ ]
245
268
  });
246
269
  },
247
270
  cellStyle: {
@@ -273,7 +296,7 @@ function createOverrideMap(customItems) {
273
296
  ]));
274
297
  }
275
298
  const ObjectList = ({ bucketName, prefix, onObjectSelect, onPrefixChange, onSelectedObjectsChange })=>{
276
- const { extraObjectListColumns, extraObjectListActions } = useDataBrowserUICustomization();
299
+ const { extraObjectListColumns, extraObjectListActions, isLocationCold } = useDataBrowserUICustomization();
277
300
  const invalidateQueries = useInvalidateQueries();
278
301
  const versionCheck = useListObjectVersions({
279
302
  Bucket: bucketName,
@@ -579,10 +602,13 @@ const ObjectList = ({ bucketName, prefix, onObjectSelect, onPrefixChange, onSele
579
602
  versionGroupLatestModified
580
603
  ]);
581
604
  const sizeColumn = useMemo(()=>createSizeColumn(), []);
582
- const storageClassColumn = useMemo(()=>createStorageClassColumn(), []);
583
- const nameColumn = useMemo(()=>createNameColumn(handlePrefixChange, handleDownload), [
605
+ const storageClassColumn = useMemo(()=>createStorageClassColumn(isLocationCold), [
606
+ isLocationCold
607
+ ]);
608
+ const nameColumn = useMemo(()=>createNameColumn(handlePrefixChange, handleDownload, isLocationCold), [
584
609
  handlePrefixChange,
585
- handleDownload
610
+ handleDownload,
611
+ isLocationCold
586
612
  ]);
587
613
  const columns = useMemo(()=>{
588
614
  const defaultColumnsMap = {
@@ -800,7 +826,7 @@ const ObjectList = ({ bucketName, prefix, onObjectSelect, onPrefixChange, onSele
800
826
  alignItems: "center",
801
827
  gap: spacing.r8,
802
828
  children: [
803
- actions.map((action)=>/*#__PURE__*/ jsx(Fragment, {
829
+ actions.map((action)=>/*#__PURE__*/ jsx(external_react_Fragment, {
804
830
  children: action.render()
805
831
  }, action.id)),
806
832
  /*#__PURE__*/ jsx(Toggle, {
@@ -184,6 +184,10 @@ export interface BucketCreateVersioningProps {
184
184
  */
185
185
  export interface DataBrowserUIProps {
186
186
  basePath?: string;
187
+ /** Returns true if the given location name (from StorageClass) is a cold storage location. */
188
+ isLocationCold?: (locationName: string) => boolean;
189
+ /** Number of days to keep a restored cold object accessible. Defaults to 5. */
190
+ coldStorageRestoreDays?: number;
187
191
  /**
188
192
  * Custom header component to render above the main content.
189
193
  * Typically used for breadcrumbs or navigation.
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,24 @@
1
+ import { parseRestoreStatus } from "../coldStorage.js";
2
+ describe('parseRestoreStatus', ()=>{
3
+ it('returns "cold" when restore header is undefined', ()=>{
4
+ expect(parseRestoreStatus(void 0)).toEqual({
5
+ status: 'cold'
6
+ });
7
+ });
8
+ it('returns "restoring" when ongoing-request="true"', ()=>{
9
+ expect(parseRestoreStatus('ongoing-request="true"')).toEqual({
10
+ status: 'restoring'
11
+ });
12
+ });
13
+ it('returns "restored" with expiry date when ongoing-request="false"', ()=>{
14
+ const header = 'ongoing-request="false", expiry-date="Fri, 02 May 2026 00:00:00 GMT"';
15
+ const result = parseRestoreStatus(header);
16
+ expect(result.status).toBe('restored');
17
+ if ('restored' === result.status) expect(result.expiryDate).toEqual(new Date('Fri, 02 May 2026 00:00:00 GMT'));
18
+ });
19
+ it('returns "restored" without expiry if date is missing', ()=>{
20
+ expect(parseRestoreStatus('ongoing-request="false"')).toEqual({
21
+ status: 'restored'
22
+ });
23
+ });
24
+ });
@@ -0,0 +1,12 @@
1
+ import type React from 'react';
2
+ export declare const COLD_STORAGE_TOOLTIP = "The Temperature of this Location is Cold.\n\nYou can move your data in this Location through a Transition rule.\n\nOnce your data are in this Location, you can only trigger a request for restoration to get a temporary access to the object.";
3
+ export declare const COLD_STORAGE_TOOLTIP_STYLE: React.CSSProperties;
4
+ export type ColdStorageStatus = {
5
+ status: 'cold';
6
+ } | {
7
+ status: 'restoring';
8
+ } | {
9
+ status: 'restored';
10
+ expiryDate?: Date;
11
+ };
12
+ export declare function parseRestoreStatus(restoreHeader: string | undefined): ColdStorageStatus;
@@ -0,0 +1,23 @@
1
+ const COLD_STORAGE_TOOLTIP = 'The Temperature of this Location is Cold.\n\nYou can move your data in this Location through a Transition rule.\n\nOnce your data are in this Location, you can only trigger a request for restoration to get a temporary access to the object.';
2
+ const COLD_STORAGE_TOOLTIP_STYLE = {
3
+ textWrap: 'wrap',
4
+ width: '24rem',
5
+ textAlign: 'left'
6
+ };
7
+ function parseRestoreStatus(restoreHeader) {
8
+ if (!restoreHeader) return {
9
+ status: 'cold'
10
+ };
11
+ if (restoreHeader.includes('ongoing-request="true"')) return {
12
+ status: 'restoring'
13
+ };
14
+ const expiryMatch = restoreHeader.match(/expiry-date="([^"]+)"/);
15
+ if (expiryMatch) return {
16
+ status: 'restored',
17
+ expiryDate: new Date(expiryMatch[1])
18
+ };
19
+ return {
20
+ status: 'restored'
21
+ };
22
+ }
23
+ export { COLD_STORAGE_TOOLTIP, COLD_STORAGE_TOOLTIP_STYLE, parseRestoreStatus };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scality/data-browser-library",
3
- "version": "1.1.11",
3
+ "version": "1.1.12",
4
4
  "description": "A modular React component library for browsing S3 buckets and objects",
5
5
  "type": "module",
6
6
  "types": "./dist/index.d.ts",