From 127a427b32ae3e9e521d887a0d4a4aa4f91ba70f Mon Sep 17 00:00:00 2001
From: fch-aa <21101725+fch-aa@users.noreply.github.com>
Date: Fri, 14 Aug 2026 16:18:32 +0200
Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(frontend)=20expose=20the=20full=20las?=
=?UTF-8?q?t-update=20date?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Keep the relative timestamp in the document header while exposing the
localized full date through the existing tooltip on hover and keyboard focus.
Signed-off-by: fch-aa <21101725+fch-aa@users.noreply.github.com>
---
CHANGELOG.md | 1 +
.../__tests__/DocHeaderInfo.spec.tsx | 111 ++++++++++++++++++
.../doc-header/components/DocHeaderInfo.tsx | 34 +++++-
3 files changed, 143 insertions(+), 3 deletions(-)
create mode 100644 src/frontend/apps/impress/src/features/docs/doc-header/__tests__/DocHeaderInfo.spec.tsx
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 53dd6ff375..8695024f99 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,7 @@ and this project adheres to
### Added
+- ✨(frontend) make the full last-update date available #1215
- ♿️(frontend) restore skip to content link after header redesign #2510
- 🌐(i18n) rename cn_CN to zh_CN, add eo_PL and zh_TW locales #2486
- ✨(backend) conditional email notification in server to server api #2554
diff --git a/src/frontend/apps/impress/src/features/docs/doc-header/__tests__/DocHeaderInfo.spec.tsx b/src/frontend/apps/impress/src/features/docs/doc-header/__tests__/DocHeaderInfo.spec.tsx
new file mode 100644
index 0000000000..2f36fc5474
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/docs/doc-header/__tests__/DocHeaderInfo.spec.tsx
@@ -0,0 +1,111 @@
+import { act, fireEvent, render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import i18next from 'i18next';
+import { DateTime } from 'luxon';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { Doc, LinkReach, Role } from '@/docs/doc-management';
+import { AppWrapper } from '@/tests/utils';
+
+import { DocHeaderInfo } from '../components/DocHeaderInfo';
+
+vi.mock('@/core', async () => {
+ const actual = await vi.importActual('@/core');
+
+ return {
+ ...actual,
+ useConfig: () => ({ data: {} }),
+ };
+});
+
+const updatedAt = '2026-08-14T14:23:00';
+const doc = {
+ id: 'doc-1',
+ abilities: {
+ partial_update: true,
+ },
+ ancestors_link_reach: LinkReach.RESTRICTED,
+ computed_link_reach: LinkReach.RESTRICTED,
+ deleted_at: null,
+ link_reach: LinkReach.RESTRICTED,
+ nb_accesses_ancestors: 0,
+ nb_accesses_direct: 1,
+ updated_at: updatedAt,
+ user_role: Role.OWNER,
+} as Doc;
+
+describe('', () => {
+ beforeEach(() => {
+ const now = DateTime.now().set({
+ year: 2026,
+ month: 8,
+ day: 14,
+ hour: 14,
+ minute: 28,
+ second: 0,
+ millisecond: 0,
+ });
+ vi.spyOn(DateTime, 'now').mockReturnValue(now);
+ });
+
+ afterEach(async () => {
+ await act(async () => {
+ await i18next.changeLanguage('en');
+ });
+ vi.restoreAllMocks();
+ });
+
+ it('keeps the relative date and exposes the full date on focus', async () => {
+ const user = userEvent.setup();
+
+ render(, { wrapper: AppWrapper });
+
+ const relativeDate = screen.getByText('5 minutes ago');
+ expect(relativeDate).toHaveTextContent('5 minutes ago');
+ expect(relativeDate.tagName).toBe('TIME');
+ expect(relativeDate).toHaveAttribute('datetime', updatedAt);
+ expect(relativeDate).not.toHaveAttribute('role', 'button');
+ expect(relativeDate).toHaveAccessibleName(
+ '5 minutes ago. 08/14/2026, 02:23 PM',
+ );
+
+ await user.tab();
+
+ expect(relativeDate).toHaveFocus();
+ expect(await screen.findByRole('tooltip')).toHaveTextContent(
+ '08/14/2026, 02:23 PM',
+ );
+ });
+
+ it('uses the current locale for the relative and full dates', async () => {
+ const user = userEvent.setup();
+
+ await act(async () => {
+ await i18next.changeLanguage('fr');
+ });
+
+ render(, { wrapper: AppWrapper });
+
+ const relativeDate = screen.getByText('il y a 5 minutes');
+ await user.tab();
+
+ expect(relativeDate).toHaveFocus();
+ expect(await screen.findByRole('tooltip')).toHaveTextContent(
+ '14/08/2026 14:23',
+ );
+ });
+
+ it('exposes the full date on hover', async () => {
+ const user = userEvent.setup();
+
+ render(, { wrapper: AppWrapper });
+
+ const relativeDate = screen.getByText('5 minutes ago');
+ fireEvent.pointerMove(relativeDate, { pointerType: 'mouse' });
+ await user.hover(relativeDate);
+
+ expect(await screen.findByRole('tooltip')).toHaveTextContent(
+ '08/14/2026, 02:23 PM',
+ );
+ });
+});
diff --git a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocHeaderInfo.tsx b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocHeaderInfo.tsx
index c5f7f8d739..390beeb984 100644
--- a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocHeaderInfo.tsx
+++ b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocHeaderInfo.tsx
@@ -1,4 +1,6 @@
+import { Tooltip } from '@gouvfr-lasuite/cunningham-react';
import { t } from 'i18next';
+import { ComponentPropsWithoutRef, forwardRef } from 'react';
import PublicSVG from '@/assets/icons/ui-kit/public.svg';
import ProtedtedSVG from '@/assets/icons/ui-kit/vpn_lock.svg';
@@ -21,10 +23,11 @@ interface DocHeaderInfoProps {
export const DocHeaderInfo = ({ doc }: DocHeaderInfoProps) => {
const { transRole } = useTrans();
const { isEditable } = useIsCollaborativeEditable(doc);
- const { relativeDate, calculateDaysLeft } = useDate();
+ const { relativeDate, formatDate, calculateDaysLeft } = useDate();
const { data: config } = useConfig();
const relativeOnly = relativeDate(doc.updated_at);
+ const fullDate = formatDate(doc.updated_at);
const trashbinCutoff = config?.TRASHBIN_CUTOFF_DAYS;
@@ -62,13 +65,38 @@ export const DocHeaderInfo = ({ doc }: DocHeaderInfoProps) => {
{dateLabel}
-
- {dateValue}
+
+ {trashbinCutoff && doc.deleted_at ? (
+ dateValue
+ ) : (
+
+
+ {relativeOnly}
+
+
+ )}
);
};
+const FocusableTime = forwardRef<
+ HTMLTimeElement,
+ ComponentPropsWithoutRef<'time'>
+>((props, ref) => );
+
+FocusableTime.displayName = 'FocusableTime';
+
const VisibilityDoc = ({ doc }: { doc: Doc }) => {
const docIsPublic = getDocLinkReach(doc) === LinkReach.PUBLIC;
const docIsAuth = getDocLinkReach(doc) === LinkReach.AUTHENTICATED;