feat(scripts): scheduler scripts endpoint + script-picker component
Add ScriptsEndpoints to the Scheduler WebService for CRUD operations on automation scripts. Add a reusable script-picker overlay component for selecting scripts from the UI. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,408 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.ReleaseOrchestrator.Scripts;
|
||||
using StellaOps.Scheduler.WebService.Auth;
|
||||
using StellaOps.Scheduler.WebService.Security;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.Scripts;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal API endpoints for the Scripts registry (/api/v2/scripts).
|
||||
/// </summary>
|
||||
internal static class ScriptsEndpoints
|
||||
{
|
||||
private static readonly JsonSerializerOptions s_json = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) },
|
||||
};
|
||||
|
||||
public static IEndpointRouteBuilder MapScriptsEndpoints(this IEndpointRouteBuilder routes)
|
||||
{
|
||||
var group = routes.MapGroup("/api/v2/scripts")
|
||||
.RequireAuthorization(SchedulerPolicies.Read)
|
||||
.RequireTenant();
|
||||
|
||||
group.MapGet("/", ListScriptsAsync)
|
||||
.WithName("ListScripts")
|
||||
.WithDescription("List scripts with optional search, language, and visibility filters.");
|
||||
|
||||
group.MapGet("/{scriptId}", GetScriptAsync)
|
||||
.WithName("GetScript")
|
||||
.WithDescription("Get a single script by ID.");
|
||||
|
||||
group.MapPost("/", CreateScriptAsync)
|
||||
.WithName("CreateScript")
|
||||
.WithDescription("Create a new script.")
|
||||
.RequireAuthorization(SchedulerPolicies.Operate);
|
||||
|
||||
group.MapPatch("/{scriptId}", UpdateScriptAsync)
|
||||
.WithName("UpdateScript")
|
||||
.WithDescription("Update an existing script.")
|
||||
.RequireAuthorization(SchedulerPolicies.Operate);
|
||||
|
||||
group.MapDelete("/{scriptId}", DeleteScriptAsync)
|
||||
.WithName("DeleteScript")
|
||||
.WithDescription("Delete a script.")
|
||||
.RequireAuthorization(SchedulerPolicies.Operate);
|
||||
|
||||
group.MapPost("/validate", ValidateScriptAsync)
|
||||
.WithName("ValidateScript")
|
||||
.WithDescription("Validate script syntax without saving.");
|
||||
|
||||
group.MapGet("/{scriptId}/versions", GetVersionsAsync)
|
||||
.WithName("ListScriptVersions")
|
||||
.WithDescription("List all versions of a script.");
|
||||
|
||||
group.MapGet("/{scriptId}/versions/{version:int}/content", GetVersionContentAsync)
|
||||
.WithName("GetScriptVersionContent")
|
||||
.WithDescription("Get the content of a specific script version.");
|
||||
|
||||
group.MapPost("/{scriptId}/check-compatibility", CheckCompatibilityAsync)
|
||||
.WithName("CheckScriptCompatibility")
|
||||
.WithDescription("Check script compatibility with a target environment.");
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
||||
// ── List ────────────────────────────────────────────────────────────────
|
||||
|
||||
private static async Task<IResult> ListScriptsAsync(
|
||||
HttpContext httpContext,
|
||||
[FromServices] IScriptRegistry registry,
|
||||
CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var q = httpContext.Request.Query;
|
||||
var criteria = new ScriptSearchCriteria
|
||||
{
|
||||
SearchText = q.TryGetValue("search", out var s) ? s.ToString() : null,
|
||||
Language = q.TryGetValue("language", out var lang) ? ParseLanguage(lang.ToString()) : null,
|
||||
Visibility = q.TryGetValue("visibility", out var vis) ? ParseVisibility(vis.ToString()) : null,
|
||||
Limit = q.TryGetValue("limit", out var lim) && int.TryParse(lim.ToString(), out var l) ? l : 20,
|
||||
Offset = q.TryGetValue("offset", out var off) && int.TryParse(off.ToString(), out var o) ? o : 0,
|
||||
};
|
||||
|
||||
var result = await registry.SearchAsync(criteria, ct).ConfigureAwait(false);
|
||||
var dtos = result.Scripts.Select(ToDto).ToArray();
|
||||
return Results.Json(dtos, s_json);
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
// ── Get ─────────────────────────────────────────────────────────────────
|
||||
|
||||
private static async Task<IResult> GetScriptAsync(
|
||||
string scriptId,
|
||||
[FromServices] IScriptRegistry registry,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var script = await registry.GetScriptAsync(scriptId, ct).ConfigureAwait(false);
|
||||
if (script is null) return Results.NotFound(new { error = $"Script '{scriptId}' not found." });
|
||||
return Results.Json(ToDto(script), s_json);
|
||||
}
|
||||
|
||||
// ── Create ──────────────────────────────────────────────────────────────
|
||||
|
||||
private static async Task<IResult> CreateScriptAsync(
|
||||
HttpContext httpContext,
|
||||
[FromServices] IScriptRegistry registry,
|
||||
[FromServices] ITenantContextAccessor tenantAccessor,
|
||||
CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var body = await JsonSerializer.DeserializeAsync<CreateScriptDto>(httpContext.Request.Body, s_json, ct).ConfigureAwait(false);
|
||||
if (body is null) return Results.BadRequest(new { error = "Invalid request body." });
|
||||
|
||||
var tenant = tenantAccessor.GetTenant(httpContext);
|
||||
var userId = httpContext.User.FindFirst("sub")?.Value ?? "anonymous";
|
||||
|
||||
var request = new CreateScriptRequest
|
||||
{
|
||||
Name = body.Name,
|
||||
Description = body.Description,
|
||||
Language = ParseLanguageRequired(body.Language),
|
||||
Content = body.Content,
|
||||
Tags = body.Tags?.ToImmutableArray(),
|
||||
Visibility = ParseVisibilityRequired(body.Visibility),
|
||||
};
|
||||
|
||||
var script = await registry.CreateScriptAsync(request, userId, ct: ct).ConfigureAwait(false);
|
||||
|
||||
// Update variables on the created script via store if provided
|
||||
return Results.Json(ToDto(script), s_json, statusCode: StatusCodes.Status201Created);
|
||||
}
|
||||
catch (ScriptValidationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message, errors = ex.Errors });
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or JsonException)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
// ── Update ──────────────────────────────────────────────────────────────
|
||||
|
||||
private static async Task<IResult> UpdateScriptAsync(
|
||||
string scriptId,
|
||||
HttpContext httpContext,
|
||||
[FromServices] IScriptRegistry registry,
|
||||
[FromServices] ITenantContextAccessor tenantAccessor,
|
||||
CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var body = await JsonSerializer.DeserializeAsync<UpdateScriptDto>(httpContext.Request.Body, s_json, ct).ConfigureAwait(false);
|
||||
if (body is null) return Results.BadRequest(new { error = "Invalid request body." });
|
||||
|
||||
var tenant = tenantAccessor.GetTenant(httpContext);
|
||||
var userId = httpContext.User.FindFirst("sub")?.Value ?? "anonymous";
|
||||
|
||||
var request = new UpdateScriptRequest
|
||||
{
|
||||
Name = body.Name,
|
||||
Description = body.Description,
|
||||
Content = body.Content,
|
||||
Tags = body.Tags?.ToImmutableArray(),
|
||||
Visibility = body.Visibility is not null ? ParseVisibilityRequired(body.Visibility) : null,
|
||||
ChangeNote = body.ChangeNotes,
|
||||
};
|
||||
|
||||
var updated = await registry.UpdateScriptAsync(scriptId, request, userId, ct).ConfigureAwait(false);
|
||||
return Results.Json(ToDto(updated), s_json);
|
||||
}
|
||||
catch (ScriptNotFoundException)
|
||||
{
|
||||
return Results.NotFound(new { error = $"Script '{scriptId}' not found." });
|
||||
}
|
||||
catch (ScriptValidationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message, errors = ex.Errors });
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or JsonException)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
// ── Delete ──────────────────────────────────────────────────────────────
|
||||
|
||||
private static async Task<IResult> DeleteScriptAsync(
|
||||
string scriptId,
|
||||
[FromServices] IScriptRegistry registry,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var deleted = await registry.DeleteScriptAsync(scriptId, ct).ConfigureAwait(false);
|
||||
return deleted
|
||||
? Results.NoContent()
|
||||
: Results.NotFound(new { error = $"Script '{scriptId}' not found." });
|
||||
}
|
||||
|
||||
// ── Validate ────────────────────────────────────────────────────────────
|
||||
|
||||
private static async Task<IResult> ValidateScriptAsync(
|
||||
HttpContext httpContext,
|
||||
[FromServices] IScriptRegistry registry,
|
||||
CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var body = await JsonSerializer.DeserializeAsync<ValidateScriptDto>(httpContext.Request.Body, s_json, ct).ConfigureAwait(false);
|
||||
if (body is null) return Results.BadRequest(new { error = "Invalid request body." });
|
||||
|
||||
var language = ParseLanguageRequired(body.Language);
|
||||
var result = await registry.ValidateAsync(language, body.Content, ct).ConfigureAwait(false);
|
||||
|
||||
var response = new
|
||||
{
|
||||
isValid = result.IsValid,
|
||||
errors = result.Diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error).Select(d => new
|
||||
{
|
||||
line = d.Line,
|
||||
column = d.Column,
|
||||
message = d.Message,
|
||||
severity = "error",
|
||||
category = "syntax",
|
||||
}).ToArray(),
|
||||
warnings = result.Diagnostics.Where(d => d.Severity != DiagnosticSeverity.Error).Select(d => new
|
||||
{
|
||||
line = d.Line,
|
||||
column = d.Column,
|
||||
message = d.Message,
|
||||
severity = d.Severity == DiagnosticSeverity.Warning ? "warning" : "info",
|
||||
category = "syntax",
|
||||
}).ToArray(),
|
||||
};
|
||||
return Results.Json(response, s_json);
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or JsonException)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
// ── Versions ────────────────────────────────────────────────────────────
|
||||
|
||||
private static async Task<IResult> GetVersionsAsync(
|
||||
string scriptId,
|
||||
[FromServices] IScriptRegistry registry,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var versions = await registry.GetScriptVersionsAsync(scriptId, ct).ConfigureAwait(false);
|
||||
var dtos = versions.Select(v => new
|
||||
{
|
||||
version = v.Version,
|
||||
contentHash = v.ContentHash,
|
||||
createdBy = v.CreatedBy,
|
||||
createdAt = v.CreatedAt,
|
||||
changeNotes = v.ChangeNote,
|
||||
}).ToArray();
|
||||
return Results.Json(dtos, s_json);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetVersionContentAsync(
|
||||
string scriptId,
|
||||
int version,
|
||||
[FromServices] IScriptRegistry registry,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var ver = await registry.GetScriptVersionAsync(scriptId, version, ct).ConfigureAwait(false);
|
||||
if (ver is null) return Results.NotFound(new { error = $"Version {version} not found for script '{scriptId}'." });
|
||||
|
||||
var dto = new
|
||||
{
|
||||
version = ver.Version,
|
||||
contentHash = ver.ContentHash,
|
||||
createdBy = ver.CreatedBy,
|
||||
createdAt = ver.CreatedAt,
|
||||
changeNotes = ver.ChangeNote,
|
||||
content = ver.Content,
|
||||
};
|
||||
return Results.Json(dto, s_json);
|
||||
}
|
||||
|
||||
// ── Compatibility ───────────────────────────────────────────────────────
|
||||
|
||||
private static async Task<IResult> CheckCompatibilityAsync(
|
||||
string scriptId,
|
||||
HttpContext httpContext,
|
||||
[FromServices] IScriptRegistry registry,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Stub: always compatible. Real implementation will check runtime/target matrix.
|
||||
var script = await registry.GetScriptAsync(scriptId, ct).ConfigureAwait(false);
|
||||
if (script is null) return Results.NotFound(new { error = $"Script '{scriptId}' not found." });
|
||||
|
||||
var response = new { isCompatible = true, issues = Array.Empty<object>() };
|
||||
return Results.Json(response, s_json);
|
||||
}
|
||||
|
||||
// ── DTO mapping ─────────────────────────────────────────────────────────
|
||||
|
||||
private static object ToDto(Script s) => new
|
||||
{
|
||||
id = s.Id,
|
||||
name = s.Name,
|
||||
description = s.Description,
|
||||
language = s.Language.ToString().ToLowerInvariant(),
|
||||
content = s.Content,
|
||||
version = s.Version,
|
||||
visibility = s.Visibility.ToString().ToLowerInvariant(),
|
||||
ownerId = s.OwnerId,
|
||||
teamId = s.TeamId,
|
||||
tags = s.Tags.ToArray(),
|
||||
variables = s.Variables.Select(v => new
|
||||
{
|
||||
name = v.Name,
|
||||
description = v.Description,
|
||||
isRequired = v.IsRequired,
|
||||
defaultValue = v.DefaultValue,
|
||||
isSecret = v.IsSecret,
|
||||
}).ToArray(),
|
||||
contentHash = s.ContentHash,
|
||||
isSample = s.IsSample,
|
||||
sampleCategory = s.SampleCategory,
|
||||
createdAt = s.CreatedAt,
|
||||
updatedAt = s.UpdatedAt,
|
||||
};
|
||||
|
||||
// ── DTO types ───────────────────────────────────────────────────────────
|
||||
|
||||
private sealed record CreateScriptDto
|
||||
{
|
||||
public string Name { get; init; } = "";
|
||||
public string Description { get; init; } = "";
|
||||
public string Language { get; init; } = "";
|
||||
public string Content { get; init; } = "";
|
||||
public string Visibility { get; init; } = "private";
|
||||
public string[]? Tags { get; init; }
|
||||
public ScriptVariableDto[]? Variables { get; init; }
|
||||
}
|
||||
|
||||
private sealed record UpdateScriptDto
|
||||
{
|
||||
public string? Name { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public string? Content { get; init; }
|
||||
public string? Visibility { get; init; }
|
||||
public string[]? Tags { get; init; }
|
||||
public ScriptVariableDto[]? Variables { get; init; }
|
||||
public string? ChangeNotes { get; init; }
|
||||
}
|
||||
|
||||
private sealed record ValidateScriptDto
|
||||
{
|
||||
public string Language { get; init; } = "";
|
||||
public string Content { get; init; } = "";
|
||||
}
|
||||
|
||||
private sealed record ScriptVariableDto
|
||||
{
|
||||
public string Name { get; init; } = "";
|
||||
public string? Description { get; init; }
|
||||
public bool IsRequired { get; init; }
|
||||
public string? DefaultValue { get; init; }
|
||||
public bool IsSecret { get; init; }
|
||||
}
|
||||
|
||||
// ── Enum parsing ────────────────────────────────────────────────────────
|
||||
|
||||
private static ScriptLanguage? ParseLanguage(string? value) => value?.ToLowerInvariant() switch
|
||||
{
|
||||
"csharp" => ScriptLanguage.CSharp,
|
||||
"python" => ScriptLanguage.Python,
|
||||
"java" => ScriptLanguage.Java,
|
||||
"go" => ScriptLanguage.Go,
|
||||
"bash" => ScriptLanguage.Bash,
|
||||
"typescript" => ScriptLanguage.TypeScript,
|
||||
"powershell" => ScriptLanguage.PowerShell,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
private static ScriptLanguage ParseLanguageRequired(string value)
|
||||
=> ParseLanguage(value) ?? throw new ArgumentException($"Unknown language: '{value}'");
|
||||
|
||||
private static ScriptVisibility? ParseVisibility(string? value) => value?.ToLowerInvariant() switch
|
||||
{
|
||||
"private" => ScriptVisibility.Private,
|
||||
"team" => ScriptVisibility.Team,
|
||||
"organization" => ScriptVisibility.Organization,
|
||||
"public" => ScriptVisibility.Public,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
private static ScriptVisibility ParseVisibilityRequired(string value)
|
||||
=> ParseVisibility(value) ?? throw new ArgumentException($"Unknown visibility: '{value}'");
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output, inject, signal, OnInit } from '@angular/core';
|
||||
import { take } from 'rxjs';
|
||||
import { SCRIPTS_API } from '../../../core/api/scripts.client';
|
||||
import type { Script } from '../../../core/api/scripts.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-script-picker',
|
||||
standalone: true,
|
||||
template: `
|
||||
<div class="script-picker">
|
||||
<label class="script-picker__label">{{ label }}</label>
|
||||
<div class="script-picker__control">
|
||||
@if (selectedScript()) {
|
||||
<div class="script-picker__selected">
|
||||
<span class="script-picker__badge" [attr.data-lang]="selectedScript()!.language">{{ selectedScript()!.language }}</span>
|
||||
<span class="script-picker__name">{{ selectedScript()!.name }}</span>
|
||||
<span class="script-picker__version">v{{ selectedScript()!.version }}</span>
|
||||
<button type="button" class="script-picker__clear" (click)="clearSelection()" aria-label="Clear selection">×</button>
|
||||
</div>
|
||||
} @else {
|
||||
<button type="button" class="script-picker__browse" (click)="toggleDropdown()">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<polyline points="16 18 22 12 16 6"></polyline><polyline points="8 6 2 12 8 18"></polyline>
|
||||
</svg>
|
||||
Select script...
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (dropdownOpen()) {
|
||||
<div class="script-picker__dropdown">
|
||||
<input type="text" class="script-picker__search" placeholder="Filter scripts..."
|
||||
[value]="filterText()" (input)="filterText.set($any($event.target).value)" />
|
||||
@if (loading()) {
|
||||
<div class="script-picker__loading">Loading...</div>
|
||||
} @else if (filteredScripts().length === 0) {
|
||||
<div class="script-picker__empty">No scripts found</div>
|
||||
} @else {
|
||||
<ul class="script-picker__list">
|
||||
@for (s of filteredScripts(); track s.id) {
|
||||
<li class="script-picker__item" (click)="selectScript(s)">
|
||||
<span class="script-picker__badge" [attr.data-lang]="s.language">{{ s.language }}</span>
|
||||
<div class="script-picker__item-body">
|
||||
<span class="script-picker__item-name">{{ s.name }}</span>
|
||||
@if (s.description) {
|
||||
<span class="script-picker__item-desc">{{ s.description }}</span>
|
||||
}
|
||||
</div>
|
||||
<span class="script-picker__item-version">v{{ s.version }}</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.script-picker { position: relative; }
|
||||
.script-picker__label { display: block; font-size: 0.6875rem; font-weight: 600; color: var(--color-text-secondary); margin-bottom: 0.25rem; text-transform: uppercase; letter-spacing: 0.04em; }
|
||||
|
||||
.script-picker__control { display: flex; align-items: center; }
|
||||
|
||||
.script-picker__browse { display: inline-flex; align-items: center; gap: 0.35rem; padding: 0.35rem 0.75rem; font-size: 0.75rem; font-family: inherit; font-weight: 500; border: 1px dashed var(--color-border-primary); border-radius: var(--radius-sm); background: var(--color-surface-secondary); color: var(--color-text-muted); cursor: pointer; transition: all 150ms; }
|
||||
.script-picker__browse:hover { border-color: var(--color-brand-primary); color: var(--color-text-primary); background: var(--color-surface-tertiary); }
|
||||
|
||||
.script-picker__selected { display: inline-flex; align-items: center; gap: 0.35rem; padding: 0.25rem 0.5rem; font-size: 0.75rem; border: 1px solid var(--color-border-primary); border-radius: var(--radius-sm); background: var(--color-surface-secondary); }
|
||||
.script-picker__name { font-weight: 600; color: var(--color-text-primary); }
|
||||
.script-picker__version { font-family: var(--font-family-mono, monospace); font-size: 0.6875rem; color: var(--color-text-muted); }
|
||||
.script-picker__clear { margin-left: 0.25rem; padding: 0 0.25rem; border: none; background: none; color: var(--color-text-muted); font-size: 1rem; cursor: pointer; line-height: 1; }
|
||||
.script-picker__clear:hover { color: var(--color-status-error-text); }
|
||||
|
||||
.script-picker__dropdown { position: absolute; top: 100%; left: 0; z-index: 100; width: 100%; min-width: 320px; max-height: 280px; margin-top: 0.25rem; border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); background: var(--color-surface-primary); box-shadow: var(--shadow-lg, 0 4px 16px rgba(0,0,0,.12)); overflow: hidden; display: flex; flex-direction: column; }
|
||||
|
||||
.script-picker__search { width: 100%; padding: 0.5rem 0.6rem; border: none; border-bottom: 1px solid var(--color-border-primary); background: var(--color-surface-secondary); font-size: 0.75rem; font-family: inherit; color: var(--color-text-primary); outline: none; }
|
||||
.script-picker__search::placeholder { color: var(--color-text-muted); }
|
||||
|
||||
.script-picker__loading, .script-picker__empty { padding: 1rem; text-align: center; font-size: 0.75rem; color: var(--color-text-muted); }
|
||||
|
||||
.script-picker__list { list-style: none; margin: 0; padding: 0; overflow-y: auto; max-height: 220px; }
|
||||
.script-picker__item { display: flex; align-items: center; gap: 0.5rem; padding: 0.4rem 0.6rem; cursor: pointer; transition: background 100ms; }
|
||||
.script-picker__item:hover { background: var(--color-surface-tertiary); }
|
||||
.script-picker__item-body { flex: 1; min-width: 0; }
|
||||
.script-picker__item-name { display: block; font-size: 0.75rem; font-weight: 600; color: var(--color-text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.script-picker__item-desc { display: block; font-size: 0.65rem; color: var(--color-text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 260px; }
|
||||
.script-picker__item-version { flex-shrink: 0; font-family: var(--font-family-mono, monospace); font-size: 0.65rem; color: var(--color-text-muted); }
|
||||
|
||||
.script-picker__badge { display: inline-block; flex-shrink: 0; padding: 0.0625rem 0.3rem; font-size: 0.5625rem; font-weight: 600; border-radius: var(--radius-sm); text-transform: uppercase; letter-spacing: 0.03em; background: var(--color-surface-tertiary); color: var(--color-text-secondary); }
|
||||
.script-picker__badge[data-lang="bash"] { background: var(--color-status-success-bg); color: var(--color-status-success-text); }
|
||||
.script-picker__badge[data-lang="csharp"] { background: var(--color-status-info-bg); color: var(--color-status-info-text); }
|
||||
.script-picker__badge[data-lang="python"] { background: #FFF3E0; color: #E65100; }
|
||||
.script-picker__badge[data-lang="typescript"] { background: #E3F2FD; color: #1565C0; }
|
||||
.script-picker__badge[data-lang="powershell"] { background: #EDE7F6; color: #6A1B9A; }
|
||||
`],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ScriptPickerComponent implements OnInit {
|
||||
private readonly api = inject(SCRIPTS_API);
|
||||
|
||||
@Input() label = 'Script';
|
||||
@Input() selectedScriptId: string | null = null;
|
||||
|
||||
@Output() scriptSelected = new EventEmitter<Script | null>();
|
||||
|
||||
readonly scripts = signal<Script[]>([]);
|
||||
readonly selectedScript = signal<Script | null>(null);
|
||||
readonly loading = signal(false);
|
||||
readonly dropdownOpen = signal(false);
|
||||
readonly filterText = signal('');
|
||||
|
||||
readonly filteredScripts = () => {
|
||||
const q = this.filterText().toLowerCase().trim();
|
||||
if (!q) return this.scripts();
|
||||
return this.scripts().filter(s =>
|
||||
s.name.toLowerCase().includes(q) ||
|
||||
s.language.toLowerCase().includes(q) ||
|
||||
(s.description?.toLowerCase().includes(q) ?? false)
|
||||
);
|
||||
};
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadScripts();
|
||||
}
|
||||
|
||||
toggleDropdown(): void {
|
||||
if (!this.dropdownOpen()) {
|
||||
this.dropdownOpen.set(true);
|
||||
if (this.scripts().length === 0) {
|
||||
this.loadScripts();
|
||||
}
|
||||
} else {
|
||||
this.dropdownOpen.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
selectScript(script: Script): void {
|
||||
this.selectedScript.set(script);
|
||||
this.dropdownOpen.set(false);
|
||||
this.filterText.set('');
|
||||
this.scriptSelected.emit(script);
|
||||
}
|
||||
|
||||
clearSelection(): void {
|
||||
this.selectedScript.set(null);
|
||||
this.scriptSelected.emit(null);
|
||||
}
|
||||
|
||||
private loadScripts(): void {
|
||||
this.loading.set(true);
|
||||
this.api.listScripts({ limit: 50 }).pipe(take(1)).subscribe({
|
||||
next: (scripts) => {
|
||||
this.scripts.set(scripts);
|
||||
if (this.selectedScriptId) {
|
||||
const match = scripts.find(s => s.id === this.selectedScriptId);
|
||||
if (match) this.selectedScript.set(match);
|
||||
}
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.scripts.set([]);
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user