Skip to content

Commit e9c5d78

Browse files
committed
[COMMS-889] Refactor to match current file structure
https://community.openproject.org/wp/COMMS-889
1 parent 19848d0 commit e9c5d78

5 files changed

Lines changed: 256 additions & 122 deletions

File tree

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
//-- copyright
2+
// OpenProject is an open source project management software.
3+
// Copyright (C) the OpenProject GmbH
4+
//
5+
// This program is free software; you can redistribute it and/or
6+
// modify it under the terms of the GNU General Public License version 3.
7+
//
8+
// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
9+
// Copyright (C) 2006-2013 Jean-Philippe Lang
10+
// Copyright (C) 2010-2013 the ChiliProject Team
11+
//
12+
// This program is free software; you can redistribute it and/or
13+
// modify it under the terms of the GNU General Public License
14+
// as published by the Free Software Foundation; either version 2
15+
// of the License, or (at your option) any later version.
16+
//
17+
// This program is distributed in the hope that it will be useful,
18+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
19+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
20+
// GNU General Public License for more details.
21+
//
22+
// You should have received a copy of the GNU General Public License
23+
// along with this program; if not, write to the Free Software
24+
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
25+
//
26+
// See COPYRIGHT and LICENSE files for more details.
27+
//++
28+
29+
import {
30+
afterEach, beforeEach, describe, expect, it, vi,
31+
} from 'vitest';
32+
import * as Turbo from '@hotwired/turbo';
33+
import { canonicalizeWorkPackageIdInUrl } from './canonicalize-work-package-id-in-url';
34+
35+
describe('canonicalizeWorkPackageIdInUrl', () => {
36+
let historyReplaceSpy:ReturnType<typeof vi.spyOn>;
37+
let canonicalLink:HTMLLinkElement | null = null;
38+
39+
function setCanonical(href:string) {
40+
const link = document.createElement('link');
41+
link.rel = 'canonical';
42+
link.href = href;
43+
document.head.appendChild(link);
44+
canonicalLink = link;
45+
}
46+
47+
beforeEach(() => {
48+
/* eslint-disable @typescript-eslint/no-empty-function */
49+
historyReplaceSpy = vi.spyOn(Turbo.session.history, 'replace').mockImplementation(() => {});
50+
/* eslint-enable @typescript-eslint/no-empty-function */
51+
});
52+
53+
afterEach(() => {
54+
vi.restoreAllMocks();
55+
canonicalLink?.remove();
56+
canonicalLink = null;
57+
window.history.replaceState({}, '', '/');
58+
});
59+
60+
it('rewrites old id to canonical id', () => {
61+
window.history.pushState({}, '', '/projects/old-proj/work_packages/OLD-42/activity');
62+
setCanonical('http://localhost/projects/new-proj/work_packages/NEW-42/activity');
63+
64+
canonicalizeWorkPackageIdInUrl();
65+
66+
const expectedUrl = new URL('/projects/old-proj/work_packages/NEW-42/activity', window.location.origin);
67+
expect(historyReplaceSpy).toHaveBeenCalledWith(expectedUrl, Turbo.session.history.restorationIdentifier);
68+
expect((Turbo.session as unknown as { view:{ lastRenderedLocation:URL } }).view.lastRenderedLocation.href)
69+
.toBe(expectedUrl.href);
70+
});
71+
72+
it('does not rewrite when url already matches canonical', () => {
73+
window.history.pushState({}, '', '/projects/demo/work_packages/DEMO-42');
74+
setCanonical('http://localhost/projects/demo/work_packages/DEMO-42');
75+
76+
canonicalizeWorkPackageIdInUrl();
77+
78+
expect(historyReplaceSpy).not.toHaveBeenCalled();
79+
});
80+
81+
it('does not rewrite when no canonical link is present', () => {
82+
window.history.pushState({}, '', '/projects/demo/work_packages/42');
83+
84+
canonicalizeWorkPackageIdInUrl();
85+
86+
expect(historyReplaceSpy).not.toHaveBeenCalled();
87+
});
88+
89+
it('does not rewrite when url does not contain /work_packages/', () => {
90+
window.history.pushState({}, '', '/projects/demo/boards');
91+
setCanonical('http://localhost/projects/demo/boards');
92+
93+
canonicalizeWorkPackageIdInUrl();
94+
95+
expect(historyReplaceSpy).not.toHaveBeenCalled();
96+
});
97+
98+
it('preserves query string and hash when rewriting the id', () => {
99+
window.history.pushState({}, '', '/projects/demo/work_packages/13?focus=description#comment-5');
100+
setCanonical('http://localhost/projects/demo/work_packages/DEMO-42');
101+
102+
canonicalizeWorkPackageIdInUrl();
103+
104+
const expectedUrl = new URL('/projects/demo/work_packages/DEMO-42?focus=description#comment-5', window.location.origin);
105+
expect(historyReplaceSpy).toHaveBeenCalledWith(expectedUrl, Turbo.session.history.restorationIdentifier);
106+
});
107+
});
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/*
2+
* -- copyright
3+
* OpenProject is an open source project management software.
4+
* Copyright (C) the OpenProject GmbH
5+
*
6+
* This program is free software; you can redistribute it and/or
7+
* modify it under the terms of the GNU General Public License version 3.
8+
*
9+
* OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
10+
* Copyright (C) 2006-2013 Jean-Philippe Lang
11+
* Copyright (C) 2010-2013 the ChiliProject Team
12+
*
13+
* This program is free software; you can redistribute it and/or
14+
* modify it under the terms of the GNU General Public License
15+
* as published by the Free Software Foundation; either version 2
16+
* of the License, or (at your option) any later version.
17+
*
18+
* This program is distributed in the hope that it will be useful,
19+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
20+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
21+
* GNU General Public License for more details.
22+
*
23+
* You should have received a copy of the GNU General Public License
24+
* along with this program; if not, write to the Free Software
25+
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
26+
*
27+
* See COPYRIGHT and LICENSE files for more details.
28+
* ++
29+
*/
30+
31+
import * as Turbo from '@hotwired/turbo';
32+
import { WP_ID_URL_PATTERN } from 'core-app/shared/helpers/work-package-id-pattern';
33+
34+
// `view` exists on the Turbo session at runtime but is absent from the project's
35+
// hand-written @types/hotwired__turbo typings; narrow-cast here, same precedent
36+
// as turbo/helpers.ts and turbo-navigation-patch.ts.
37+
interface TurboSessionWithView extends Turbo.TurboSession {
38+
view:{ lastRenderedLocation:URL };
39+
}
40+
41+
// Compiled once: matches a work package id anywhere in the pathname, reusing the
42+
// shared WP_ID_URL_PATTERN so numeric ("42") and semantic ("PROJ-42") ids both match.
43+
const workPackageIdPathPattern = new URLPattern({
44+
pathname: `{*}/work_packages/:id(${WP_ID_URL_PATTERN}){/*}?`,
45+
});
46+
47+
export function canonicalizeWorkPackageIdInUrl():void {
48+
const currentPath = window.location.pathname;
49+
const currentId = workPackageIdPathPattern.exec({ pathname: currentPath })?.pathname.groups.id;
50+
if (!currentId) return;
51+
52+
const canonical = document.querySelector<HTMLLinkElement>('link[rel="canonical"]');
53+
if (!canonical?.href) return;
54+
55+
const canonicalPath = new URL(canonical.href).pathname;
56+
const canonicalId = workPackageIdPathPattern.exec({ pathname: canonicalPath })?.pathname.groups.id;
57+
if (!canonicalId || canonicalId === currentId) return;
58+
59+
const newPath = currentPath.replace(`/work_packages/${currentId}`, `/work_packages/${canonicalId}`);
60+
const newUrl = new URL(newPath + window.location.search + window.location.hash, window.location.origin);
61+
62+
// Use Turbo's history.replace (not window.history.replaceState) so Turbo's internal
63+
// location stays in sync — otherwise back/forward popstate resolves the stale URL.
64+
// Pass the current restorationIdentifier explicitly: History.replace defaults to a
65+
// fresh uuid, orphaning the recorded scroll-restoration data.
66+
Turbo.session.history.replace(newUrl, Turbo.session.history.restorationIdentifier);
67+
// PageView#cacheSnapshot keys the snapshot cache by lastRenderedLocation; left stale,
68+
// back/forward misses the cache and re-fetches instead of restoring instantly.
69+
(Turbo.session as TurboSessionWithView).view.lastRenderedLocation = newUrl;
70+
}

