From dd40a997d95131efcd835f9fc3109d1ab9200972 Mon Sep 17 00:00:00 2001 From: Gleb Volkov Date: Tue, 10 Feb 2026 16:04:38 +0100 Subject: [PATCH] Add support for setting a custom help URL for extension actions/conditions/expressions (#8103) Only show in developer changelog --- 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 | 138 ++++++++++++++++++ GDevelop.js/types.d.ts | 2 + GDevelop.js/types/gdeventsfunction.js | 2 + .../EventsFunctionPropertiesEditor.js | 39 +++++ .../InstructionEditorDialog.js | 9 +- .../InstructionParametersEditor.js | 48 +++--- newIDE/app/src/UI/HelpIcon/index.js | 5 +- newIDE/app/src/Utils/HelpLink.spec.js | 75 ++++++++++ .../src/stories/everything-else.stories.js | 6 +- 15 files changed, 474 insertions(+), 21 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 7c941232e..4f5caf097 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 886b74017..9f1d8a9da 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 000000000..bbbdbda17 --- /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 3aaa85696..2d85245b6 100644 --- a/GDJS/GDJS/Events/CodeGeneration/MetadataDeclarationHelper.cpp +++ b/GDJS/GDJS/Events/CodeGeneration/MetadataDeclarationHelper.cpp @@ -535,6 +535,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 { @@ -556,6 +559,9 @@ MetadataDeclarationHelper::DeclareExpressionMetadata( expression.AddCodeOnlyParameter("currentScene", ""); DeclareEventsFunctionParameters(freeEventsFunctions, eventsFunction, expression, 0); + if (!eventsFunction.GetHelpUrl().empty()) { + expression.SetHelpPath(eventsFunction.GetHelpUrl()); + } return expression; } } @@ -581,6 +587,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( @@ -607,6 +616,9 @@ gd::InstructionMetadata &MetadataDeclarationHelper::DeclareInstructionMetadata( action.AddCodeOnlyParameter("currentScene", ""); DeclareEventsFunctionParameters(freeEventsFunctions, eventsFunction, action, 0); + if (!eventsFunction.GetHelpUrl().empty()) { + action.SetHelpPath(eventsFunction.GetHelpUrl()); + } return action; } else { @@ -621,6 +633,9 @@ gd::InstructionMetadata &MetadataDeclarationHelper::DeclareInstructionMetadata( action.AddCodeOnlyParameter("currentScene", ""); DeclareEventsFunctionParameters(freeEventsFunctions, eventsFunction, action, 0); + if (!eventsFunction.GetHelpUrl().empty()) { + action.SetHelpPath(eventsFunction.GetHelpUrl()); + } return action; } } else { @@ -634,6 +649,9 @@ gd::InstructionMetadata &MetadataDeclarationHelper::DeclareInstructionMetadata( action.AddCodeOnlyParameter("currentScene", ""); DeclareEventsFunctionParameters(freeEventsFunctions, eventsFunction, action, 0); + if (!eventsFunction.GetHelpUrl().empty()) { + action.SetHelpPath(eventsFunction.GetHelpUrl()); + } return action; } } @@ -726,6 +744,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 { @@ -751,6 +772,9 @@ MetadataDeclarationHelper::DeclareBehaviorExpressionMetadata( GetExtensionIconUrl(extension)); DeclareEventsFunctionParameters(eventsBasedBehavior.GetEventsFunctions(), eventsFunction, expression, 2); + if (!eventsFunction.GetHelpUrl().empty()) { + expression.SetHelpPath(eventsFunction.GetHelpUrl()); + } return expression; } } @@ -779,6 +803,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(); @@ -804,6 +831,9 @@ MetadataDeclarationHelper::DeclareBehaviorInstructionMetadata( DeclareEventsFunctionParameters(eventsBasedBehavior.GetEventsFunctions(), eventsFunction, action, 2); + if (!eventsFunction.GetHelpUrl().empty()) { + action.SetHelpPath(eventsFunction.GetHelpUrl()); + } return action; } else { auto &action = behaviorMetadata.AddScopedAction( @@ -817,6 +847,9 @@ MetadataDeclarationHelper::DeclareBehaviorInstructionMetadata( DeclareEventsFunctionParameters(eventsBasedBehavior.GetEventsFunctions(), eventsFunction, action, 2); + if (!eventsFunction.GetHelpUrl().empty()) { + action.SetHelpPath(eventsFunction.GetHelpUrl()); + } return action; } } else { @@ -834,6 +867,9 @@ MetadataDeclarationHelper::DeclareBehaviorInstructionMetadata( DeclareEventsFunctionParameters(eventsBasedBehavior.GetEventsFunctions(), eventsFunction, action, 2); + if (!eventsFunction.GetHelpUrl().empty()) { + action.SetHelpPath(eventsFunction.GetHelpUrl()); + } return action; } } @@ -900,6 +936,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 { @@ -926,6 +965,9 @@ MetadataDeclarationHelper::DeclareObjectExpressionMetadata( DeclareEventsFunctionParameters(eventsBasedObject.GetEventsFunctions(), eventsFunction, expression, 1); + if (!eventsFunction.GetHelpUrl().empty()) { + expression.SetHelpPath(eventsFunction.GetHelpUrl()); + } return expression; } } @@ -955,6 +997,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(); @@ -979,6 +1024,9 @@ MetadataDeclarationHelper::DeclareObjectInstructionMetadata( DeclareEventsFunctionParameters(eventsBasedObject.GetEventsFunctions(), eventsFunction, action, 1); + if (!eventsFunction.GetHelpUrl().empty()) { + action.SetHelpPath(eventsFunction.GetHelpUrl()); + } return action; } else { auto &action = objectMetadata.AddScopedAction( @@ -991,6 +1039,9 @@ MetadataDeclarationHelper::DeclareObjectInstructionMetadata( DeclareEventsFunctionParameters(eventsBasedObject.GetEventsFunctions(), eventsFunction, action, 1); + if (!eventsFunction.GetHelpUrl().empty()) { + action.SetHelpPath(eventsFunction.GetHelpUrl()); + } return action; } } else { @@ -1008,6 +1059,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 210f68a3d..9f9a8761c 100644 --- a/GDevelop.js/Bindings/Bindings.idl +++ b/GDevelop.js/Bindings/Bindings.idl @@ -3206,6 +3206,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 0a70aea01..f4a372ac5 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 5388f624d..0daeea381 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(); @@ -114,6 +180,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 385917639..24d7eff06 100644 --- a/GDevelop.js/types.d.ts +++ b/GDevelop.js/types.d.ts @@ -2306,6 +2306,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/GDevelop.js/types/gdeventsfunction.js b/GDevelop.js/types/gdeventsfunction.js index a6a92436c..0fdb01baf 100644 --- a/GDevelop.js/types/gdeventsfunction.js +++ b/GDevelop.js/types/gdeventsfunction.js @@ -25,6 +25,8 @@ declare class gdEventsFunction { isPrivate(): boolean; setAsync(isAsync: boolean): gdEventsFunction; isAsync(): boolean; + setHelpUrl(helpUrl: string): gdEventsFunction; + 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 3ebc98b31..208725761 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; @@ -446,6 +450,41 @@ export const EventsFunctionPropertiesEditor = ({ /> )} + + {(() => { + 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 ? ( Help ) : isCondition ? ( diff --git a/newIDE/app/src/EventsSheet/InstructionEditor/InstructionParametersEditor.js b/newIDE/app/src/EventsSheet/InstructionEditor/InstructionParametersEditor.js index 1ab2d124b..005b7913c 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'; @@ -293,8 +298,8 @@ const InstructionParametersEditor = React.forwardRef< {({ i18n }) => ( - - + + - - {instructionMetadata.getDescription()} - + + + {instructionMetadata.getDescription()} + + {helpPage && isDocumentationAbsoluteUrl(helpPage) && ( + + )} + {instructionExtraInformation && ( @@ -464,20 +474,22 @@ 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/UI/HelpIcon/index.js b/newIDE/app/src/UI/HelpIcon/index.js index 488b6def5..8ba4ad4f7 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 000000000..f2f68a5e5 --- /dev/null +++ b/newIDE/app/src/Utils/HelpLink.spec.js @@ -0,0 +1,75 @@ +// @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 35763f00a..fa831a4b9 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)