From 6482b65b7a2381933f8ef9a0722e2d3d73072e4b Mon Sep 17 00:00:00 2001 From: Jason Pan Date: Tue, 6 Oct 2026 11:36:21 -0400 Subject: [PATCH 1/6] fix(content-sharing): omit unchanged shared link expiration --- .../__tests__/sharingService.test.ts | 11 +- .../hooks/__tests__/useSharingService.test.ts | 2 + .../hooks/useSharingService.ts | 1 + .../content-sharing/sharingService.ts | 5 +- .../convertSharingServiceData.test.ts | 285 ++++++++++++++++++ .../utils/convertSharingServiceData.ts | 33 +- 6 files changed, 329 insertions(+), 8 deletions(-) diff --git a/src/elements/content-sharing/__tests__/sharingService.test.ts b/src/elements/content-sharing/__tests__/sharingService.test.ts index 9b2de9c501..22705b7aab 100644 --- a/src/elements/content-sharing/__tests__/sharingService.test.ts +++ b/src/elements/content-sharing/__tests__/sharingService.test.ts @@ -215,6 +215,7 @@ describe('elements/content-sharing/sharingService', () => { undefined, // access undefined, // isDownloadAvailable undefined, // serverUrl + undefined, ); expect(mockItemApiInstance.updateSharedLink).toHaveBeenCalledWith( options, @@ -247,6 +248,7 @@ describe('elements/content-sharing/sharingService', () => { options: { ...options, access: 'open', + currentExpiresAt: 1700000000000, isDownloadAvailable: true, serverUrl: 'https://example.com/server-url', }, @@ -268,6 +270,7 @@ describe('elements/content-sharing/sharingService', () => { 'open', true, 'https://example.com/server-url', + 1700000000000, ); expect(mockItemApiInstance.updateSharedLink).toHaveBeenCalledWith( options, @@ -297,7 +300,13 @@ describe('elements/content-sharing/sharingService', () => { await service.updateSharedLink(sharedLinkSettings); - expect(convertSharedLinkSettings).toHaveBeenCalledWith(sharedLinkSettings, undefined, undefined, undefined); + expect(convertSharedLinkSettings).toHaveBeenCalledWith( + sharedLinkSettings, + undefined, + undefined, + undefined, + undefined, + ); }); test('should reject with 404 error when hasSharedLink is false', async () => { diff --git a/src/elements/content-sharing/hooks/__tests__/useSharingService.test.ts b/src/elements/content-sharing/hooks/__tests__/useSharingService.test.ts index 0565aeea3c..2c75dac640 100644 --- a/src/elements/content-sharing/hooks/__tests__/useSharingService.test.ts +++ b/src/elements/content-sharing/hooks/__tests__/useSharingService.test.ts @@ -178,6 +178,7 @@ describe('elements/content-sharing/hooks/useSharingService', () => { onRemoveSharedLink: expect.any(Function), options: { access: mockSharedLink.access, + currentExpiresAt: null, isDownloadAvailable: mockSharedLink.settings.isDownloadAvailable, id: mockItemId, permissions: { can_set_share_access, can_share }, @@ -236,6 +237,7 @@ describe('elements/content-sharing/hooks/useSharingService', () => { onRemoveSharedLink: expect.any(Function), options: { access: mockSharedLink.access, + currentExpiresAt: null, isDownloadAvailable: mockSharedLink.settings.isDownloadAvailable, id: mockItemId, permissions: { can_set_share_access, can_share }, diff --git a/src/elements/content-sharing/hooks/useSharingService.ts b/src/elements/content-sharing/hooks/useSharingService.ts index fff815ee2f..909de1a7c5 100644 --- a/src/elements/content-sharing/hooks/useSharingService.ts +++ b/src/elements/content-sharing/hooks/useSharingService.ts @@ -58,6 +58,7 @@ export const useSharingService = ({ can_share: sharingServiceProps?.can_share, }, serverUrl: sharingServiceProps?.serverUrl, + currentExpiresAt: sharedLink?.expiresAt ?? null, isDownloadAvailable: sharedLink?.settings?.isDownloadAvailable ?? false, }; diff --git a/src/elements/content-sharing/sharingService.ts b/src/elements/content-sharing/sharingService.ts index f468354af9..0a82dfcc65 100644 --- a/src/elements/content-sharing/sharingService.ts +++ b/src/elements/content-sharing/sharingService.ts @@ -15,6 +15,7 @@ export interface ItemData { export interface Options extends ItemData { access?: string; + currentExpiresAt?: number | null; isDownloadAvailable?: boolean; serverUrl?: string; } @@ -69,12 +70,12 @@ export const createSharingService = ({ return Promise.reject(Object.assign(new Error('Shared link not found'), { status: 404 })); } - const { access, isDownloadAvailable, serverUrl } = options; + const { access, currentExpiresAt, isDownloadAvailable, serverUrl } = options; return new Promise((resolve, reject) => { itemApiInstance.updateSharedLink( { id, permissions }, - convertSharedLinkSettings(sharedLinkSettings, access, isDownloadAvailable, serverUrl), + convertSharedLinkSettings(sharedLinkSettings, access, isDownloadAvailable, serverUrl, currentExpiresAt), data => { onUpdateSharedLink(data); resolve(data); diff --git a/src/elements/content-sharing/utils/__tests__/convertSharingServiceData.test.ts b/src/elements/content-sharing/utils/__tests__/convertSharingServiceData.test.ts index de150794d4..1e31c13287 100644 --- a/src/elements/content-sharing/utils/__tests__/convertSharingServiceData.test.ts +++ b/src/elements/content-sharing/utils/__tests__/convertSharingServiceData.test.ts @@ -119,6 +119,291 @@ describe('elements/content-sharing/utils/convertSharingServiceData', () => { expect(result.unshared_at).toBeNull(); }); + + test('should omit unshared_at when the submitted date is the same local calendar day', () => { + const settings = { + ...mockSettings, + expiration: new Date(2024, 11, 31, 0, 0, 0), + }; + + const result = convertSharedLinkSettings( + settings, + ACCESS_OPEN, + true, + mockServerUrl, + new Date(2024, 11, 31, 18, 30, 0).getTime(), + ); + + expect(result.unshared_at).toBeUndefined(); + }); + + test('should include unshared_at when the submitted date is a different local calendar day', () => { + const settings = { + ...mockSettings, + expiration: new Date(2024, 11, 31, 0, 0, 0), + }; + + const result = convertSharedLinkSettings( + settings, + ACCESS_OPEN, + true, + mockServerUrl, + new Date(2024, 11, 30, 23, 0, 0).getTime(), + ); + + expect(result.unshared_at).toBe('2024-12-31T23:59:59.000Z'); + }); + + test('should include unshared_at when expiration is toggled on and there was no current expiration', () => { + const result = convertSharedLinkSettings(mockSettings, ACCESS_OPEN, true, mockServerUrl, null); + + expect(result.unshared_at).toBe('2024-12-31T23:59:59.000Z'); + }); + + test('should set unshared_at to null when expiration is toggled off and there was a current expiration', () => { + const settingsWithoutExpiration = { + ...mockSettings, + isExpirationEnabled: false, + }; + + const result = convertSharedLinkSettings( + settingsWithoutExpiration, + ACCESS_OPEN, + true, + mockServerUrl, + new Date(2024, 11, 31, 18, 30, 0).getTime(), + ); + + expect(result.unshared_at).toBeNull(); + }); + + test('should include unshared_at when the current expiration argument is omitted', () => { + const result = convertSharedLinkSettings(mockSettings, ACCESS_OPEN, true, mockServerUrl); + + expect(result.unshared_at).toBe('2024-12-31T23:59:59.000Z'); + }); + + describe('which shared link fields are sent together', () => { + const sameDay = new Date(2024, 11, 31, 18, 30, 0).getTime(); + const otherDay = new Date(2024, 11, 30, 23, 0, 0).getTime(); + const sameDaySettings = { + ...mockSettings, + expiration: new Date(2024, 11, 31, 0, 0, 0), + }; + + test.each([ + { + name: 'day - same, download, vanity name, no expiration', + inputs: { + settings: { ...sameDaySettings, isDownloadEnabled: true, vanityName: 'vanity-name' }, + access: ACCESS_COMPANY, + isDownloadAvailable: true, + serverUrl: mockServerUrl, + currentExpiresAt: sameDay, + }, + expected_body: { + permissions: { can_preview: false, can_download: true }, + vanity_url: 'https://example.com/server-url/vanity-name', + }, + }, + { + name: 'day - same, download, no vanity name, no expiration', + inputs: { + settings: { ...sameDaySettings, isDownloadEnabled: true, vanityName: '' }, + access: ACCESS_COMPANY, + isDownloadAvailable: true, + serverUrl: '', + currentExpiresAt: sameDay, + }, + expected_body: { + permissions: { can_preview: false, can_download: true }, + vanity_url: '', + }, + }, + { + name: 'day - same, vanity name, download off, no expiration', + inputs: { + settings: { ...sameDaySettings, isDownloadEnabled: false, vanityName: 'vanity-name' }, + access: ACCESS_COMPANY, + isDownloadAvailable: true, + serverUrl: mockServerUrl, + currentExpiresAt: sameDay, + }, + expected_body: { + permissions: { can_preview: true, can_download: false }, + vanity_url: 'https://example.com/server-url/vanity-name', + }, + }, + { + name: 'day - same, download off, no vanity name, no expiration', + inputs: { + settings: { ...sameDaySettings, isDownloadEnabled: false, vanityName: '' }, + access: ACCESS_COMPANY, + isDownloadAvailable: true, + serverUrl: '', + currentExpiresAt: sameDay, + }, + expected_body: { + permissions: { can_preview: true, can_download: false }, + vanity_url: '', + }, + }, + { + name: 'day - different, expiration, download, vanity name', + inputs: { + settings: { ...sameDaySettings, isDownloadEnabled: true, vanityName: 'vanity-name' }, + access: ACCESS_COMPANY, + isDownloadAvailable: true, + serverUrl: mockServerUrl, + currentExpiresAt: otherDay, + }, + expected_body: { + permissions: { can_preview: false, can_download: true }, + unshared_at: '2024-12-31T23:59:59.000Z', + vanity_url: 'https://example.com/server-url/vanity-name', + }, + }, + { + name: 'day - different, expiration, download, no vanity name', + inputs: { + settings: { ...sameDaySettings, isDownloadEnabled: true, vanityName: '' }, + access: ACCESS_COMPANY, + isDownloadAvailable: true, + serverUrl: '', + currentExpiresAt: otherDay, + }, + expected_body: { + permissions: { can_preview: false, can_download: true }, + unshared_at: '2024-12-31T23:59:59.000Z', + vanity_url: '', + }, + }, + { + name: 'day - different, expiration, vanity name, download off', + inputs: { + settings: { ...sameDaySettings, isDownloadEnabled: false, vanityName: 'vanity-name' }, + access: ACCESS_COMPANY, + isDownloadAvailable: true, + serverUrl: mockServerUrl, + currentExpiresAt: otherDay, + }, + expected_body: { + permissions: { can_preview: true, can_download: false }, + unshared_at: '2024-12-31T23:59:59.000Z', + vanity_url: 'https://example.com/server-url/vanity-name', + }, + }, + { + name: 'day - different, expiration, download off, no vanity name', + inputs: { + settings: { ...sameDaySettings, isDownloadEnabled: false, vanityName: '' }, + access: ACCESS_COMPANY, + isDownloadAvailable: true, + serverUrl: '', + currentExpiresAt: otherDay, + }, + expected_body: { + permissions: { can_preview: true, can_download: false }, + unshared_at: '2024-12-31T23:59:59.000Z', + vanity_url: '', + }, + }, + { + name: 'day - off, expiration null, download, vanity name', + inputs: { + settings: { + ...sameDaySettings, + isDownloadEnabled: true, + isExpirationEnabled: false, + vanityName: 'vanity-name', + }, + access: ACCESS_COMPANY, + isDownloadAvailable: true, + serverUrl: mockServerUrl, + currentExpiresAt: sameDay, + }, + expected_body: { + permissions: { can_preview: false, can_download: true }, + unshared_at: null, + vanity_url: 'https://example.com/server-url/vanity-name', + }, + }, + { + name: 'day - off, expiration null, download, no vanity name', + inputs: { + settings: { + ...sameDaySettings, + isDownloadEnabled: true, + isExpirationEnabled: false, + vanityName: '', + }, + access: ACCESS_COMPANY, + isDownloadAvailable: true, + serverUrl: '', + currentExpiresAt: sameDay, + }, + expected_body: { + permissions: { can_preview: false, can_download: true }, + unshared_at: null, + vanity_url: '', + }, + }, + { + name: 'day - off, expiration null, vanity name, download off', + inputs: { + settings: { + ...sameDaySettings, + isDownloadEnabled: false, + isExpirationEnabled: false, + vanityName: 'vanity-name', + }, + access: ACCESS_COMPANY, + isDownloadAvailable: true, + serverUrl: mockServerUrl, + currentExpiresAt: sameDay, + }, + expected_body: { + permissions: { can_preview: true, can_download: false }, + unshared_at: null, + vanity_url: 'https://example.com/server-url/vanity-name', + }, + }, + { + name: 'day - off, expiration null, download off, no vanity name', + inputs: { + settings: { + ...sameDaySettings, + isDownloadEnabled: false, + isExpirationEnabled: false, + vanityName: '', + }, + access: ACCESS_COMPANY, + isDownloadAvailable: true, + serverUrl: '', + currentExpiresAt: sameDay, + }, + expected_body: { + permissions: { can_preview: true, can_download: false }, + unshared_at: null, + vanity_url: '', + }, + }, + ])('$name', ({ inputs, expected_body }) => { + const { settings, access, isDownloadAvailable, serverUrl, currentExpiresAt } = inputs; + const result = + currentExpiresAt === undefined + ? convertSharedLinkSettings(settings, access, isDownloadAvailable, serverUrl) + : convertSharedLinkSettings( + settings, + access, + isDownloadAvailable, + serverUrl, + currentExpiresAt, + ); + + expect(result).toEqual(expected_body); + }); + }); }); describe('vanity URL', () => { diff --git a/src/elements/content-sharing/utils/convertSharingServiceData.ts b/src/elements/content-sharing/utils/convertSharingServiceData.ts index 715e872db6..ed85ef5149 100644 --- a/src/elements/content-sharing/utils/convertSharingServiceData.ts +++ b/src/elements/content-sharing/utils/convertSharingServiceData.ts @@ -16,7 +16,7 @@ export interface ConvertSharedLinkSettingsReturnType { can_edit?: boolean; can_preview: boolean; }; - unshared_at: string | null; + unshared_at?: string | null; vanity_url: string; } @@ -42,22 +42,45 @@ export const convertSharedLinkPermissions = (permissionLevel: string) => { * - Changing the settings for a shared link in any other scenario. The access level is saved from the initial calls to the Item API and * convertItemResponse, so it is in internal USM format. */ +const isSameLocalCalendarDay = (left: Date, right: Date) => + left.getFullYear() === right.getFullYear() && + left.getMonth() === right.getMonth() && + left.getDate() === right.getDate(); + export const convertSharedLinkSettings = ( newSettings: SharedLinkSettings, accessLevel: string, isDownloadAvailable: boolean, serverUrl: string, + currentExpiresAt?: number | null, ): ConvertSharedLinkSettingsReturnType => { const { expiration, isDownloadEnabled, isExpirationEnabled, isPasswordEnabled, password, vanityName } = newSettings; + const unsharedAt = + expiration && isExpirationEnabled + ? convertISOStringToUTCDate(new Date(expiration).toISOString()).toISOString() + : null; + const convertedSettings: ConvertSharedLinkSettingsReturnType = { - unshared_at: - expiration && isExpirationEnabled - ? convertISOStringToUTCDate(new Date(expiration).toISOString()).toISOString() - : null, vanity_url: serverUrl && vanityName ? `${serverUrl}${vanityName}` : '', }; + if (currentExpiresAt === undefined) { + convertedSettings.unshared_at = unsharedAt; + } else { + const submittedHasExpiration = Boolean(expiration && isExpirationEnabled); + const currentHasExpiration = currentExpiresAt !== null; + const expirationChanged = + submittedHasExpiration !== currentHasExpiration || + (submittedHasExpiration && + currentHasExpiration && + !isSameLocalCalendarDay(new Date(expiration), new Date(currentExpiresAt))); + + if (expirationChanged) { + convertedSettings.unshared_at = unsharedAt; + } + } + // Download permissions can only be set on "company" or "open" shared links. if (accessLevel !== ACCESS_COLLAB) { const permissions: ConvertSharedLinkSettingsReturnType['permissions'] = { can_preview: !isDownloadEnabled }; From 18d7b00145c9781b1a0ae372976e5e18a54e021a Mon Sep 17 00:00:00 2001 From: Jason Pan Date: Thu, 8 Oct 2026 18:08:48 -0400 Subject: [PATCH 2/6] fix(content-sharing): parse expiration dates as local calendar days --- src/components/date-picker/DatePicker.tsx | 13 +------ .../convertSharingServiceData.test.ts | 24 +++++++++++- .../utils/convertSharingServiceData.ts | 37 +++++++++++-------- src/utils/__tests__/datetime.test.ts | 21 +++++++++++ src/utils/datetime.js.flow | 22 +++++++++++ src/utils/datetime.ts | 22 +++++++++++ 6 files changed, 112 insertions(+), 27 deletions(-) diff --git a/src/components/date-picker/DatePicker.tsx b/src/components/date-picker/DatePicker.tsx index 8b25b4cfaf..5d34da3f8d 100644 --- a/src/components/date-picker/DatePicker.tsx +++ b/src/components/date-picker/DatePicker.tsx @@ -18,7 +18,7 @@ import Label from '../label'; import PlainButton from '../plain-button'; import Tooltip, { TooltipPosition, TooltipTheme } from '../tooltip'; -import { convertDateToUnixMidnightTime } from '../../utils/datetime'; +import { convertDateToUnixMidnightTime, ISO_DATE_FORMAT_PATTERN, parseLocalCalendarDate } from '../../utils/datetime'; import './DatePicker.scss'; @@ -70,8 +70,6 @@ const ENTER_KEY = 'Enter'; const ESCAPE_KEY = 'Escape'; const TAB_KEY = 'Tab'; -const ISO_DATE_FORMAT_PATTERN = /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$/; - export enum DateFormat { ISO_STRING_DATE_FORMAT = 'isoString', LOCALE_DATE_STRING_DATE_FORMAT = 'localeDateString', @@ -598,14 +596,7 @@ class DatePicker extends React.Component { }; parseDisplayDateType = (dateString?: string | null): Date | null => { - if (dateString && ISO_DATE_FORMAT_PATTERN.test(dateString)) { - // Calling new Date('YYYY-MM-DD') without 'T00:00:00' yields undesired results: - // E.g. new Date('2017-06-01') => May 31 2017 - // E.g. new Date('2017-06-01T00:00:00') => June 01 2017 - // See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/Date#parameters - return new Date(`${dateString}T00:00:00`); - } - return null; + return dateString ? parseLocalCalendarDate(dateString) : null; }; formatValue = (date: Date | null): string | number => { diff --git a/src/elements/content-sharing/utils/__tests__/convertSharingServiceData.test.ts b/src/elements/content-sharing/utils/__tests__/convertSharingServiceData.test.ts index 1e31c13287..479aefc7d2 100644 --- a/src/elements/content-sharing/utils/__tests__/convertSharingServiceData.test.ts +++ b/src/elements/content-sharing/utils/__tests__/convertSharingServiceData.test.ts @@ -9,7 +9,10 @@ import { import { convertISOStringToUTCDate } from '../../../../utils/datetime'; import { convertSharedLinkPermissions, convertSharedLinkSettings } from '../convertSharingServiceData'; -jest.mock('../../../../utils/datetime'); +jest.mock('../../../../utils/datetime', () => ({ + ...jest.requireActual('../../../../utils/datetime'), + convertISOStringToUTCDate: jest.fn(), +})); describe('elements/content-sharing/utils/convertSharingServiceData', () => { beforeEach(() => { @@ -120,6 +123,25 @@ describe('elements/content-sharing/utils/convertSharingServiceData', () => { expect(result.unshared_at).toBeNull(); }); + test('should omit unshared_at when the date field submits the saved local calendar day', () => { + const saved = new Date('2026-10-15T23:59:00-07:00'); + const submittedDay = [ + saved.getFullYear(), + String(saved.getMonth() + 1).padStart(2, '0'), + String(saved.getDate()).padStart(2, '0'), + ].join('-'); + const settings = { + ...mockSettings, + expiration: submittedDay, + isDownloadEnabled: false, + }; + + const result = convertSharedLinkSettings(settings, ACCESS_OPEN, true, mockServerUrl, saved.getTime()); + + expect(result.unshared_at).toBeUndefined(); + expect(result.permissions).toEqual({ can_preview: true, can_download: false }); + }); + test('should omit unshared_at when the submitted date is the same local calendar day', () => { const settings = { ...mockSettings, diff --git a/src/elements/content-sharing/utils/convertSharingServiceData.ts b/src/elements/content-sharing/utils/convertSharingServiceData.ts index ed85ef5149..6408eab1a6 100644 --- a/src/elements/content-sharing/utils/convertSharingServiceData.ts +++ b/src/elements/content-sharing/utils/convertSharingServiceData.ts @@ -5,7 +5,7 @@ import { PERMISSION_CAN_EDIT, PERMISSION_CAN_PREVIEW, } from '../../../constants'; -import { convertISOStringToUTCDate } from '../../../utils/datetime'; +import { convertISOStringToUTCDate, ISO_DATE_FORMAT_PATTERN, parseLocalCalendarDate } from '../../../utils/datetime'; import type { SharedLinkSettings } from '../types'; @@ -42,6 +42,18 @@ export const convertSharedLinkPermissions = (permissionLevel: string) => { * - Changing the settings for a shared link in any other scenario. The access level is saved from the initial calls to the Item API and * convertItemResponse, so it is in internal USM format. */ +const localCalendarDate = (value: Date | number | string) => { + if (typeof value === 'string') { + if (ISO_DATE_FORMAT_PATTERN.test(value)) { + return parseLocalCalendarDate(value) ?? new Date(NaN); + } + + return new Date(value); + } + + return new Date(value); +}; + const isSameLocalCalendarDay = (left: Date, right: Date) => left.getFullYear() === right.getFullYear() && left.getMonth() === right.getMonth() && @@ -65,20 +77,15 @@ export const convertSharedLinkSettings = ( vanity_url: serverUrl && vanityName ? `${serverUrl}${vanityName}` : '', }; - if (currentExpiresAt === undefined) { - convertedSettings.unshared_at = unsharedAt; - } else { - const submittedHasExpiration = Boolean(expiration && isExpirationEnabled); - const currentHasExpiration = currentExpiresAt !== null; - const expirationChanged = - submittedHasExpiration !== currentHasExpiration || - (submittedHasExpiration && - currentHasExpiration && - !isSameLocalCalendarDay(new Date(expiration), new Date(currentExpiresAt))); - - if (expirationChanged) { - convertedSettings.unshared_at = unsharedAt; - } + const submittedDate = expiration && isExpirationEnabled ? localCalendarDate(expiration) : null; + const currentDate = typeof currentExpiresAt === 'number' ? new Date(currentExpiresAt) : null; + const isSameDay = + submittedDate && currentDate + ? isSameLocalCalendarDay(submittedDate, currentDate) + : submittedDate === currentDate; + + if (currentExpiresAt === undefined || !isSameDay) { + convertedSettings.unshared_at = isExpirationEnabled ? unsharedAt : null; } // Download permissions can only be set on "company" or "open" shared links. diff --git a/src/utils/__tests__/datetime.test.ts b/src/utils/__tests__/datetime.test.ts index fb13627a47..955e0c518c 100644 --- a/src/utils/__tests__/datetime.test.ts +++ b/src/utils/__tests__/datetime.test.ts @@ -10,6 +10,7 @@ import { isTomorrow, isValidDate, isYesterday, + parseLocalCalendarDate, } from '../datetime'; describe('utils/datetime', () => { @@ -207,4 +208,24 @@ describe('utils/datetime', () => { expect(input).toEqual(to); }); }); + + describe('parseLocalCalendarDate()', () => { + test('should parse a date-only string as local midnight', () => { + const result = parseLocalCalendarDate('2017-06-01'); + + expect(result).not.toBeNull(); + expect(result?.getFullYear()).toBe(2017); + expect(result?.getMonth()).toBe(5); + expect(result?.getDate()).toBe(1); + expect(result?.getHours()).toBe(0); + }); + + test('should return null for a string that is not a calendar date', () => { + expect(parseLocalCalendarDate('2017-13-01')).toBeNull(); + }); + + test('should return null for a February day that does not exist', () => { + expect(parseLocalCalendarDate('2026-02-30')).toBeNull(); + }); + }); }); diff --git a/src/utils/datetime.js.flow b/src/utils/datetime.js.flow index cac050c5f2..7dbb4b7743 100644 --- a/src/utils/datetime.js.flow +++ b/src/utils/datetime.js.flow @@ -233,6 +233,27 @@ function convertISOStringToUTCDate(isoString: string): Date { return new Date(localizedUnixTimeInMs); } +export const ISO_DATE_FORMAT_PATTERN = /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$/; + +function parseLocalCalendarDate(dateString: string): Date | null { + if (!ISO_DATE_FORMAT_PATTERN.test(dateString)) { + return null; + } + + // Calling new Date('YYYY-MM-DD') without 'T00:00:00' yields undesired results: + // E.g. new Date('2017-06-01') => May 31 2017 + // E.g. new Date('2017-06-01T00:00:00') => June 01 2017 + // See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/Date#parameters + const parsedDate = new Date(`${dateString}T00:00:00`); + const [year, month, day] = dateString.split('-').map(Number); + + if (parsedDate.getFullYear() !== year || parsedDate.getMonth() !== month - 1 || parsedDate.getDate() !== day) { + return null; + } + + return parsedDate; +} + export { convertToDate, convertToMs, @@ -247,4 +268,5 @@ export { isCurrentYear, formatTime, addTime, + parseLocalCalendarDate, }; diff --git a/src/utils/datetime.ts b/src/utils/datetime.ts index 1eb2fb0381..45484b8e3f 100644 --- a/src/utils/datetime.ts +++ b/src/utils/datetime.ts @@ -237,6 +237,27 @@ function convertISOStringToUTCDate(isoString: string): Date { return new Date(localizedUnixTimeInMs); } +export const ISO_DATE_FORMAT_PATTERN = /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$/; + +function parseLocalCalendarDate(dateString: string): Date | null { + if (!ISO_DATE_FORMAT_PATTERN.test(dateString)) { + return null; + } + + // Calling new Date('YYYY-MM-DD') without 'T00:00:00' yields undesired results: + // E.g. new Date('2017-06-01') => May 31 2017 + // E.g. new Date('2017-06-01T00:00:00') => June 01 2017 + // See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/Date#parameters + const parsedDate = new Date(`${dateString}T00:00:00`); + const [year, month, day] = dateString.split('-').map(Number); + + if (parsedDate.getFullYear() !== year || parsedDate.getMonth() !== month - 1 || parsedDate.getDate() !== day) { + return null; + } + + return parsedDate; +} + export { addTime, convertDateToUnixMidnightTime, @@ -251,4 +272,5 @@ export { isTomorrow, isValidDate, isYesterday, + parseLocalCalendarDate, }; From a8cefc86f2d28075cc3787df2ae463375e8fe7ab Mon Sep 17 00:00:00 2001 From: Jason Pan Date: Thu, 8 Oct 2026 18:43:01 -0400 Subject: [PATCH 3/6] fix(content-sharing): compare expiration by local day label --- src/components/date-picker/DatePicker.tsx | 13 ++++++-- .../utils/convertSharingServiceData.ts | 33 ++++++++----------- src/utils/__tests__/datetime.test.ts | 21 ------------ src/utils/datetime.js.flow | 22 ------------- src/utils/datetime.ts | 22 ------------- 5 files changed, 24 insertions(+), 87 deletions(-) diff --git a/src/components/date-picker/DatePicker.tsx b/src/components/date-picker/DatePicker.tsx index 5d34da3f8d..8b25b4cfaf 100644 --- a/src/components/date-picker/DatePicker.tsx +++ b/src/components/date-picker/DatePicker.tsx @@ -18,7 +18,7 @@ import Label from '../label'; import PlainButton from '../plain-button'; import Tooltip, { TooltipPosition, TooltipTheme } from '../tooltip'; -import { convertDateToUnixMidnightTime, ISO_DATE_FORMAT_PATTERN, parseLocalCalendarDate } from '../../utils/datetime'; +import { convertDateToUnixMidnightTime } from '../../utils/datetime'; import './DatePicker.scss'; @@ -70,6 +70,8 @@ const ENTER_KEY = 'Enter'; const ESCAPE_KEY = 'Escape'; const TAB_KEY = 'Tab'; +const ISO_DATE_FORMAT_PATTERN = /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$/; + export enum DateFormat { ISO_STRING_DATE_FORMAT = 'isoString', LOCALE_DATE_STRING_DATE_FORMAT = 'localeDateString', @@ -596,7 +598,14 @@ class DatePicker extends React.Component { }; parseDisplayDateType = (dateString?: string | null): Date | null => { - return dateString ? parseLocalCalendarDate(dateString) : null; + if (dateString && ISO_DATE_FORMAT_PATTERN.test(dateString)) { + // Calling new Date('YYYY-MM-DD') without 'T00:00:00' yields undesired results: + // E.g. new Date('2017-06-01') => May 31 2017 + // E.g. new Date('2017-06-01T00:00:00') => June 01 2017 + // See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/Date#parameters + return new Date(`${dateString}T00:00:00`); + } + return null; }; formatValue = (date: Date | null): string | number => { diff --git a/src/elements/content-sharing/utils/convertSharingServiceData.ts b/src/elements/content-sharing/utils/convertSharingServiceData.ts index 6408eab1a6..7ba86f7671 100644 --- a/src/elements/content-sharing/utils/convertSharingServiceData.ts +++ b/src/elements/content-sharing/utils/convertSharingServiceData.ts @@ -5,7 +5,7 @@ import { PERMISSION_CAN_EDIT, PERMISSION_CAN_PREVIEW, } from '../../../constants'; -import { convertISOStringToUTCDate, ISO_DATE_FORMAT_PATTERN, parseLocalCalendarDate } from '../../../utils/datetime'; +import { convertISOStringToUTCDate } from '../../../utils/datetime'; import type { SharedLinkSettings } from '../types'; @@ -42,22 +42,19 @@ export const convertSharedLinkPermissions = (permissionLevel: string) => { * - Changing the settings for a shared link in any other scenario. The access level is saved from the initial calls to the Item API and * convertItemResponse, so it is in internal USM format. */ -const localCalendarDate = (value: Date | number | string) => { - if (typeof value === 'string') { - if (ISO_DATE_FORMAT_PATTERN.test(value)) { - return parseLocalCalendarDate(value) ?? new Date(NaN); - } +const DATE_ONLY_PATTERN = /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$/; - return new Date(value); +const localDayLabel = (value: Date | number | string) => { + if (typeof value === 'string' && DATE_ONLY_PATTERN.test(value)) { + return value; } - return new Date(value); -}; + const date = value instanceof Date ? value : new Date(value); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); -const isSameLocalCalendarDay = (left: Date, right: Date) => - left.getFullYear() === right.getFullYear() && - left.getMonth() === right.getMonth() && - left.getDate() === right.getDate(); + return `${date.getFullYear()}-${month}-${day}`; +}; export const convertSharedLinkSettings = ( newSettings: SharedLinkSettings, @@ -77,14 +74,10 @@ export const convertSharedLinkSettings = ( vanity_url: serverUrl && vanityName ? `${serverUrl}${vanityName}` : '', }; - const submittedDate = expiration && isExpirationEnabled ? localCalendarDate(expiration) : null; - const currentDate = typeof currentExpiresAt === 'number' ? new Date(currentExpiresAt) : null; - const isSameDay = - submittedDate && currentDate - ? isSameLocalCalendarDay(submittedDate, currentDate) - : submittedDate === currentDate; + const submittedDay = expiration && isExpirationEnabled ? localDayLabel(expiration) : ''; + const currentDay = typeof currentExpiresAt === 'number' ? localDayLabel(currentExpiresAt) : ''; - if (currentExpiresAt === undefined || !isSameDay) { + if (currentExpiresAt === undefined || submittedDay !== currentDay) { convertedSettings.unshared_at = isExpirationEnabled ? unsharedAt : null; } diff --git a/src/utils/__tests__/datetime.test.ts b/src/utils/__tests__/datetime.test.ts index 955e0c518c..fb13627a47 100644 --- a/src/utils/__tests__/datetime.test.ts +++ b/src/utils/__tests__/datetime.test.ts @@ -10,7 +10,6 @@ import { isTomorrow, isValidDate, isYesterday, - parseLocalCalendarDate, } from '../datetime'; describe('utils/datetime', () => { @@ -208,24 +207,4 @@ describe('utils/datetime', () => { expect(input).toEqual(to); }); }); - - describe('parseLocalCalendarDate()', () => { - test('should parse a date-only string as local midnight', () => { - const result = parseLocalCalendarDate('2017-06-01'); - - expect(result).not.toBeNull(); - expect(result?.getFullYear()).toBe(2017); - expect(result?.getMonth()).toBe(5); - expect(result?.getDate()).toBe(1); - expect(result?.getHours()).toBe(0); - }); - - test('should return null for a string that is not a calendar date', () => { - expect(parseLocalCalendarDate('2017-13-01')).toBeNull(); - }); - - test('should return null for a February day that does not exist', () => { - expect(parseLocalCalendarDate('2026-02-30')).toBeNull(); - }); - }); }); diff --git a/src/utils/datetime.js.flow b/src/utils/datetime.js.flow index 7dbb4b7743..cac050c5f2 100644 --- a/src/utils/datetime.js.flow +++ b/src/utils/datetime.js.flow @@ -233,27 +233,6 @@ function convertISOStringToUTCDate(isoString: string): Date { return new Date(localizedUnixTimeInMs); } -export const ISO_DATE_FORMAT_PATTERN = /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$/; - -function parseLocalCalendarDate(dateString: string): Date | null { - if (!ISO_DATE_FORMAT_PATTERN.test(dateString)) { - return null; - } - - // Calling new Date('YYYY-MM-DD') without 'T00:00:00' yields undesired results: - // E.g. new Date('2017-06-01') => May 31 2017 - // E.g. new Date('2017-06-01T00:00:00') => June 01 2017 - // See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/Date#parameters - const parsedDate = new Date(`${dateString}T00:00:00`); - const [year, month, day] = dateString.split('-').map(Number); - - if (parsedDate.getFullYear() !== year || parsedDate.getMonth() !== month - 1 || parsedDate.getDate() !== day) { - return null; - } - - return parsedDate; -} - export { convertToDate, convertToMs, @@ -268,5 +247,4 @@ export { isCurrentYear, formatTime, addTime, - parseLocalCalendarDate, }; diff --git a/src/utils/datetime.ts b/src/utils/datetime.ts index 45484b8e3f..1eb2fb0381 100644 --- a/src/utils/datetime.ts +++ b/src/utils/datetime.ts @@ -237,27 +237,6 @@ function convertISOStringToUTCDate(isoString: string): Date { return new Date(localizedUnixTimeInMs); } -export const ISO_DATE_FORMAT_PATTERN = /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$/; - -function parseLocalCalendarDate(dateString: string): Date | null { - if (!ISO_DATE_FORMAT_PATTERN.test(dateString)) { - return null; - } - - // Calling new Date('YYYY-MM-DD') without 'T00:00:00' yields undesired results: - // E.g. new Date('2017-06-01') => May 31 2017 - // E.g. new Date('2017-06-01T00:00:00') => June 01 2017 - // See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/Date#parameters - const parsedDate = new Date(`${dateString}T00:00:00`); - const [year, month, day] = dateString.split('-').map(Number); - - if (parsedDate.getFullYear() !== year || parsedDate.getMonth() !== month - 1 || parsedDate.getDate() !== day) { - return null; - } - - return parsedDate; -} - export { addTime, convertDateToUnixMidnightTime, @@ -272,5 +251,4 @@ export { isTomorrow, isValidDate, isYesterday, - parseLocalCalendarDate, }; From 77d4c9712d81a6f58da1c43d78ec120f77ad6fcb Mon Sep 17 00:00:00 2001 From: Jason Pan Date: Thu, 8 Oct 2026 19:08:25 -0400 Subject: [PATCH 4/6] test(content-sharing): drop repeated expiration field cases --- .../convertSharingServiceData.test.ts | 228 ------------------ 1 file changed, 228 deletions(-) diff --git a/src/elements/content-sharing/utils/__tests__/convertSharingServiceData.test.ts b/src/elements/content-sharing/utils/__tests__/convertSharingServiceData.test.ts index 479aefc7d2..68ffda290d 100644 --- a/src/elements/content-sharing/utils/__tests__/convertSharingServiceData.test.ts +++ b/src/elements/content-sharing/utils/__tests__/convertSharingServiceData.test.ts @@ -198,234 +198,6 @@ describe('elements/content-sharing/utils/convertSharingServiceData', () => { expect(result.unshared_at).toBeNull(); }); - - test('should include unshared_at when the current expiration argument is omitted', () => { - const result = convertSharedLinkSettings(mockSettings, ACCESS_OPEN, true, mockServerUrl); - - expect(result.unshared_at).toBe('2024-12-31T23:59:59.000Z'); - }); - - describe('which shared link fields are sent together', () => { - const sameDay = new Date(2024, 11, 31, 18, 30, 0).getTime(); - const otherDay = new Date(2024, 11, 30, 23, 0, 0).getTime(); - const sameDaySettings = { - ...mockSettings, - expiration: new Date(2024, 11, 31, 0, 0, 0), - }; - - test.each([ - { - name: 'day - same, download, vanity name, no expiration', - inputs: { - settings: { ...sameDaySettings, isDownloadEnabled: true, vanityName: 'vanity-name' }, - access: ACCESS_COMPANY, - isDownloadAvailable: true, - serverUrl: mockServerUrl, - currentExpiresAt: sameDay, - }, - expected_body: { - permissions: { can_preview: false, can_download: true }, - vanity_url: 'https://example.com/server-url/vanity-name', - }, - }, - { - name: 'day - same, download, no vanity name, no expiration', - inputs: { - settings: { ...sameDaySettings, isDownloadEnabled: true, vanityName: '' }, - access: ACCESS_COMPANY, - isDownloadAvailable: true, - serverUrl: '', - currentExpiresAt: sameDay, - }, - expected_body: { - permissions: { can_preview: false, can_download: true }, - vanity_url: '', - }, - }, - { - name: 'day - same, vanity name, download off, no expiration', - inputs: { - settings: { ...sameDaySettings, isDownloadEnabled: false, vanityName: 'vanity-name' }, - access: ACCESS_COMPANY, - isDownloadAvailable: true, - serverUrl: mockServerUrl, - currentExpiresAt: sameDay, - }, - expected_body: { - permissions: { can_preview: true, can_download: false }, - vanity_url: 'https://example.com/server-url/vanity-name', - }, - }, - { - name: 'day - same, download off, no vanity name, no expiration', - inputs: { - settings: { ...sameDaySettings, isDownloadEnabled: false, vanityName: '' }, - access: ACCESS_COMPANY, - isDownloadAvailable: true, - serverUrl: '', - currentExpiresAt: sameDay, - }, - expected_body: { - permissions: { can_preview: true, can_download: false }, - vanity_url: '', - }, - }, - { - name: 'day - different, expiration, download, vanity name', - inputs: { - settings: { ...sameDaySettings, isDownloadEnabled: true, vanityName: 'vanity-name' }, - access: ACCESS_COMPANY, - isDownloadAvailable: true, - serverUrl: mockServerUrl, - currentExpiresAt: otherDay, - }, - expected_body: { - permissions: { can_preview: false, can_download: true }, - unshared_at: '2024-12-31T23:59:59.000Z', - vanity_url: 'https://example.com/server-url/vanity-name', - }, - }, - { - name: 'day - different, expiration, download, no vanity name', - inputs: { - settings: { ...sameDaySettings, isDownloadEnabled: true, vanityName: '' }, - access: ACCESS_COMPANY, - isDownloadAvailable: true, - serverUrl: '', - currentExpiresAt: otherDay, - }, - expected_body: { - permissions: { can_preview: false, can_download: true }, - unshared_at: '2024-12-31T23:59:59.000Z', - vanity_url: '', - }, - }, - { - name: 'day - different, expiration, vanity name, download off', - inputs: { - settings: { ...sameDaySettings, isDownloadEnabled: false, vanityName: 'vanity-name' }, - access: ACCESS_COMPANY, - isDownloadAvailable: true, - serverUrl: mockServerUrl, - currentExpiresAt: otherDay, - }, - expected_body: { - permissions: { can_preview: true, can_download: false }, - unshared_at: '2024-12-31T23:59:59.000Z', - vanity_url: 'https://example.com/server-url/vanity-name', - }, - }, - { - name: 'day - different, expiration, download off, no vanity name', - inputs: { - settings: { ...sameDaySettings, isDownloadEnabled: false, vanityName: '' }, - access: ACCESS_COMPANY, - isDownloadAvailable: true, - serverUrl: '', - currentExpiresAt: otherDay, - }, - expected_body: { - permissions: { can_preview: true, can_download: false }, - unshared_at: '2024-12-31T23:59:59.000Z', - vanity_url: '', - }, - }, - { - name: 'day - off, expiration null, download, vanity name', - inputs: { - settings: { - ...sameDaySettings, - isDownloadEnabled: true, - isExpirationEnabled: false, - vanityName: 'vanity-name', - }, - access: ACCESS_COMPANY, - isDownloadAvailable: true, - serverUrl: mockServerUrl, - currentExpiresAt: sameDay, - }, - expected_body: { - permissions: { can_preview: false, can_download: true }, - unshared_at: null, - vanity_url: 'https://example.com/server-url/vanity-name', - }, - }, - { - name: 'day - off, expiration null, download, no vanity name', - inputs: { - settings: { - ...sameDaySettings, - isDownloadEnabled: true, - isExpirationEnabled: false, - vanityName: '', - }, - access: ACCESS_COMPANY, - isDownloadAvailable: true, - serverUrl: '', - currentExpiresAt: sameDay, - }, - expected_body: { - permissions: { can_preview: false, can_download: true }, - unshared_at: null, - vanity_url: '', - }, - }, - { - name: 'day - off, expiration null, vanity name, download off', - inputs: { - settings: { - ...sameDaySettings, - isDownloadEnabled: false, - isExpirationEnabled: false, - vanityName: 'vanity-name', - }, - access: ACCESS_COMPANY, - isDownloadAvailable: true, - serverUrl: mockServerUrl, - currentExpiresAt: sameDay, - }, - expected_body: { - permissions: { can_preview: true, can_download: false }, - unshared_at: null, - vanity_url: 'https://example.com/server-url/vanity-name', - }, - }, - { - name: 'day - off, expiration null, download off, no vanity name', - inputs: { - settings: { - ...sameDaySettings, - isDownloadEnabled: false, - isExpirationEnabled: false, - vanityName: '', - }, - access: ACCESS_COMPANY, - isDownloadAvailable: true, - serverUrl: '', - currentExpiresAt: sameDay, - }, - expected_body: { - permissions: { can_preview: true, can_download: false }, - unshared_at: null, - vanity_url: '', - }, - }, - ])('$name', ({ inputs, expected_body }) => { - const { settings, access, isDownloadAvailable, serverUrl, currentExpiresAt } = inputs; - const result = - currentExpiresAt === undefined - ? convertSharedLinkSettings(settings, access, isDownloadAvailable, serverUrl) - : convertSharedLinkSettings( - settings, - access, - isDownloadAvailable, - serverUrl, - currentExpiresAt, - ); - - expect(result).toEqual(expected_body); - }); - }); }); describe('vanity URL', () => { From 5dad36c4da3dd3c4ee7f5f3ee985924d41c2dcef Mon Sep 17 00:00:00 2001 From: Jason Pan Date: Fri, 9 Oct 2026 15:39:50 -0400 Subject: [PATCH 5/6] fix(content-sharing): compare expiration by calendar day Compare the form's calendar date with the saved instant's local day. An untouched expiration stays off the request. --- .../convertSharingServiceData.test.ts | 45 +++++++------------ .../utils/convertSharingServiceData.ts | 36 ++++++++------- 2 files changed, 36 insertions(+), 45 deletions(-) diff --git a/src/elements/content-sharing/utils/__tests__/convertSharingServiceData.test.ts b/src/elements/content-sharing/utils/__tests__/convertSharingServiceData.test.ts index 68ffda290d..6a23cd19a6 100644 --- a/src/elements/content-sharing/utils/__tests__/convertSharingServiceData.test.ts +++ b/src/elements/content-sharing/utils/__tests__/convertSharingServiceData.test.ts @@ -123,46 +123,27 @@ describe('elements/content-sharing/utils/convertSharingServiceData', () => { expect(result.unshared_at).toBeNull(); }); - test('should omit unshared_at when the date field submits the saved local calendar day', () => { - const saved = new Date('2026-10-15T23:59:00-07:00'); - const submittedDay = [ - saved.getFullYear(), - String(saved.getMonth() + 1).padStart(2, '0'), - String(saved.getDate()).padStart(2, '0'), - ].join('-'); - const settings = { - ...mockSettings, - expiration: submittedDay, - isDownloadEnabled: false, - }; - - const result = convertSharedLinkSettings(settings, ACCESS_OPEN, true, mockServerUrl, saved.getTime()); - - expect(result.unshared_at).toBeUndefined(); - expect(result.permissions).toEqual({ can_preview: true, can_download: false }); - }); - - test('should omit unshared_at when the submitted date is the same local calendar day', () => { - const settings = { - ...mockSettings, - expiration: new Date(2024, 11, 31, 0, 0, 0), - }; - + test('should keep the other shared link fields when the calendar date is omitted', () => { const result = convertSharedLinkSettings( - settings, + { + ...mockSettings, + expiration: { year: 2026, month: 10, day: 16 }, + isDownloadEnabled: false, + }, ACCESS_OPEN, true, mockServerUrl, - new Date(2024, 11, 31, 18, 30, 0).getTime(), + new Date(2026, 9, 16, 2, 59, 0).getTime(), ); expect(result.unshared_at).toBeUndefined(); + expect(result.permissions).toEqual({ can_preview: true, can_download: false }); }); test('should include unshared_at when the submitted date is a different local calendar day', () => { const settings = { ...mockSettings, - expiration: new Date(2024, 11, 31, 0, 0, 0), + expiration: { year: 2024, month: 12, day: 31, toString: () => '2024-12-31' }, }; const result = convertSharedLinkSettings( @@ -177,7 +158,13 @@ describe('elements/content-sharing/utils/convertSharingServiceData', () => { }); test('should include unshared_at when expiration is toggled on and there was no current expiration', () => { - const result = convertSharedLinkSettings(mockSettings, ACCESS_OPEN, true, mockServerUrl, null); + const result = convertSharedLinkSettings( + { ...mockSettings, expiration: { year: 2024, month: 12, day: 31, toString: () => '2024-12-31' } }, + ACCESS_OPEN, + true, + mockServerUrl, + null, + ); expect(result.unshared_at).toBe('2024-12-31T23:59:59.000Z'); }); diff --git a/src/elements/content-sharing/utils/convertSharingServiceData.ts b/src/elements/content-sharing/utils/convertSharingServiceData.ts index 7ba86f7671..3bc836b62b 100644 --- a/src/elements/content-sharing/utils/convertSharingServiceData.ts +++ b/src/elements/content-sharing/utils/convertSharingServiceData.ts @@ -42,18 +42,16 @@ export const convertSharedLinkPermissions = (permissionLevel: string) => { * - Changing the settings for a shared link in any other scenario. The access level is saved from the initial calls to the Item API and * convertItemResponse, so it is in internal USM format. */ -const DATE_ONLY_PATTERN = /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$/; +const pad = (value: number) => String(value).padStart(2, '0'); -const localDayLabel = (value: Date | number | string) => { - if (typeof value === 'string' && DATE_ONLY_PATTERN.test(value)) { - return value; - } +const localCalendarDay = (value: number | { day: number; month: number; year: number }) => { + if (typeof value === 'number') { + const date = new Date(value); - const date = value instanceof Date ? value : new Date(value); - const month = String(date.getMonth() + 1).padStart(2, '0'); - const day = String(date.getDate()).padStart(2, '0'); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; + } - return `${date.getFullYear()}-${month}-${day}`; + return `${value.year}-${pad(value.month)}-${pad(value.day)}`; }; export const convertSharedLinkSettings = ( @@ -65,19 +63,25 @@ export const convertSharedLinkSettings = ( ): ConvertSharedLinkSettingsReturnType => { const { expiration, isDownloadEnabled, isExpirationEnabled, isPasswordEnabled, password, vanityName } = newSettings; - const unsharedAt = - expiration && isExpirationEnabled - ? convertISOStringToUTCDate(new Date(expiration).toISOString()).toISOString() - : null; - const convertedSettings: ConvertSharedLinkSettingsReturnType = { vanity_url: serverUrl && vanityName ? `${serverUrl}${vanityName}` : '', }; - const submittedDay = expiration && isExpirationEnabled ? localDayLabel(expiration) : ''; - const currentDay = typeof currentExpiresAt === 'number' ? localDayLabel(currentExpiresAt) : ''; + // The form holds a calendar date, { year: 2026, month: 10, day: 16 }. + // The saved value is an instant, 2026-10-15T23:59:00-07:00, which is Oct 16 2:59am in New York. + // Both local days are 2026-10-16, so an untouched date is left off the request. + const submittedDay = + expiration && isExpirationEnabled && typeof expiration === 'object' && 'year' in expiration + ? localCalendarDay(expiration) + : ''; + const currentDay = typeof currentExpiresAt === 'number' ? localCalendarDay(currentExpiresAt) : ''; if (currentExpiresAt === undefined || submittedDay !== currentDay) { + const unsharedAt = + expiration && isExpirationEnabled + ? convertISOStringToUTCDate(new Date(expiration).toISOString()).toISOString() + : null; + convertedSettings.unshared_at = isExpirationEnabled ? unsharedAt : null; } From 45b27160bb0166facadaf3cbca8367d1223bb3c1 Mon Sep 17 00:00:00 2001 From: Jason Pan Date: Fri, 9 Oct 2026 18:56:51 -0400 Subject: [PATCH 6/6] fix(content-sharing): simplify expiration day compare Check the switch and the new date once. Leave unshared_at off the request when the calendar day is unchanged. --- .../utils/convertSharingServiceData.ts | 45 +++++++++++-------- 1 file changed, 27 insertions(+), 18 deletions(-) diff --git a/src/elements/content-sharing/utils/convertSharingServiceData.ts b/src/elements/content-sharing/utils/convertSharingServiceData.ts index 3bc836b62b..67c53cf6cb 100644 --- a/src/elements/content-sharing/utils/convertSharingServiceData.ts +++ b/src/elements/content-sharing/utils/convertSharingServiceData.ts @@ -44,13 +44,18 @@ export const convertSharedLinkPermissions = (permissionLevel: string) => { */ const pad = (value: number) => String(value).padStart(2, '0'); -const localCalendarDay = (value: number | { day: number; month: number; year: number }) => { +/** + * Normalize dates from the API (number) and content-sharing (object) to a string in the format YYYY-MM-DD. + */ +const normalizeDateToYearMonthDay = (value: number | { day: number; month: number; year: number }) => { + // currentExpiresAt comes in as a number if (typeof value === 'number') { const date = new Date(value); return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; } + // User-set expiration comes in as an object return `${value.year}-${pad(value.month)}-${pad(value.day)}`; }; @@ -61,28 +66,32 @@ export const convertSharedLinkSettings = ( serverUrl: string, currentExpiresAt?: number | null, ): ConvertSharedLinkSettingsReturnType => { - const { expiration, isDownloadEnabled, isExpirationEnabled, isPasswordEnabled, password, vanityName } = newSettings; + const { + expiration: newExpiration, + isDownloadEnabled, + isExpirationEnabled, + isPasswordEnabled, + password, + vanityName, + } = newSettings; const convertedSettings: ConvertSharedLinkSettingsReturnType = { vanity_url: serverUrl && vanityName ? `${serverUrl}${vanityName}` : '', }; - // The form holds a calendar date, { year: 2026, month: 10, day: 16 }. - // The saved value is an instant, 2026-10-15T23:59:00-07:00, which is Oct 16 2:59am in New York. - // Both local days are 2026-10-16, so an untouched date is left off the request. - const submittedDay = - expiration && isExpirationEnabled && typeof expiration === 'object' && 'year' in expiration - ? localCalendarDay(expiration) - : ''; - const currentDay = typeof currentExpiresAt === 'number' ? localCalendarDay(currentExpiresAt) : ''; - - if (currentExpiresAt === undefined || submittedDay !== currentDay) { - const unsharedAt = - expiration && isExpirationEnabled - ? convertISOStringToUTCDate(new Date(expiration).toISOString()).toISOString() - : null; - - convertedSettings.unshared_at = isExpirationEnabled ? unsharedAt : null; + if (!isExpirationEnabled || !newExpiration) { + if (currentExpiresAt !== null) { + convertedSettings.unshared_at = null; + } + } else { + const submittedYearMonthDay = normalizeDateToYearMonthDay(newExpiration); + const currentYearMonthDay = currentExpiresAt ? normalizeDateToYearMonthDay(currentExpiresAt) : ''; + + if (submittedYearMonthDay !== currentYearMonthDay) { + convertedSettings.unshared_at = convertISOStringToUTCDate( + new Date(newExpiration).toISOString(), + ).toISOString(); + } } // Download permissions can only be set on "company" or "open" shared links.