frontend/src/turbo/turbo-global-listeners.spec.ts

Lines changed: 2 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,9 @@
2727
//++
2828

2929
import {
30-
afterEach, beforeEach, describe, expect, it, vi,
30+
afterEach, beforeEach, describe, expect, it,
3131
} from 'vitest';
32-
import * as Turbo from '@hotwired/turbo';
33-
import { addTurboGlobalListeners, canonicalizeWorkPackageIdInUrl } from './turbo-global-listeners';
32+
import { addTurboGlobalListeners } from './turbo-global-listeners';
3433

3534
describe('addTurboGlobalListeners — OPCE custom element morph guard', () => {
3635
let controller:AbortController;
@@ -70,79 +69,3 @@ describe('addTurboGlobalListeners — OPCE custom element morph guard', () => {
7069
expect(notCancelled).toBe(true);
7170
});
7271
});
73-
74-
describe('canonicalizeWorkPackageIdInUrl', () => {
75-
let historyReplaceSpy:ReturnType<typeof vi.spyOn>;
76-
let canonicalLink:HTMLLinkElement | null = null;
77-
78-
function setCanonical(href:string) {
79-
const link = document.createElement('link');
80-
link.rel = 'canonical';
81-
link.href = href;
82-
document.head.appendChild(link);
83-
canonicalLink = link;
84-
}
85-
86-
beforeEach(() => {
87-
/* eslint-disable @typescript-eslint/no-empty-function */
88-
historyReplaceSpy = vi.spyOn(Turbo.session.history, 'replace').mockImplementation(() => {});
89-
/* eslint-enable @typescript-eslint/no-empty-function */
90-
});
91-
92-
afterEach(() => {
93-
// Deterministic per-test cleanup: restore the spy, drop the injected canonical
94-
// link and reset the URL rather than relying on the next test's setup.
95-
vi.restoreAllMocks();
96-
canonicalLink?.remove();
97-
canonicalLink = null;
98-
window.history.replaceState({}, '', '/');
99-
});
100-
101-
it('rewrites old id to canonical id', () => {
102-
window.history.pushState({}, '', '/projects/old-proj/work_packages/OLD-42/activity');
103-
setCanonical('http://localhost/projects/new-proj/work_packages/NEW-42/activity');
104-
105-
canonicalizeWorkPackageIdInUrl();
106-
107-
const expectedUrl = new URL('/projects/old-proj/work_packages/NEW-42/activity', window.location.origin);
108-
expect(historyReplaceSpy).toHaveBeenCalledWith(expectedUrl, Turbo.session.history.restorationIdentifier);
109-
expect((Turbo.session as unknown as { view:{ lastRenderedLocation:URL } }).view.lastRenderedLocation.href)
110-
.toBe(expectedUrl.href);
111-
});
112-
113-
it('does not rewrite when url already matches canonical', () => {
114-
window.history.pushState({}, '', '/projects/demo/work_packages/DEMO-42');
115-
setCanonical('http://localhost/projects/demo/work_packages/DEMO-42');
116-
117-
canonicalizeWorkPackageIdInUrl();
118-
119-
expect(historyReplaceSpy).not.toHaveBeenCalled();
120-
});
121-
122-
it('does not rewrite when no canonical link is present', () => {
123-
window.history.pushState({}, '', '/projects/demo/work_packages/42');
124-
125-
canonicalizeWorkPackageIdInUrl();
126-
127-
expect(historyReplaceSpy).not.toHaveBeenCalled();
128-
});
129-
130-
it('does not rewrite when url does not contain /work_packages/', () => {
131-
window.history.pushState({}, '', '/projects/demo/boards');
132-
setCanonical('http://localhost/projects/demo/boards');
133-
134-
canonicalizeWorkPackageIdInUrl();
135-
136-
expect(historyReplaceSpy).not.toHaveBeenCalled();
137-
});
138-
139-
it('preserves query string and hash when rewriting the id', () => {
140-
window.history.pushState({}, '', '/projects/demo/work_packages/13?focus=description#comment-5');
141-
setCanonical('http://localhost/projects/demo/work_packages/DEMO-42');
142-
143-
canonicalizeWorkPackageIdInUrl();
144-
145-
const expectedUrl = new URL('/projects/demo/work_packages/DEMO-42?focus=description#comment-5', window.location.origin);
146-
expect(historyReplaceSpy).toHaveBeenCalledWith(expectedUrl, Turbo.session.history.restorationIdentifier);
147-
});
148-
});

