Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions Resources/Public/JavaScript/Backend/component-documentation.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,20 @@ export default class ComponentDocumentation extends VariantFeature {
}
}, { capture: true });
this.listen(window, 'beforeunload', (event) => {
// Reuse discard approval for this unload unless another navigation guard cancelled it.
if (this.dirty && (!this.approvedNavigation || this.approvedNavigation.defaultPrevented)) {
// Approval covers only the destination actually opened by this attempt.
const navigation = this.approvedNavigation;
const destination = window.frameElement?.src;
const approved = navigation && !navigation.defaultPrevented
&& navigation.detail?.url && navigation.detail.url === destination;
if (this.dirty && !approved) {
navigation?.preventDefault();
event.preventDefault(); event.returnValue = '';
}
this.approvedNavigation = null;
});
// TYPO3 navigation replaces the content frame without necessarily unloading the top window.
this.navigationGuard = (event) => {
this.approvedNavigation?.preventDefault();
this.approvedNavigation = null;
if (!this.dirty) return;
if (!window.confirm('Discard unsaved documentation changes?')) event.preventDefault();
Expand All @@ -63,6 +69,7 @@ export default class ComponentDocumentation extends VariantFeature {
get dirty() { return this.loaded && this.markdown !== this.baseline; }

changed() {
this.approvedNavigation?.preventDefault();
this.approvedNavigation = null;
if (this.saveButton) this.saveButton.disabled = !this.loaded || this.readOnly || this.pending || !this.dirty;
if (this.resetButton) this.resetButton.disabled = !this.loaded || this.pending || !this.dirty;
Expand Down
174 changes: 133 additions & 41 deletions Resources/Public/JavaScript/Backend/component-tree-container.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,13 +52,40 @@ class FrontendStudioComponentTree extends Tree {
tooltip: '',
};
this.pendingNodeSelectionTimeout = null;
this.filterGeneration = 0;
this.filterComplete = Promise.resolve();
this.dataLoadComplete = Promise.resolve();
}

disconnectedCallback() {
this.clearPendingNodeSelection();
super.disconnectedCallback();
}

get currentFilterRequest() {
return this.pendingFilterRequest ?? null;
}

set currentFilterRequest(request) {
this.pendingFilterRequest = request;
// Core's void filter() rethrows failures without finishing its pipeline.
// Report them through its notification API, then let its abort handler
// finish normally. Configure only this request through Ajax's public API.
request?.addMiddleware(async (input, next) => {
try {
const response = await next(input);
if (!response.ok) throw new Error(`Tree search failed (${response.status}).`);
await response.clone().json();
return response;
} catch (error) {
if (!input.signal.aborted && !(error instanceof DOMException && error.name === 'AbortError')) {
this.errorNotification(error);
}
throw new DOMException('Tree search was interrupted.', 'AbortError');
}
});
}

getNodeStatus(node) {
return this.getStoredNodeStatus(node) ?? {
expanded: node.depth < initialExpansionLevel,
Expand Down Expand Up @@ -442,19 +469,56 @@ class FrontendStudioComponentTree extends Tree {

async refreshOrFilterTree() {
if (this.searchTerm !== null && this.searchTerm !== '') {
this.filter(this.searchTerm);
await this.waitForRefreshToFinish();
await this.filter(this.searchTerm);
return;
}

await this.loadData();
}

filter(searchTerm) {
const generation = ++this.filterGeneration;
this.currentFilterRequest?.abort();
// Core's old request can clear currentFilterRequest while a newer one is
// running. Serialize its pipelines and skip queries superseded by reset.
this.filterComplete = this.filterComplete.then(async () => {
await this.dataLoadComplete;
if (generation !== this.filterGeneration) return;
super.filter(searchTerm);
await this.waitForRefreshToFinish();
});
return this.filterComplete;
}

loadData() {
this.dataLoadComplete = super.loadData();
return this.dataLoadComplete;
}

async waitForRefreshToFinish() {
await this.dataLoadComplete;
while (this.loading === true || this.currentFilterRequest != null) {
await new Promise((resolve) => window.setTimeout(resolve, 50));
await new Promise((resolve) => this.addEventListener('frontend-studio:tree:idle', resolve, { once: true }));
}
}

updated(changedProperties) {
super.updated(changedProperties);
if (this.loading !== true && this.currentFilterRequest == null) {
this.dispatchEvent(new Event('frontend-studio:tree:idle'));
}
}

async resetFilterForNavigation() {
// Core resetFilter() does not cancel or join its filter response pipeline.
++this.filterGeneration;
this.currentFilterRequest?.abort();
await this.filterComplete;
await this.waitForRefreshToFinish();
await this.resetFilter();
// TYPO3 13's resetFilter() can start a reload without returning its promise.
await this.waitForRefreshToFinish();
}
}

customElements.define('andersundsehr-frontend-studio-component-tree', FrontendStudioComponentTree);
Expand Down Expand Up @@ -572,55 +636,83 @@ class FrontendStudioComponentTreeContainer extends LitElement {
await this.navigateToNode(node);
};

async navigateToNode(node, navigationApproved = false) {
const moduleMenu = top.TYPO3.ModuleMenu.App;
const moduleConfiguration = ModuleUtility.getFromName(moduleMenu.getCurrentModule());
const currentContentUrl = Viewport.ContentContainer.get()?.location?.href || Viewport.ContentContainer.getUrl();
const currentQueryParams = currentContentUrl !== null
? new URL(currentContentUrl, window.location.origin).searchParams
: null;
const contentParameters = {
[node.nodeType === 'component' ? 'component' : 'componentVariant']: node.identifier,
};
['site', 'language'].forEach((parameter) => {
const value = currentQueryParams?.get(parameter);
if (value !== null && value !== undefined) {
contentParameters[parameter] = value;
async navigateToNode(node, navigation = null) {
const alreadyConfirmed = navigation !== null;
navigation ??= new CustomEvent('frontend-studio:before-navigate', { cancelable: true, detail: { url: null } });
let navigated = false;
try {
const moduleMenu = top.TYPO3.ModuleMenu.App;
const moduleConfiguration = ModuleUtility.getFromName(moduleMenu.getCurrentModule());
const currentContentUrl = Viewport.ContentContainer.get()?.location?.href || Viewport.ContentContainer.getUrl();
const currentQueryParams = currentContentUrl !== null
? new URL(currentContentUrl, window.location.origin).searchParams
: null;
const contentParameters = {
[node.nodeType === 'component' ? 'component' : 'componentVariant']: node.identifier,
};
['site', 'language'].forEach((parameter) => {
const value = currentQueryParams?.get(parameter);
if (value !== null && value !== undefined) {
contentParameters[parameter] = value;
}
});
const contentUrl = createUrl(moduleConfiguration.link, contentParameters);
contentUrl.searchParams.delete(node.nodeType === 'component' ? 'componentVariant' : 'component');

if (navigation.defaultPrevented || (!alreadyConfirmed && !top.document.dispatchEvent(navigation))) {
return false;
}
});
const contentUrl = createUrl(moduleConfiguration.link, contentParameters);
contentUrl.searchParams.delete(node.nodeType === 'component' ? 'componentVariant' : 'component');

if (!navigationApproved && !top.document.dispatchEvent(new CustomEvent('frontend-studio:before-navigate', { cancelable: true }))) {
const previousNode = this.getNodeFromCurrentContentUrl()
?? this.getNodeFromStoredState(ModuleStateStorage.current(componentTreeModuleStateType));
if (previousNode !== null) {
await this.selectNode(previousNode, false);
} else {
this.tree.resetSelectedNodes();

navigation.detail.url = contentUrl.href;
await Viewport.ContentContainer.setUrl(contentUrl);
navigated = true;
ModuleStateStorage.updateWithTreeIdentifier(componentTreeModuleStateType, node.identifier, node.__treeIdentifier);
return true;
} catch (error) {
Notification.error('Variant navigation failed', error?.message || 'The variant could not be opened.');
return false;
} finally {
if (!navigated) {
navigation.preventDefault();
if (!alreadyConfirmed) await this.restoreSelection();
}
return;
}
}

ModuleStateStorage.updateWithTreeIdentifier(componentTreeModuleStateType, node.identifier, node.__treeIdentifier);
Viewport.ContentContainer.setUrl(contentUrl);
async restoreSelection() {
const previousNode = this.getNodeFromCurrentContentUrl()
?? this.getNodeFromStoredState(ModuleStateStorage.current(componentTreeModuleStateType));
if (previousNode !== null) {
await this.selectNode(previousNode, false);
} else {
this.tree.resetSelectedNodes();
}
}

async selectVariant(identifier) {
if (!this.tree || !identifier) return;
let node = this.tree.nodes.find((candidate) => candidate.nodeType === 'variant' && candidate.identifier === identifier);
if (!node) {
// Confirm before changing the filter; a cancelled Docs link must leave the tree untouched.
if (!top.document.dispatchEvent(new CustomEvent('frontend-studio:before-navigate', { cancelable: true }))) return;
await this.tree.resetFilter();
// TYPO3 13's resetFilter() can start a reload without returning its promise.
await this.tree.waitForRefreshToFinish();
const searchInput = this.toolbar?.querySelector(this.toolbar.settings.searchInput);
if (searchInput) searchInput.value = '';
node = this.tree.nodes.find((candidate) => candidate.nodeType === 'variant' && candidate.identifier === identifier);
if (node) {
await this.selectNode(node, false);
await this.navigateToNode(node, true);
const navigation = new CustomEvent('frontend-studio:before-navigate', { cancelable: true, detail: { url: null } });
if (!top.document.dispatchEvent(navigation)) return;
let navigated = false;
try {
await this.tree.resetFilterForNavigation();
node = this.tree.nodes.find((candidate) => candidate.nodeType === 'variant' && candidate.identifier === identifier);
if (node && !navigation.defaultPrevented) {
await this.selectNode(node, false);
navigated = await this.navigateToNode(node, navigation);
}
} catch (error) {
Notification.error('Variant navigation failed', error?.message || 'The variant could not be opened.');
} finally {
const searchInput = this.toolbar?.querySelector(this.toolbar.settings.searchInput);
if (searchInput) searchInput.value = this.tree.searchTerm ?? '';
if (!navigated) {
navigation.preventDefault();
await this.restoreSelection();
}
}
return;
}
Expand Down
4 changes: 4 additions & 0 deletions Tests/browser/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ on the first run and caches it in `var/.cache/typo3-tree/`.
The initial run needs network access to `raw.githubusercontent.com`; cached runs work offline.
Only unrelated backend chrome and HTTP endpoint responses are stubbed.
The tests click real Overview links in a content iframe and edit the actual documentation editor.
Controlled request gates exercise pending and overlapping filters, stale responses,
deleted or renamed variants, failed reloads and rejected navigation.
After unsuccessful navigation, the tests attempt an unrelated browser navigation,
dismiss its native unload warning and verify that the same edited document remains mounted.

The consumer package exports TypeScript directly and has no separate compilation step.
Keep its manifest, TypeScript configuration and source unchanged when working on extension UI or contributor tooling.
Expand Down
2 changes: 1 addition & 1 deletion Tests/browser/componentOverviewIntegration.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ for (const version of [13, 14]) {
await ui.ready(false);
assert.deepEqual(dialogs, ['confirm', 'confirm'], 'discard confirmation must not be followed by a native unload dialog');
const state = await page.evaluate(() => ({ order, stored, selected: container.getSelectedNode().identifier, navigations, persisted, streams: streams.map(stream => ({ uri: stream.uri, closed: stream.closed })) }));
assert.deepEqual(state.order, ['confirm', 'confirm', 'persist', 'navigate']);
assert.deepEqual(state.order, ['confirm', 'confirm', 'navigate', 'persist']);
assert.equal(state.selected, 'site:card:Mobile:Dark');
assert.deepEqual(state.stored, { identifier: state.selected, treeIdentifier: 'site_site:card_site:card:Mobile:Dark' });
assert.equal(state.persisted.length, 1);
Expand Down
4 changes: 2 additions & 2 deletions Tests/browser/componentOverviewNavigation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ test('component selection opens overview while variant links and site/language r
assert.equal(ui.navigations[0].searchParams.get('site'), 'preview');
assert.equal(ui.navigations[0].searchParams.get('language'), 'de-DE');
assert.equal(ui.container.getNodeFromCurrentContentUrl(), ui.component);
assert.deepEqual(ui.order, ['confirm', 'persist', 'navigate']);
assert.deepEqual(ui.order, ['confirm', 'navigate', 'persist']);
assert.equal(ui.stored().identifier, ui.component.identifier);
await ui.select(ui.variant);
assert.equal(ui.navigations[1].searchParams.get('componentVariant'), 'site:card:Default');
Expand All @@ -109,7 +109,7 @@ for (const firstComponentHasVariants of [true, false]) {
assert.equal(ui.navigations[0].searchParams.get('site'), 'preview');
assert.equal(ui.navigations[0].searchParams.get('language'), 'de-DE');
assert.equal(ui.stored().identifier, ui.component.identifier);
assert.deepEqual(ui.order, ['confirm', 'persist', 'navigate']);
assert.deepEqual(ui.order, ['confirm', 'navigate', 'persist']);
});
}

Expand Down
Loading
Loading