- New-job dialog gains a Start-from dropdown: pick a catalog blueprint, fill its typed slots (time/enum/weekdays/text), save instantiates it via POST /api/cron/blueprints/instantiate - Delivery field is now an editable picker fed by GET /api/cron/delivery-targets (origin + local + connected platforms) - Blueprints and targets fetched on editor open; 422 slot errors surface the backend detail Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,58 @@
|
|||||||
|
package com.m57.hermescontrol.data.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class CronBlueprintListResponse(
|
||||||
|
val blueprints: List<CronBlueprint> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class CronBlueprint(
|
||||||
|
val key: String,
|
||||||
|
val title: String = "",
|
||||||
|
val description: String = "",
|
||||||
|
val category: String = "",
|
||||||
|
val tags: List<String> = emptyList(),
|
||||||
|
val fields: List<CronBlueprintField> = emptyList(),
|
||||||
|
val schedule: String = "",
|
||||||
|
val scheduleHuman: String = "",
|
||||||
|
val command: String = "",
|
||||||
|
val appUrl: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class CronBlueprintField(
|
||||||
|
val name: String,
|
||||||
|
val type: String = "text",
|
||||||
|
val label: String = "",
|
||||||
|
val default: JsonElement? = null,
|
||||||
|
val options: List<String> = emptyList(),
|
||||||
|
val optional: Boolean = false,
|
||||||
|
val strict: Boolean = true,
|
||||||
|
val help: String = "",
|
||||||
|
) {
|
||||||
|
val defaultText: String
|
||||||
|
get() = (default as? JsonPrimitive)?.content.orEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class DeliveryTargetsResponse(
|
||||||
|
val targets: List<DeliveryTarget> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class DeliveryTarget(
|
||||||
|
val id: String,
|
||||||
|
val name: String = "",
|
||||||
|
val home_target_set: Boolean = false,
|
||||||
|
val home_env_var: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class InstantiateBlueprintRequest(
|
||||||
|
val blueprint: String,
|
||||||
|
val values: Map<String, String>,
|
||||||
|
)
|
||||||
@@ -19,10 +19,12 @@ import com.m57.hermescontrol.data.model.CreateProfileRequest
|
|||||||
import com.m57.hermescontrol.data.model.CreateTaskBody
|
import com.m57.hermescontrol.data.model.CreateTaskBody
|
||||||
import com.m57.hermescontrol.data.model.CreateWebhookRequest
|
import com.m57.hermescontrol.data.model.CreateWebhookRequest
|
||||||
import com.m57.hermescontrol.data.model.CredentialPoolResponse
|
import com.m57.hermescontrol.data.model.CredentialPoolResponse
|
||||||
|
import com.m57.hermescontrol.data.model.CronBlueprintListResponse
|
||||||
import com.m57.hermescontrol.data.model.CronJob
|
import com.m57.hermescontrol.data.model.CronJob
|
||||||
import com.m57.hermescontrol.data.model.CuratorResponse
|
import com.m57.hermescontrol.data.model.CuratorResponse
|
||||||
import com.m57.hermescontrol.data.model.DebugShareResponse
|
import com.m57.hermescontrol.data.model.DebugShareResponse
|
||||||
import com.m57.hermescontrol.data.model.DeleteWebhookResponse
|
import com.m57.hermescontrol.data.model.DeleteWebhookResponse
|
||||||
|
import com.m57.hermescontrol.data.model.DeliveryTargetsResponse
|
||||||
import com.m57.hermescontrol.data.model.DoctorResponse
|
import com.m57.hermescontrol.data.model.DoctorResponse
|
||||||
import com.m57.hermescontrol.data.model.EnvVarConfig
|
import com.m57.hermescontrol.data.model.EnvVarConfig
|
||||||
import com.m57.hermescontrol.data.model.EnvVarDeleteRequest
|
import com.m57.hermescontrol.data.model.EnvVarDeleteRequest
|
||||||
@@ -31,6 +33,7 @@ import com.m57.hermescontrol.data.model.EnvVarRevealResponse
|
|||||||
import com.m57.hermescontrol.data.model.EnvVarUpdate
|
import com.m57.hermescontrol.data.model.EnvVarUpdate
|
||||||
import com.m57.hermescontrol.data.model.HealthStatus
|
import com.m57.hermescontrol.data.model.HealthStatus
|
||||||
import com.m57.hermescontrol.data.model.HookResponse
|
import com.m57.hermescontrol.data.model.HookResponse
|
||||||
|
import com.m57.hermescontrol.data.model.InstantiateBlueprintRequest
|
||||||
import com.m57.hermescontrol.data.model.KanbanBoardResponse
|
import com.m57.hermescontrol.data.model.KanbanBoardResponse
|
||||||
import com.m57.hermescontrol.data.model.KanbanBoardsResponse
|
import com.m57.hermescontrol.data.model.KanbanBoardsResponse
|
||||||
import com.m57.hermescontrol.data.model.LearningGraphResponse
|
import com.m57.hermescontrol.data.model.LearningGraphResponse
|
||||||
@@ -308,6 +311,17 @@ interface HermesApiService {
|
|||||||
@Path("id") id: String,
|
@Path("id") id: String,
|
||||||
): Response<Unit>
|
): Response<Unit>
|
||||||
|
|
||||||
|
@GET("api/cron/blueprints")
|
||||||
|
suspend fun getCronBlueprints(): Response<CronBlueprintListResponse>
|
||||||
|
|
||||||
|
@POST("api/cron/blueprints/instantiate")
|
||||||
|
suspend fun instantiateBlueprint(
|
||||||
|
@Body body: InstantiateBlueprintRequest,
|
||||||
|
): Response<CronJob>
|
||||||
|
|
||||||
|
@GET("api/cron/delivery-targets")
|
||||||
|
suspend fun getCronDeliveryTargets(): Response<DeliveryTargetsResponse>
|
||||||
|
|
||||||
@POST("api/gateway/start")
|
@POST("api/gateway/start")
|
||||||
suspend fun startGateway(): Response<Unit>
|
suspend fun startGateway(): Response<Unit>
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ import androidx.compose.material3.Card
|
|||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.Checkbox
|
import androidx.compose.material3.Checkbox
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.ExposedDropdownMenuAnchorType
|
||||||
|
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||||
|
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -50,10 +55,14 @@ import androidx.compose.ui.window.DialogProperties
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import com.m57.hermescontrol.R
|
import com.m57.hermescontrol.R
|
||||||
|
import com.m57.hermescontrol.data.model.CronBlueprint
|
||||||
|
import com.m57.hermescontrol.data.model.CronBlueprintField
|
||||||
import com.m57.hermescontrol.data.model.CronJob
|
import com.m57.hermescontrol.data.model.CronJob
|
||||||
|
import com.m57.hermescontrol.data.model.DeliveryTarget
|
||||||
import com.m57.hermescontrol.theme.LocalSpacing
|
import com.m57.hermescontrol.theme.LocalSpacing
|
||||||
import com.m57.hermescontrol.ui.common.EmptyState
|
import com.m57.hermescontrol.ui.common.EmptyState
|
||||||
import com.m57.hermescontrol.ui.common.ErrorState
|
import com.m57.hermescontrol.ui.common.ErrorState
|
||||||
|
import com.m57.hermescontrol.ui.common.ExposedDropdownField
|
||||||
import com.m57.hermescontrol.ui.common.HermesScaffold
|
import com.m57.hermescontrol.ui.common.HermesScaffold
|
||||||
import com.m57.hermescontrol.ui.common.LoadingState
|
import com.m57.hermescontrol.ui.common.LoadingState
|
||||||
import com.m57.hermescontrol.ui.common.NavIcon
|
import com.m57.hermescontrol.ui.common.NavIcon
|
||||||
@@ -241,6 +250,8 @@ fun CronJobsScreen(
|
|||||||
state = state.editorState,
|
state = state.editorState,
|
||||||
onFieldChange = { name, value -> viewModel.updateEditorField(name, value) },
|
onFieldChange = { name, value -> viewModel.updateEditorField(name, value) },
|
||||||
onToggleNoAgent = { viewModel.toggleNoAgent() },
|
onToggleNoAgent = { viewModel.toggleNoAgent() },
|
||||||
|
onSelectBlueprint = { viewModel.selectBlueprint(it) },
|
||||||
|
onBlueprintFieldChange = { name, value -> viewModel.updateBlueprintValue(name, value) },
|
||||||
onSave = { viewModel.saveEditor() },
|
onSave = { viewModel.saveEditor() },
|
||||||
onDismiss = { viewModel.closeEditor() },
|
onDismiss = { viewModel.closeEditor() },
|
||||||
onClearToast = { viewModel.clearEditorToast() },
|
onClearToast = { viewModel.clearEditorToast() },
|
||||||
@@ -275,6 +286,8 @@ fun CronJobEditorDialog(
|
|||||||
state: CronJobEditorState,
|
state: CronJobEditorState,
|
||||||
onFieldChange: (String, String) -> Unit,
|
onFieldChange: (String, String) -> Unit,
|
||||||
onToggleNoAgent: () -> Unit,
|
onToggleNoAgent: () -> Unit,
|
||||||
|
onSelectBlueprint: (String?) -> Unit,
|
||||||
|
onBlueprintFieldChange: (String, String) -> Unit,
|
||||||
onSave: () -> Unit,
|
onSave: () -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onClearToast: () -> Unit,
|
onClearToast: () -> Unit,
|
||||||
@@ -360,127 +373,155 @@ fun CronJobEditorDialog(
|
|||||||
.verticalScroll(rememberScrollState()),
|
.verticalScroll(rememberScrollState()),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
// Name
|
val blueprint = state.selectedBlueprint
|
||||||
OutlinedTextField(
|
|
||||||
value = state.name,
|
|
||||||
onValueChange = { onFieldChange("name", it) },
|
|
||||||
label = { Text(stringResource(R.string.cron_edit_field_name)) },
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
singleLine = true,
|
|
||||||
)
|
|
||||||
|
|
||||||
// Schedule (required)
|
// Start-from blueprint picker (new jobs only; hidden when the
|
||||||
OutlinedTextField(
|
// catalog failed to load)
|
||||||
value = state.schedule,
|
if (state.isNew && state.blueprints.isNotEmpty()) {
|
||||||
onValueChange = { onFieldChange("schedule", it) },
|
BlueprintStartFromDropdown(
|
||||||
label = { Text(stringResource(R.string.cron_edit_field_schedule)) },
|
blueprints = state.blueprints,
|
||||||
placeholder = { Text(stringResource(R.string.cron_edit_hint_schedule)) },
|
selectedKey = state.selectedBlueprintKey,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
onSelect = onSelectBlueprint,
|
||||||
singleLine = true,
|
)
|
||||||
)
|
blueprint?.let {
|
||||||
|
|
||||||
// Prompt
|
|
||||||
OutlinedTextField(
|
|
||||||
value = state.prompt,
|
|
||||||
onValueChange = { onFieldChange("prompt", it) },
|
|
||||||
label = { Text(stringResource(R.string.cron_edit_field_prompt)) },
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(120.dp),
|
|
||||||
textStyle =
|
|
||||||
MaterialTheme.typography.bodySmall.copy(
|
|
||||||
fontFamily = FontFamily.Monospace,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Delivery
|
|
||||||
OutlinedTextField(
|
|
||||||
value = state.deliver,
|
|
||||||
onValueChange = { onFieldChange("deliver", it) },
|
|
||||||
label = { Text(stringResource(R.string.cron_edit_field_deliver)) },
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
singleLine = true,
|
|
||||||
supportingText = {
|
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.cron_edit_hint_deliver),
|
text = it.description,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
},
|
}
|
||||||
)
|
}
|
||||||
|
|
||||||
// Skills
|
if (blueprint != null) {
|
||||||
OutlinedTextField(
|
// Blueprint mode: typed slots only — the backend renders the
|
||||||
value = state.skills,
|
// prompt and schedule from the filled values.
|
||||||
onValueChange = { onFieldChange("skills", it) },
|
blueprint.fields.forEach { field ->
|
||||||
label = { Text(stringResource(R.string.cron_edit_field_skills)) },
|
BlueprintSlotField(
|
||||||
placeholder = { Text(stringResource(R.string.cron_edit_hint_skills)) },
|
field = field,
|
||||||
modifier =
|
value = state.blueprintValues[field.name].orEmpty(),
|
||||||
Modifier
|
deliveryOptions = state.deliveryOptions,
|
||||||
.fillMaxWidth()
|
deliveryTargets = state.deliveryTargets,
|
||||||
.height(100.dp),
|
onValueChange = { onBlueprintFieldChange(field.name, it) },
|
||||||
textStyle = MaterialTheme.typography.bodySmall,
|
)
|
||||||
)
|
}
|
||||||
|
} else {
|
||||||
// Model
|
// Name
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = state.model,
|
value = state.name,
|
||||||
onValueChange = { onFieldChange("model", it) },
|
onValueChange = { onFieldChange("name", it) },
|
||||||
label = { Text(stringResource(R.string.cron_edit_field_model)) },
|
label = { Text(stringResource(R.string.cron_edit_field_name)) },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
)
|
|
||||||
|
|
||||||
// Provider
|
|
||||||
OutlinedTextField(
|
|
||||||
value = state.provider,
|
|
||||||
onValueChange = { onFieldChange("provider", it) },
|
|
||||||
label = { Text(stringResource(R.string.cron_edit_field_provider)) },
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
singleLine = true,
|
|
||||||
)
|
|
||||||
|
|
||||||
// Base URL
|
|
||||||
OutlinedTextField(
|
|
||||||
value = state.base_url,
|
|
||||||
onValueChange = { onFieldChange("base_url", it) },
|
|
||||||
label = { Text(stringResource(R.string.cron_edit_field_base_url)) },
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
singleLine = true,
|
|
||||||
)
|
|
||||||
|
|
||||||
// Script path
|
|
||||||
OutlinedTextField(
|
|
||||||
value = state.script,
|
|
||||||
onValueChange = { onFieldChange("script", it) },
|
|
||||||
label = { Text(stringResource(R.string.cron_edit_field_script)) },
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
singleLine = true,
|
|
||||||
)
|
|
||||||
|
|
||||||
// Work directory
|
|
||||||
OutlinedTextField(
|
|
||||||
value = state.workdir,
|
|
||||||
onValueChange = { onFieldChange("workdir", it) },
|
|
||||||
label = { Text(stringResource(R.string.cron_edit_field_workdir)) },
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
singleLine = true,
|
|
||||||
)
|
|
||||||
|
|
||||||
// No Agent toggle
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Checkbox(
|
|
||||||
checked = state.no_agent,
|
|
||||||
onCheckedChange = { onToggleNoAgent() },
|
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
|
||||||
Text(
|
// Schedule (required)
|
||||||
text = stringResource(R.string.cron_edit_field_no_agent),
|
OutlinedTextField(
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
value = state.schedule,
|
||||||
|
onValueChange = { onFieldChange("schedule", it) },
|
||||||
|
label = { Text(stringResource(R.string.cron_edit_field_schedule)) },
|
||||||
|
placeholder = { Text(stringResource(R.string.cron_edit_hint_schedule)) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Prompt
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.prompt,
|
||||||
|
onValueChange = { onFieldChange("prompt", it) },
|
||||||
|
label = { Text(stringResource(R.string.cron_edit_field_prompt)) },
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(120.dp),
|
||||||
|
textStyle =
|
||||||
|
MaterialTheme.typography.bodySmall.copy(
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Delivery
|
||||||
|
DeliverField(
|
||||||
|
label = stringResource(R.string.cron_edit_field_deliver),
|
||||||
|
value = state.deliver,
|
||||||
|
options = state.deliveryOptions,
|
||||||
|
deliveryTargets = state.deliveryTargets,
|
||||||
|
onValueChange = { onFieldChange("deliver", it) },
|
||||||
|
hint = stringResource(R.string.cron_edit_hint_deliver),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Skills
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.skills,
|
||||||
|
onValueChange = { onFieldChange("skills", it) },
|
||||||
|
label = { Text(stringResource(R.string.cron_edit_field_skills)) },
|
||||||
|
placeholder = { Text(stringResource(R.string.cron_edit_hint_skills)) },
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(100.dp),
|
||||||
|
textStyle = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Model
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.model,
|
||||||
|
onValueChange = { onFieldChange("model", it) },
|
||||||
|
label = { Text(stringResource(R.string.cron_edit_field_model)) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Provider
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.provider,
|
||||||
|
onValueChange = { onFieldChange("provider", it) },
|
||||||
|
label = { Text(stringResource(R.string.cron_edit_field_provider)) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Base URL
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.base_url,
|
||||||
|
onValueChange = { onFieldChange("base_url", it) },
|
||||||
|
label = { Text(stringResource(R.string.cron_edit_field_base_url)) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Script path
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.script,
|
||||||
|
onValueChange = { onFieldChange("script", it) },
|
||||||
|
label = { Text(stringResource(R.string.cron_edit_field_script)) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Work directory
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.workdir,
|
||||||
|
onValueChange = { onFieldChange("workdir", it) },
|
||||||
|
label = { Text(stringResource(R.string.cron_edit_field_workdir)) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
// No Agent toggle
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Checkbox(
|
||||||
|
checked = state.no_agent,
|
||||||
|
onCheckedChange = { onToggleNoAgent() },
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.cron_edit_field_no_agent),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
@@ -537,3 +578,175 @@ private fun RunDetailRow(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun BlueprintStartFromDropdown(
|
||||||
|
blueprints: List<CronBlueprint>,
|
||||||
|
selectedKey: String?,
|
||||||
|
onSelect: (String?) -> Unit,
|
||||||
|
) {
|
||||||
|
var expanded by remember { mutableStateOf(false) }
|
||||||
|
val selectedTitle =
|
||||||
|
blueprints.firstOrNull { it.key == selectedKey }?.title
|
||||||
|
?: stringResource(R.string.cron_edit_blank_job)
|
||||||
|
|
||||||
|
ExposedDropdownMenuBox(
|
||||||
|
expanded = expanded,
|
||||||
|
onExpandedChange = { expanded = it },
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = selectedTitle,
|
||||||
|
onValueChange = {},
|
||||||
|
readOnly = true,
|
||||||
|
label = { Text(stringResource(R.string.cron_edit_start_from)) },
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable),
|
||||||
|
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
||||||
|
singleLine = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
ExposedDropdownMenu(
|
||||||
|
expanded = expanded,
|
||||||
|
onDismissRequest = { expanded = false },
|
||||||
|
) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.cron_edit_blank_job)) },
|
||||||
|
onClick = {
|
||||||
|
onSelect(null)
|
||||||
|
expanded = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
blueprints.forEach { blueprint ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(blueprint.title) },
|
||||||
|
onClick = {
|
||||||
|
onSelect(blueprint.key)
|
||||||
|
expanded = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun BlueprintSlotField(
|
||||||
|
field: CronBlueprintField,
|
||||||
|
value: String,
|
||||||
|
deliveryOptions: List<String>,
|
||||||
|
deliveryTargets: List<DeliveryTarget>,
|
||||||
|
onValueChange: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
when {
|
||||||
|
field.name == "deliver" -> {
|
||||||
|
DeliverField(
|
||||||
|
label = field.label,
|
||||||
|
value = value,
|
||||||
|
options = deliveryOptions,
|
||||||
|
deliveryTargets = deliveryTargets,
|
||||||
|
onValueChange = onValueChange,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
field.type == "enum" || field.type == "weekdays" -> {
|
||||||
|
ExposedDropdownField(
|
||||||
|
label = field.label,
|
||||||
|
options = field.options,
|
||||||
|
selectedValue = value,
|
||||||
|
onOptionSelected = onValueChange,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = value,
|
||||||
|
onValueChange = onValueChange,
|
||||||
|
label = { Text(field.label) },
|
||||||
|
placeholder =
|
||||||
|
if (field.type == "time") {
|
||||||
|
{ Text(stringResource(R.string.cron_edit_hint_time)) }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
supportingText =
|
||||||
|
if (field.help.isNotBlank()) {
|
||||||
|
{
|
||||||
|
Text(
|
||||||
|
text = field.help,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delivery target picker: an editable dropdown fed by the backend's
|
||||||
|
* delivery-targets endpoint (origin + local + connected platforms). Picking
|
||||||
|
* fills the field; typing keeps the legacy free-text behaviour (e.g.
|
||||||
|
* `telegram:chat_id`).
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun DeliverField(
|
||||||
|
label: String,
|
||||||
|
value: String,
|
||||||
|
options: List<String>,
|
||||||
|
deliveryTargets: List<DeliveryTarget>,
|
||||||
|
onValueChange: (String) -> Unit,
|
||||||
|
hint: String? = null,
|
||||||
|
) {
|
||||||
|
var expanded by remember { mutableStateOf(false) }
|
||||||
|
val displayNames =
|
||||||
|
deliveryTargets.associate { it.id to it.name.ifBlank { it.id } } + ("origin" to "origin")
|
||||||
|
|
||||||
|
ExposedDropdownMenuBox(
|
||||||
|
expanded = expanded,
|
||||||
|
onExpandedChange = { expanded = it },
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = value,
|
||||||
|
onValueChange = onValueChange,
|
||||||
|
label = { Text(label) },
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryEditable),
|
||||||
|
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
||||||
|
supportingText =
|
||||||
|
hint?.let {
|
||||||
|
{
|
||||||
|
Text(
|
||||||
|
text = it,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
singleLine = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
ExposedDropdownMenu(
|
||||||
|
expanded = expanded,
|
||||||
|
onDismissRequest = { expanded = false },
|
||||||
|
) {
|
||||||
|
options.forEach { id ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(displayNames[id] ?: id) },
|
||||||
|
onClick = {
|
||||||
|
onValueChange(id)
|
||||||
|
expanded = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ package com.m57.hermescontrol.ui.cron
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.m57.hermescontrol.data.model.CreateCronJobRequest
|
import com.m57.hermescontrol.data.model.CreateCronJobRequest
|
||||||
|
import com.m57.hermescontrol.data.model.CronBlueprint
|
||||||
import com.m57.hermescontrol.data.model.CronJob
|
import com.m57.hermescontrol.data.model.CronJob
|
||||||
|
import com.m57.hermescontrol.data.model.DeliveryTarget
|
||||||
|
import com.m57.hermescontrol.data.model.InstantiateBlueprintRequest
|
||||||
import com.m57.hermescontrol.data.model.UpdateCronJobRequest
|
import com.m57.hermescontrol.data.model.UpdateCronJobRequest
|
||||||
import com.m57.hermescontrol.data.remote.ApiClient
|
import com.m57.hermescontrol.data.remote.ApiClient
|
||||||
import com.m57.hermescontrol.data.remote.NetworkResult
|
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||||
@@ -51,7 +54,26 @@ data class CronJobEditorState(
|
|||||||
val workdir: String = "",
|
val workdir: String = "",
|
||||||
val enabled: Boolean = true,
|
val enabled: Boolean = true,
|
||||||
val no_agent: Boolean = false,
|
val no_agent: Boolean = false,
|
||||||
)
|
// Blueprint start-from state (new jobs only)
|
||||||
|
val blueprints: List<CronBlueprint> = emptyList(),
|
||||||
|
val deliveryTargets: List<DeliveryTarget> = emptyList(),
|
||||||
|
val selectedBlueprintKey: String? = null,
|
||||||
|
val blueprintValues: Map<String, String> = emptyMap(),
|
||||||
|
) {
|
||||||
|
val selectedBlueprint: CronBlueprint?
|
||||||
|
get() = blueprints.firstOrNull { it.key == selectedBlueprintKey }
|
||||||
|
|
||||||
|
/** Delivery ids offered by the picker: origin, then local + connected platforms. */
|
||||||
|
val deliveryOptions: List<String>
|
||||||
|
get() =
|
||||||
|
buildList {
|
||||||
|
add("origin")
|
||||||
|
addAll(deliveryTargets.map { it.id })
|
||||||
|
if (deliveryTargets.none { it.id == "local" }) {
|
||||||
|
add("local")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class CronJobsViewModel :
|
class CronJobsViewModel :
|
||||||
ViewModel(),
|
ViewModel(),
|
||||||
@@ -169,6 +191,50 @@ class CronJobsViewModel :
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
loadEditorBlueprints()
|
||||||
|
loadDeliveryTargets()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadEditorBlueprints() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val result =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
safeApiCall { ApiClient.hermesApi.getCronBlueprints() }
|
||||||
|
}
|
||||||
|
when (result) {
|
||||||
|
is NetworkResult.Success -> {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(editorState = it.editorState.copy(blueprints = result.data.blueprints))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is NetworkResult.Failure -> {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
editorState =
|
||||||
|
it.editorState.copy(
|
||||||
|
toastMessage = "Failed to load blueprints: ${result.error.message}",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadDeliveryTargets() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val result =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
safeApiCall { ApiClient.hermesApi.getCronDeliveryTargets() }
|
||||||
|
}
|
||||||
|
if (result is NetworkResult.Success) {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(editorState = it.editorState.copy(deliveryTargets = result.data.targets))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Failure: editor keeps working; the deliver picker falls back to origin/local.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun openEditJobDialog(id: String) {
|
fun openEditJobDialog(id: String) {
|
||||||
@@ -230,12 +296,45 @@ class CronJobsViewModel :
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
loadDeliveryTargets()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun closeEditor() {
|
fun closeEditor() {
|
||||||
_uiState.update { it.copy(editorState = CronJobEditorState()) }
|
_uiState.update { it.copy(editorState = CronJobEditorState()) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Pick a start-from blueprint (null = blank job); seeds its slot defaults. */
|
||||||
|
fun selectBlueprint(key: String?) {
|
||||||
|
_uiState.update { state ->
|
||||||
|
val editor = state.editorState
|
||||||
|
val blueprint = key?.let { k -> editor.blueprints.firstOrNull { it.key == k } }
|
||||||
|
val values = blueprint?.fields?.associate { it.name to it.defaultText }.orEmpty()
|
||||||
|
state.copy(
|
||||||
|
editorState =
|
||||||
|
editor.copy(
|
||||||
|
selectedBlueprintKey = key,
|
||||||
|
blueprintValues = values,
|
||||||
|
toastMessage = null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateBlueprintValue(
|
||||||
|
name: String,
|
||||||
|
value: String,
|
||||||
|
) {
|
||||||
|
_uiState.update { state ->
|
||||||
|
state.copy(
|
||||||
|
editorState =
|
||||||
|
state.editorState.copy(
|
||||||
|
blueprintValues = state.editorState.blueprintValues + (name to value),
|
||||||
|
toastMessage = null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun updateEditorField(
|
fun updateEditorField(
|
||||||
name: String,
|
name: String,
|
||||||
value: String,
|
value: String,
|
||||||
@@ -252,7 +351,7 @@ class CronJobsViewModel :
|
|||||||
|
|
||||||
fun saveEditor() {
|
fun saveEditor() {
|
||||||
val editor = _uiState.value.editorState
|
val editor = _uiState.value.editorState
|
||||||
if (editor.schedule.isBlank()) {
|
if (editor.selectedBlueprintKey == null && editor.schedule.isBlank()) {
|
||||||
_uiState.update { it.copy(editorState = editor.copy(toastMessage = "Schedule is required")) }
|
_uiState.update { it.copy(editorState = editor.copy(toastMessage = "Schedule is required")) }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -261,7 +360,16 @@ class CronJobsViewModel :
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val result =
|
val result =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
if (editor.isNew) {
|
if (editor.isNew && editor.selectedBlueprintKey != null) {
|
||||||
|
safeApiCall {
|
||||||
|
ApiClient.hermesApi.instantiateBlueprint(
|
||||||
|
InstantiateBlueprintRequest(
|
||||||
|
blueprint = editor.selectedBlueprintKey,
|
||||||
|
values = editor.blueprintValues,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else if (editor.isNew) {
|
||||||
safeApiCall {
|
safeApiCall {
|
||||||
ApiClient.hermesApi.createCronJob(
|
ApiClient.hermesApi.createCronJob(
|
||||||
CreateCronJobRequest(
|
CreateCronJobRequest(
|
||||||
|
|||||||
@@ -743,6 +743,8 @@
|
|||||||
<string name="cron_action_add">Add cron job</string>
|
<string name="cron_action_add">Add cron job</string>
|
||||||
<string name="cron_edit_title">Edit Cron Job</string>
|
<string name="cron_edit_title">Edit Cron Job</string>
|
||||||
<string name="cron_edit_title_new">New Cron Job</string>
|
<string name="cron_edit_title_new">New Cron Job</string>
|
||||||
|
<string name="cron_edit_start_from">Start from</string>
|
||||||
|
<string name="cron_edit_blank_job">Blank job</string>
|
||||||
<string name="cron_edit_field_name">Name</string>
|
<string name="cron_edit_field_name">Name</string>
|
||||||
<string name="cron_edit_field_schedule">Schedule</string>
|
<string name="cron_edit_field_schedule">Schedule</string>
|
||||||
<string name="cron_edit_field_prompt">Prompt</string>
|
<string name="cron_edit_field_prompt">Prompt</string>
|
||||||
@@ -756,6 +758,7 @@
|
|||||||
<string name="cron_edit_field_no_agent">No agent (script-only)</string>
|
<string name="cron_edit_field_no_agent">No agent (script-only)</string>
|
||||||
<string name="cron_edit_hint_schedule">e.g. 0 9 * * * or every 2h</string>
|
<string name="cron_edit_hint_schedule">e.g. 0 9 * * * or every 2h</string>
|
||||||
<string name="cron_edit_hint_deliver">e.g. origin, local, all, or telegram:chat_id</string>
|
<string name="cron_edit_hint_deliver">e.g. origin, local, all, or telegram:chat_id</string>
|
||||||
|
<string name="cron_edit_hint_time">HH:MM</string>
|
||||||
<string name="cron_edit_hint_skills">One per line</string>
|
<string name="cron_edit_hint_skills">One per line</string>
|
||||||
<string name="cron_edit_action_save">Save</string>
|
<string name="cron_edit_action_save">Save</string>
|
||||||
<string name="cron_discard_title">Discard changes?</string>
|
<string name="cron_discard_title">Discard changes?</string>
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
package com.m57.hermescontrol.ui.cron
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.data.model.CronBlueprint
|
||||||
|
import com.m57.hermescontrol.data.model.CronBlueprintField
|
||||||
|
import com.m57.hermescontrol.data.model.CronBlueprintListResponse
|
||||||
|
import com.m57.hermescontrol.data.model.CronJob
|
||||||
|
import com.m57.hermescontrol.data.model.DeliveryTarget
|
||||||
|
import com.m57.hermescontrol.data.model.DeliveryTargetsResponse
|
||||||
|
import com.m57.hermescontrol.data.model.InstantiateBlueprintRequest
|
||||||
|
import com.m57.hermescontrol.data.remote.ApiClient
|
||||||
|
import com.m57.hermescontrol.data.remote.HermesApiService
|
||||||
|
import io.mockk.coEvery
|
||||||
|
import io.mockk.coVerify
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
|
import io.mockk.mockkObject
|
||||||
|
import io.mockk.slot
|
||||||
|
import io.mockk.unmockkAll
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
|
import kotlinx.coroutines.test.resetMain
|
||||||
|
import kotlinx.coroutines.test.setMain
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import retrofit2.Response
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class CronJobsViewModelTest {
|
||||||
|
private val testDispatcher = StandardTestDispatcher()
|
||||||
|
private val mockApi = mockk<HermesApiService>(relaxed = true)
|
||||||
|
|
||||||
|
private val morningBrief =
|
||||||
|
CronBlueprint(
|
||||||
|
key = "morning-brief",
|
||||||
|
title = "Morning briefing",
|
||||||
|
description = "A short daily briefing.",
|
||||||
|
fields =
|
||||||
|
listOf(
|
||||||
|
CronBlueprintField(
|
||||||
|
name = "time",
|
||||||
|
type = "time",
|
||||||
|
label = "What time?",
|
||||||
|
default = JsonPrimitive("08:00"),
|
||||||
|
help = "24h local time, e.g. 08:00",
|
||||||
|
),
|
||||||
|
CronBlueprintField(
|
||||||
|
name = "deliver",
|
||||||
|
type = "enum",
|
||||||
|
label = "Where to deliver?",
|
||||||
|
default = JsonPrimitive("origin"),
|
||||||
|
options = listOf("origin", "local", "telegram"),
|
||||||
|
strict = false,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun createViewModel(): CronJobsViewModel {
|
||||||
|
val vm = CronJobsViewModel()
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
return vm
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pump the test scheduler while letting the real Dispatchers.IO hops
|
||||||
|
* (safeLaunchLoad / withContext(IO)) land their resumptions.
|
||||||
|
*/
|
||||||
|
private fun settle() {
|
||||||
|
repeat(20) {
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
Thread.sleep(10)
|
||||||
|
}
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stubEditorData() {
|
||||||
|
coEvery { mockApi.getCronBlueprints() } returns
|
||||||
|
Response.success(CronBlueprintListResponse(blueprints = listOf(morningBrief)))
|
||||||
|
coEvery { mockApi.getCronDeliveryTargets() } returns
|
||||||
|
Response.success(
|
||||||
|
DeliveryTargetsResponse(
|
||||||
|
targets =
|
||||||
|
listOf(
|
||||||
|
DeliveryTarget(id = "local", name = "Local (save only)", home_target_set = true),
|
||||||
|
DeliveryTarget(id = "telegram", name = "Telegram", home_target_set = true),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
coEvery { mockApi.getCronJobs() } returns Response.success(emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
Dispatchers.setMain(testDispatcher)
|
||||||
|
mockkObject(ApiClient)
|
||||||
|
every { ApiClient.hermesApi } returns mockApi
|
||||||
|
stubEditorData()
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
Dispatchers.resetMain()
|
||||||
|
unmockkAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `openNewJobDialog loads blueprints and delivery targets`() {
|
||||||
|
val vm = createViewModel()
|
||||||
|
|
||||||
|
vm.openNewJobDialog()
|
||||||
|
settle()
|
||||||
|
|
||||||
|
val editor = vm.uiState.value.editorState
|
||||||
|
assertTrue(editor.isOpen)
|
||||||
|
assertTrue(editor.isNew)
|
||||||
|
assertEquals(listOf("morning-brief"), editor.blueprints.map { it.key })
|
||||||
|
assertEquals(listOf("local", "telegram"), editor.deliveryTargets.map { it.id })
|
||||||
|
// origin first, then local + connected platforms
|
||||||
|
assertEquals(listOf("origin", "local", "telegram"), editor.deliveryOptions)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `selectBlueprint seeds slot defaults`() {
|
||||||
|
val vm = createViewModel()
|
||||||
|
vm.openNewJobDialog()
|
||||||
|
settle()
|
||||||
|
|
||||||
|
vm.selectBlueprint("morning-brief")
|
||||||
|
|
||||||
|
val editor = vm.uiState.value.editorState
|
||||||
|
assertEquals("morning-brief", editor.selectedBlueprintKey)
|
||||||
|
assertEquals(mapOf("time" to "08:00", "deliver" to "origin"), editor.blueprintValues)
|
||||||
|
assertEquals("Morning briefing", editor.selectedBlueprint?.title)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `selectBlueprint null returns to blank job`() {
|
||||||
|
val vm = createViewModel()
|
||||||
|
vm.openNewJobDialog()
|
||||||
|
settle()
|
||||||
|
|
||||||
|
vm.selectBlueprint("morning-brief")
|
||||||
|
vm.selectBlueprint(null)
|
||||||
|
|
||||||
|
val editor = vm.uiState.value.editorState
|
||||||
|
assertNull(editor.selectedBlueprintKey)
|
||||||
|
assertTrue(editor.blueprintValues.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `updateBlueprintValue overrides a seeded slot`() {
|
||||||
|
val vm = createViewModel()
|
||||||
|
vm.openNewJobDialog()
|
||||||
|
settle()
|
||||||
|
vm.selectBlueprint("morning-brief")
|
||||||
|
|
||||||
|
vm.updateBlueprintValue("time", "09:30")
|
||||||
|
|
||||||
|
assertEquals("09:30", vm.uiState.value.editorState.blueprintValues["time"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `saveEditor with blueprint instantiated`() {
|
||||||
|
val vm = createViewModel()
|
||||||
|
vm.openNewJobDialog()
|
||||||
|
settle()
|
||||||
|
vm.selectBlueprint("morning-brief")
|
||||||
|
coEvery { mockApi.instantiateBlueprint(any()) } returns
|
||||||
|
Response.success(CronJob(id = "j1", name = "Morning briefing"))
|
||||||
|
coEvery { mockApi.getCronJobs() } returns Response.success(emptyList())
|
||||||
|
|
||||||
|
vm.saveEditor()
|
||||||
|
settle()
|
||||||
|
|
||||||
|
val requestSlot = slot<InstantiateBlueprintRequest>()
|
||||||
|
coVerify { mockApi.instantiateBlueprint(capture(requestSlot)) }
|
||||||
|
assertEquals("morning-brief", requestSlot.captured.blueprint)
|
||||||
|
assertEquals(mapOf("time" to "08:00", "deliver" to "origin"), requestSlot.captured.values)
|
||||||
|
assertFalse(vm.uiState.value.editorState.isOpen)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `saveEditor blueprint validation error surfaces backend detail`() {
|
||||||
|
val vm = createViewModel()
|
||||||
|
vm.openNewJobDialog()
|
||||||
|
settle()
|
||||||
|
vm.selectBlueprint("morning-brief")
|
||||||
|
coEvery { mockApi.instantiateBlueprint(any()) } returns
|
||||||
|
Response.error(422, """{"detail":"invalid time '25:00' - use HH:MM (24h)"}""".toResponseBody())
|
||||||
|
|
||||||
|
vm.saveEditor()
|
||||||
|
settle()
|
||||||
|
|
||||||
|
val toast = vm.uiState.value.editorState.toastMessage.orEmpty()
|
||||||
|
assertTrue(toast, toast.contains("invalid time '25:00'"))
|
||||||
|
assertFalse(vm.uiState.value.editorState.isSaving)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `saveEditor blank job still creates via createCronJob`() {
|
||||||
|
val vm = createViewModel()
|
||||||
|
vm.openNewJobDialog()
|
||||||
|
settle()
|
||||||
|
vm.updateEditorField("name", "Test job")
|
||||||
|
vm.updateEditorField("schedule", "0 9 * * *")
|
||||||
|
vm.updateEditorField("prompt", "Say hi")
|
||||||
|
coEvery { mockApi.createCronJob(any()) } returns
|
||||||
|
Response.success(CronJob(id = "j2", name = "Test job"))
|
||||||
|
coEvery { mockApi.getCronJobs() } returns Response.success(emptyList())
|
||||||
|
|
||||||
|
vm.saveEditor()
|
||||||
|
settle()
|
||||||
|
|
||||||
|
coVerify { mockApi.createCronJob(any()) }
|
||||||
|
coVerify(exactly = 0) { mockApi.instantiateBlueprint(any()) }
|
||||||
|
assertFalse(vm.uiState.value.editorState.isOpen)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user