frontend/src/turbo/turbo-global-listeners.ts

Lines changed: 29 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,31 @@
1-
import * as Turbo from '@hotwired/turbo';
1+
//-- copyright
2+
// OpenProject is an open source project management software.
3+
// Copyright (C) the OpenProject GmbH
4+
//
5+
// This program is free software; you can redistribute it and/or
6+
// modify it under the terms of the GNU General Public License version 3.
7+
//
8+
// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
9+
// Copyright (C) 2006-2013 Jean-Philippe Lang
10+
// Copyright (C) 2010-2013 the ChiliProject Team
11+
//
12+
// This program is free software; you can redistribute it and/or
13+
// modify it under the terms of the GNU General Public License
14+
// as published by the Free Software Foundation; either version 2
15+
// of the License, or (at your option) any later version.
16+
//
17+
// This program is distributed in the hope that it will be useful,
18+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
19+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
20+
// GNU General Public License for more details.
21+
//
22+
// You should have received a copy of the GNU General Public License
23+
// along with this program; if not, write to the Free Software
24+
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
25+
//
26+
// See COPYRIGHT and LICENSE files for more details.
27+
//++
28+
229
import { DeviceService } from 'core-app/core/browser/device.service';
330
import { scrollHeaderOnMobile } from 'core-app/core/setup/globals/global-listeners/top-menu-scroll';
431
import { detectOnboardingTour } from 'core-app/core/setup/globals/onboarding/onboarding_tour_trigger';
@@ -12,22 +39,9 @@ import {
1239
focusFirstErroneousField,
1340
initMainMenuExpandStatus,
1441
} from 'core-app/core/setup/globals/global-listeners/setup-server-response';
15-
import { WP_ID_URL_PATTERN } from 'core-app/shared/helpers/work-package-id-pattern';
42+
import { canonicalizeWorkPackageIdInUrl } from 'core-app/core/setup/globals/canonicalize-work-package-id-in-url';
1643
import { isOpenProjectCustomElement } from './openproject-custom-element';
1744

