diff --git a/skate-plugin/build.gradle.kts b/skate-plugin/build.gradle.kts index 17932a2b9..236c66981 100644 --- a/skate-plugin/build.gradle.kts +++ b/skate-plugin/build.gradle.kts @@ -98,6 +98,11 @@ buildConfig { } } +tasks.withType().configureEach { + kotlinOptions { + freeCompilerArgs += "-opt-in=kotlin.RequiresOptIn" + } +} configure { val kotlinVersion = libs.versions.kotlin.get() // Flag to disable Compose's kotlin version check because they're often behind diff --git a/skate-plugin/src/main/kotlin/com/slack/sgp/intellij/filetemplate/CreateCircuitFeature.kt b/skate-plugin/src/main/kotlin/com/slack/sgp/intellij/filetemplate/CreateCircuitFeature.kt new file mode 100644 index 000000000..666f0bbd0 --- /dev/null +++ b/skate-plugin/src/main/kotlin/com/slack/sgp/intellij/filetemplate/CreateCircuitFeature.kt @@ -0,0 +1,18 @@ +package com.slack.sgp.intellij.filetemplate + +import com.intellij.ide.actions.CreateFileFromTemplateDialog +import com.intellij.openapi.project.DumbAware +import com.intellij.openapi.project.Project +import com.intellij.psi.PsiDirectory +import org.jetbrains.kotlin.idea.KotlinFileType + +class CreateCircuitFeature : CustomCreateFileAction( "New Circuit Feature", "Creates new Circuit Feature", KotlinFileType.INSTANCE.icon), DumbAware { + override fun buildDialog(project: Project, directory: PsiDirectory, builder: CreateFileFromTemplateDialog.Builder) { + builder.setTitle("New Circuit Feature Name") + .addKind("Presenter + Compose UI", KotlinFileType.INSTANCE.icon, "Circuit Presenter and Compose UI") + .addKind("Presenter only", KotlinFileType.INSTANCE.icon, "Circuit Presenter (without UI)") + } + + override fun getActionName(directory: PsiDirectory, newName: String, templateName: String) = "Circuit feature" +} + diff --git a/skate-plugin/src/main/kotlin/com/slack/sgp/intellij/filetemplate/CustomCreateFileAction.kt b/skate-plugin/src/main/kotlin/com/slack/sgp/intellij/filetemplate/CustomCreateFileAction.kt new file mode 100644 index 000000000..a749cdd78 --- /dev/null +++ b/skate-plugin/src/main/kotlin/com/slack/sgp/intellij/filetemplate/CustomCreateFileAction.kt @@ -0,0 +1,74 @@ +package com.slack.sgp.intellij.filetemplate + +import com.intellij.codeInsight.template.impl.TemplateSettings +import com.intellij.ide.actions.CreateFileFromTemplateAction +import com.intellij.ide.fileTemplates.FileTemplate +import com.intellij.ide.fileTemplates.FileTemplateManager +import com.intellij.ide.fileTemplates.FileTemplateUtil +import com.intellij.openapi.diagnostic.logger +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.LocalFileSystem +import com.intellij.psi.PsiDirectory +import com.intellij.psi.PsiFile +import com.intellij.psi.PsiManager +import com.slack.sgp.intellij.filetemplate.model.SettingsParser +import com.slack.sgp.intellij.filetemplate.model.TemplateSetting +import org.jetbrains.kotlin.idea.gradleTooling.get +import java.io.File +import java.io.FileNotFoundException +import java.util.* +import javax.swing.Icon + +abstract class CustomCreateFileAction(title: String, desc: String, icon: Icon) : CreateFileFromTemplateAction(title, desc, icon) { + + public override fun createFileFromTemplate(name: String, template: FileTemplate, dir: PsiDirectory): PsiFile? { + val templateSettings = loadTemplateSettings() + logger().info("LINHH") + logger().info(templateSettings.toString()) + val properties = FileTemplateManager.getInstance(dir.project).defaultProperties.apply { + setProperty("NAME", name) + } + val templates = listOf(template) + getTemplateChildren(dir.project, template) + + val createdFiles = mutableListOf() + templates.forEach { fileTemplate -> + createdFiles.add(createFileForTemplate(fileTemplate, name, dir, properties, templateSettings)) + } + return createdFiles.firstOrNull() + } + + private fun createFileForTemplate( + template: FileTemplate, + name: String, + dir: PsiDirectory, + properties: Properties, + templateSettings: Map + ): PsiFile? { + val suffix = templateSettings[template.name]?.fileNameSuffix ?: "" + val targetDir = getTargetDirectory(dir, suffix) + return FileTemplateUtil.createFromTemplate(template, name + suffix, properties, targetDir).containingFile + } + + private fun getTemplateChildren(project: Project, template: FileTemplate): List { + val allTemplate = FileTemplateManager.getInstance(project).allJ2eeTemplates + val filtered = allTemplate.filter { it.name.contains("child") && it.name.contains(template.name) } + return filtered + + } + + private fun loadTemplateSettings(): Map { + val stream = this.javaClass.classLoader.getResourceAsStream("fileTemplateSettings.yaml") + ?: throw FileNotFoundException("File template settings file not found") + return SettingsParser(stream).getTemplates() + } + + private fun getTargetDirectory(dir: PsiDirectory, suffix: String): PsiDirectory { + if (suffix.contains("Test") && !dir.virtualFile.path.contains("test")) { + val testPath = File(dir.virtualFile.path.replace("src/main", "src/test")) + testPath.mkdirs() + val testFolder = LocalFileSystem.getInstance().refreshAndFindFileByIoFile(testPath) + return testFolder?.let { PsiManager.getInstance(dir.project).findDirectory(it) } as PsiDirectory + } + return dir + } +} \ No newline at end of file diff --git a/skate-plugin/src/main/kotlin/com/slack/sgp/intellij/filetemplate/UdfViewModelMigrate.kt b/skate-plugin/src/main/kotlin/com/slack/sgp/intellij/filetemplate/UdfViewModelMigrate.kt new file mode 100644 index 000000000..866a361d5 --- /dev/null +++ b/skate-plugin/src/main/kotlin/com/slack/sgp/intellij/filetemplate/UdfViewModelMigrate.kt @@ -0,0 +1,17 @@ +package com.slack.sgp.intellij.filetemplate + +import com.intellij.ide.actions.CreateFileFromTemplateDialog +import com.intellij.openapi.project.DumbAware +import com.intellij.openapi.project.Project +import com.intellij.psi.PsiDirectory +import org.jetbrains.kotlin.idea.KotlinFileType + +class UdfViewModelMigrate : CustomCreateFileAction( "UDF ViewModel Convert", "Convert to UDF ViewModel", KotlinFileType.INSTANCE.icon) , + DumbAware { + override fun buildDialog(project: Project, directory: PsiDirectory, builder: CreateFileFromTemplateDialog.Builder) { + builder.setTitle("UDF ViewModel Convert") + .addKind("UdfViewModel conversion", KotlinFileType.INSTANCE.icon, "UdfViewModel convert") + } + + override fun getActionName(p0: PsiDirectory?, p1: String, p2: String?): String = "UDF ViewModel Convert" +} \ No newline at end of file diff --git a/skate-plugin/src/main/kotlin/com/slack/sgp/intellij/filetemplate/model/FileTemplateSettings.kt b/skate-plugin/src/main/kotlin/com/slack/sgp/intellij/filetemplate/model/FileTemplateSettings.kt new file mode 100644 index 000000000..b2e62f6de --- /dev/null +++ b/skate-plugin/src/main/kotlin/com/slack/sgp/intellij/filetemplate/model/FileTemplateSettings.kt @@ -0,0 +1,9 @@ +package com.slack.sgp.intellij.filetemplate.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class FileTemplateSettings( + @SerialName("templates") val templates: List, +) diff --git a/skate-plugin/src/main/kotlin/com/slack/sgp/intellij/filetemplate/model/SettingsParser.kt b/skate-plugin/src/main/kotlin/com/slack/sgp/intellij/filetemplate/model/SettingsParser.kt new file mode 100644 index 000000000..7a234caee --- /dev/null +++ b/skate-plugin/src/main/kotlin/com/slack/sgp/intellij/filetemplate/model/SettingsParser.kt @@ -0,0 +1,23 @@ +package com.slack.sgp.intellij.filetemplate.model + +import com.charleskorn.kaml.Yaml +import com.charleskorn.kaml.YamlConfiguration +import com.charleskorn.kaml.decodeFromStream +import java.io.InputStream + + +class SettingsParser(inputStream: InputStream) { + private var templates: Map? = null + + init { + parseFileTemplateFromSettingFile(inputStream) + } + private fun parseFileTemplateFromSettingFile(inputStream: InputStream) { + val table = Yaml(configuration = YamlConfiguration(strictMode = false)).decodeFromStream(inputStream) + templates = table.templates.associateBy { it.name } + } + + fun getTemplates(): Map { + return templates ?: throw IllegalStateException("Templates not loaded properly") + } +} \ No newline at end of file diff --git a/skate-plugin/src/main/kotlin/com/slack/sgp/intellij/filetemplate/model/TemplateSetting.kt b/skate-plugin/src/main/kotlin/com/slack/sgp/intellij/filetemplate/model/TemplateSetting.kt new file mode 100644 index 000000000..306b62624 --- /dev/null +++ b/skate-plugin/src/main/kotlin/com/slack/sgp/intellij/filetemplate/model/TemplateSetting.kt @@ -0,0 +1,7 @@ +package com.slack.sgp.intellij.filetemplate.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class TemplateSetting(val name: String, @SerialName("file_name_suffix") val fileNameSuffix: String) diff --git a/skate-plugin/src/main/kotlin/com/slack/sgp/intellij/projectgen/ProjectGenPresenter.kt b/skate-plugin/src/main/kotlin/com/slack/sgp/intellij/projectgen/ProjectGenPresenter.kt index 89d908c95..74d4dbaa4 100644 --- a/skate-plugin/src/main/kotlin/com/slack/sgp/intellij/projectgen/ProjectGenPresenter.kt +++ b/skate-plugin/src/main/kotlin/com/slack/sgp/intellij/projectgen/ProjectGenPresenter.kt @@ -15,12 +15,14 @@ */ package com.slack.sgp.intellij.projectgen +import androidx.compose.material.DropdownMenu import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import com.intellij.openapi.diagnostic.logger import com.slack.circuit.runtime.presenter.Presenter import java.io.File import org.apache.commons.io.FileExistsException @@ -105,6 +107,26 @@ internal class ProjectGenPresenter( ) private val circuit = CheckboxElement(false, name = "Circuit", hint = "Enables Circuit") + private val circuitFeatureName = + TextElement( + "", + "Circuit Feature Name (optional)", + description = + "", + indentLevel = 3, + initialVisibility = false, + dependentElements = listOf(circuit), + ) + + private val circuitFeatureTemplate = + DropdownElement( + initialVisibility = false, + label = "Using templates:", + indentLevel = 3, + selectedText = "Circuit Presenter and Compose UI", + dropdownList = listOf("Circuit Presenter and Compose UI", "Circuit Presenter (without UI)") + ) + private val compose = CheckboxElement(false, name = "Compose", hint = "Enables Jetpack Compose.") @@ -126,6 +148,8 @@ internal class ProjectGenPresenter( daggerRuntimeOnly, compose, circuit, + circuitFeatureName, + circuitFeatureTemplate ) @Composable @@ -137,6 +161,8 @@ internal class ProjectGenPresenter( robolectric.isVisible = android.isChecked androidTest.isVisible = android.isChecked daggerRuntimeOnly.isVisible = dagger.isChecked + circuitFeatureName.isVisible = circuit.isChecked + circuitFeatureTemplate.isVisible = circuit.isChecked && circuitFeatureName.value.isNotBlank() var showDoneDialog by remember { mutableStateOf(false) } var showErrorDialog by remember { mutableStateOf(false) } @@ -180,6 +206,8 @@ internal class ProjectGenPresenter( robolectric.reset() compose.reset() circuit.reset() + circuitFeatureTemplate.reset() + circuitFeatureName.reset() } private fun generate() { @@ -206,6 +234,8 @@ internal class ProjectGenPresenter( compose = compose.isChecked, androidTest = androidTest.isChecked, circuit = circuit.isChecked, + circuitFeatureName = circuitFeatureName.value, + circuitFeatureTemplate = circuitFeatureTemplate.selectedText ) } @@ -223,6 +253,8 @@ internal class ProjectGenPresenter( compose: Boolean, androidTest: Boolean, circuit: Boolean, + circuitFeatureName: String, + circuitFeatureTemplate: String, ) { val features = mutableListOf() val androidLibraryEnabled = @@ -252,7 +284,10 @@ internal class ProjectGenPresenter( } if (circuit) { - features += CircuitFeature + features += CircuitFeature(packageName, circuitFeatureName, circuitFeatureTemplate) + logger().info("HERERE") + logger().info(circuitFeatureName) + logger().info(circuitFeatureTemplate) } val buildFile = BuildFile(emptyList()) diff --git a/skate-plugin/src/main/kotlin/com/slack/sgp/intellij/projectgen/ProjectGenUi.kt b/skate-plugin/src/main/kotlin/com/slack/sgp/intellij/projectgen/ProjectGenUi.kt index 1b62b486b..9e9f22c0f 100644 --- a/skate-plugin/src/main/kotlin/com/slack/sgp/intellij/projectgen/ProjectGenUi.kt +++ b/skate-plugin/src/main/kotlin/com/slack/sgp/intellij/projectgen/ProjectGenUi.kt @@ -33,20 +33,18 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollbarAdapter import androidx.compose.foundation.verticalScroll import androidx.compose.material.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.Checkbox -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.LocalContentColor -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.TextField -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.material.DropdownMenu +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material3.* +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import com.intellij.openapi.diagnostic.logger +import slack.tooling.projectgen.* import slack.tooling.projectgen.CheckboxElement import slack.tooling.projectgen.DividerElement import slack.tooling.projectgen.SectionElement @@ -55,6 +53,7 @@ import slack.tooling.projectgen.TextElement private const val INDENT_SIZE = 16 // dp // @OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class) @Composable internal fun ProjectGen(state: ProjectGenScreen.State, modifier: Modifier = Modifier) { if (state.showDoneDialog) { @@ -122,6 +121,39 @@ internal fun ProjectGen(state: ProjectGenScreen.State, modifier: Modifier = Modi element.description?.let { Text(it, style = MaterialTheme.typography.bodySmall) } } } + is DropdownElement -> { + var expanded by remember { mutableStateOf(false) } + Box(Modifier.padding(start = (element.indentLevel * INDENT_SIZE).dp)) { + IconButton(onClick = { expanded = false }) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = "More" + ) + } + + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = !expanded }, + ) { + + logger().info("LINHHHH") + TextField( + value = element.selectedText, + label = { Text(element.label) }, + onValueChange = {}, + readOnly = true, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + modifier = Modifier.menuAnchor() + ) + + ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + element.dropdownList.forEach { item -> + DropdownMenuItem(text = {Text(item)}, onClick = { expanded = false; element.selectedText = item }) + } + } + } + } + } } } Button( diff --git a/skate-plugin/src/main/kotlin/com/slack/sgp/intellij/projectgen/UiElement.kt b/skate-plugin/src/main/kotlin/com/slack/sgp/intellij/projectgen/UiElement.kt index 6f9edb919..60c9d82fd 100644 --- a/skate-plugin/src/main/kotlin/com/slack/sgp/intellij/projectgen/UiElement.kt +++ b/skate-plugin/src/main/kotlin/com/slack/sgp/intellij/projectgen/UiElement.kt @@ -21,6 +21,8 @@ import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import com.intellij.openapi.diagnostic.logger +import com.slack.sgp.intellij.projectgen.ProjectGenPresenter @Stable internal sealed interface UiElement { @@ -55,6 +57,20 @@ internal class CheckboxElement( override var isVisible: Boolean by mutableStateOf(isVisible) } +internal class DropdownElement( + val initialVisibility: Boolean, + val label: String, + val indentLevel: Int = 0, + val dropdownList: List, + var selectedText: String, +) : UiElement { + override var isVisible: Boolean by mutableStateOf(initialVisibility) + override fun reset() { + isVisible = initialVisibility + } + +} + @Suppress("LongParameterList") internal class TextElement( private val initialValue: String, diff --git a/skate-plugin/src/main/kotlin/com/slack/sgp/intellij/projectgen/models.kt b/skate-plugin/src/main/kotlin/com/slack/sgp/intellij/projectgen/models.kt index 0a66a5c83..b9df46316 100644 --- a/skate-plugin/src/main/kotlin/com/slack/sgp/intellij/projectgen/models.kt +++ b/skate-plugin/src/main/kotlin/com/slack/sgp/intellij/projectgen/models.kt @@ -15,6 +15,12 @@ */ package slack.tooling.projectgen +import com.intellij.ide.fileTemplates.FileTemplateManager +import com.intellij.openapi.project.ProjectManager +import com.intellij.openapi.vfs.LocalFileSystem +import com.intellij.psi.PsiDirectory +import com.intellij.psi.PsiManager +import com.slack.sgp.intellij.filetemplate.CreateCircuitFeature import com.squareup.kotlinpoet.FileSpec import java.io.File @@ -289,10 +295,26 @@ internal object ComposeFeature : Feature, SlackFeatureVisitor { } } -internal object CircuitFeature : Feature, SlackFeatureVisitor { +internal class CircuitFeature(val packageName: String, val circuitFeatureName: String, val templateName: String) : Feature, + SlackFeatureVisitor { override fun writeToSlackFeatures(builder: FileSpec.Builder) { builder.addStatement("circuit()") } + + override fun renderFiles(projectDir: File) { + if (circuitFeatureName.isBlank()) return + val project = ProjectManager.getInstance().openProjects.first() + val allTemplate = FileTemplateManager.getInstance(project).allJ2eeTemplates + val template = allTemplate.find { it.name == templateName } + val mainSrcDir = + projectDir.resolve("src/main/kotlin/${packageName.replace(".", "/")}").apply { mkdirs() } + val testFolder = LocalFileSystem.getInstance().refreshAndFindFileByIoFile(mainSrcDir) + val psiDir = testFolder?.let { PsiManager.getInstance(project).findDirectory(it) } as PsiDirectory + if (template != null) { + FileTemplateManager.getInstance(project).defaultProperties.setProperty("PACKAGE_NAME", packageName) + CreateCircuitFeature().createFileFromTemplate(circuitFeatureName, template, psiDir) + } + } } internal class ReadMeFile { diff --git a/skate-plugin/src/main/resources/META-INF/plugin.xml b/skate-plugin/src/main/resources/META-INF/plugin.xml index da4cf4c1c..62c6da6db 100644 --- a/skate-plugin/src/main/resources/META-INF/plugin.xml +++ b/skate-plugin/src/main/resources/META-INF/plugin.xml @@ -49,12 +49,22 @@ - - - + + + + + + + + + + + + + \ No newline at end of file diff --git a/skate-plugin/src/main/resources/fileTemplateSettings.yaml b/skate-plugin/src/main/resources/fileTemplateSettings.yaml new file mode 100644 index 000000000..bb588c88a --- /dev/null +++ b/skate-plugin/src/main/resources/fileTemplateSettings.yaml @@ -0,0 +1,30 @@ +templates: + - name: Circuit Presenter and Compose UI + file_name_suffix: Screen + + - name: Circuit Presenter and Compose UI.child.0 + file_name_suffix: PresenterTest + + - name: Circuit Presenter and Compose UI.child.1 + file_name_suffix: UiTest + + - name: Circuit Presenter and Compose UI.child.2 + file_name_suffix: Presenter + + - name: Circuit Presenter and Compose UI.child.3 + file_name_suffix: Ui + + - name: Circuit Presenter (without UI) + file_name_suffix: Screen + + - name: Circuit Presenter (without UI).child.0 + file_name_suffix: PresenterTest + + - name: Circuit Presenter (without UI).child.2 + file_name_suffix: Presenter + + - name: UdfViewModel convert + file_name_suffix: Screen + + - name: UdfViewModel convert.child.0 + file_name_suffix: ViewModel \ No newline at end of file diff --git a/skate-plugin/src/main/resources/fileTemplates/j2ee/Circuit Presenter (without UI).child.0.kt.ft b/skate-plugin/src/main/resources/fileTemplates/j2ee/Circuit Presenter (without UI).child.0.kt.ft new file mode 100644 index 000000000..5310ff82f --- /dev/null +++ b/skate-plugin/src/main/resources/fileTemplates/j2ee/Circuit Presenter (without UI).child.0.kt.ft @@ -0,0 +1,42 @@ +#if (${PACKAGE_NAME} && ${PACKAGE_NAME} != "")package ${PACKAGE_NAME} + +#end +#parse("File Header.java") +import com.slack.circuit.test.FakeNavigator +import com.slack.circuit.test.test +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import slack.foundation.coroutines.test.CoroutinesRule +import slack.test.SlackJvmTest + +class ${NAME}PresenterTest : SlackJvmTest() { + @get:Rule + val coroutineRule = CoroutinesRule() + + private val screen = ${NAME}Screen() + private val navigator = FakeNavigator() + + private val presenter = ${NAME}Presenter( + screen = screen, + navigator = navigator + ) + + @Test + fun `User taps text - tap counter is incremented`() = runTest { + presenter.test { + // Initial state + var state = awaitItem() + + assertEquals(state.message, "0") + + state.eventSink(${NAME}Screen.Event.UserTappedText) + state = awaitItem() + + assertEquals(state.message, "1") + + cancelAndIgnoreRemainingEvents() + } + } +} diff --git a/skate-plugin/src/main/resources/fileTemplates/j2ee/Circuit Presenter (without UI).child.2.kt.ft b/skate-plugin/src/main/resources/fileTemplates/j2ee/Circuit Presenter (without UI).child.2.kt.ft new file mode 100644 index 000000000..ded0d2d27 --- /dev/null +++ b/skate-plugin/src/main/resources/fileTemplates/j2ee/Circuit Presenter (without UI).child.2.kt.ft @@ -0,0 +1,47 @@ +#if (${PACKAGE_NAME} && ${PACKAGE_NAME} != "")package ${PACKAGE_NAME} + +#end +#parse("File Header.java") +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import com.slack.circuit.runtime.Navigator +import com.slack.circuit.runtime.presenter.Presenter +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import slack.di.UserScope +import slack.libraries.foundation.compose.rememberStableCoroutineScope + +/** + * TODO (remove): This Circuit [Presenter] was generated without UI or [CircuitInject], and can be + * used with legacy views via `by circuitState()` in your Fragment or Activity. + */ +class ${NAME}Presenter +@AssistedInject +constructor( + @Assisted private val screen: ${NAME}Screen, + @Assisted private val navigator: Navigator, +) : Presenter<${NAME}Screen.State> { + + @Composable + override fun present(): ${NAME}Screen.State { + val scope = rememberStableCoroutineScope() + var tapCounter by rememberSaveable { mutableIntStateOf(0) } + + return ${NAME}Screen.State( + message = tapCounter.toString() + ) { event -> + when (event) { + is ${NAME}Screen.Event.UserTappedText -> tapCounter++ + } + } + } + + @AssistedFactory + interface Factory { + fun create(screen: ${NAME}Screen, navigator: Navigator): ${NAME}Presenter + } +} \ No newline at end of file diff --git a/skate-plugin/src/main/resources/fileTemplates/j2ee/Circuit Presenter (without UI).kt.ft b/skate-plugin/src/main/resources/fileTemplates/j2ee/Circuit Presenter (without UI).kt.ft new file mode 100644 index 000000000..1fb9e8996 --- /dev/null +++ b/skate-plugin/src/main/resources/fileTemplates/j2ee/Circuit Presenter (without UI).kt.ft @@ -0,0 +1,23 @@ +#if (${PACKAGE_NAME} && ${PACKAGE_NAME} != "")package ${PACKAGE_NAME} + +#end +#parse("File Header.java") +import androidx.compose.runtime.Immutable +import com.slack.circuit.runtime.CircuitUiEvent +import com.slack.circuit.runtime.CircuitUiState +import com.slack.circuit.runtime.screen.Screen +import kotlinx.parcelize.Parcelize + +@Parcelize +class ${NAME}Screen : Screen { + + data class State( + val message: String = "", + val eventSink: (Event) -> Unit = {} + ) : CircuitUiState + + @Immutable + sealed interface Event : CircuitUiEvent { + data object UserTappedText : Event + } +} diff --git a/skate-plugin/src/main/resources/fileTemplates/j2ee/Circuit Presenter and Compose UI.child.0.kt.ft b/skate-plugin/src/main/resources/fileTemplates/j2ee/Circuit Presenter and Compose UI.child.0.kt.ft new file mode 100644 index 000000000..bec9af5cb --- /dev/null +++ b/skate-plugin/src/main/resources/fileTemplates/j2ee/Circuit Presenter and Compose UI.child.0.kt.ft @@ -0,0 +1,42 @@ +#if (${PACKAGE_NAME} && ${PACKAGE_NAME} != "")package ${PACKAGE_NAME} + +#end +#parse("File Header.java") +import com.slack.circuit.test.FakeNavigator +import com.slack.circuit.test.test +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import slack.foundation.coroutines.test.CoroutinesRule +import slack.test.SlackJvmTest + +class ${NAME}PresenterTest : SlackJvmTest() { + @get:Rule + val coroutineRule = CoroutinesRule() + + private val screen = ${NAME}Screen() + private val navigator = FakeNavigator(screen) + + private val presenter = ${NAME}Presenter( + screen = screen, + navigator = navigator + ) + + @Test + fun `User taps text - tap counter is incremented`() = runTest { + presenter.test { + // Initial state + var state = awaitItem() + + assertEquals(state.message, "0") + + state.eventSink(${NAME}Screen.Event.UserTappedText) + state = awaitItem() + + assertEquals(state.message, "1") + + cancelAndIgnoreRemainingEvents() + } + } +} diff --git a/skate-plugin/src/main/resources/fileTemplates/j2ee/Circuit Presenter and Compose UI.child.1.kt.ft b/skate-plugin/src/main/resources/fileTemplates/j2ee/Circuit Presenter and Compose UI.child.1.kt.ft new file mode 100644 index 000000000..d0a340952 --- /dev/null +++ b/skate-plugin/src/main/resources/fileTemplates/j2ee/Circuit Presenter and Compose UI.child.1.kt.ft @@ -0,0 +1,44 @@ +#if (${PACKAGE_NAME} && ${PACKAGE_NAME} != "")package ${PACKAGE_NAME} + +#end +#parse("File Header.java") +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasClickAction +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import kotlin.test.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import slack.test.android.SlackAndroidJvmTest + +class ${NAME}UiTest : SlackAndroidJvmTest() { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun `Clicking text emits tap event`() { + with(composeTestRule) { + var lastEvent: ${NAME}Screen.Event? = null + val uiState = ${NAME}Screen.State(message = "Test message") { + lastEvent = it + } + + setContent { + ${NAME}(state = uiState) + } + + onNodeWithText("Test message").assertIsDisplayed() + onNode( + hasText("Test message") + .and(hasClickAction()) + ) + .performClick() + + assertEquals(${NAME}Screen.Event.UserTappedText, lastEvent) + } + } +} diff --git a/skate-plugin/src/main/resources/fileTemplates/j2ee/Circuit Presenter and Compose UI.child.2.kt.ft b/skate-plugin/src/main/resources/fileTemplates/j2ee/Circuit Presenter and Compose UI.child.2.kt.ft new file mode 100644 index 000000000..eaeb61843 --- /dev/null +++ b/skate-plugin/src/main/resources/fileTemplates/j2ee/Circuit Presenter and Compose UI.child.2.kt.ft @@ -0,0 +1,45 @@ +#if (${PACKAGE_NAME} && ${PACKAGE_NAME} != "")package ${PACKAGE_NAME} + +#end +#parse("File Header.java") +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import com.slack.circuit.codegen.annotations.CircuitInject +import com.slack.circuit.runtime.Navigator +import com.slack.circuit.runtime.presenter.Presenter +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import slack.di.UserScope +import slack.libraries.foundation.compose.rememberStableCoroutineScope + +class ${NAME}Presenter +@AssistedInject +constructor( + @Assisted private val screen: ${NAME}Screen, + @Assisted private val navigator: Navigator, +) : Presenter<${NAME}Screen.State> { + + @Composable + override fun present(): ${NAME}Screen.State { + val scope = rememberStableCoroutineScope() + var tapCounter by rememberSaveable { mutableIntStateOf(0) } + + return ${NAME}Screen.State( + message = tapCounter.toString() + ) { event -> + when (event) { + is ${NAME}Screen.Event.UserTappedText -> tapCounter++ + } + } + } + + @CircuitInject(${NAME}Screen::class, UserScope::class) + @AssistedFactory + interface Factory { + fun create(screen: ${NAME}Screen, navigator: Navigator): ${NAME}Presenter + } +} \ No newline at end of file diff --git a/skate-plugin/src/main/resources/fileTemplates/j2ee/Circuit Presenter and Compose UI.child.3.kt.ft b/skate-plugin/src/main/resources/fileTemplates/j2ee/Circuit Presenter and Compose UI.child.3.kt.ft new file mode 100644 index 000000000..3d1dab06a --- /dev/null +++ b/skate-plugin/src/main/resources/fileTemplates/j2ee/Circuit Presenter and Compose UI.child.3.kt.ft @@ -0,0 +1,19 @@ +#if (${PACKAGE_NAME} && ${PACKAGE_NAME} != "")package ${PACKAGE_NAME} + +#end +#parse("File Header.java") +import androidx.compose.foundation.clickable +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.slack.circuit.codegen.annotations.CircuitInject +import slack.di.UserScope + +@CircuitInject(${NAME}Screen::class, UserScope::class) +@Composable +fun ${NAME}(state: ${NAME}Screen.State, modifier: Modifier = Modifier) { + Text( + text = state.message, + modifier = modifier.clickable { state.eventSink(${NAME}Screen.Event.UserTappedText) } + ) +} \ No newline at end of file diff --git a/skate-plugin/src/main/resources/fileTemplates/j2ee/Circuit Presenter and Compose UI.kt.ft b/skate-plugin/src/main/resources/fileTemplates/j2ee/Circuit Presenter and Compose UI.kt.ft new file mode 100644 index 000000000..1fb9e8996 --- /dev/null +++ b/skate-plugin/src/main/resources/fileTemplates/j2ee/Circuit Presenter and Compose UI.kt.ft @@ -0,0 +1,23 @@ +#if (${PACKAGE_NAME} && ${PACKAGE_NAME} != "")package ${PACKAGE_NAME} + +#end +#parse("File Header.java") +import androidx.compose.runtime.Immutable +import com.slack.circuit.runtime.CircuitUiEvent +import com.slack.circuit.runtime.CircuitUiState +import com.slack.circuit.runtime.screen.Screen +import kotlinx.parcelize.Parcelize + +@Parcelize +class ${NAME}Screen : Screen { + + data class State( + val message: String = "", + val eventSink: (Event) -> Unit = {} + ) : CircuitUiState + + @Immutable + sealed interface Event : CircuitUiEvent { + data object UserTappedText : Event + } +} diff --git a/skate-plugin/src/main/resources/fileTemplates/j2ee/UdfViewModel convert.child.0.kt.ft b/skate-plugin/src/main/resources/fileTemplates/j2ee/UdfViewModel convert.child.0.kt.ft new file mode 100644 index 000000000..2d89651f2 --- /dev/null +++ b/skate-plugin/src/main/resources/fileTemplates/j2ee/UdfViewModel convert.child.0.kt.ft @@ -0,0 +1,36 @@ +#if (${PACKAGE_NAME} && ${PACKAGE_NAME} != "")package ${PACKAGE_NAME} + +#end +#parse("File Header.java") +import androidx.lifecycle.ViewModel +import com.squareup.anvil.annotations.ContributesMultibinding +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import slack.coreui.di.presenter.ViewModelKey +import slack.coreui.viewmodel.UdfViewModel +import slack.di.UserScope +import slack.foundation.coroutines.CloseableCoroutineScope +import slack.foundation.coroutines.SlackDispatchers + +@ContributesMultibinding(UserScope::class, boundType = ViewModel::class) +@ViewModelKey(${NAME}ViewModel::class) +class ${NAME}ViewModel +@Inject +constructor( + slackDispatchers: SlackDispatchers +) : + UdfViewModel<${NAME}Screen.State>(CloseableCoroutineScope.newMainScope(slackDispatchers)), + ${NAME}Screen.Events { + + private val state = MutableStateFlow(${NAME}Screen.State(events = this)) + private var tapCounter = 0 + + override fun state(): StateFlow<${NAME}Screen.State> = state + + override fun userTappedText() { + tapCounter++ + state.update { it.copy(message = tapCounter.toString()) } + } +} diff --git a/skate-plugin/src/main/resources/fileTemplates/j2ee/UdfViewModel convert.kt.ft b/skate-plugin/src/main/resources/fileTemplates/j2ee/UdfViewModel convert.kt.ft new file mode 100644 index 000000000..ff9931bba --- /dev/null +++ b/skate-plugin/src/main/resources/fileTemplates/j2ee/UdfViewModel convert.kt.ft @@ -0,0 +1,22 @@ +#if (${PACKAGE_NAME} && ${PACKAGE_NAME} != "")package ${PACKAGE_NAME} + +#end +#parse("File Header.java") +import androidx.compose.runtime.Immutable +import com.slack.circuit.runtime.CircuitUiState +import com.slack.circuit.runtime.screen.Screen +import kotlinx.parcelize.Parcelize + +@Parcelize +class ${NAME}Screen : Screen { + + data class State( + val message: String = "", + val events: Events + ) : CircuitUiState + + @Immutable + interface Events { + fun userTappedText() + } +} diff --git a/skate-plugin/src/test/kotlin/com/slack/sgp/intellij/filetemplates/CustomCreateFileFromTemplateTest.kt b/skate-plugin/src/test/kotlin/com/slack/sgp/intellij/filetemplates/CustomCreateFileFromTemplateTest.kt new file mode 100644 index 000000000..a13bdebe7 --- /dev/null +++ b/skate-plugin/src/test/kotlin/com/slack/sgp/intellij/filetemplates/CustomCreateFileFromTemplateTest.kt @@ -0,0 +1,66 @@ +package com.slack.sgp.intellij.filetemplates + +import com.intellij.ide.fileTemplates.impl.CustomFileTemplate +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.slack.sgp.intellij.filetemplate.CreateCircuitFeature + +class CustomCreateFileFromTemplateTest : BasePlatformTestCase() { + private val testTemplate = CustomFileTemplate("test template", "kt").apply { + text = """ + #if (${'$'}{PACKAGE_NAME} && ${'$'}{PACKAGE_NAME} != "")package ${'$'}{PACKAGE_NAME} + + #end + @Parcelize + class ${'$'}{NAME}Screen : Screen { + } + """.trimIndent() + } + + private val circuitTemplate = CustomFileTemplate("Circuit Presenter (without UI)", "kt").apply { + text = """ + #if (${'$'}{PACKAGE_NAME} && ${'$'}{PACKAGE_NAME} != "")package ${'$'}{PACKAGE_NAME} + + #end + @Parcelize + class ${'$'}{NAME}Screen : Screen { + } + """.trimIndent() + } + + fun testClassCreatedFromTemplate() = doTest( + template = testTemplate, + userInput = "MyClass", + expectedFileName = "MyClass.kt", + expectedClassName = "MyClass" + ) + + fun testClassNameCreatedWithSuffix() = doTest( + template = circuitTemplate, + userInput = "MyClass", + expectedFileName = "MyClassScreen.kt", + expectedClassName = "MyClass" + ) + private fun doTest( + template: CustomFileTemplate, + userInput: String, + existentPath: String? = null, + expectedFileName: String, + expectedClassName: String + ) { + if (existentPath != null) { + myFixture.tempDirFixture.findOrCreateDir(existentPath) + } + + val actDir = myFixture.psiManager.findDirectory(myFixture.tempDirFixture.findOrCreateDir("."))!! + val file = CreateCircuitFeature().createFileFromTemplate(userInput, template, actDir)!! + + assertEquals(expectedFileName, file.name) + + val expectedContent = """ + @Parcelize + class ${expectedClassName}Screen : Screen { + } + """.trimIndent() + assertEquals(expectedContent, file.text) + } +} \ No newline at end of file diff --git a/skate-plugin/src/test/kotlin/com/slack/sgp/intellij/filetemplates/FileTemplateSettingsParserTest.kt b/skate-plugin/src/test/kotlin/com/slack/sgp/intellij/filetemplates/FileTemplateSettingsParserTest.kt new file mode 100644 index 000000000..d3ab318a4 --- /dev/null +++ b/skate-plugin/src/test/kotlin/com/slack/sgp/intellij/filetemplates/FileTemplateSettingsParserTest.kt @@ -0,0 +1,27 @@ +package com.slack.sgp.intellij.filetemplates + +import com.charleskorn.kaml.MissingRequiredPropertyException +import com.google.common.truth.Truth.assertThat +import com.intellij.testFramework.UsefulTestCase.assertThrows +import com.slack.sgp.intellij.filetemplate.model.SettingsParser +import org.junit.Test + +class FileTemplateSettingsParserTest { + @Test + fun testSuccessfulParse() { + val templateStream = this.javaClass.classLoader.getResourceAsStream("test_file_templates.yaml") + val templates = templateStream?.let { SettingsParser(it).getTemplates() } + assertThat(templates).isNotNull() + assertThat(templates?.size).isEqualTo(2) + assertThat(templates?.get("Template1")?.fileNameSuffix).isEqualTo("Main") + assertThat(templates?.get("Template2")?.fileNameSuffix).isEqualTo("Test") + } + + @Test + fun testMissingProperties() { + assertThrows(MissingRequiredPropertyException::class.java) { + val templateStream = this.javaClass.classLoader.getResourceAsStream("test_malformed_file_templates_setting.yaml") + templateStream?.let { SettingsParser(it).getTemplates() } + } + } +} \ No newline at end of file diff --git a/skate-plugin/src/test/resources/test_file_templates.yaml b/skate-plugin/src/test/resources/test_file_templates.yaml new file mode 100644 index 000000000..54275ed97 --- /dev/null +++ b/skate-plugin/src/test/resources/test_file_templates.yaml @@ -0,0 +1,7 @@ +templates: + - name: Template1 + file_name_suffix: Main + extra: True + + - name: Template2 + file_name_suffix: Test \ No newline at end of file diff --git a/skate-plugin/src/test/resources/test_malformed_file_templates_setting.yaml b/skate-plugin/src/test/resources/test_malformed_file_templates_setting.yaml new file mode 100644 index 000000000..85f892a4e --- /dev/null +++ b/skate-plugin/src/test/resources/test_malformed_file_templates_setting.yaml @@ -0,0 +1,5 @@ +templates: + - name: Template1 + + - name: Template2 + file_name_suffix: Test \ No newline at end of file