diff --git a/rubberduckvba.Server/Api/Admin/AdminController.cs b/rubberduckvba.Server/Api/Admin/AdminController.cs index c478ca3..382b1ee 100644 --- a/rubberduckvba.Server/Api/Admin/AdminController.cs +++ b/rubberduckvba.Server/Api/Admin/AdminController.cs @@ -7,6 +7,7 @@ namespace rubberduckvba.Server.Api.Admin; [ApiController] +[EnableCors(CorsPolicies.AllowAll)] public class AdminController(ConfigurationOptions options, HangfireLauncherService hangfire, CacheService cache) : ControllerBase { /// @@ -14,7 +15,6 @@ public class AdminController(ConfigurationOptions options, HangfireLauncherServi /// /// The unique identifier of the enqueued job. [Authorize("github")] - [EnableCors(CorsPolicies.AllowAuthenticated)] [HttpPost("admin/update/xmldoc")] public IActionResult UpdateXmldocContent() { @@ -27,7 +27,6 @@ public IActionResult UpdateXmldocContent() /// /// The unique identifier of the enqueued job. [Authorize("github")] - [EnableCors(CorsPolicies.AllowAuthenticated)] [HttpPost("admin/update/tags")] public IActionResult UpdateTagMetadata() { @@ -36,7 +35,6 @@ public IActionResult UpdateTagMetadata() } [Authorize("github")] - [EnableCors(CorsPolicies.AllowAuthenticated)] [HttpPost("admin/cache/clear")] public IActionResult ClearCache() { @@ -46,7 +44,6 @@ public IActionResult ClearCache() #if DEBUG [AllowAnonymous] - [EnableCors(CorsPolicies.AllowAll)] [HttpGet("admin/config/current")] public IActionResult Config() { diff --git a/rubberduckvba.Server/Api/Features/FeatureEditViewModel.cs b/rubberduckvba.Server/Api/Features/FeatureEditViewModel.cs index 62868d6..fa3d9f6 100644 --- a/rubberduckvba.Server/Api/Features/FeatureEditViewModel.cs +++ b/rubberduckvba.Server/Api/Features/FeatureEditViewModel.cs @@ -17,7 +17,7 @@ public static FeatureEditViewModel Default(RepositoryId repository, FeatureOptio Repositories = repositories, RepositoryId = repository, - ParentId = parent?.Id, + FeatureId = parent?.Id, Name = parent is null ? "NewFeature" : $"New{parent.Name}Feature", Title = "Feature Title", ShortDescription = "A short description; markdown is supported.", @@ -29,7 +29,7 @@ public Feature ToFeature() return new Feature { Id = Id ?? default, - FeatureId = ParentId, + ParentId = FeatureId, RepositoryId = RepositoryId, Name = Name, Title = Title, @@ -43,7 +43,7 @@ public Feature ToFeature() public FeatureEditViewModel(Feature model, FeatureOptionViewModel[] features, RepositoryOptionViewModel[] repositories) { Id = model.Id; - ParentId = model.FeatureId; + FeatureId = model.ParentId; RepositoryId = model.RepositoryId; Name = model.Name; @@ -59,7 +59,7 @@ public FeatureEditViewModel(Feature model, FeatureOptionViewModel[] features, Re } public int? Id { get; init; } - public int? ParentId { get; init; } + public int? FeatureId { get; init; } public RepositoryId RepositoryId { get; init; } public string Name { get; init; } diff --git a/rubberduckvba.Server/Api/Features/FeatureViewModel.cs b/rubberduckvba.Server/Api/Features/FeatureViewModel.cs index a47439d..062734c 100644 --- a/rubberduckvba.Server/Api/Features/FeatureViewModel.cs +++ b/rubberduckvba.Server/Api/Features/FeatureViewModel.cs @@ -11,7 +11,7 @@ public FeatureViewModel(Feature model, bool summaryOnly = false) DateInserted = model.DateTimeInserted; DateUpdated = model.DateTimeUpdated; - FeatureId = model.FeatureId; + FeatureId = model.ParentId; FeatureName = model.FeatureName; Name = model.Name; diff --git a/rubberduckvba.Server/Api/Features/FeaturesController.cs b/rubberduckvba.Server/Api/Features/FeaturesController.cs index 6868419..b60c9a3 100644 --- a/rubberduckvba.Server/Api/Features/FeaturesController.cs +++ b/rubberduckvba.Server/Api/Features/FeaturesController.cs @@ -9,8 +9,14 @@ namespace rubberduckvba.Server.Api.Features; +public class MarkdownContentViewModel +{ + public string Content { get; init; } = string.Empty; +} + [AllowAnonymous] +[EnableCors(CorsPolicies.AllowAll)] public class FeaturesController : RubberduckApiController { private readonly CacheService cache; @@ -18,8 +24,9 @@ public class FeaturesController : RubberduckApiController private readonly FeatureServices features; private readonly IRepository assetsRepository; private readonly IRepository tagsRepository; + private readonly IMarkdownFormattingService markdownService; - public FeaturesController(CacheService cache, IRubberduckDbService db, FeatureServices features, + public FeaturesController(CacheService cache, IRubberduckDbService db, FeatureServices features, IMarkdownFormattingService markdownService, IRepository assetsRepository, IRepository tagsRepository, ILogger logger) : base(logger) { @@ -28,6 +35,7 @@ public FeaturesController(CacheService cache, IRubberduckDbService db, FeatureSe this.features = features; this.assetsRepository = assetsRepository; this.tagsRepository = tagsRepository; + this.markdownService = markdownService; } private static RepositoryOptionViewModel[] RepositoryOptions { get; } = @@ -38,8 +46,6 @@ await db.GetTopLevelFeatures(repositoryId) .ContinueWith(t => t.Result.Select(e => new FeatureOptionViewModel { Id = e.Id, Name = e.Name, Title = e.Title }).ToArray()); [HttpGet("features")] - [EnableCors(CorsPolicies.AllowAll)] - [AllowAnonymous] public IActionResult Index() { return GuardInternalAction(() => @@ -68,8 +74,6 @@ public IActionResult Index() } [HttpGet("features/{name}")] - [EnableCors(CorsPolicies.AllowAll)] - [AllowAnonymous] public IActionResult Info([FromRoute] string name) { return GuardInternalAction(() => @@ -85,8 +89,6 @@ public IActionResult Info([FromRoute] string name) } [HttpGet("inspections/{name}")] - [EnableCors(CorsPolicies.AllowAll)] - [AllowAnonymous] public IActionResult Inspection([FromRoute] string name) { return GuardInternalAction(() => @@ -107,8 +109,6 @@ public IActionResult Inspection([FromRoute] string name) } [HttpGet("annotations/{name}")] - [EnableCors(CorsPolicies.AllowAll)] - [AllowAnonymous] public IActionResult Annotation([FromRoute] string name) { return GuardInternalAction(() => @@ -129,8 +129,6 @@ public IActionResult Annotation([FromRoute] string name) } [HttpGet("quickfixes/{name}")] - [EnableCors(CorsPolicies.AllowAll)] - [AllowAnonymous] public IActionResult QuickFix([FromRoute] string name) { return GuardInternalAction(() => @@ -151,7 +149,6 @@ public IActionResult QuickFix([FromRoute] string name) } [HttpGet("features/create")] - [EnableCors(CorsPolicies.AllowAuthenticated)] [Authorize("github")] public async Task> Create([FromQuery] RepositoryId repository = RepositoryId.Rubberduck, [FromQuery] int? parentId = default) { @@ -162,8 +159,7 @@ public async Task> Create([FromQuery] Reposit return Ok(model); } - [HttpPost("create")] - [EnableCors(CorsPolicies.AllowAuthenticated)] + [HttpPost("features/create")] [Authorize("github")] public async Task> Create([FromBody] FeatureEditViewModel model) { @@ -172,21 +168,21 @@ public async Task> Create([FromBody] FeatureE return BadRequest("Model is invalid for this endpoint."); } - var existing = await db.ResolveFeature(model.RepositoryId, model.Name); - if (existing != null) + var existingId = await db.GetFeatureId(model.RepositoryId, model.Name); + if (existingId != null) { return BadRequest($"Model [Name] must be unique; feature '{model.Name}' already exists."); } var feature = model.ToFeature(); - var result = await db.SaveFeature(feature); + var result = await db.SaveFeature(feature); var features = await GetFeatureOptions(model.RepositoryId); + return Ok(new FeatureEditViewModel(result, features, RepositoryOptions)); } [HttpPost("features/update")] - [EnableCors(CorsPolicies.AllowAuthenticated)] [Authorize("github")] public async Task> Update([FromBody] FeatureEditViewModel model) { @@ -195,18 +191,48 @@ public async Task> Update([FromBody] FeatureE return BadRequest("Model is invalid for this endpoint."); } - var existing = await db.ResolveFeature(model.RepositoryId, model.Name); - if (existing is null) + var existingId = await db.GetFeatureId(model.RepositoryId, model.Name); + if (existingId is null) { return BadRequest("Model is invalid for this endpoint."); } - var result = await db.SaveFeature(model.ToFeature()); + var feature = model.ToFeature(); + + var result = await db.SaveFeature(feature); var features = await GetFeatureOptions(model.RepositoryId); return new FeatureEditViewModel(result, features, RepositoryOptions); } + [HttpPost("features/delete")] + [Authorize("github")] + public async Task Delete([FromBody] IFeature model) + { + if (model.Id == default) + { + throw new ArgumentException("Model is invalid for this endpoint."); + } + var existingId = await db.GetFeatureId(RepositoryId.Rubberduck, model.Name); + if (existingId is null) + { + throw new ArgumentException("Model is invalid for this endpoint."); + } + + await db.DeleteFeature(existingId.Value); + } + + [HttpPost("markdown/format")] + public MarkdownContentViewModel FormatMarkdown([FromBody] MarkdownContentViewModel model) + { + var markdown = model.Content; + var formatted = markdownService.FormatMarkdownDocument(markdown, withSyntaxHighlighting: true); + return new MarkdownContentViewModel + { + Content = formatted + }; + } + private InspectionsFeatureViewModel GetInspections() { InspectionsFeatureViewModel result; @@ -274,5 +300,4 @@ private FeatureViewModel GetFeature(string name) return result; } - -} +} \ No newline at end of file diff --git a/rubberduckvba.Server/Data/Repository.cs b/rubberduckvba.Server/Data/Repository.cs index d2bdbc8..0c60312 100644 --- a/rubberduckvba.Server/Data/Repository.cs +++ b/rubberduckvba.Server/Data/Repository.cs @@ -9,6 +9,7 @@ namespace rubberduckvba.Server.Data; public interface IRepository where TEntity : Entity { + bool TryGetId(string name, out int id); int GetId(string name); TEntity GetById(int id); IEnumerable GetAll(); @@ -17,6 +18,7 @@ public interface IRepository where TEntity : Entity IEnumerable Insert(IEnumerable entities); void Update(TEntity entity); void Update(IEnumerable entities); + void Delete(int id); } public abstract class QueryableRepository where T : class @@ -71,8 +73,34 @@ ParentFKColumnName is null || !parentId.HasValue ? GetAll() : Query(db => db.Query($"{SelectSql} WHERE a.[{ParentFKColumnName}]=@parentId", new { parentId })); + public virtual bool TryGetId(string name, out int id) + { + id = default; + + var result = Get(db => db.QuerySingleOrDefault($"SELECT [Id] FROM [dbo].[{TableName}] WHERE [Name]=@name", new { name })); + if (result.HasValue) + { + id = result.Value; + return true; + } + + return false; + } + public virtual int GetId(string name) => Get(db => db.QuerySingle($"SELECT [Id] FROM [dbo].[{TableName}] WHERE [Name]=@name", new { name })); public virtual TEntity GetById(int id) => Get(db => db.QuerySingle(SelectSql + " WHERE a.[Id]=@id", new { id })); + + public virtual void Delete(int id) + { + using var db = new SqlConnection(ConnectionString); + db.Open(); + + using var transaction = db.BeginTransaction(); + db.Execute($"DELETE FROM [dbo].[{TableName}] WHERE [Id]=@id", new { id }, transaction); + + transaction.Commit(); + } + public virtual TEntity Insert(TEntity entity) => Insert([entity]).Single(); public virtual IEnumerable Insert(IEnumerable entities) { diff --git a/rubberduckvba.Server/GitHubAuthenticationHandler.cs b/rubberduckvba.Server/GitHubAuthenticationHandler.cs index 51ee239..18b635f 100644 --- a/rubberduckvba.Server/GitHubAuthenticationHandler.cs +++ b/rubberduckvba.Server/GitHubAuthenticationHandler.cs @@ -1,5 +1,6 @@  using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Options; using rubberduckvba.Server.Services; using System.Security.Claims; @@ -10,12 +11,19 @@ namespace rubberduckvba.Server; public class GitHubAuthenticationHandler : AuthenticationHandler { private readonly IGitHubClientService _github; + private readonly IMemoryCache _cache; - public GitHubAuthenticationHandler(IGitHubClientService github, + private static readonly MemoryCacheEntryOptions _options = new MemoryCacheEntryOptions + { + SlidingExpiration = TimeSpan.FromMinutes(60), + }; + + public GitHubAuthenticationHandler(IGitHubClientService github, IMemoryCache cache, IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder) : base(options, logger, encoder) { _github = github; + _cache = cache; } protected async override Task HandleAuthenticateAsync() @@ -28,12 +36,27 @@ protected async override Task HandleAuthenticateAsync() return AuthenticateResult.Fail("Access token was not provided"); } + if (_cache.TryGetValue(token, out var cached) && cached is AuthenticationTicket cachedTicket) + { + var cachedPrincipal = cachedTicket.Principal; + + Context.User = cachedPrincipal; + Thread.CurrentPrincipal = cachedPrincipal; + + Logger.LogInformation($"Successfully retrieved authentication ticket from cached token for {cachedPrincipal.Identity!.Name}; token will not be revalidated."); + return AuthenticateResult.Success(cachedTicket); + } + var principal = await _github.ValidateTokenAsync(token); if (principal is ClaimsPrincipal) { Context.User = principal; Thread.CurrentPrincipal = principal; - return AuthenticateResult.Success(new AuthenticationTicket(principal, "github")); + + var ticket = new AuthenticationTicket(principal, "github"); + _cache.Set(token, ticket, _options); + + return AuthenticateResult.Success(ticket); } return AuthenticateResult.Fail("An invalid access token was provided"); diff --git a/rubberduckvba.Server/Model/Entity/FeatureEntity.cs b/rubberduckvba.Server/Model/Entity/FeatureEntity.cs index 38b2803..a623791 100644 --- a/rubberduckvba.Server/Model/Entity/FeatureEntity.cs +++ b/rubberduckvba.Server/Model/Entity/FeatureEntity.cs @@ -4,7 +4,7 @@ namespace rubberduckvba.Server.Model.Entity; public record class FeatureEntity : Entity { - public int? FeatureId { get; init; } + public int? ParentId { get; init; } public string FeatureName { get; init; } = default!; public int RepositoryId { get; init; } public string Title { get; init; } = default!; diff --git a/rubberduckvba.Server/Model/Feature.cs b/rubberduckvba.Server/Model/Feature.cs index b7d0b30..9926c0c 100644 --- a/rubberduckvba.Server/Model/Feature.cs +++ b/rubberduckvba.Server/Model/Feature.cs @@ -27,7 +27,7 @@ public Feature(FeatureEntity entity) : this() DateTimeInserted = entity.DateTimeInserted; DateTimeUpdated = entity.DateTimeUpdated; Name = entity.Name; - FeatureId = entity.FeatureId; + ParentId = entity.ParentId; FeatureName = entity.FeatureName; RepositoryId = (Services.RepositoryId)entity.RepositoryId; Title = entity.Title; @@ -45,7 +45,7 @@ public Feature(FeatureEntity entity) : this() public DateTime? DateTimeUpdated { get; init; } public string Name { get; init; } = string.Empty; - public int? FeatureId { get; init; } + public int? ParentId { get; init; } public string FeatureName { get; init; } = string.Empty; public Services.RepositoryId RepositoryId { get; init; } = Services.RepositoryId.Rubberduck; public string Title { get; init; } = string.Empty; @@ -69,7 +69,7 @@ public Feature(FeatureEntity entity) : this() IsNew = IsNew, Name = Name, ShortDescription = ShortDescription, - FeatureId = FeatureId, + ParentId = ParentId, FeatureName = FeatureName, RepositoryId = (int)Services.RepositoryId.Rubberduck, Title = Title, diff --git a/rubberduckvba.Server/Program.cs b/rubberduckvba.Server/Program.cs index 05feb62..32d6b85 100644 --- a/rubberduckvba.Server/Program.cs +++ b/rubberduckvba.Server/Program.cs @@ -3,6 +3,7 @@ using Hangfire.Dashboard; using Hangfire.SqlServer; using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Options; using NLog.Config; using NLog.Extensions.Logging; @@ -35,7 +36,6 @@ public class HangfireAuthenticationFilter : IDashboardAuthorizationFilter public static class CorsPolicies { public const string AllowAll = "AllowAll"; - public const string AllowAuthenticated = "AllowAuthenticated"; } public class Program @@ -57,17 +57,9 @@ public static void Main(string[] args) { policy .SetIsOriginAllowed(origin => true) - .AllowAnyHeader() - .WithMethods("OPTIONS", "GET", "POST") - .Build(); - }); - builder.AddPolicy(CorsPolicies.AllowAuthenticated, policy => - { - policy - .SetIsOriginAllowed(origin => true) - .WithHeaders("X-ACCESS-TOKEN") .WithMethods("OPTIONS", "GET", "POST") - .AllowCredentials() + .AllowAnyHeader() + .AllowAnyOrigin() .Build(); }); }); @@ -235,6 +227,7 @@ private static void ConfigureServices(IServiceCollection services) services.AddSingleton(); services.AddSession(ConfigureSession); + services.AddSingleton(provider => new MemoryCache(new MemoryCacheOptions(), provider.GetRequiredService())); } private static void ConfigureLogging(IServiceCollection services) diff --git a/rubberduckvba.Server/Services/RubberduckDbService.cs b/rubberduckvba.Server/Services/RubberduckDbService.cs index 95a88ad..ab4eb7d 100644 --- a/rubberduckvba.Server/Services/RubberduckDbService.cs +++ b/rubberduckvba.Server/Services/RubberduckDbService.cs @@ -69,6 +69,7 @@ public interface IRubberduckDbService Task ResolveFeature(RepositoryId repositoryId, string name); Task GetFeatureId(RepositoryId repositoryId, string name); Task SaveFeature(Feature feature); + Task DeleteFeature(int id); } public class RubberduckDbService : IRubberduckDbService @@ -160,7 +161,7 @@ public async Task ResolveFeature(RepositoryId repositoryId, string { var features = _featureServices.Get(topLevelOnly: false).ToList(); var feature = features.Single(e => string.Equals(e.Name, name, StringComparison.OrdinalIgnoreCase)); - var children = features.Where(e => e.FeatureId == feature.Id); + var children = features.Where(e => e.ParentId == feature.Id); return new FeatureGraph(feature.ToEntity()) { Features = children.ToArray() @@ -238,6 +239,11 @@ public async Task ResolveFeature(RepositoryId repositoryId, string // return graph; } + public async Task DeleteFeature(int id) + { + _featureServices.DeleteFeature(id); + } + public async Task SaveFeature(Feature feature) { if (feature.Id == default) diff --git a/rubberduckvba.Server/Services/rubberduckdb/FeatureServices.cs b/rubberduckvba.Server/Services/rubberduckdb/FeatureServices.cs index 2a0033f..e2fd5fa 100644 --- a/rubberduckvba.Server/Services/rubberduckdb/FeatureServices.cs +++ b/rubberduckvba.Server/Services/rubberduckdb/FeatureServices.cs @@ -11,11 +11,11 @@ public class FeatureServices( IRepository quickfixRepository, IRepository annotationRepository) { - public int GetId(string name) => featureRepository.GetId(name); + public int? GetId(string name) => featureRepository.TryGetId(name, out var id) ? id : null; public IEnumerable Get(bool topLevelOnly = true) { return featureRepository.GetAll() - .Where(e => !topLevelOnly || e.FeatureId is null) + .Where(e => !topLevelOnly || e.ParentId is null) .Select(e => new Feature(e)); } @@ -94,4 +94,6 @@ feature with public void Insert(IEnumerable inspections) => inspectionRepository.Insert(inspections.Select(inspection => inspection.ToEntity())); public void Insert(IEnumerable quickFixes) => quickfixRepository.Insert(quickFixes.Select(quickfix => quickfix.ToEntity())); public void Insert(IEnumerable annotations) => annotationRepository.Insert(annotations.Select(annotation => annotation.ToEntity())); + + public void DeleteFeature(int id) => featureRepository.Delete(id); } diff --git a/rubberduckvba.client/src/app/app.module.ts b/rubberduckvba.client/src/app/app.module.ts index 86f5ce9..6cf1f25 100644 --- a/rubberduckvba.client/src/app/app.module.ts +++ b/rubberduckvba.client/src/app/app.module.ts @@ -26,6 +26,8 @@ import { AnnotationItemBoxComponent } from './components/feature-item-box/annota import { BlogLinkBoxComponent } from './components/blog-link-box/blog-link-box.component'; import { QuickFixItemBoxComponent } from './components/feature-item-box/quickfix-item-box/quickfix-item-box.component'; +import { EditFeatureComponent } from './components/edit-feature/edit-feature.component'; + import { HomeComponent } from './routes/home/home.component'; import { AboutComponent } from './routes/about/about.component'; import { FeaturesComponent } from './routes/features/features.component'; @@ -78,7 +80,8 @@ export class LowerCaseUrlSerializer extends DefaultUrlSerializer { InspectionComponent, AnnotationComponent, QuickFixComponent, - AboutComponent + AboutComponent, + EditFeatureComponent ], bootstrap: [AppComponent], imports: [ diff --git a/rubberduckvba.client/src/app/components/edit-feature/edit-feature.component.html b/rubberduckvba.client/src/app/components/edit-feature/edit-feature.component.html new file mode 100644 index 0000000..f1299fa --- /dev/null +++ b/rubberduckvba.client/src/app/components/edit-feature/edit-feature.component.html @@ -0,0 +1,167 @@ + + + + + + + + + +
+ + + +
+
+ + +
+ + + +
+
+ + +
+ + + +
+
diff --git a/rubberduckvba.client/src/app/components/edit-feature/edit-feature.component.ts b/rubberduckvba.client/src/app/components/edit-feature/edit-feature.component.ts new file mode 100644 index 0000000..9346649 --- /dev/null +++ b/rubberduckvba.client/src/app/components/edit-feature/edit-feature.component.ts @@ -0,0 +1,134 @@ +import { Component, EventEmitter, Input, OnChanges, OnInit, Output, SimpleChanges, TemplateRef, ViewChild, inject, input } from "@angular/core"; +import { BehaviorSubject } from "rxjs"; +import { EditSubFeatureViewModelClass, MarkdownContent, SubFeatureViewModel, SubFeatureViewModelClass, UserViewModel } from "../../model/feature.model"; +import { FaIconLibrary } from "@fortawesome/angular-fontawesome"; +import { fas } from "@fortawesome/free-solid-svg-icons"; +import { NgbModal } from "@ng-bootstrap/ng-bootstrap"; +import { ApiClientService } from "../../services/api-client.service"; + +export enum AdminAction { + Edit = 'edit', + EditSummary = 'summary', + Create = 'create', + Delete = 'delete', +} + +@Component({ + selector: 'edit-feature', + templateUrl: './edit-feature.component.html' +}) +export class EditFeatureComponent implements OnInit, OnChanges { + private readonly _user: BehaviorSubject = new BehaviorSubject(null!); + private readonly _feature: BehaviorSubject = new BehaviorSubject(null!); + + private _action: AdminAction = AdminAction.Create; + + @ViewChild('editModal', { read: TemplateRef }) editModal: TemplateRef | undefined; + @ViewChild('createModal', { read: TemplateRef }) createModal: TemplateRef | undefined; + @ViewChild('deleteModal', { read: TemplateRef }) deleteModal: TemplateRef | undefined; + + public modal = inject(NgbModal); + + @Input() + public set feature(value: SubFeatureViewModel | undefined) { + if (value != null) { + this._feature.next(new EditSubFeatureViewModelClass(value)); + } + } + + public get feature(): EditSubFeatureViewModelClass { + return this._feature.value; + } + + @Input() + public set action(value: AdminAction) { + this._action = value; + } + + public get action(): AdminAction { + return this._action; + } + + @Output() + public onApplyChanges = new EventEmitter(); + + + public subfeature: EditSubFeatureViewModelClass = null!; + + constructor(private fa: FaIconLibrary, private api: ApiClientService) { + fa.addIconPacks(fas); + } + + ngOnChanges(changes: SimpleChanges): void { + } + + ngOnInit(): void { + } + + public doAction(): void { + const localModal = this.action == 'delete' ? this.deleteModal + : this.action == 'create' ? this.createModal + : this.editModal; + const size = this.action == 'delete' ? 'modal-m' : 'modal-xl'; + + if (this.action == 'create') { + const parentId = this.feature.id; + const parentName = this.feature.name; + const parentTitle = this.feature.title; + + this.subfeature = new EditSubFeatureViewModelClass({ + dateInserted: '', + dateUpdated: '', + description: '', + id: undefined, + isHidden: false, + isNew: false, + name: 'NewFeature1', + title: 'New Feature', + shortDescription: '', + featureId: parentId, + featureName: parentName, + featureTitle: parentTitle, + isCollapsed: false, + isDetailsCollapsed: true, + }); + } + + this.modal.open(localModal, { modalDialogClass: size }); + } + + public onConfirmChanges(): void { + this.modal.dismissAll(); + this.api.saveFeature(this.feature).subscribe(saved => { + window.location.reload(); + }); + } + + public onConfirmCreate(): void { + this.modal.dismissAll(); + this.api.createFeature(this.subfeature).subscribe(saved => { + window.location.reload(); + }); + } + + public onPreviewDescription(): void { + const raw = this.action == 'create' + ? this.subfeature.description + : this.feature.description; + this.api.formatMarkdown(raw).subscribe((formatted: MarkdownContent) => { + if (this.action == 'create') { + this.subfeature.descriptionPreview = formatted.content; + } + else { + this.feature.descriptionPreview = formatted.content; + } + }); + } + + public onDeleteFeature(): void { + this.modal.dismissAll(); + this.api.deleteFeature(this.feature).subscribe(() => { + window.location.reload(); + }); + } +} diff --git a/rubberduckvba.client/src/app/components/feature-box/feature-box.component.html b/rubberduckvba.client/src/app/components/feature-box/feature-box.component.html index a63f3e1..908b67c 100644 --- a/rubberduckvba.client/src/app/components/feature-box/feature-box.component.html +++ b/rubberduckvba.client/src/app/components/feature-box/feature-box.component.html @@ -29,5 +29,14 @@

{{feature.title}}

+ diff --git a/rubberduckvba.client/src/app/components/feature-box/feature-box.component.ts b/rubberduckvba.client/src/app/components/feature-box/feature-box.component.ts index 7a6eb20..0b5d70d 100644 --- a/rubberduckvba.client/src/app/components/feature-box/feature-box.component.ts +++ b/rubberduckvba.client/src/app/components/feature-box/feature-box.component.ts @@ -1,17 +1,20 @@ import { Component, Input, OnChanges, OnInit, SimpleChanges, TemplateRef, ViewChild, inject } from '@angular/core'; -import { FeatureViewModel, QuickFixViewModel, SubFeatureViewModel } from '../../model/feature.model'; +import { FeatureViewModel, QuickFixViewModel, SubFeatureViewModel, UserViewModel } from '../../model/feature.model'; import { BehaviorSubject } from 'rxjs'; import { FaIconLibrary } from '@fortawesome/angular-fontawesome'; import { fas } from '@fortawesome/free-solid-svg-icons'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { AuthService } from '../../services/auth.service'; +import { AdminAction } from '../edit-feature/edit-feature.component'; @Component({ selector: 'feature-box', templateUrl: './feature-box.component.html' }) -export class FeatureBoxComponent implements OnInit { +export class FeatureBoxComponent implements OnInit, OnChanges { private readonly _info: BehaviorSubject = new BehaviorSubject(null!); + private readonly _user: BehaviorSubject = new BehaviorSubject(null!); @ViewChild('content', { read: TemplateRef }) content: TemplateRef | undefined; public modal = inject(NgbModal); @@ -22,6 +25,20 @@ export class FeatureBoxComponent implements OnInit { @Input() public hasOwnDetailsPage: boolean = false; + public get isProtected(): boolean { + return this.feature?.name == 'Inspections' + || this.feature?.name == 'QuickFixes' + || this.feature?.name == 'Annotations' + || this.feature?.name == 'CodeInspections' + || this.feature?.name == 'CommentAnnotations'; + } + + public get hasXmlDocFeatures(): boolean { + return this.feature?.name == 'Inspections' + || this.feature?.name == 'QuickFixes' + || this.feature?.name == 'Annotations'; + } + @Input() public set feature(value: FeatureViewModel | undefined) { if (value != null) { @@ -29,6 +46,11 @@ export class FeatureBoxComponent implements OnInit { } } + public editAction: AdminAction = AdminAction.EditSummary; + public editDetailsAction: AdminAction = AdminAction.Edit; + public createAction: AdminAction = AdminAction.Create; + public deleteAction: AdminAction = AdminAction.Delete; + public get feature(): FeatureViewModel | undefined { return this._info.value as FeatureViewModel; } @@ -37,27 +59,28 @@ export class FeatureBoxComponent implements OnInit { return this._info.value as SubFeatureViewModel; } - private readonly _quickfixes: BehaviorSubject = new BehaviorSubject(null!); - - @Input() - public set quickFixes(value: QuickFixViewModel[]) { - if (value != null) { - this._quickfixes.next(value); - } + constructor(private fa: FaIconLibrary, private auth: AuthService) { + fa.addIconPacks(fas); } - - public get quickFixes(): QuickFixViewModel[] { - return this._quickfixes.value; + ngOnChanges(changes: SimpleChanges): void { + console.log(changes); } - constructor(private fa: FaIconLibrary) { - fa.addIconPacks(fas); + ngOnInit(): void { + this.auth.getUser().subscribe(vm => { + this._user.next(vm); + }); } - ngOnInit(): void { + public applyChanges(model: any): void { + this._info.next(model); } public showDetails(): void { this.modal.open(this.content); } + + public get user(): UserViewModel { + return this._user.getValue(); + } } diff --git a/rubberduckvba.client/src/app/components/feature-info/feature-info.component.html b/rubberduckvba.client/src/app/components/feature-info/feature-info.component.html index 7e4620a..2453a08 100644 --- a/rubberduckvba.client/src/app/components/feature-info/feature-info.component.html +++ b/rubberduckvba.client/src/app/components/feature-info/feature-info.component.html @@ -4,6 +4,9 @@

HomeFeatures{{feature?.featureName}}

{{feature?.title}}

+
+ +

@@ -34,6 +37,7 @@

{{feature?.title}}

+
diff --git a/rubberduckvba.client/src/app/components/feature-info/feature-info.component.ts b/rubberduckvba.client/src/app/components/feature-info/feature-info.component.ts index 1c51009..19de4d5 100644 --- a/rubberduckvba.client/src/app/components/feature-info/feature-info.component.ts +++ b/rubberduckvba.client/src/app/components/feature-info/feature-info.component.ts @@ -1,17 +1,24 @@ import { Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core'; -import { AnnotationViewModel, AnnotationsFeatureViewModel, BlogLink, FeatureViewModel, InspectionViewModel, InspectionsFeatureViewModel, QuickFixViewModel, QuickFixesFeatureViewModel, SubFeatureViewModel, XmlDocItemViewModel, XmlDocOrFeatureViewModel, XmlDocViewModel } from '../../model/feature.model'; +import { AnnotationViewModel, AnnotationsFeatureViewModel, BlogLink, FeatureViewModel, InspectionViewModel, InspectionsFeatureViewModel, QuickFixViewModel, QuickFixesFeatureViewModel, SubFeatureViewModel, UserViewModel, XmlDocItemViewModel, XmlDocOrFeatureViewModel, XmlDocViewModel } from '../../model/feature.model'; import { BehaviorSubject } from 'rxjs'; import { FaIconLibrary } from '@fortawesome/angular-fontawesome'; import { fas } from '@fortawesome/free-solid-svg-icons'; import { ApiClientService } from '../../services/api-client.service'; +import { AuthService } from '../../services/auth.service'; +import { AdminAction } from '../edit-feature/edit-feature.component'; @Component({ selector: 'feature-info', templateUrl: './feature-info.component.html', }) -export class FeatureInfoComponent implements OnInit, OnChanges { +export class FeatureInfoComponent implements OnInit { private readonly _info: BehaviorSubject = new BehaviorSubject(null!); + private readonly _user: BehaviorSubject = new BehaviorSubject(null!); + + public editAction: AdminAction = AdminAction.Edit; + public createAction: AdminAction = AdminAction.Create; + public deleteAction: AdminAction = AdminAction.Delete; public filterState = { // searchbox @@ -68,11 +75,20 @@ export class FeatureInfoComponent implements OnInit, OnChanges { return feature?.links ?? []; } - constructor(private api: ApiClientService, private fa: FaIconLibrary) { + public get user(): UserViewModel { + return this._user.getValue(); + } + + constructor(private auth: AuthService, private api: ApiClientService, private fa: FaIconLibrary) { fa.addIconPacks(fas); } ngOnInit(): void { + this.auth.getUser().subscribe(result => { + if (result) { + this._user.next(result as UserViewModel); + } + }); this.api.getFeature('quickfixes').subscribe(result => { if (result) { this._quickfixes.next((result as QuickFixesFeatureViewModel).quickFixes.slice()); @@ -80,10 +96,6 @@ export class FeatureInfoComponent implements OnInit, OnChanges { }); } - - ngOnChanges(changes: SimpleChanges): void { - } - public onFilter(): void { this.filterByNameOrDescription(this.filterState.filterText); } diff --git a/rubberduckvba.client/src/app/model/feature.model.ts b/rubberduckvba.client/src/app/model/feature.model.ts index 97a0534..fad751f 100644 --- a/rubberduckvba.client/src/app/model/feature.model.ts +++ b/rubberduckvba.client/src/app/model/feature.model.ts @@ -1,5 +1,5 @@ export interface ViewModel { - id: number; + id: number | undefined; dateInserted: string; dateUpdated: string; name: string; @@ -10,6 +10,10 @@ export interface ViewModel { isDetailsCollapsed: boolean; } +export interface MarkdownContent { + content: string; +} + export interface SubFeatureViewModel extends ViewModel { featureId?: number; featureName?: string; @@ -17,6 +21,7 @@ export interface SubFeatureViewModel extends ViewModel { title: string; description: string; + shortDescription: string; } export interface XmlDocViewModel extends SubFeatureViewModel { @@ -185,7 +190,7 @@ export interface AnnotationViewModel extends XmlDocViewModel { export type XmlDocItemViewModel = InspectionViewModel | QuickFixViewModel | AnnotationViewModel; export class ViewModelBase implements ViewModel { - id: number; + id: number | undefined; dateInserted: string; dateUpdated: string; name: string; @@ -261,17 +266,29 @@ export class SubFeatureViewModelClass extends ViewModelBase implements SubFeatur featureTitle?: string | undefined; title: string; description: string; + shortDescription: string; constructor(model: SubFeatureViewModel) { super(model); this.title = model.title; this.description = model.description; + this.shortDescription = model.shortDescription; this.isDetailsCollapsed = true; this.featureId = model.featureId; this.featureName = model.featureName; } } +export class EditSubFeatureViewModelClass extends SubFeatureViewModelClass { + constructor(model: SubFeatureViewModel) { + super(model); + this.isDetailsCollapsed = false; + this.descriptionPreview = model.description; + } + + public descriptionPreview: string; +} + export class InspectionViewModelClass extends SubFeatureViewModelClass implements InspectionViewModel { inspectionType: string; defaultSeverity: string; diff --git a/rubberduckvba.client/src/app/routes/features/features.component.html b/rubberduckvba.client/src/app/routes/features/features.component.html index 2366bed..f55801a 100644 --- a/rubberduckvba.client/src/app/routes/features/features.component.html +++ b/rubberduckvba.client/src/app/routes/features/features.component.html @@ -25,7 +25,7 @@

Your IDE is incomplete without...

diff --git a/rubberduckvba.client/src/app/routes/features/features.component.ts b/rubberduckvba.client/src/app/routes/features/features.component.ts index cf7c8e6..5e1acd7 100644 --- a/rubberduckvba.client/src/app/routes/features/features.component.ts +++ b/rubberduckvba.client/src/app/routes/features/features.component.ts @@ -1,4 +1,4 @@ -import { Component, OnChanges, OnInit, SimpleChanges } from '@angular/core'; +import { Component, OnInit } from '@angular/core'; import { ApiClientService } from "../../services/api-client.service"; import { FaIconLibrary } from '@fortawesome/angular-fontawesome'; import { fas } from '@fortawesome/free-solid-svg-icons'; @@ -9,7 +9,7 @@ import { FeatureViewModel, QuickFixViewModel } from '../../model/feature.model'; selector: 'app-features', templateUrl: './features.component.html', }) -export class FeaturesComponent implements OnInit, OnChanges { +export class FeaturesComponent implements OnInit { private readonly _features: BehaviorSubject = new BehaviorSubject(null!); public set features(value: FeatureViewModel[]) { @@ -19,19 +19,10 @@ export class FeaturesComponent implements OnInit, OnChanges { return this._features.getValue(); } - private readonly _quickFixes: BehaviorSubject = new BehaviorSubject(null!); - public get quickFixes(): QuickFixViewModel[] { - return this._quickFixes.value; - } - constructor(private api: ApiClientService, private fa: FaIconLibrary) { fa.addIconPacks(fas); } - ngOnChanges(changes: SimpleChanges): void { - console.log(changes); - } - ngOnInit(): void { this.api.getFeatureSummaries().subscribe(result => { if (result) { diff --git a/rubberduckvba.client/src/app/services/api-client.service.ts b/rubberduckvba.client/src/app/services/api-client.service.ts index 5be402b..431441d 100644 --- a/rubberduckvba.client/src/app/services/api-client.service.ts +++ b/rubberduckvba.client/src/app/services/api-client.service.ts @@ -1,6 +1,6 @@ import { Injectable } from "@angular/core"; import { LatestTags, Tag } from "../model/tags.model"; -import { AnnotationViewModel, AnnotationViewModelClass, AnnotationsFeatureViewModel, AnnotationsFeatureViewModelClass, FeatureViewModel, FeatureViewModelClass, InspectionViewModel, InspectionViewModelClass, InspectionsFeatureViewModel, InspectionsFeatureViewModelClass, QuickFixViewModel, QuickFixViewModelClass, QuickFixesFeatureViewModel, QuickFixesFeatureViewModelClass, SubFeatureViewModel, SubFeatureViewModelClass, UserViewModel, XmlDocOrFeatureViewModel } from "../model/feature.model"; +import { AnnotationViewModel, AnnotationViewModelClass, AnnotationsFeatureViewModel, AnnotationsFeatureViewModelClass, FeatureViewModel, FeatureViewModelClass, InspectionViewModel, InspectionViewModelClass, InspectionsFeatureViewModel, InspectionsFeatureViewModelClass, MarkdownContent, QuickFixViewModel, QuickFixViewModelClass, QuickFixesFeatureViewModel, QuickFixesFeatureViewModelClass, SubFeatureViewModel, SubFeatureViewModelClass, UserViewModel, XmlDocOrFeatureViewModel } from "../model/feature.model"; import { DownloadInfo } from "../model/downloads.model"; import { DataService } from "./data.service"; import { environment } from "../../environments/environment.prod"; @@ -82,4 +82,27 @@ export class ApiClientService { return model; })); } + + public createFeature(model: SubFeatureViewModel): Observable { + const url = `${environment.apiBaseUrl}features/create`; + return this.data.postAsync(url, model).pipe(map(result => new SubFeatureViewModelClass(result as SubFeatureViewModel))); + } + + public saveFeature(model: SubFeatureViewModel): Observable { + const url = `${environment.apiBaseUrl}features/update`; + return this.data.postAsync(url, model).pipe(map(result => new SubFeatureViewModelClass(result as SubFeatureViewModel))); + } + + public deleteFeature(model: SubFeatureViewModel): Observable { + const url = `${environment.apiBaseUrl}features/delete`; + return this.data.postAsync(url, model).pipe(map(() => model)); + } + + public formatMarkdown(raw: string): Observable { + const url = `${environment.apiBaseUrl}markdown/format`; + const content: MarkdownContent = { + content: raw + }; + return this.data.postAsync(url, content); + } } diff --git a/rubberduckvba.client/src/app/services/data.service.ts b/rubberduckvba.client/src/app/services/data.service.ts index c66a376..748b7ea 100644 --- a/rubberduckvba.client/src/app/services/data.service.ts +++ b/rubberduckvba.client/src/app/services/data.service.ts @@ -14,13 +14,11 @@ export class DataService { let headers = new HttpHeaders() .append('accept', 'application/json'); const token = sessionStorage.getItem('github:access_token'); - let withCreds = false; if (token) { headers = headers.append('X-ACCESS-TOKEN', token); - withCreds = true; } - return this.http.get(url, { headers, withCredentials: withCreds }) + return this.http.get(url, { headers }) .pipe( map(result => result), timeout(this.timeout), @@ -38,15 +36,13 @@ export class DataService { .append('Content-Type', 'application/json; charset=utf-8'); const token = sessionStorage.getItem('github:access_token'); - let withCreds = false; if (token) { headers = headers.append('X-ACCESS-TOKEN', token); - withCreds = true; } return (content - ? this.http.post(url, content, { headers, withCredentials: withCreds }) - : this.http.post(url, null, { headers, withCredentials: withCreds })) + ? this.http.post(url, content, { headers } ) + : this.http.post(url, null, { headers })) .pipe( map(result => result), timeout(this.timeout), diff --git a/rubberduckvba.client/src/environments/environment.prod.ts b/rubberduckvba.client/src/environments/environment.prod.ts index d3de473..e95e3c7 100644 --- a/rubberduckvba.client/src/environments/environment.prod.ts +++ b/rubberduckvba.client/src/environments/environment.prod.ts @@ -1,4 +1,4 @@ export const environment = { production: true, - apiBaseUrl: 'https://api.rubberduckvba.com/' + apiBaseUrl: 'https://localhost:44314/' }; diff --git a/rubberduckvba.client/src/environments/environment.ts b/rubberduckvba.client/src/environments/environment.ts index fbede92..0fd806b 100644 --- a/rubberduckvba.client/src/environments/environment.ts +++ b/rubberduckvba.client/src/environments/environment.ts @@ -4,7 +4,7 @@ export const environment = { production: false, - apiBaseUrl: 'https://api.rubberduckvba.com/' + apiBaseUrl: 'https://localhost:44314/' }; /*