18-
// `view` exists on the Turbo session at runtime but is absent from the project's
19-
// hand-written @types/hotwired__turbo typings; narrow-cast here, same precedent
20-
// as turbo/helpers.ts and turbo-navigation-patch.ts.
21-
interface TurboSessionWithView extends Turbo.TurboSession {
22-
view:{ lastRenderedLocation:URL };
23-
}
24-
25-
// Compiled once: matches a work package id anywhere in the pathname, reusing the
26-
// shared WP_ID_URL_PATTERN so numeric ("42") and semantic ("PROJ-42") ids both match.
27-
const workPackageIdPathPattern = new URLPattern({
28-
pathname: `{*}/work_packages/:id(${WP_ID_URL_PATTERN}){/*}?`,
29-
});
30-
3145
export function addTurboGlobalListeners(target:Document = document, signal?:AbortSignal) {
3246
const runOnRenderAndLoad = () => {
3347
// Add to content if warnings displayed
@@ -85,28 +99,3 @@ export function addTurboGlobalListeners(target:Document = document, signal?:Abor
8599
}
86100
}, { signal });
87101
}
88-
89-
export function canonicalizeWorkPackageIdInUrl():void {
90-
const currentPath = window.location.pathname;
91-
const currentId = workPackageIdPathPattern.exec({ pathname: currentPath })?.pathname.groups.id;
92-
if (!currentId) return;
93-
94-
const canonical = document.querySelector<HTMLLinkElement>('link[rel="canonical"]');
95-
if (!canonical?.href) return;
96-
97-
const canonicalPath = new URL(canonical.href).pathname;
98-
const canonicalId = workPackageIdPathPattern.exec({ pathname: canonicalPath })?.pathname.groups.id;
99-
if (!canonicalId || canonicalId === currentId) return;
100-
101-
const newPath = currentPath.replace(`/work_packages/${currentId}`, `/work_packages/${canonicalId}`);
102-
const newUrl = new URL(newPath + window.location.search + window.location.hash, window.location.origin);
103-
104-
// Use Turbo's history.replace (not window.history.replaceState) so Turbo's internal
105-
// location stays in sync — otherwise back/forward popstate resolves the stale URL.
106-
// Pass the current restorationIdentifier explicitly: History.replace defaults to a
107-
// fresh uuid, orphaning the recorded scroll-restoration data.
108-
Turbo.session.history.replace(newUrl, Turbo.session.history.restorationIdentifier);
109-
// PageView#cacheSnapshot keys the snapshot cache by lastRenderedLocation; left stale,
110-
// back/forward misses the cache and re-fetches instead of restoring instantly.
111-
(Turbo.session as TurboSessionWithView).view.lastRenderedLocation = newUrl;
112-
}

0 commit comments

Comments
 (0)