diff --git a/api-editor/backend/src/main/kotlin/com/larsreimann/api_editor/codegen/PythonCodeGenerator.kt b/api-editor/backend/src/main/kotlin/com/larsreimann/api_editor/codegen/PythonCodeGenerator.kt index 8a6495539..e1d0264bc 100644 --- a/api-editor/backend/src/main/kotlin/com/larsreimann/api_editor/codegen/PythonCodeGenerator.kt +++ b/api-editor/backend/src/main/kotlin/com/larsreimann/api_editor/codegen/PythonCodeGenerator.kt @@ -117,10 +117,14 @@ fun PythonAttribute.toPythonCode() = buildString { } fun PythonClass.toPythonCode() = buildString { + val todoComment = todoComment(todo) val docstring = docstring() val constructorString = constructor?.toPythonCode() ?: "" val methodsString = methods.joinToString("\n\n") { it.toPythonCode() } + if (todoComment.isNotBlank()) { + appendLine(todoComment) + } appendLine("class $name:") if (docstring.isNotBlank()) { appendIndented("\"\"\"\n") @@ -143,6 +147,7 @@ fun PythonClass.toPythonCode() = buildString { } fun PythonConstructor.toPythonCode() = buildString { + val todoComment = todoComment(todo) val parametersString = parameters.toPythonCode() val boundariesString = parameters .mapNotNull { it.boundary?.toPythonCode(it.name) } @@ -155,6 +160,9 @@ fun PythonConstructor.toPythonCode() = buildString { ?.let { "self.instance = ${it.toPythonCode()}" } ?: "" + if (todoComment.isNotBlank()) { + appendLine(todoComment) + } appendLine("def __init__($parametersString):") if (boundariesString.isNotBlank()) { appendIndented(boundariesString) @@ -202,6 +210,7 @@ fun PythonEnumInstance.toPythonCode(enumName: String): String { } fun PythonFunction.toPythonCode() = buildString { + val todoComment = todoComment(todo) val parametersString = parameters.toPythonCode() val docstring = docstring() val boundariesString = parameters @@ -211,6 +220,9 @@ fun PythonFunction.toPythonCode() = buildString { ?.let { "return ${it.toPythonCode()}" } ?: "" + if (todoComment.isNotBlank()) { + appendLine(todoComment) + } if (isStaticMethod()) { appendLine("@staticmethod") } @@ -357,3 +369,18 @@ fun Boundary.toPythonCode(parameterName: String) = buildString { appendIndented("raise ValueError(f'Valid values of $parameterName must be less than or equal to $upperIntervalLimit, but {$parameterName} was assigned.')") } } + +fun todoComment(message: String) = buildString { + if (message.isBlank()) { + return "" + } + + val lines = message.lines() + val firstLine = lines.first() + val remainingLines = lines.drop(1) + + appendLine("# TODO: $firstLine") + remainingLines.forEach { + appendIndented(it, indent = " ".repeat(8)) + } +} diff --git a/api-editor/backend/src/main/kotlin/com/larsreimann/api_editor/codegen/Util.kt b/api-editor/backend/src/main/kotlin/com/larsreimann/api_editor/codegen/Util.kt index a570db791..b5ef8edb9 100644 --- a/api-editor/backend/src/main/kotlin/com/larsreimann/api_editor/codegen/Util.kt +++ b/api-editor/backend/src/main/kotlin/com/larsreimann/api_editor/codegen/Util.kt @@ -11,13 +11,7 @@ internal fun String.prependIndentUnlessBlank(indent: String = " "): String { .joinToString("\n") } -internal fun StringBuilder.appendIndented(init: StringBuilder.() -> Unit): StringBuilder { - val stringToIndent = StringBuilder().apply(init).toString() - append(stringToIndent.prependIndentUnlessBlank()) - return this -} - -internal fun StringBuilder.appendIndented(value: String): StringBuilder { - append(value.prependIndentUnlessBlank()) +internal fun StringBuilder.appendIndented(value: String, indent: String = " "): StringBuilder { + append(value.prependIndentUnlessBlank(indent)) return this } 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 bdc761051..ff1e79838 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 @@ -128,6 +128,12 @@ object RequiredAnnotation : EditorAnnotation() { override val validTargets = PARAMETERS } +@Serializable +data class TodoAnnotation(val message: String) : EditorAnnotation() { + @Transient + override val validTargets = ANY_DECLARATION +} + @Serializable sealed class DefaultValue @@ -179,7 +185,6 @@ val ANY_DECLARATION = setOf( FUNCTION_PARAMETER ) val GLOBAL_DECLARATIONS = setOf(CLASS, GLOBAL_FUNCTION) -val CLASSES = setOf(CLASS) val FUNCTIONS = setOf(GLOBAL_FUNCTION, METHOD) val PARAMETERS = setOf( CONSTRUCTOR_PARAMETER, diff --git a/api-editor/backend/src/main/kotlin/com/larsreimann/api_editor/mutable_model/PythonAst.kt b/api-editor/backend/src/main/kotlin/com/larsreimann/api_editor/mutable_model/PythonAst.kt index 6572d13da..92b81f85e 100644 --- a/api-editor/backend/src/main/kotlin/com/larsreimann/api_editor/mutable_model/PythonAst.kt +++ b/api-editor/backend/src/main/kotlin/com/larsreimann/api_editor/mutable_model/PythonAst.kt @@ -62,6 +62,7 @@ class PythonClass( methods: List = emptyList(), var isPublic: Boolean = true, var description: String = "", + var todo: String = "", override val annotations: MutableList = mutableListOf(), var originalClass: OriginalPythonClass? = null ) : PythonDeclaration() { @@ -81,7 +82,8 @@ class PythonClass( class PythonConstructor( parameters: List = emptyList(), - val callToOriginalAPI: PythonCall? = null + val callToOriginalAPI: PythonCall? = null, + var todo: String = "" ) : PythonAstNode() { val parameters = MutableContainmentList(parameters) @@ -126,6 +128,7 @@ class PythonFunction( results: List = emptyList(), var isPublic: Boolean = true, var description: String = "", + var todo: String = "", var isPure: Boolean = false, override val annotations: MutableList = mutableListOf(), var callToOriginalAPI: PythonCall? = null diff --git a/api-editor/backend/src/main/kotlin/com/larsreimann/api_editor/transformation/TodoAnnotationProcessor.kt b/api-editor/backend/src/main/kotlin/com/larsreimann/api_editor/transformation/TodoAnnotationProcessor.kt new file mode 100644 index 000000000..03efeaf85 --- /dev/null +++ b/api-editor/backend/src/main/kotlin/com/larsreimann/api_editor/transformation/TodoAnnotationProcessor.kt @@ -0,0 +1,32 @@ +package com.larsreimann.api_editor.transformation + +import com.larsreimann.api_editor.model.TodoAnnotation +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.modeling.descendants + +/** + * Processes and removes `@todo` annotations. + */ +fun PythonPackage.processTodoAnnotations() { + this.descendants() + .filterIsInstance() + .forEach { it.processTodoAnnotations() } +} + +private fun PythonDeclaration.processTodoAnnotations() { + this.annotations + .filterIsInstance() + .forEach { + when (this) { + is PythonClass -> this.todo = it.message + is PythonFunction -> this.todo = it.message + 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 70c7e01c3..9d38c3924 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 @@ -29,6 +29,7 @@ private fun PythonPackage.preprocess(newPackageName: String) { private fun PythonPackage.processAnnotations() { processRemoveAnnotations() processDescriptionAnnotations() + processTodoAnnotations() 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 25e117179..b8cb6cc93 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,8 +181,8 @@ class AnnotationValidator(private val annotatedPythonPackage: SerializablePython companion object { private var possibleCombinations = buildMap> { - this["Attribute"] = mutableSetOf("Description", "Rename") - this["Boundary"] = mutableSetOf("Description", "Group", "Optional", "Rename", "Required") + this["Attribute"] = mutableSetOf("Description", "Rename", "Todo") + this["Boundary"] = mutableSetOf("Description", "Group", "Optional", "Rename", "Required", "Todo") this["CalledAfter"] = mutableSetOf("CalledAfter", "Description", "Group", "Move", "Pure", "Rename") this["Constant"] = mutableSetOf() this["Description"] = mutableSetOf( @@ -195,9 +195,10 @@ class AnnotationValidator(private val annotatedPythonPackage: SerializablePython "Optional", "Pure", "Rename", - "Required" + "Required", + "Todo" ) - this["Enum"] = mutableSetOf("Description", "Group", "Rename", "Required") + this["Enum"] = mutableSetOf("Description", "Group", "Rename", "Required", "Todo") this["Group"] = mutableSetOf( "Boundary", @@ -209,10 +210,11 @@ class AnnotationValidator(private val annotatedPythonPackage: SerializablePython "Optional", "Pure", "Rename", - "Required" + "Required", + "Todo" ) this["Move"] = mutableSetOf("CalledAfter", "Description", "Group", "Pure", "Rename") - this["Optional"] = mutableSetOf("Boundary", "Description", "Group", "Rename") + this["Optional"] = mutableSetOf("Boundary", "Description", "Group", "Rename", "Todo") this["Pure"] = mutableSetOf("CalledAfter", "Description", "Group", "Move", "Rename") this["Remove"] = mutableSetOf() this["Rename"] = mutableSetOf( @@ -225,9 +227,19 @@ class AnnotationValidator(private val annotatedPythonPackage: SerializablePython "Move", "Optional", "Pure", + "Required", "Todo" + ) + this["Required"] = mutableSetOf("Boundary", "Description", "Enum", "Group", "Rename", "Todo") + this["Todo"] = mutableSetOf( + "Attribute", + "Boundary", + "Description", + "Enum", + "Group", + "Optional", + "Rename", "Required" ) - this["Required"] = mutableSetOf("Boundary", "Description", "Enum", "Group", "Rename") } } } diff --git a/api-editor/backend/src/test/kotlin/com/larsreimann/api_editor/codegen/PythonCodeGeneratorTest.kt b/api-editor/backend/src/test/kotlin/com/larsreimann/api_editor/codegen/PythonCodeGeneratorTest.kt index 0c3720587..f5f3fc9b7 100644 --- a/api-editor/backend/src/test/kotlin/com/larsreimann/api_editor/codegen/PythonCodeGeneratorTest.kt +++ b/api-editor/backend/src/test/kotlin/com/larsreimann/api_editor/codegen/PythonCodeGeneratorTest.kt @@ -238,6 +238,21 @@ class PythonCodeGeneratorTest { | self.testAttribute2: int = 2 """.trimMargin() } + + @Test + fun `should store todo if it is not blank`() { + val testClass = PythonClass( + name = "TestClass", + todo = "Lorem ipsum\n\nDolor sit amet" + ) + + testClass.toPythonCode() shouldBe """ + |# TODO: Lorem ipsum + | Dolor sit amet + |class TestClass: + | pass + """.trimMargin() + } } @Nested @@ -441,6 +456,20 @@ class PythonCodeGeneratorTest { | self.instance = OriginalClass() """.trimMargin() } + + @Test + fun `should store todo if it is not blank`() { + val testConstructor = PythonConstructor( + todo = "Lorem ipsum\n\nDolor sit amet" + ) + + testConstructor.toPythonCode() shouldBe """ + |# TODO: Lorem ipsum + | Dolor sit amet + |def __init__(): + | pass + """.trimMargin() + } } @Nested @@ -687,6 +716,21 @@ class PythonCodeGeneratorTest { | pass """.trimMargin() } + + @Test + fun `should store todo if it is not blank`() { + val testFunction = PythonFunction( + name = "testFunction", + todo = "Lorem ipsum\n\nDolor sit amet" + ) + + testFunction.toPythonCode() shouldBe """ + |# TODO: Lorem ipsum + | Dolor sit amet + |def testFunction(): + | pass + """.trimMargin() + } } @Nested diff --git a/api-editor/backend/src/test/kotlin/com/larsreimann/api_editor/transformation/TodoAnnotationProcessorTest.kt b/api-editor/backend/src/test/kotlin/com/larsreimann/api_editor/transformation/TodoAnnotationProcessorTest.kt new file mode 100644 index 000000000..85c600b02 --- /dev/null +++ b/api-editor/backend/src/test/kotlin/com/larsreimann/api_editor/transformation/TodoAnnotationProcessorTest.kt @@ -0,0 +1,73 @@ +package com.larsreimann.api_editor.transformation + +import com.larsreimann.api_editor.model.TodoAnnotation +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 io.kotest.matchers.collections.shouldBeEmpty +import io.kotest.matchers.shouldBe +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class TodoAnnotationProcessorTest { + private lateinit var testClass: PythonClass + private lateinit var testFunction: PythonFunction + private lateinit var testPackage: PythonPackage + + @BeforeEach + fun reset() { + testClass = PythonClass( + name = "TestClass", + annotations = mutableListOf(TodoAnnotation("Refactor class")) + ) + testFunction = PythonFunction( + name = "testFunction", + annotations = mutableListOf(TodoAnnotation("Refactor function")) + ) + 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 TodoAnnotation of classes`() { + testPackage.processTodoAnnotations() + + testClass.todo shouldBe "Refactor class" + } + + @Test + fun `should remove TodoAnnotation of classes`() { + testPackage.processTodoAnnotations() + + testClass.annotations + .filterIsInstance() + .shouldBeEmpty() + } + + @Test + fun `should process TodoAnnotation of functions`() { + testPackage.processTodoAnnotations() + + testFunction.todo shouldBe "Refactor function" + } + + @Test + fun `should remove TodoAnnotation of functions`() { + testPackage.processTodoAnnotations() + + testFunction.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 74f63d68c..3cbe48ebd 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 @@ -29,6 +29,7 @@ fun AnnotationDropdown( showRemove: Boolean = false, showRename: Boolean = false, showRequired: Boolean = false, + showTodo: Boolean = false, ) { var expanded by remember { mutableStateOf(false) } @@ -102,6 +103,11 @@ fun AnnotationDropdown( Text(labels.getString("AnnotationDropdown.Option.Required")) } } + if (showTodo) { + DropdownMenuItem(onClick = {}) { + Text(labels.getString("AnnotationDropdown.Option.Todo")) + } + } } } } diff --git a/api-editor/desktop/src/main/resources/i18n/labels.properties b/api-editor/desktop/src/main/resources/i18n/labels.properties index 7ac66b3fd..c42c768a9 100644 --- a/api-editor/desktop/src/main/resources/i18n/labels.properties +++ b/api-editor/desktop/src/main/resources/i18n/labels.properties @@ -37,3 +37,4 @@ AnnotationDropdown.Option.Pure=@pure AnnotationDropdown.Option.Remove=@remove AnnotationDropdown.Option.Rename=@rename AnnotationDropdown.Option.Required=@required +AnnotationDropdown.Option.Todo=@todo diff --git a/api-editor/gui/src/app/App.tsx b/api-editor/gui/src/app/App.tsx index bc0fd0c37..7d8510b39 100644 --- a/api-editor/gui/src/app/App.tsx +++ b/api-editor/gui/src/app/App.tsx @@ -23,6 +23,7 @@ import { GroupForm } from '../features/annotations/forms/GroupForm'; import { MoveForm } from '../features/annotations/forms/MoveForm'; import { OptionalForm } from '../features/annotations/forms/OptionalForm'; import { RenameForm } from '../features/annotations/forms/RenameForm'; +import { TodoForm } from '../features/annotations/forms/TodoForm'; import { PackageDataImportDialog } from '../features/packageData/PackageDataImportDialog'; import { SelectionView } from '../features/packageData/selectionView/SelectionView'; import { TreeView } from '../features/packageData/treeView/TreeView'; @@ -131,6 +132,7 @@ export const App: React.FC = function () { )} {currentUserAction.type === 'rename' && } + {currentUserAction.type === 'todo' && } diff --git a/api-editor/gui/src/common/FilterHelpButton.tsx b/api-editor/gui/src/common/FilterHelpButton.tsx index 785a89c26..0a4a2143c 100644 --- a/api-editor/gui/src/common/FilterHelpButton.tsx +++ b/api-editor/gui/src/common/FilterHelpButton.tsx @@ -87,7 +87,7 @@ export const FilterHelpButton = function () { with one of{' '} @attribute, @boundary, @calledAfter, @constant, @description, @enum, @group, - @move, @optional, @pure, @remove, @renaming, @required + @move, @optional, @pure, @remove, @renaming, @required, @todo . diff --git a/api-editor/gui/src/features/annotatedPackageData/model/AnnotatedPythonPackageBuilder.ts b/api-editor/gui/src/features/annotatedPackageData/model/AnnotatedPythonPackageBuilder.ts index 2cc8ba5f8..2213195ea 100644 --- a/api-editor/gui/src/features/annotatedPackageData/model/AnnotatedPythonPackageBuilder.ts +++ b/api-editor/gui/src/features/annotatedPackageData/model/AnnotatedPythonPackageBuilder.ts @@ -26,6 +26,7 @@ import { InferableRenameAnnotation, InferableRequiredAnnotation, InferableDescriptionAnnotation, + InferableTodoAnnotation, } from './InferableAnnotation'; export class AnnotatedPythonPackageBuilder { @@ -150,6 +151,7 @@ export class AnnotatedPythonPackageBuilder { 'Remove', 'Rename', 'Required', + 'Todo', ]; #getExistingAnnotations(target: string): InferableAnnotation[] { @@ -250,6 +252,12 @@ export class AnnotatedPythonPackageBuilder { return new InferableRequiredAnnotation(); } break; + case 'Todo': + const todoAnnotation = this.annotationStore.todos[target]; + if (todoAnnotation) { + return new InferableTodoAnnotation(todoAnnotation); + } + break; } return undefined; } diff --git a/api-editor/gui/src/features/annotatedPackageData/model/InferableAnnotation.ts b/api-editor/gui/src/features/annotatedPackageData/model/InferableAnnotation.ts index c9d88a652..57ee201e5 100644 --- a/api-editor/gui/src/features/annotatedPackageData/model/InferableAnnotation.ts +++ b/api-editor/gui/src/features/annotatedPackageData/model/InferableAnnotation.ts @@ -13,6 +13,7 @@ import { MoveAnnotation, OptionalAnnotation, RenameAnnotation, + TodoAnnotation, } from '../../annotations/annotationSlice'; const dataPathPrefix = 'com.larsreimann.api_editor.model.'; @@ -168,3 +169,12 @@ export class InferableRemoveAnnotation extends InferableAnnotation { super(dataPathPrefix + 'RemoveAnnotation'); } } + +export class InferableTodoAnnotation extends InferableAnnotation { + readonly message: string; + + constructor(todoAnnotation: TodoAnnotation) { + super(dataPathPrefix + 'TodoAnnotation'); + this.message = todoAnnotation.newTodo; + } +} diff --git a/api-editor/gui/src/features/annotations/AnnotationDropdown.tsx b/api-editor/gui/src/features/annotations/AnnotationDropdown.tsx index 0de717de0..69d0a0d1c 100644 --- a/api-editor/gui/src/features/annotations/AnnotationDropdown.tsx +++ b/api-editor/gui/src/features/annotations/AnnotationDropdown.tsx @@ -14,6 +14,7 @@ import { showOptionalAnnotationForm, showRenameAnnotationForm, showDescriptionAnnotationForm, + showTodoAnnotationForm, } from '../ui/uiSlice'; interface AnnotationDropdownProps { @@ -30,6 +31,7 @@ interface AnnotationDropdownProps { showRename?: boolean; showRequired?: boolean; showRemove?: boolean; + showTodo?: boolean; target: string; } @@ -47,6 +49,7 @@ export const AnnotationDropdown: React.FC = function ({ showRename = false, showRequired = false, showRemove = false, + showTodo = false, target, }) { const dispatch = useAppDispatch(); @@ -112,6 +115,7 @@ export const AnnotationDropdown: React.FC = function ({ dispatch(showRenameAnnotationForm(target))}>@rename )} {showRequired && dispatch(addRequired({ target }))}>@required} + {showTodo && dispatch(showTodoAnnotationForm(target))}>@todo} diff --git a/api-editor/gui/src/features/annotations/AnnotationImportDialog.tsx b/api-editor/gui/src/features/annotations/AnnotationImportDialog.tsx index 3c5ef152e..dc20584cb 100644 --- a/api-editor/gui/src/features/annotations/AnnotationImportDialog.tsx +++ b/api-editor/gui/src/features/annotations/AnnotationImportDialog.tsx @@ -36,6 +36,7 @@ export const AnnotationImportDialog: React.FC = function () { renamings: {}, requireds: {}, removes: {}, + todos: {}, }); const dispatch = useAppDispatch(); diff --git a/api-editor/gui/src/features/annotations/AnnotationView.tsx b/api-editor/gui/src/features/annotations/AnnotationView.tsx index 63ffa4fb1..9d1dab2e7 100644 --- a/api-editor/gui/src/features/annotations/AnnotationView.tsx +++ b/api-editor/gui/src/features/annotations/AnnotationView.tsx @@ -20,6 +20,7 @@ import { removeRenaming, removeRequired, removeRemove, + removeTodo, selectAttribute, selectBoundary, selectCalledAfters, @@ -33,6 +34,7 @@ import { selectRenaming, selectRequired, selectRemove, + selectTodo, } from './annotationSlice'; import { showAttributeAnnotationForm, @@ -44,6 +46,7 @@ import { showMoveAnnotationForm, showOptionalAnnotationForm, showRenameAnnotationForm, + showTodoAnnotationForm, } from '../ui/uiSlice'; interface AnnotationViewProps { @@ -66,6 +69,7 @@ export const AnnotationView: React.FC = function ({ target const removeAnnotation = useAppSelector(selectRemove(target)); const renameAnnotation = useAppSelector(selectRenaming(target)); const requiredAnnotation = useAppSelector(selectRequired(target)); + const todoAnnotation = useAppSelector(selectTodo(target)); if ( !attributeAnnotation && @@ -80,7 +84,8 @@ export const AnnotationView: React.FC = function ({ target !pureAnnotation && !removeAnnotation && !renameAnnotation && - !requiredAnnotation + !requiredAnnotation && + !todoAnnotation ) { // eslint-disable-next-line react/jsx-no-useless-fragment return <>; @@ -171,6 +176,13 @@ export const AnnotationView: React.FC = function ({ target /> )} {requiredAnnotation && dispatch(removeRequired(target))} />} + {todoAnnotation && ( + dispatch(showTodoAnnotationForm(target))} + onDelete={() => dispatch(removeTodo(target))} + /> + )} ); }; diff --git a/api-editor/gui/src/features/annotations/annotationSlice.ts b/api-editor/gui/src/features/annotations/annotationSlice.ts index f863d45d2..8873503c3 100644 --- a/api-editor/gui/src/features/annotations/annotationSlice.ts +++ b/api-editor/gui/src/features/annotations/annotationSlice.ts @@ -42,6 +42,9 @@ export interface AnnotationStore { removes: { [target: string]: RemoveAnnotation; }; + todos: { + [target: string]: TodoAnnotation; + }; } export interface AttributeAnnotation { @@ -271,6 +274,18 @@ export interface RemoveAnnotation { readonly target: string; } +export interface TodoAnnotation { + /** + * ID of the annotated Python declaration. + */ + readonly target: string; + + /** + * A Todo for the declaration. + */ + readonly newTodo: string; +} + // Initial state ------------------------------------------------------------------------------------------------------- export const initialState: AnnotationStore = { @@ -287,6 +302,7 @@ export const initialState: AnnotationStore = { renamings: {}, requireds: {}, removes: {}, + todos: {}, }; // Thunks -------------------------------------------------------------------------------------------------------------- @@ -447,6 +463,12 @@ const annotationsSlice = createSlice({ removeRemove(state, action: PayloadAction) { delete state.removes[action.payload]; }, + upsertTodo(state, action: PayloadAction) { + state.todos[action.payload.target] = action.payload; + }, + removeTodo(state, action: PayloadAction) { + delete state.todos[action.payload]; + }, }, extraReducers(builder) { builder.addCase(initializeAnnotations.fulfilled, (state, action) => action.payload); @@ -482,6 +504,8 @@ export const { removeRenaming, addRequired, removeRequired, + upsertTodo, + removeTodo, addRemove, removeRemove, } = actions; @@ -540,3 +564,7 @@ export const selectRemove = (target: string) => (state: RootState): RemoveAnnotation | undefined => selectAnnotations(state).removes[target]; +export const selectTodo = + (target: string) => + (state: RootState): TodoAnnotation | undefined => + selectAnnotations(state).todos[target]; diff --git a/api-editor/gui/src/features/annotations/forms/TodoForm.tsx b/api-editor/gui/src/features/annotations/forms/TodoForm.tsx new file mode 100644 index 000000000..ee44484a3 --- /dev/null +++ b/api-editor/gui/src/features/annotations/forms/TodoForm.tsx @@ -0,0 +1,88 @@ +import { FormControl, FormErrorIcon, FormErrorMessage, FormLabel, Textarea } from '@chakra-ui/react'; +import React, { useEffect } from 'react'; +import { useForm } from 'react-hook-form'; +import { useAppDispatch, useAppSelector } from '../../../app/hooks'; +import { PythonDeclaration } from '../../packageData/model/PythonDeclaration'; +import { selectTodo, upsertTodo } from '../annotationSlice'; +import { AnnotationForm } from './AnnotationForm'; +import { hideAnnotationForm } from '../../ui/uiSlice'; + +interface TodoFormProps { + readonly target: PythonDeclaration; +} + +interface TodoFormState { + newTodo: string; +} + +export const TodoForm: React.FC = function ({ target }) { + const targetPath = target.pathAsString(); + const prevNewTodo = useAppSelector(selectTodo(targetPath))?.newTodo; + + // Hooks ----------------------------------------------------------------------------------------------------------- + + const dispatch = useAppDispatch(); + const { + register, + handleSubmit, + setFocus, + reset, + formState: { errors }, + } = useForm({ + defaultValues: { + newTodo: '', + }, + }); + + useEffect(() => { + try { + setFocus('newTodo'); + } catch (e) { + // ignore + } + }, [setFocus]); + + useEffect(() => { + reset({ + newTodo: prevNewTodo ?? '', + }); + }, [reset, prevNewTodo]); + + // Event handlers -------------------------------------------------------------------------------------------------- + + const onSave = (data: TodoFormState) => { + dispatch( + upsertTodo({ + target: targetPath, + ...data, + }), + ); + dispatch(hideAnnotationForm()); + }; + + const onCancel = () => { + dispatch(hideAnnotationForm()); + }; + + // Rendering ------------------------------------------------------------------------------------------------------- + + return ( + + + New todo for "{target.name}": +