From 522a3de1762fcef7296e1be5e38f83e93a8a0b04 Mon Sep 17 00:00:00 2001 From: Gleb Volkov Date: Wed, 31 Dec 2025 15:39:03 +0100 Subject: [PATCH 1/2] Add the ability to specify a help url for extensions' actions/conditions/expressions --- Core/GDCore/Project/EventsFunction.cpp | 4 + Core/GDCore/Project/EventsFunction.h | 14 +++ Core/tests/EventsFunction.cpp | 88 +++++++++++++++++++ .../MetadataDeclarationHelper.cpp | 54 ++++++++++++ GDevelop.js/Bindings/Bindings.idl | 2 + GDevelop.js/__tests__/Core.js | 9 ++ .../__tests__/MetadataDeclarationHelper.js | 72 +++++++++++++++ GDevelop.js/types.d.ts | 2 + .../EventsFunctionPropertiesEditor.js | 23 +++++ .../InstructionEditorDialog.js | 9 +- .../InstructionParametersEditor.js | 42 +++++---- ...SelectorInstructionOrExpressionListItem.js | 14 ++- .../SelectorInstructionsTreeListItem.js | 15 +++- newIDE/app/src/UI/HelpIcon/index.js | 5 +- newIDE/app/src/Utils/HelpLink.spec.js | 76 ++++++++++++++++ .../src/stories/everything-else.stories.js | 6 +- 16 files changed, 415 insertions(+), 20 deletions(-) create mode 100644 Core/tests/EventsFunction.cpp create mode 100644 newIDE/app/src/Utils/HelpLink.spec.js diff --git a/Core/GDCore/Project/EventsFunction.cpp b/Core/GDCore/Project/EventsFunction.cpp index 7c941232e3b1..4f5caf09776a 100644 --- a/Core/GDCore/Project/EventsFunction.cpp +++ b/Core/GDCore/Project/EventsFunction.cpp @@ -75,6 +75,9 @@ void EventsFunction::SerializeTo(SerializerElement& element) const { if (isAsync) { element.SetBoolAttribute("async", isAsync); } + if (!helpUrl.empty()) { + element.SetAttribute("helpUrl", helpUrl); + } events.SerializeTo(element.AddChild("events")); gd::String functionTypeStr = "Action"; @@ -116,6 +119,7 @@ void EventsFunction::UnserializeFrom(gd::Project& project, getterName = element.GetStringAttribute("getterName"); isPrivate = element.GetBoolAttribute("private"); isAsync = element.GetBoolAttribute("async"); + helpUrl = element.GetStringAttribute("helpUrl"); events.UnserializeFrom(project, element.GetChild("events")); gd::String functionTypeStr = element.GetStringAttribute("functionType"); diff --git a/Core/GDCore/Project/EventsFunction.h b/Core/GDCore/Project/EventsFunction.h index 886b7401759c..9f1d8a9dac4c 100644 --- a/Core/GDCore/Project/EventsFunction.h +++ b/Core/GDCore/Project/EventsFunction.h @@ -223,6 +223,19 @@ class GD_CORE_API EventsFunction { return *this; } + /** + * \brief Get the help URL for this function. + */ + const gd::String& GetHelpUrl() const { return helpUrl; } + + /** + * \brief Set the help URL for this function. + */ + EventsFunction& SetHelpUrl(const gd::String& helpUrl_) { + helpUrl = helpUrl_; + return *this; + } + /** * \brief Return the events. */ @@ -304,6 +317,7 @@ class GD_CORE_API EventsFunction { gd::ObjectGroupsContainer objectGroups; bool isPrivate = false; bool isAsync = false; + gd::String helpUrl; }; } // namespace gd diff --git a/Core/tests/EventsFunction.cpp b/Core/tests/EventsFunction.cpp new file mode 100644 index 000000000000..bbbdbda17703 --- /dev/null +++ b/Core/tests/EventsFunction.cpp @@ -0,0 +1,88 @@ +/* + * GDevelop Core + * Copyright 2008-2016 Florian Rival (Florian.Rival@gmail.com). All rights + * reserved. This project is released under the MIT License. + */ +/** + * @file Tests covering EventsFunction + */ +#include "GDCore/Project/EventsFunction.h" +#include "GDCore/Project/Project.h" +#include "GDCore/Serialization/SerializerElement.h" +#include "catch.hpp" + +TEST_CASE("EventsFunction", "[common]") { + SECTION("Basic properties") { + gd::EventsFunction eventsFunction; + + eventsFunction.SetName("MyFunction"); + eventsFunction.SetFullName("My Function"); + eventsFunction.SetDescription("A test function"); + eventsFunction.SetGroup("Test Group"); + eventsFunction.SetSentence("Do something with _PARAM1_"); + + REQUIRE(eventsFunction.GetName() == "MyFunction"); + REQUIRE(eventsFunction.GetFullName() == "My Function"); + REQUIRE(eventsFunction.GetDescription() == "A test function"); + REQUIRE(eventsFunction.GetGroup() == "Test Group"); + REQUIRE(eventsFunction.GetSentence() == "Do something with _PARAM1_"); + } + + SECTION("Help URL") { + gd::EventsFunction eventsFunction; + + // Default should be empty + REQUIRE(eventsFunction.GetHelpUrl() == ""); + + // Can set a help URL + eventsFunction.SetHelpUrl("https://example.com/help"); + REQUIRE(eventsFunction.GetHelpUrl() == "https://example.com/help"); + + // Can clear the help URL + eventsFunction.SetHelpUrl(""); + REQUIRE(eventsFunction.GetHelpUrl() == ""); + } + + SECTION("Serialization with help URL") { + gd::Project project; + + gd::EventsFunction eventsFunction; + eventsFunction.SetName("MyFunction"); + eventsFunction.SetFullName("My Function"); + eventsFunction.SetDescription("A test function"); + eventsFunction.SetHelpUrl("https://example.com/custom-help"); + + gd::SerializerElement element; + eventsFunction.SerializeTo(element); + + gd::EventsFunction eventsFunction2; + eventsFunction2.UnserializeFrom(project, element); + + REQUIRE(eventsFunction2.GetName() == "MyFunction"); + REQUIRE(eventsFunction2.GetFullName() == "My Function"); + REQUIRE(eventsFunction2.GetDescription() == "A test function"); + REQUIRE(eventsFunction2.GetHelpUrl() == "https://example.com/custom-help"); + } + + SECTION("Serialization without help URL") { + gd::Project project; + + gd::EventsFunction eventsFunction; + eventsFunction.SetName("MyFunction"); + eventsFunction.SetFullName("My Function"); + eventsFunction.SetDescription("A test function"); + // No help URL set + + gd::SerializerElement element; + eventsFunction.SerializeTo(element); + + gd::EventsFunction eventsFunction2; + eventsFunction2.UnserializeFrom(project, element); + + REQUIRE(eventsFunction2.GetName() == "MyFunction"); + REQUIRE(eventsFunction2.GetFullName() == "My Function"); + REQUIRE(eventsFunction2.GetDescription() == "A test function"); + REQUIRE(eventsFunction2.GetHelpUrl() == ""); + } +} + diff --git a/GDJS/GDJS/Events/CodeGeneration/MetadataDeclarationHelper.cpp b/GDJS/GDJS/Events/CodeGeneration/MetadataDeclarationHelper.cpp index a443eae049e9..65d48f2c594f 100644 --- a/GDJS/GDJS/Events/CodeGeneration/MetadataDeclarationHelper.cpp +++ b/GDJS/GDJS/Events/CodeGeneration/MetadataDeclarationHelper.cpp @@ -534,6 +534,9 @@ MetadataDeclarationHelper::DeclareExpressionMetadata( expressionAndCondition.AddCodeOnlyParameter("currentScene", ""); DeclareEventsFunctionParameters(freeEventsFunctions, eventsFunction, expressionAndCondition, 0); + if (!eventsFunction.GetHelpUrl().empty()) { + expressionAndCondition.SetHelpPath(eventsFunction.GetHelpUrl()); + } expressionAndConditions.push_back(expressionAndCondition); return expressionAndConditions.back(); } else { @@ -555,6 +558,9 @@ MetadataDeclarationHelper::DeclareExpressionMetadata( expression.AddCodeOnlyParameter("currentScene", ""); DeclareEventsFunctionParameters(freeEventsFunctions, eventsFunction, expression, 0); + if (!eventsFunction.GetHelpUrl().empty()) { + expression.SetHelpPath(eventsFunction.GetHelpUrl()); + } return expression; } } @@ -580,6 +586,9 @@ gd::InstructionMetadata &MetadataDeclarationHelper::DeclareInstructionMetadata( condition.AddCodeOnlyParameter("currentScene", ""); DeclareEventsFunctionParameters(freeEventsFunctions, eventsFunction, condition, 0); + if (!eventsFunction.GetHelpUrl().empty()) { + condition.SetHelpPath(eventsFunction.GetHelpUrl()); + } return condition; } else if (functionType == gd::EventsFunction::ActionWithOperator) { if (freeEventsFunctions.HasEventsFunctionNamed( @@ -606,6 +615,9 @@ gd::InstructionMetadata &MetadataDeclarationHelper::DeclareInstructionMetadata( action.AddCodeOnlyParameter("currentScene", ""); DeclareEventsFunctionParameters(freeEventsFunctions, eventsFunction, action, 0); + if (!eventsFunction.GetHelpUrl().empty()) { + action.SetHelpPath(eventsFunction.GetHelpUrl()); + } return action; } else { @@ -620,6 +632,9 @@ gd::InstructionMetadata &MetadataDeclarationHelper::DeclareInstructionMetadata( action.AddCodeOnlyParameter("currentScene", ""); DeclareEventsFunctionParameters(freeEventsFunctions, eventsFunction, action, 0); + if (!eventsFunction.GetHelpUrl().empty()) { + action.SetHelpPath(eventsFunction.GetHelpUrl()); + } return action; } } else { @@ -633,6 +648,9 @@ gd::InstructionMetadata &MetadataDeclarationHelper::DeclareInstructionMetadata( action.AddCodeOnlyParameter("currentScene", ""); DeclareEventsFunctionParameters(freeEventsFunctions, eventsFunction, action, 0); + if (!eventsFunction.GetHelpUrl().empty()) { + action.SetHelpPath(eventsFunction.GetHelpUrl()); + } return action; } } @@ -725,6 +743,9 @@ MetadataDeclarationHelper::DeclareBehaviorExpressionMetadata( GetExtensionIconUrl(extension)); DeclareEventsFunctionParameters(eventsBasedBehavior.GetEventsFunctions(), eventsFunction, expressionAndCondition, 2); + if (!eventsFunction.GetHelpUrl().empty()) { + expressionAndCondition.SetHelpPath(eventsFunction.GetHelpUrl()); + } expressionAndConditions.push_back(expressionAndCondition); return expressionAndConditions.back(); } else { @@ -750,6 +771,9 @@ MetadataDeclarationHelper::DeclareBehaviorExpressionMetadata( GetExtensionIconUrl(extension)); DeclareEventsFunctionParameters(eventsBasedBehavior.GetEventsFunctions(), eventsFunction, expression, 2); + if (!eventsFunction.GetHelpUrl().empty()) { + expression.SetHelpPath(eventsFunction.GetHelpUrl()); + } return expression; } } @@ -778,6 +802,9 @@ MetadataDeclarationHelper::DeclareBehaviorInstructionMetadata( GetExtensionIconUrl(extension), GetExtensionIconUrl(extension)); DeclareEventsFunctionParameters(eventsBasedBehavior.GetEventsFunctions(), eventsFunction, condition, 2); + if (!eventsFunction.GetHelpUrl().empty()) { + condition.SetHelpPath(eventsFunction.GetHelpUrl()); + } return condition; } else if (functionType == gd::EventsFunction::ActionWithOperator) { auto &eventsFunctionsContainer = eventsBasedBehavior.GetEventsFunctions(); @@ -803,6 +830,9 @@ MetadataDeclarationHelper::DeclareBehaviorInstructionMetadata( DeclareEventsFunctionParameters(eventsBasedBehavior.GetEventsFunctions(), eventsFunction, action, 2); + if (!eventsFunction.GetHelpUrl().empty()) { + action.SetHelpPath(eventsFunction.GetHelpUrl()); + } return action; } else { auto &action = behaviorMetadata.AddScopedAction( @@ -816,6 +846,9 @@ MetadataDeclarationHelper::DeclareBehaviorInstructionMetadata( DeclareEventsFunctionParameters(eventsBasedBehavior.GetEventsFunctions(), eventsFunction, action, 2); + if (!eventsFunction.GetHelpUrl().empty()) { + action.SetHelpPath(eventsFunction.GetHelpUrl()); + } return action; } } else { @@ -833,6 +866,9 @@ MetadataDeclarationHelper::DeclareBehaviorInstructionMetadata( DeclareEventsFunctionParameters(eventsBasedBehavior.GetEventsFunctions(), eventsFunction, action, 2); + if (!eventsFunction.GetHelpUrl().empty()) { + action.SetHelpPath(eventsFunction.GetHelpUrl()); + } return action; } } @@ -899,6 +935,9 @@ MetadataDeclarationHelper::DeclareObjectExpressionMetadata( DeclareEventsFunctionParameters(eventsBasedObject.GetEventsFunctions(), eventsFunction, expressionAndCondition, 1); + if (!eventsFunction.GetHelpUrl().empty()) { + expressionAndCondition.SetHelpPath(eventsFunction.GetHelpUrl()); + } expressionAndConditions.push_back(expressionAndCondition); return expressionAndConditions.back(); } else { @@ -925,6 +964,9 @@ MetadataDeclarationHelper::DeclareObjectExpressionMetadata( DeclareEventsFunctionParameters(eventsBasedObject.GetEventsFunctions(), eventsFunction, expression, 1); + if (!eventsFunction.GetHelpUrl().empty()) { + expression.SetHelpPath(eventsFunction.GetHelpUrl()); + } return expression; } } @@ -954,6 +996,9 @@ MetadataDeclarationHelper::DeclareObjectInstructionMetadata( DeclareEventsFunctionParameters(eventsBasedObject.GetEventsFunctions(), eventsFunction, condition, 1); + if (!eventsFunction.GetHelpUrl().empty()) { + condition.SetHelpPath(eventsFunction.GetHelpUrl()); + } return condition; } else if (functionType == gd::EventsFunction::ActionWithOperator) { auto &eventsFunctionsContainer = eventsBasedObject.GetEventsFunctions(); @@ -978,6 +1023,9 @@ MetadataDeclarationHelper::DeclareObjectInstructionMetadata( DeclareEventsFunctionParameters(eventsBasedObject.GetEventsFunctions(), eventsFunction, action, 1); + if (!eventsFunction.GetHelpUrl().empty()) { + action.SetHelpPath(eventsFunction.GetHelpUrl()); + } return action; } else { auto &action = objectMetadata.AddScopedAction( @@ -990,6 +1038,9 @@ MetadataDeclarationHelper::DeclareObjectInstructionMetadata( DeclareEventsFunctionParameters(eventsBasedObject.GetEventsFunctions(), eventsFunction, action, 1); + if (!eventsFunction.GetHelpUrl().empty()) { + action.SetHelpPath(eventsFunction.GetHelpUrl()); + } return action; } } else { @@ -1007,6 +1058,9 @@ MetadataDeclarationHelper::DeclareObjectInstructionMetadata( DeclareEventsFunctionParameters(eventsBasedObject.GetEventsFunctions(), eventsFunction, action, 1); + if (!eventsFunction.GetHelpUrl().empty()) { + action.SetHelpPath(eventsFunction.GetHelpUrl()); + } return action; } } diff --git a/GDevelop.js/Bindings/Bindings.idl b/GDevelop.js/Bindings/Bindings.idl index 5ddbe2d01686..087a3b9c1864 100644 --- a/GDevelop.js/Bindings/Bindings.idl +++ b/GDevelop.js/Bindings/Bindings.idl @@ -3165,6 +3165,8 @@ interface EventsFunction { boolean IsPrivate(); [Ref] EventsFunction SetAsync(boolean isAsync); boolean IsAsync(); + [Ref] EventsFunction SetHelpUrl([Const] DOMString helpUrl); + [Const, Ref] DOMString GetHelpUrl(); boolean IsAction(); boolean IsExpression(); boolean IsCondition(); diff --git a/GDevelop.js/__tests__/Core.js b/GDevelop.js/__tests__/Core.js index 07ea513fea09..6eb5d907053e 100644 --- a/GDevelop.js/__tests__/Core.js +++ b/GDevelop.js/__tests__/Core.js @@ -4579,6 +4579,15 @@ describe('libGD.js', function () { expect(eventsFunction.getDescription()).toBe('My description'); eventsFunction.delete(); }); + it('can have a help URL', function () { + const eventsFunction = new gd.EventsFunction(); + expect(eventsFunction.getHelpUrl()).toBe(''); + eventsFunction.setHelpUrl('https://example.com/help'); + expect(eventsFunction.getHelpUrl()).toBe('https://example.com/help'); + eventsFunction.setHelpUrl(''); + expect(eventsFunction.getHelpUrl()).toBe(''); + eventsFunction.delete(); + }); }); describe('gd.EventsFunctionsExtension', () => { diff --git a/GDevelop.js/__tests__/MetadataDeclarationHelper.js b/GDevelop.js/__tests__/MetadataDeclarationHelper.js index 5388f624dd4c..220b9129a12c 100644 --- a/GDevelop.js/__tests__/MetadataDeclarationHelper.js +++ b/GDevelop.js/__tests__/MetadataDeclarationHelper.js @@ -114,6 +114,78 @@ describe('MetadataDeclarationHelper', () => { project.delete(); }); + it('can create metadata for free actions with a help URL', () => { + const extension = new gd.PlatformExtension(); + const project = new gd.Project(); + + const eventExtension = project.insertNewEventsFunctionsExtension( + 'MyExtension', + 0 + ); + const freeEventsFunctions = eventExtension.getEventsFunctions(); + const eventFunction = freeEventsFunctions.insertNewEventsFunction( + 'MyFunction', + 0 + ); + eventFunction.setFunctionType(gd.EventsFunction.Action); + eventFunction.setFullName('My function'); + eventFunction.setDescription('My function description.'); + eventFunction.setSentence('My function sentence'); + eventFunction.setHelpUrl('https://example.com/help'); + + const metadataDeclarationHelper = new gd.MetadataDeclarationHelper(); + metadataDeclarationHelper.generateFreeFunctionMetadata( + project, + extension, + eventExtension, + eventFunction + ); + metadataDeclarationHelper.delete(); + + expect(extension.getAllActions().has('MyFunction')).toBe(true); + const action = extension.getAllActions().get('MyFunction'); + expect(action.getHelpPath()).toBe('https://example.com/help'); + + extension.delete(); + project.delete(); + }); + + it('does not set help path when help URL is empty', () => { + const extension = new gd.PlatformExtension(); + const project = new gd.Project(); + + const eventExtension = project.insertNewEventsFunctionsExtension( + 'MyExtension', + 0 + ); + const freeEventsFunctions = eventExtension.getEventsFunctions(); + const eventFunction = freeEventsFunctions.insertNewEventsFunction( + 'MyFunction', + 0 + ); + eventFunction.setFunctionType(gd.EventsFunction.Action); + eventFunction.setFullName('My function'); + eventFunction.setDescription('My function description.'); + eventFunction.setSentence('My function sentence'); + // Help URL is not set (empty by default) + + const metadataDeclarationHelper = new gd.MetadataDeclarationHelper(); + metadataDeclarationHelper.generateFreeFunctionMetadata( + project, + extension, + eventExtension, + eventFunction + ); + metadataDeclarationHelper.delete(); + + expect(extension.getAllActions().has('MyFunction')).toBe(true); + const action = extension.getAllActions().get('MyFunction'); + expect(action.getHelpPath()).toBe(''); + + extension.delete(); + project.delete(); + }); + it('can create metadata for free expressions', () => { const extension = new gd.PlatformExtension(); const project = new gd.Project(); diff --git a/GDevelop.js/types.d.ts b/GDevelop.js/types.d.ts index 4f40c764e896..d343540ff1f5 100644 --- a/GDevelop.js/types.d.ts +++ b/GDevelop.js/types.d.ts @@ -2277,6 +2277,8 @@ export class EventsFunction extends EmscriptenObject { isPrivate(): boolean; setAsync(isAsync: boolean): EventsFunction; isAsync(): boolean; + setHelpUrl(helpUrl: string): EventsFunction; + getHelpUrl(): string; isAction(): boolean; isExpression(): boolean; isCondition(): boolean; diff --git a/newIDE/app/src/EventsFunctionsExtensionEditor/EventsFunctionConfigurationEditor/EventsFunctionPropertiesEditor.js b/newIDE/app/src/EventsFunctionsExtensionEditor/EventsFunctionConfigurationEditor/EventsFunctionPropertiesEditor.js index 3ebc98b31ba8..244683d7d4f7 100644 --- a/newIDE/app/src/EventsFunctionsExtensionEditor/EventsFunctionConfigurationEditor/EventsFunctionPropertiesEditor.js +++ b/newIDE/app/src/EventsFunctionsExtensionEditor/EventsFunctionConfigurationEditor/EventsFunctionPropertiesEditor.js @@ -446,6 +446,29 @@ export const EventsFunctionPropertiesEditor = ({ /> )} + + Help page URL} + translatableHintText={t`Enter a URL to a help page for this action/condition/expression`} + helperMarkdownText={i18n._( + t`Optional. Enter a full URL (starting with https://) to a help page. A help icon will appear next to the action/condition/expression title in the editor, allowing users to quickly access documentation.` + )} + fullWidth + value={ + eventsFunction.getHelpUrl + ? eventsFunction.getHelpUrl() + : '' + } + onChange={text => { + if (eventsFunction.setHelpUrl) { + eventsFunction.setHelpUrl(text); + if (onConfigurationUpdated) onConfigurationUpdated(); + forceUpdate(); + } + }} + /> + {type === gd.EventsFunction.ActionWithOperator ? ( Help ) : isCondition ? ( diff --git a/newIDE/app/src/EventsSheet/InstructionEditor/InstructionParametersEditor.js b/newIDE/app/src/EventsSheet/InstructionEditor/InstructionParametersEditor.js index 1ab2d124b092..a46bd606c2e8 100644 --- a/newIDE/app/src/EventsSheet/InstructionEditor/InstructionParametersEditor.js +++ b/newIDE/app/src/EventsSheet/InstructionEditor/InstructionParametersEditor.js @@ -10,6 +10,11 @@ import { mapFor } from '../../Utils/MapFor'; import EmptyMessage from '../../UI/EmptyMessage'; import ParameterRenderingService from '../ParameterRenderingService'; import HelpButton from '../../UI/HelpButton'; +import HelpIcon from '../../UI/HelpIcon'; +import { + isRelativePathToDocumentationRoot, + isDocumentationAbsoluteUrl, +} from '../../Utils/HelpLink'; import { type ResourceManagementProps } from '../../ResourcesList/ResourceSource'; import { Column, Line, Spacer } from '../../UI/Grid'; import AlertMessage from '../../UI/AlertMessage'; @@ -306,9 +311,14 @@ const InstructionParametersEditor = React.forwardRef< }} /> - - {instructionMetadata.getDescription()} - + + + {instructionMetadata.getDescription()} + + {helpPage && isDocumentationAbsoluteUrl(helpPage) && ( + + )} + {instructionExtraInformation && ( @@ -464,18 +474,20 @@ const InstructionParametersEditor = React.forwardRef< )} - {!noHelpButton && helpPage && ( - Help for this condition - ) : ( - Help for this action - ) - } - /> - )} + {!noHelpButton && + helpPage && + isRelativePathToDocumentationRoot(helpPage) && ( + Help for this condition + ) : ( + Help for this action + ) + } + /> + )} diff --git a/newIDE/app/src/EventsSheet/InstructionEditor/SelectorListItems/SelectorInstructionOrExpressionListItem.js b/newIDE/app/src/EventsSheet/InstructionEditor/SelectorListItems/SelectorInstructionOrExpressionListItem.js index 91cedf98c88f..28b809666d9c 100644 --- a/newIDE/app/src/EventsSheet/InstructionEditor/SelectorListItems/SelectorInstructionOrExpressionListItem.js +++ b/newIDE/app/src/EventsSheet/InstructionEditor/SelectorListItems/SelectorInstructionOrExpressionListItem.js @@ -2,10 +2,12 @@ import * as React from 'react'; import { ListItem } from '../../../UI/List'; import ListIcon from '../../../UI/ListIcon'; +import HelpIcon from '../../../UI/HelpIcon'; import { type EnumeratedInstructionOrExpressionMetadata } from '../../../InstructionOrExpression/EnumeratedInstructionOrExpressionMetadata'; import { getInstructionListItemValue, getInstructionListItemKey } from './Keys'; import { type SearchMatch } from '../../../UI/Search/UseSearchStructuredItem'; import HighlightedText from '../../../UI/Search/HighlightedText'; +import { isDocumentationAbsoluteUrl } from '../../../Utils/HelpLink'; type Props = {| id?: string, @@ -40,6 +42,9 @@ export const renderInstructionOrExpressionListItem = ({ return text; }; + const helpPath = instructionOrExpressionMetadata.metadata.getHelpPath(); + const hasCustomHelpUrl = helpPath && isDocumentationAbsoluteUrl(helpPath); + return ( + {getRenderedText('displayedName')} + {hasCustomHelpUrl && ( + + )} + + } secondaryText={getRenderedText('fullGroupName')} leftIcon={ = {| instructionTreeNode: InstructionOrExpressionTreeNode, @@ -53,10 +55,21 @@ export const renderInstructionOrExpressionTree = < const instructionMetadata: T = instructionOrGroup; const value = getInstructionListItemValue(instructionOrGroup.type); const selected = selectedValue === value; + const helpPath = instructionMetadata.metadata.getHelpPath(); + const hasCustomHelpUrl = helpPath && isDocumentationAbsoluteUrl(helpPath); return ( + {instructionMetadata.displayedName} + + + ) : ( + instructionMetadata.displayedName + ) + } selected={selected} id={ // TODO: This id is used by in app tutorials. When in app tutorials diff --git a/newIDE/app/src/UI/HelpIcon/index.js b/newIDE/app/src/UI/HelpIcon/index.js index 488b6def52f0..8ba4ad4f7d1d 100644 --- a/newIDE/app/src/UI/HelpIcon/index.js +++ b/newIDE/app/src/UI/HelpIcon/index.js @@ -39,7 +39,10 @@ const HelpIcon = (props: PropsType) => { return ( Window.openExternalURL(getHelpLink(helpPagePath))} + onClick={event => { + event.stopPropagation(); + Window.openExternalURL(getHelpLink(helpPagePath)); + }} disabled={props.disabled} style={props.style} size={props.size} diff --git a/newIDE/app/src/Utils/HelpLink.spec.js b/newIDE/app/src/Utils/HelpLink.spec.js new file mode 100644 index 000000000000..7e583305a7db --- /dev/null +++ b/newIDE/app/src/Utils/HelpLink.spec.js @@ -0,0 +1,76 @@ +// @flow +import { + isRelativePathToDocumentationRoot, + isDocumentationAbsoluteUrl, + getHelpLink, +} from './HelpLink'; + +describe('HelpLink', () => { + describe('isRelativePathToDocumentationRoot', () => { + it('returns true for paths starting with /', () => { + expect(isRelativePathToDocumentationRoot('/test')).toBe(true); + expect(isRelativePathToDocumentationRoot('/all-features/audio')).toBe( + true + ); + }); + + it('returns false for absolute URLs', () => { + expect(isRelativePathToDocumentationRoot('https://example.com')).toBe( + false + ); + expect(isRelativePathToDocumentationRoot('http://example.com')).toBe( + false + ); + }); + + it('returns false for empty string', () => { + expect(isRelativePathToDocumentationRoot('')).toBe(false); + }); + }); + + describe('isDocumentationAbsoluteUrl', () => { + it('returns true for https URLs', () => { + expect(isDocumentationAbsoluteUrl('https://example.com/help')).toBe(true); + expect(isDocumentationAbsoluteUrl('https://wiki.gdevelop.io')).toBe(true); + }); + + it('returns true for http URLs', () => { + expect(isDocumentationAbsoluteUrl('http://example.com/help')).toBe(true); + }); + + it('returns false for relative paths', () => { + expect(isDocumentationAbsoluteUrl('/test')).toBe(false); + expect(isDocumentationAbsoluteUrl('/all-features/audio')).toBe(false); + }); + + it('returns false for empty string', () => { + expect(isDocumentationAbsoluteUrl('')).toBe(false); + }); + }); + + describe('getHelpLink', () => { + it('returns wiki link for relative paths', () => { + expect(getHelpLink('/test')).toBe( + 'https://wiki.gdevelop.io/gdevelop5/test?utm_source=gdevelop&utm_medium=help-link' + ); + }); + + it('returns wiki link with anchor for relative paths', () => { + expect(getHelpLink('/test', 'section1')).toBe( + 'https://wiki.gdevelop.io/gdevelop5/test?utm_source=gdevelop&utm_medium=help-link#section1' + ); + }); + + it('returns the absolute URL as-is', () => { + expect(getHelpLink('https://example.com/custom-help')).toBe( + 'https://example.com/custom-help' + ); + }); + + it('returns empty string for invalid paths', () => { + expect(getHelpLink('')).toBe(''); + expect(getHelpLink('invalid')).toBe(''); + }); + }); +}); + diff --git a/newIDE/app/src/stories/everything-else.stories.js b/newIDE/app/src/stories/everything-else.stories.js index 7dda5c2577ce..695775eabd05 100644 --- a/newIDE/app/src/stories/everything-else.stories.js +++ b/newIDE/app/src/stories/everything-else.stories.js @@ -914,7 +914,11 @@ storiesOf('UI Building Blocks/HelpButton', module) storiesOf('UI Building Blocks/HelpIcon', module) .addDecorator(paperDecorator) - .add('default', () => ); + .add('default (wiki path)', () => ) + .add('absolute URL (custom help)', () => ( + + )) + .add('small size', () => ); storiesOf('PropertiesEditor', module) .addDecorator(paperDecorator) From bd65d82d953728fc14a93d2dca4a28dc9860b194 Mon Sep 17 00:00:00 2001 From: Gleb Volkov Date: Wed, 31 Dec 2025 16:10:35 +0100 Subject: [PATCH 2/2] Minor fixes: remove defensive coding, add URL validation, improve storybook coverage, add missing tests --- .../__tests__/MetadataDeclarationHelper.js | 66 +++++++++++++++++++ .../EventsFunctionPropertiesEditor.js | 57 ++++++++++------ .../src/stories/everything-else.stories.js | 13 +++- 3 files changed, 114 insertions(+), 22 deletions(-) diff --git a/GDevelop.js/__tests__/MetadataDeclarationHelper.js b/GDevelop.js/__tests__/MetadataDeclarationHelper.js index 220b9129a12c..0daeea381f35 100644 --- a/GDevelop.js/__tests__/MetadataDeclarationHelper.js +++ b/GDevelop.js/__tests__/MetadataDeclarationHelper.js @@ -43,6 +43,72 @@ describe('MetadataDeclarationHelper', () => { project.delete(); }); + it('propagates helpUrl to action metadata', () => { + const extension = new gd.PlatformExtension(); + const project = new gd.Project(); + + const eventExtension = project.insertNewEventsFunctionsExtension( + 'MyExtension', + 0 + ); + const freeEventsFunctions = eventExtension.getEventsFunctions(); + const eventFunction = freeEventsFunctions.insertNewEventsFunction( + 'MyFunction', + 0 + ); + eventFunction.setFunctionType(gd.EventsFunction.Action); + eventFunction.setHelpUrl('https://example.com/custom-help'); + + const metadataDeclarationHelper = new gd.MetadataDeclarationHelper(); + metadataDeclarationHelper.generateFreeFunctionMetadata( + project, + extension, + eventExtension, + eventFunction + ); + metadataDeclarationHelper.delete(); + + expect(extension.getAllActions().has('MyFunction')).toBe(true); + const action = extension.getAllActions().get('MyFunction'); + expect(action.getHelpPath()).toBe('https://example.com/custom-help'); + + extension.delete(); + project.delete(); + }); + + it('does not set helpPath when helpUrl is empty', () => { + const extension = new gd.PlatformExtension(); + const project = new gd.Project(); + + const eventExtension = project.insertNewEventsFunctionsExtension( + 'MyExtension', + 0 + ); + const freeEventsFunctions = eventExtension.getEventsFunctions(); + const eventFunction = freeEventsFunctions.insertNewEventsFunction( + 'MyFunction', + 0 + ); + eventFunction.setFunctionType(gd.EventsFunction.Action); + // No helpUrl set (empty by default) + + const metadataDeclarationHelper = new gd.MetadataDeclarationHelper(); + metadataDeclarationHelper.generateFreeFunctionMetadata( + project, + extension, + eventExtension, + eventFunction + ); + metadataDeclarationHelper.delete(); + + expect(extension.getAllActions().has('MyFunction')).toBe(true); + const action = extension.getAllActions().get('MyFunction'); + expect(action.getHelpPath()).toBe(''); + + extension.delete(); + project.delete(); + }); + it('can create metadata for free actions with an underscore and unicode characters', () => { const extension = new gd.PlatformExtension(); const project = new gd.Project(); diff --git a/newIDE/app/src/EventsFunctionsExtensionEditor/EventsFunctionConfigurationEditor/EventsFunctionPropertiesEditor.js b/newIDE/app/src/EventsFunctionsExtensionEditor/EventsFunctionConfigurationEditor/EventsFunctionPropertiesEditor.js index 244683d7d4f7..998a9d27bd47 100644 --- a/newIDE/app/src/EventsFunctionsExtensionEditor/EventsFunctionConfigurationEditor/EventsFunctionPropertiesEditor.js +++ b/newIDE/app/src/EventsFunctionsExtensionEditor/EventsFunctionConfigurationEditor/EventsFunctionPropertiesEditor.js @@ -24,6 +24,10 @@ import useForceUpdate from '../../Utils/UseForceUpdate'; import Checkbox from '../../UI/Checkbox'; import { type ExtensionItemConfigurationAttribute } from '../../EventsFunctionsExtensionEditor'; import { useResponsiveWindowSize } from '../../UI/Responsive/ResponsiveWindowMeasurer'; +import { + isRelativePathToDocumentationRoot, + isDocumentationAbsoluteUrl, +} from '../../Utils/HelpLink'; const gd: libGDevelop = global.gd; @@ -447,27 +451,38 @@ export const EventsFunctionPropertiesEditor = ({ )} - Help page URL} - translatableHintText={t`Enter a URL to a help page for this action/condition/expression`} - helperMarkdownText={i18n._( - t`Optional. Enter a full URL (starting with https://) to a help page. A help icon will appear next to the action/condition/expression title in the editor, allowing users to quickly access documentation.` - )} - fullWidth - value={ - eventsFunction.getHelpUrl - ? eventsFunction.getHelpUrl() - : '' - } - onChange={text => { - if (eventsFunction.setHelpUrl) { - eventsFunction.setHelpUrl(text); - if (onConfigurationUpdated) onConfigurationUpdated(); - forceUpdate(); - } - }} - /> + {(() => { + const helpUrl = eventsFunction.getHelpUrl(); + const isValidHelpUrl = + !helpUrl || + isDocumentationAbsoluteUrl(helpUrl) || + isRelativePathToDocumentationRoot(helpUrl); + return ( + Help page URL} + translatableHintText={t`Enter a URL to a help page for this action/condition/expression`} + helperMarkdownText={i18n._( + t`Optional. Enter a full URL (starting with https://) to a help page. A help icon will appear next to the action/condition/expression title in the editor, allowing users to quickly access documentation.` + )} + errorText={ + !isValidHelpUrl ? ( + + This is not a URL starting with "http://" or + "https://". + + ) : null + } + fullWidth + value={helpUrl} + onChange={text => { + eventsFunction.setHelpUrl(text); + if (onConfigurationUpdated) onConfigurationUpdated(); + forceUpdate(); + }} + /> + ); + })()} {type === gd.EventsFunction.ActionWithOperator ? ( diff --git a/newIDE/app/src/stories/everything-else.stories.js b/newIDE/app/src/stories/everything-else.stories.js index 695775eabd05..57faba46229f 100644 --- a/newIDE/app/src/stories/everything-else.stories.js +++ b/newIDE/app/src/stories/everything-else.stories.js @@ -72,6 +72,7 @@ import InstructionEditorMenu from '../EventsSheet/InstructionEditor/InstructionE import { PopoverButton } from './PopoverButton'; import MiniToolbar, { MiniToolbarText } from '../UI/MiniToolbar'; import { Column, Line } from '../UI/Grid'; +import { ListItem } from '../UI/List'; import DragAndDropTestBed from './DragAndDropTestBed'; import EditorMosaic from '../UI/EditorMosaic'; import FlatButton from '../UI/FlatButton'; @@ -918,7 +919,17 @@ storiesOf('UI Building Blocks/HelpIcon', module) .add('absolute URL (custom help)', () => ( )) - .add('small size', () => ); + .add('small size', () => ) + .add('in ListItem context', () => ( + + My Custom Action + + + } + /> + )); storiesOf('PropertiesEditor', module) .addDecorator(paperDecorator)