diff --git a/api-editor/backend/src/main/kotlin/com/larsreimann/api_editor/model/editorAnnotations.kt b/api-editor/backend/src/main/kotlin/com/larsreimann/api_editor/model/editorAnnotations.kt index 59d11c181..bdc761051 100644 --- a/api-editor/backend/src/main/kotlin/com/larsreimann/api_editor/model/editorAnnotations.kt +++ b/api-editor/backend/src/main/kotlin/com/larsreimann/api_editor/model/editorAnnotations.kt @@ -60,6 +60,13 @@ data class ConstantAnnotation(val defaultValue: DefaultValue) : EditorAnnotation override val validTargets = PARAMETERS } +@Serializable +data class DescriptionAnnotation(val newDescription: String) : EditorAnnotation() { + + @Transient + override val validTargets = ANY_DECLARATION +} + @Serializable data class EnumAnnotation(val enumName: String, val pairs: List) : EditorAnnotation() { @@ -112,7 +119,7 @@ object RemoveAnnotation : EditorAnnotation() { @Serializable data class RenameAnnotation(val newName: String) : EditorAnnotation() { @Transient - override val validTargets = CLASSES.union(FUNCTIONS).union(PARAMETERS) + override val validTargets = ANY_DECLARATION } @Serializable @@ -164,6 +171,13 @@ enum class AnnotationTarget(private val target: String) { } } +val ANY_DECLARATION = setOf( + CLASS, + GLOBAL_FUNCTION, + METHOD, + CONSTRUCTOR_PARAMETER, + FUNCTION_PARAMETER +) val GLOBAL_DECLARATIONS = setOf(CLASS, GLOBAL_FUNCTION) val CLASSES = setOf(CLASS) val FUNCTIONS = setOf(GLOBAL_FUNCTION, METHOD) diff --git a/api-editor/backend/src/main/kotlin/com/larsreimann/api_editor/transformation/DescriptionAnnotationProcessor.kt b/api-editor/backend/src/main/kotlin/com/larsreimann/api_editor/transformation/DescriptionAnnotationProcessor.kt new file mode 100644 index 000000000..03bc5b121 --- /dev/null +++ b/api-editor/backend/src/main/kotlin/com/larsreimann/api_editor/transformation/DescriptionAnnotationProcessor.kt @@ -0,0 +1,34 @@ +package com.larsreimann.api_editor.transformation + +import com.larsreimann.api_editor.model.DescriptionAnnotation +import com.larsreimann.api_editor.mutable_model.PythonClass +import com.larsreimann.api_editor.mutable_model.PythonDeclaration +import com.larsreimann.api_editor.mutable_model.PythonFunction +import com.larsreimann.api_editor.mutable_model.PythonPackage +import com.larsreimann.api_editor.mutable_model.PythonParameter +import com.larsreimann.modeling.descendants + +/** + * Processes and removes `@description` annotations. + */ +fun PythonPackage.processDescriptionAnnotations() { + this.descendants() + .filterIsInstance() + .forEach { it.processDescriptionAnnotations() } +} + +private fun PythonDeclaration.processDescriptionAnnotations() { + this.annotations + .filterIsInstance() + .forEach { + when (this) { + is PythonClass -> this.description = it.newDescription + is PythonFunction -> this.description = it.newDescription + is PythonParameter -> this.description = it.newDescription + else -> { + // Do nothing + } + } + this.annotations.remove(it) + } +} diff --git a/api-editor/backend/src/main/kotlin/com/larsreimann/api_editor/transformation/TransformationPlan.kt b/api-editor/backend/src/main/kotlin/com/larsreimann/api_editor/transformation/TransformationPlan.kt index 640afe56b..70c7e01c3 100644 --- a/api-editor/backend/src/main/kotlin/com/larsreimann/api_editor/transformation/TransformationPlan.kt +++ b/api-editor/backend/src/main/kotlin/com/larsreimann/api_editor/transformation/TransformationPlan.kt @@ -28,6 +28,7 @@ private fun PythonPackage.preprocess(newPackageName: String) { */ private fun PythonPackage.processAnnotations() { processRemoveAnnotations() + processDescriptionAnnotations() processRenameAnnotations() processMoveAnnotations() processBoundaryAnnotations() diff --git a/api-editor/backend/src/main/kotlin/com/larsreimann/api_editor/validation/AnnotationValidator.kt b/api-editor/backend/src/main/kotlin/com/larsreimann/api_editor/validation/AnnotationValidator.kt index 5ba835376..25e117179 100644 --- a/api-editor/backend/src/main/kotlin/com/larsreimann/api_editor/validation/AnnotationValidator.kt +++ b/api-editor/backend/src/main/kotlin/com/larsreimann/api_editor/validation/AnnotationValidator.kt @@ -181,15 +181,28 @@ class AnnotationValidator(private val annotatedPythonPackage: SerializablePython companion object { private var possibleCombinations = buildMap> { - this["Attribute"] = mutableSetOf("Rename") - this["Boundary"] = mutableSetOf("Group", "Optional", "Rename", "Required") - this["CalledAfter"] = mutableSetOf("CalledAfter", "Group", "Move", "Pure", "Rename") + this["Attribute"] = mutableSetOf("Description", "Rename") + this["Boundary"] = mutableSetOf("Description", "Group", "Optional", "Rename", "Required") + this["CalledAfter"] = mutableSetOf("CalledAfter", "Description", "Group", "Move", "Pure", "Rename") this["Constant"] = mutableSetOf() - this["Enum"] = mutableSetOf("Group", "Rename", "Required") + this["Description"] = mutableSetOf( + "Attribute", + "Boundary", + "CalledAfter", + "Enum", + "Group", + "Move", + "Optional", + "Pure", + "Rename", + "Required" + ) + this["Enum"] = mutableSetOf("Description", "Group", "Rename", "Required") this["Group"] = mutableSetOf( "Boundary", "CalledAfter", + "Description", "Enum", "Group", "Move", @@ -198,14 +211,15 @@ class AnnotationValidator(private val annotatedPythonPackage: SerializablePython "Rename", "Required" ) - this["Move"] = mutableSetOf("CalledAfter", "Group", "Pure", "Rename") - this["Optional"] = mutableSetOf("Boundary", "Group", "Rename") - this["Pure"] = mutableSetOf("CalledAfter", "Group", "Move", "Rename") + this["Move"] = mutableSetOf("CalledAfter", "Description", "Group", "Pure", "Rename") + this["Optional"] = mutableSetOf("Boundary", "Description", "Group", "Rename") + this["Pure"] = mutableSetOf("CalledAfter", "Description", "Group", "Move", "Rename") this["Remove"] = mutableSetOf() this["Rename"] = mutableSetOf( "Attribute", "Boundary", "CalledAfter", + "Description", "Enum", "Group", "Move", @@ -213,7 +227,7 @@ class AnnotationValidator(private val annotatedPythonPackage: SerializablePython "Pure", "Required" ) - this["Required"] = mutableSetOf("Boundary", "Enum", "Group", "Rename") + this["Required"] = mutableSetOf("Boundary", "Description", "Enum", "Group", "Rename") } } } diff --git a/api-editor/backend/src/test/kotlin/com/larsreimann/api_editor/transformation/DescriptionAnnotationProcessorTest.kt b/api-editor/backend/src/test/kotlin/com/larsreimann/api_editor/transformation/DescriptionAnnotationProcessorTest.kt new file mode 100644 index 000000000..57a5ca978 --- /dev/null +++ b/api-editor/backend/src/test/kotlin/com/larsreimann/api_editor/transformation/DescriptionAnnotationProcessorTest.kt @@ -0,0 +1,99 @@ +package com.larsreimann.api_editor.transformation + +import com.larsreimann.api_editor.model.DescriptionAnnotation +import com.larsreimann.api_editor.mutable_model.PythonClass +import com.larsreimann.api_editor.mutable_model.PythonFunction +import com.larsreimann.api_editor.mutable_model.PythonModule +import com.larsreimann.api_editor.mutable_model.PythonPackage +import com.larsreimann.api_editor.mutable_model.PythonParameter +import io.kotest.matchers.collections.shouldBeEmpty +import io.kotest.matchers.shouldBe +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class DescriptionAnnotationProcessorTest { + private lateinit var testClass: PythonClass + private lateinit var testFunction: PythonFunction + private lateinit var testParameter: PythonParameter + private lateinit var testPackage: PythonPackage + + @BeforeEach + fun reset() { + testClass = PythonClass( + name = "TestClass", + description = "Lorem ipsum", + annotations = mutableListOf(DescriptionAnnotation("Important class")) + ) + testParameter = PythonParameter( + name = "testParameter", + description = "Lorem ipsum", + annotations = mutableListOf(DescriptionAnnotation("Important parameter")) + ) + testFunction = PythonFunction( + name = "testFunction", + description = "Lorem ipsum", + annotations = mutableListOf(DescriptionAnnotation("Important function")), + parameters = mutableListOf(testParameter) + ) + testPackage = PythonPackage( + distribution = "testPackage", + name = "testPackage", + version = "1.0.0", + modules = listOf( + PythonModule( + name = "testModule", + classes = listOf(testClass), + functions = listOf(testFunction) + ) + ) + ) + } + + @Test + fun `should process DescriptionAnnotation of classes`() { + testPackage.processDescriptionAnnotations() + + testClass.description shouldBe "Important class" + } + + @Test + fun `should remove DescriptionAnnotation of classes`() { + testPackage.processDescriptionAnnotations() + + testClass.annotations + .filterIsInstance() + .shouldBeEmpty() + } + + @Test + fun `should process DescriptionAnnotation of functions`() { + testPackage.processDescriptionAnnotations() + + testFunction.description shouldBe "Important function" + } + + @Test + fun `should remove DescriptionAnnotation of functions`() { + testPackage.processDescriptionAnnotations() + + testFunction.annotations + .filterIsInstance() + .shouldBeEmpty() + } + + @Test + fun `should process DescriptionAnnotation of parameters`() { + testPackage.processDescriptionAnnotations() + + testParameter.description shouldBe "Important parameter" + } + + @Test + fun `should remove DescriptionAnnotation of parameters`() { + testPackage.processDescriptionAnnotations() + + testParameter.annotations + .filterIsInstance() + .shouldBeEmpty() + } +} diff --git a/api-editor/desktop/src/main/kotlin/com/larsreimann/python_api_editor/AnnotationDropdown.kt b/api-editor/desktop/src/main/kotlin/com/larsreimann/python_api_editor/AnnotationDropdown.kt index 3b03ef8f8..74f63d68c 100644 --- a/api-editor/desktop/src/main/kotlin/com/larsreimann/python_api_editor/AnnotationDropdown.kt +++ b/api-editor/desktop/src/main/kotlin/com/larsreimann/python_api_editor/AnnotationDropdown.kt @@ -20,6 +20,7 @@ fun AnnotationDropdown( showBoundary: Boolean = false, showCalledAfter: Boolean = false, showConstant: Boolean = false, + showDescription: Boolean = false, showEnum: Boolean = false, showGroup: Boolean = false, showMove: Boolean = false, @@ -56,6 +57,11 @@ fun AnnotationDropdown( Text(labels.getString("AnnotationDropdown.Option.Constant")) } } + if (showDescription) { + DropdownMenuItem(onClick = {}) { + Text(labels.getString("AnnotationDropdown.Option.Description")) + } + } if (showEnum) { DropdownMenuItem(onClick = {}) { Text(labels.getString("AnnotationDropdown.Option.Enum")) diff --git a/api-editor/desktop/src/main/resources/i18n/labels.properties b/api-editor/desktop/src/main/resources/i18n/labels.properties index 57226e31a..7ac66b3fd 100644 --- a/api-editor/desktop/src/main/resources/i18n/labels.properties +++ b/api-editor/desktop/src/main/resources/i18n/labels.properties @@ -28,6 +28,7 @@ AnnotationDropdown.Option.Attribute=@attribute AnnotationDropdown.Option.Boundary=@boundary AnnotationDropdown.Option.CalledAfter=@calledAfter AnnotationDropdown.Option.Constant=@constant +AnnotationDropdown.Option.Description=@description AnnotationDropdown.Option.Enum=@enum AnnotationDropdown.Option.Group=@group AnnotationDropdown.Option.Move=@move diff --git a/api-editor/gui/src/app/App.tsx b/api-editor/gui/src/app/App.tsx index a09631ee8..bc0fd0c37 100644 --- a/api-editor/gui/src/app/App.tsx +++ b/api-editor/gui/src/app/App.tsx @@ -17,6 +17,7 @@ import { initializeAnnotations, persistAnnotations, selectAnnotations } from '.. import { BoundaryForm } from '../features/annotations/forms/BoundaryForm'; import { CalledAfterForm } from '../features/annotations/forms/CalledAfterForm'; import { ConstantForm } from '../features/annotations/forms/ConstantForm'; +import { DescriptionForm } from '../features/annotations/forms/DescriptionForm'; import { EnumForm } from '../features/annotations/forms/EnumForm'; import { GroupForm } from '../features/annotations/forms/GroupForm'; import { MoveForm } from '../features/annotations/forms/MoveForm'; @@ -47,6 +48,8 @@ import { selectFilteredPythonPackage, selectPythonPackage, } from '../features/packageData/apiSlice'; +import { PythonClass } from '../features/packageData/model/PythonClass'; +import { PythonParameter } from '../features/packageData/model/PythonParameter'; export const App: React.FC = function () { useIndexedDB(); @@ -103,6 +106,12 @@ export const App: React.FC = function () { {currentUserAction.type === 'constant' && ( )} + {currentUserAction.type === 'description' && + (userActionTarget instanceof PythonClass || + userActionTarget instanceof PythonFunction || + userActionTarget instanceof PythonParameter) && ( + + )} {currentUserAction.type === 'enum' && } {currentUserAction.type === 'group' && ( - @attribute, @boundary, @calledAfter, @constant, @enum, @group, @move, @optional, - @pure, @remove, @renaming, @required + @attribute, @boundary, @calledAfter, @constant, @description, @enum, @group, + @move, @optional, @pure, @remove, @renaming, @required . diff --git a/api-editor/gui/src/features/annotatedPackageData/model/AnnotatedPythonPackageBuilder.ts b/api-editor/gui/src/features/annotatedPackageData/model/AnnotatedPythonPackageBuilder.ts index 5f059188d..2cc8ba5f8 100644 --- a/api-editor/gui/src/features/annotatedPackageData/model/AnnotatedPythonPackageBuilder.ts +++ b/api-editor/gui/src/features/annotatedPackageData/model/AnnotatedPythonPackageBuilder.ts @@ -25,6 +25,7 @@ import { InferableRemoveAnnotation, InferableRenameAnnotation, InferableRequiredAnnotation, + InferableDescriptionAnnotation, } from './InferableAnnotation'; export class AnnotatedPythonPackageBuilder { @@ -140,6 +141,7 @@ export class AnnotatedPythonPackageBuilder { 'Boundary', 'CalledAfters', 'Constant', + 'Description', 'Enum', 'Groups', 'Move', @@ -192,6 +194,12 @@ export class AnnotatedPythonPackageBuilder { return new InferableConstantAnnotation(constantAnnotation); } break; + case 'Description': + const descriptionAnnotation = this.annotationStore.descriptions[target]; + if (descriptionAnnotation) { + return new InferableDescriptionAnnotation(descriptionAnnotation); + } + break; case 'Groups': const groupAnnotations = this.annotationStore.groups[target]; if (!groupAnnotations) { diff --git a/api-editor/gui/src/features/annotatedPackageData/model/InferableAnnotation.ts b/api-editor/gui/src/features/annotatedPackageData/model/InferableAnnotation.ts index 9cd13be13..c9d88a652 100644 --- a/api-editor/gui/src/features/annotatedPackageData/model/InferableAnnotation.ts +++ b/api-editor/gui/src/features/annotatedPackageData/model/InferableAnnotation.ts @@ -4,6 +4,7 @@ import { CalledAfterAnnotation, ComparisonOperator, ConstantAnnotation, + DescriptionAnnotation, DefaultType, DefaultValue, EnumAnnotation, @@ -92,6 +93,15 @@ export class InferableConstantAnnotation extends InferableAnnotation { } } +export class InferableDescriptionAnnotation extends InferableAnnotation { + readonly newDescription: string; + + constructor(descriptionAnnotation: DescriptionAnnotation) { + super(dataPathPrefix + 'DescriptionAnnotation'); + this.newDescription = descriptionAnnotation.newDescription; + } +} + export class InferableGroupAnnotation extends InferableAnnotation { readonly groupName: string; readonly parameters: string[]; diff --git a/api-editor/gui/src/features/annotations/AnnotationDropdown.tsx b/api-editor/gui/src/features/annotations/AnnotationDropdown.tsx index 4b1f7812d..0de717de0 100644 --- a/api-editor/gui/src/features/annotations/AnnotationDropdown.tsx +++ b/api-editor/gui/src/features/annotations/AnnotationDropdown.tsx @@ -13,6 +13,7 @@ import { showMoveAnnotationForm, showOptionalAnnotationForm, showRenameAnnotationForm, + showDescriptionAnnotationForm, } from '../ui/uiSlice'; interface AnnotationDropdownProps { @@ -20,6 +21,7 @@ interface AnnotationDropdownProps { showBoundary?: boolean; showCalledAfter?: boolean; showConstant?: boolean; + showDescription?: boolean; showEnum?: boolean; showGroup?: boolean; showMove?: boolean; @@ -36,6 +38,7 @@ export const AnnotationDropdown: React.FC = function ({ showBoundary = false, showCalledAfter = false, showConstant = false, + showDescription = false, showGroup = false, showEnum = false, showMove = false, @@ -79,6 +82,11 @@ export const AnnotationDropdown: React.FC = function ({ {showConstant && ( dispatch(showConstantAnnotationForm(target))}>@constant )} + {showDescription && ( + dispatch(showDescriptionAnnotationForm(target))}> + @description + + )} {showEnum && dispatch(showEnumAnnotationForm(target))}>@enum} {showGroup && ( = function ({ target const boundaryAnnotation = useAppSelector(selectBoundary(target)); const calledAfterAnnotation = useAppSelector(selectCalledAfters(target)); const constantAnnotation = useAppSelector(selectConstant(target)); + const descriptionAnnotation = useAppSelector(selectDescription(target)); const enumAnnotation = useAppSelector(selectEnum(target)); const groupAnnotations = useAppSelector(selectGroups(target)); const moveAnnotation = useAppSelector(selectMove(target)); @@ -68,6 +72,7 @@ export const AnnotationView: React.FC = function ({ target !boundaryAnnotation && !calledAfterAnnotation && !constantAnnotation && + !descriptionAnnotation && !enumAnnotation && !groupAnnotations && !moveAnnotation && @@ -115,6 +120,13 @@ export const AnnotationView: React.FC = function ({ target onDelete={() => dispatch(removeConstant(target))} /> )} + {descriptionAnnotation && ( + dispatch(showDescriptionAnnotationForm(target))} + onDelete={() => dispatch(removeDescription(target))} + /> + )} {enumAnnotation && ( ) { delete state.constants[action.payload]; }, + upsertDescription(state, action: PayloadAction) { + state.descriptions[action.payload.target] = action.payload; + }, + removeDescription(state, action: PayloadAction) { + delete state.descriptions[action.payload]; + }, upsertEnum(state, action: PayloadAction) { state.enums[action.payload.target] = action.payload; }, @@ -444,6 +466,8 @@ export const { removeCalledAfter, upsertConstant, removeConstant, + upsertDescription, + removeDescription, upsertEnum, removeEnum, upsertGroup, @@ -480,6 +504,10 @@ export const selectConstant = (target: string) => (state: RootState): ConstantAnnotation | undefined => selectAnnotations(state).constants[target]; +export const selectDescription = + (target: string) => + (state: RootState): DescriptionAnnotation | undefined => + selectAnnotations(state).descriptions[target]; export const selectEnum = (target: string) => (state: RootState): EnumAnnotation | undefined => diff --git a/api-editor/gui/src/features/annotations/forms/DescriptionForm.tsx b/api-editor/gui/src/features/annotations/forms/DescriptionForm.tsx new file mode 100644 index 000000000..0e05f0888 --- /dev/null +++ b/api-editor/gui/src/features/annotations/forms/DescriptionForm.tsx @@ -0,0 +1,78 @@ +import { FormControl, FormLabel, Textarea } from '@chakra-ui/react'; +import React, { useEffect } from 'react'; +import { useForm } from 'react-hook-form'; +import { useAppDispatch, useAppSelector } from '../../../app/hooks'; +import { selectDescription, upsertDescription } from '../annotationSlice'; +import { AnnotationForm } from './AnnotationForm'; +import { hideAnnotationForm } from '../../ui/uiSlice'; +import { PythonClass } from '../../packageData/model/PythonClass'; +import { PythonFunction } from '../../packageData/model/PythonFunction'; +import { PythonParameter } from '../../packageData/model/PythonParameter'; + +interface DescriptionFormProps { + readonly target: PythonClass | PythonFunction | PythonParameter; +} + +interface DescriptionFormState { + newDescription: string; +} + +export const DescriptionForm: React.FC = function ({ target }) { + const targetPath = target.pathAsString(); + const prevNewDescription = useAppSelector(selectDescription(targetPath))?.newDescription; + const oldDescription = target.description; + + // Hooks ----------------------------------------------------------------------------------------------------------- + + const dispatch = useAppDispatch(); + const { register, handleSubmit, setFocus, reset } = useForm({ + defaultValues: { + newDescription: '', + }, + }); + + useEffect(() => { + try { + setFocus('newDescription'); + } catch (e) { + // ignore + } + }, [setFocus]); + + useEffect(() => { + reset({ + newDescription: prevNewDescription ?? oldDescription, + }); + }, [reset, prevNewDescription, oldDescription]); + + // Event handlers -------------------------------------------------------------------------------------------------- + + const onSave = (data: DescriptionFormState) => { + dispatch( + upsertDescription({ + target: targetPath, + ...data, + }), + ); + dispatch(hideAnnotationForm()); + }; + + const onCancel = () => { + dispatch(hideAnnotationForm()); + }; + + // Rendering ------------------------------------------------------------------------------------------------------- + + return ( + + + New description for "{target.name}": +