mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-16 10:55:00 +03:00
fix(clients): clarify renewal preview times and estimates (#6662)
* fix(clients): clarify renewal preview times and estimates The preview rendered RFC3339 strings directly and labeled the server's Local zone without explaining which clock it represented. Its renewal count also looked like a record of renewals already performed. Display readable dates with an explicit UTC offset while preserving the server's wall time, per-date DST offset and fractional cutoff precision. Explain the panel/server timezone and read-only preview, and label the renewal count as estimated allowance consumption in all 13 locales. Let the first-cutoff button wrap on narrow screens and update the English and Chinese client documentation plus display/form regression coverage. This changes presentation only: renewal calculations, stored expiry values, API payloads and first-use behavior remain unchanged. * test(clients): remove redundant renewal preview copy checks The locale-only preview case repeated the Local timezone branch without covering a distinct behavior. Exact textContent comparisons after matching the same text with getByText also only pinned constant wording, contrary to the repository's testing guidance. Remove that duplicate case, its locale setup and the redundant copy assertions. Keep the UTC, per-boundary DST offset, fractional precision and original datetime checks, together with the existing real form-save tests. Production rendering, translations and renewal behavior are unchanged. --------- Co-authored-by: JacktheRanger <219502738+JacktheRanger@users.noreply.github.com>
This commit is contained in:
@@ -12,6 +12,19 @@ import type { ClientFormValues } from '@/schemas/client';
|
||||
type RenewalFields = Pick<ClientFormValues, 'reset' | 'resetDay' | 'resetWeekday' | 'resetMax'>;
|
||||
type RenewalMode = 'none' | 'interval' | 'weekly' | 'monthly';
|
||||
|
||||
function RenewalDate({ value }: { value: string }) {
|
||||
// Keep the server's wall time and per-date offset, including across DST.
|
||||
// Parsing into a browser-local Date would show a different renewal boundary.
|
||||
const display = value
|
||||
.replace('T', ' ')
|
||||
.replace(/(Z|[+-]\d{2}:\d{2})$/, (offset) => ` UTC${offset === 'Z' ? '+00:00' : offset}`);
|
||||
return (
|
||||
<time dateTime={value} dir="ltr">
|
||||
{display}
|
||||
</time>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ClientRenewalFields({
|
||||
active,
|
||||
expiryTime,
|
||||
@@ -146,9 +159,17 @@ export default function ClientRenewalFields({
|
||||
</Typography.Paragraph>
|
||||
)}
|
||||
{preview && (
|
||||
<Space orientation="vertical" size={4} style={{ marginBottom: 16 }}>
|
||||
<Space orientation="vertical" size={4} style={{ width: '100%', marginBottom: 16 }}>
|
||||
<Typography.Text>
|
||||
{t('pages.clients.renewPreview', { zone: preview.timeZone })}
|
||||
{t('pages.clients.renewPreview', {
|
||||
zone:
|
||||
preview.timeZone === 'Local'
|
||||
? t('pages.clients.renewServerLocal')
|
||||
: preview.timeZone,
|
||||
})}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary">
|
||||
{t('pages.clients.renewPreviewHint')}
|
||||
</Typography.Text>
|
||||
{delayedStart || preview.delayedStart ? (
|
||||
<Typography.Text type="secondary">
|
||||
@@ -160,22 +181,30 @@ export default function ClientRenewalFields({
|
||||
{t('pages.clients.renewNeedsExpiry')}
|
||||
</Typography.Text>
|
||||
{preview.suggestedExpiryTime > 0 && (
|
||||
<Button onClick={() => setExpiry(preview.suggestedExpiryTime)}>
|
||||
{t('pages.clients.renewSetExpiry')}: {preview.suggestedExpiry}
|
||||
<Button
|
||||
style={{ maxWidth: '100%', height: 'auto', whiteSpace: 'normal' }}
|
||||
onClick={() => setExpiry(preview.suggestedExpiryTime)}
|
||||
>
|
||||
<span>
|
||||
{t('pages.clients.renewSetExpiry')}:{' '}
|
||||
<RenewalDate value={preview.suggestedExpiry} />
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Typography.Text>
|
||||
{t('pages.clients.renewAt')}: {preview.renewAt}
|
||||
{t('pages.clients.renewAt')}: <RenewalDate value={preview.renewAt} />
|
||||
</Typography.Text>
|
||||
<Typography.Text>
|
||||
{t('pages.clients.renewValidThrough')}: {preview.validThrough}
|
||||
{t('pages.clients.renewValidThrough')}:{' '}
|
||||
<RenewalDate value={preview.validThrough} />
|
||||
</Typography.Text>
|
||||
{preview.nextExpiry && (
|
||||
<Typography.Text>
|
||||
{t('pages.clients.renewNextExpiry')}: {preview.nextExpiry}
|
||||
{t('pages.clients.renewNextExpiry')}:{' '}
|
||||
<RenewalDate value={preview.nextExpiry} />
|
||||
</Typography.Text>
|
||||
)}
|
||||
<Typography.Text>
|
||||
|
||||
@@ -74,7 +74,11 @@ it('keeps bulk renewal disabled by default and requires explicit cutoff selectio
|
||||
}),
|
||||
]),
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: /Set first cycle cutoff/ }));
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', {
|
||||
name: 'Set first cycle cutoff: 2030-01-01 00:00:00 UTC+08:00',
|
||||
}),
|
||||
);
|
||||
await submit();
|
||||
await waitFor(() =>
|
||||
expect(bulkCreate).toHaveBeenLastCalledWith([
|
||||
|
||||
@@ -50,7 +50,9 @@ it('preserves monthly clients, previews backend dates, and saves exclusive weekl
|
||||
);
|
||||
const mode = screen.getByLabelText('Auto renewal');
|
||||
expect(mode.closest('.ant-select')?.textContent).toContain('Calendar monthly');
|
||||
await waitFor(() => expect(document.body.textContent).toContain('2029-12-31T23:59:59+08:00'));
|
||||
await waitFor(() =>
|
||||
expect(document.body.textContent).toContain('2029-12-31 23:59:59 UTC+08:00'),
|
||||
);
|
||||
expect(post).toHaveBeenCalledWith(
|
||||
'/panel/api/clients/renewalPreview',
|
||||
expect.objectContaining({ resetMax: 3, resetCount: 2 }),
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { expect, it, vi } from 'vitest';
|
||||
import { screen } from '@testing-library/react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
|
||||
import ClientRenewalFields from '@/pages/clients/ClientRenewalFields';
|
||||
import { HttpUtil, Msg } from '@/utils';
|
||||
import { renderWithProviders } from './test-utils';
|
||||
|
||||
function PreviewForm() {
|
||||
const form = useForm({ defaultValues: { reset: 1, resetDay: 0, resetWeekday: 0, resetMax: 0 } });
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<ClientRenewalFields active expiryTime={1790485200000} setExpiry={() => {}} />
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'explains Local UTC without converting into the browser timezone',
|
||||
zone: 'Local',
|
||||
label: 'Server local time',
|
||||
dates: ['2026-09-27T05:00:00Z', '2026-09-27T04:59:59Z', '2026-09-28T05:00:00Z'],
|
||||
displayed: [
|
||||
'2026-09-27 05:00:00 UTC+00:00',
|
||||
'2026-09-27 04:59:59 UTC+00:00',
|
||||
'2026-09-28 05:00:00 UTC+00:00',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'preserves different offsets across a daylight-saving transition',
|
||||
zone: 'America/New_York',
|
||||
label: 'America/New_York',
|
||||
dates: ['2030-03-10T00:00:00-05:00', '2030-03-09T23:59:59-05:00', '2030-03-17T00:00:00-04:00'],
|
||||
displayed: [
|
||||
'2030-03-10 00:00:00 UTC-05:00',
|
||||
'2030-03-09 23:59:59 UTC-05:00',
|
||||
'2030-03-17 00:00:00 UTC-04:00',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'keeps fractional cutoffs and fractional-hour server offsets',
|
||||
zone: 'Local',
|
||||
label: 'Server local time',
|
||||
dates: [
|
||||
'2030-01-01T00:00:00.123+05:45',
|
||||
'2030-01-01T00:00:00+05:45',
|
||||
'2030-01-02T00:00:00.123+05:45',
|
||||
],
|
||||
displayed: [
|
||||
'2030-01-01 00:00:00.123 UTC+05:45',
|
||||
'2030-01-01 00:00:00 UTC+05:45',
|
||||
'2030-01-02 00:00:00.123 UTC+05:45',
|
||||
],
|
||||
},
|
||||
])('$name', async ({ zone, label, dates, displayed }) => {
|
||||
const post = vi.spyOn(HttpUtil, 'post').mockResolvedValue(
|
||||
new Msg(true, '', {
|
||||
timeZone: zone,
|
||||
renewAt: dates[0],
|
||||
validThrough: dates[1],
|
||||
nextExpiry: dates[2],
|
||||
suggestedExpiryTime: 0,
|
||||
suggestedExpiry: '',
|
||||
renewals: 1,
|
||||
canRenew: true,
|
||||
delayedStart: false,
|
||||
}),
|
||||
);
|
||||
try {
|
||||
renderWithProviders(<PreviewForm />);
|
||||
await screen.findByText(`Renewal preview (panel time zone: ${label})`);
|
||||
const times = Array.from(document.querySelectorAll('time'));
|
||||
expect(times.map((el) => el.textContent)).toEqual(displayed);
|
||||
expect(times.map((el) => el.dateTime)).toEqual(dates);
|
||||
expect(times.map((el) => el.dir)).toEqual(['ltr', 'ltr', 'ltr']);
|
||||
} finally {
|
||||
post.mockRestore();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user