From c55b018c0d6357c52cb7c1809e7bfca392560c9e Mon Sep 17 00:00:00 2001 From: Carl Tibule Date: Sun, 19 Feb 2023 20:51:31 -0600 Subject: [PATCH] Added page and endpoints for managing Tags --- YABA.API/Controllers/BookmarksController.cs | 34 +- YABA.API/Controllers/TagsController.cs | 81 +++- YABA.API/Settings/AutoMapperProfile.cs | 2 +- YABA.API/ViewModels/Tags/DeleteTagsRequest.cs | 7 + YABA.API/ViewModels/Tags/HideTagsRequest.cs | 7 + YABA.API/ViewModels/Tags/TagResponse.cs | 1 + YABA.Common/DTOs/Bookmarks/BookmarkDTO.cs | 2 +- YABA.Common/DTOs/Tags/CreateTagDTO.cs | 11 + YABA.Common/DTOs/Tags/TagDTO.cs | 1 - YABA.Common/DTOs/Tags/TagSummaryDTO.cs | 9 - YABA.Common/DTOs/Tags/UpdateTagDTO.cs | 10 + YABA.Service/BookmarkService.cs | 28 +- .../Configuration/AutoMapperProfile.cs | 3 +- YABA.Service/Interfaces/IBookmarkService.cs | 6 +- YABA.Service/Interfaces/ITagsService.cs | 10 +- YABA.Service/TagsService.cs | 104 ++++- yaba-web/src/App.js | 22 +- yaba-web/src/api/index.js | 8 +- yaba-web/src/api/v1/tags.js | 62 ++- yaba-web/src/components/external/index.js | 6 + yaba-web/src/components/header.jsx | 1 + yaba-web/src/components/index.js | 6 +- .../src/components/shared/interceptModal.jsx | 32 ++ .../src/components/tags/upsertTagModal.jsx | 73 +++ yaba-web/src/views/bookmarksListView.jsx | 2 +- yaba-web/src/views/index.js | 6 +- yaba-web/src/views/tagsView.jsx | 432 ++++++++++++++++++ 27 files changed, 896 insertions(+), 70 deletions(-) create mode 100644 YABA.API/ViewModels/Tags/DeleteTagsRequest.cs create mode 100644 YABA.API/ViewModels/Tags/HideTagsRequest.cs create mode 100644 YABA.Common/DTOs/Tags/CreateTagDTO.cs delete mode 100644 YABA.Common/DTOs/Tags/TagSummaryDTO.cs create mode 100644 YABA.Common/DTOs/Tags/UpdateTagDTO.cs create mode 100644 yaba-web/src/components/shared/interceptModal.jsx create mode 100644 yaba-web/src/components/tags/upsertTagModal.jsx create mode 100644 yaba-web/src/views/tagsView.jsx diff --git a/YABA.API/Controllers/BookmarksController.cs b/YABA.API/Controllers/BookmarksController.cs index ef664d1..703f016 100644 --- a/YABA.API/Controllers/BookmarksController.cs +++ b/YABA.API/Controllers/BookmarksController.cs @@ -39,20 +39,6 @@ namespace YABA.API.Controllers return CreatedAtAction(nameof(Create), _mapper.Map(result)); } - [HttpPost("{id}/Tags")] - [ProducesResponseType(typeof(IEnumerable),(int)HttpStatusCode.OK)] - [ProducesResponseType((int)HttpStatusCode.NotFound)] - public async Task UpdateBookmarkTags(int id, [FromBody] UpdateBookmarkTagRequest request) - { - if (request.Tags == null || !request.Tags.Any()) return BadRequest(); - - var result = await _bookmarkService.UpdateBookmarkTags(id, request.Tags); - - if (result == null) return NotFound(); - - return Ok(_mapper.Map>(result)); - } - [HttpPut("{id}")] [ProducesResponseType(typeof(BookmarkResponse), (int)HttpStatusCode.OK)] [ProducesResponseType((int)HttpStatusCode.NotFound)] @@ -106,18 +92,6 @@ namespace YABA.API.Controllers return Ok(_mapper.Map(result)); } - [HttpGet("{id}/Tags")] - [ProducesResponseType(typeof(IEnumerable), (int)HttpStatusCode.OK)] - [ProducesResponseType((int)HttpStatusCode.NotFound)] - public IActionResult GetBookmarkTags(int id) - { - var result = _bookmarkService.GetBookmarkTags(id); - - if (result == null) return NotFound(); - - return Ok(_mapper.Map>(result)); - } - [HttpDelete("{id}")] [ProducesResponseType((int)HttpStatusCode.NoContent)] [ProducesResponseType((int)HttpStatusCode.NotFound)] @@ -160,5 +134,13 @@ namespace YABA.API.Controllers return NoContent(); } + [HttpGet("Tags")] + [ProducesResponseType((int)HttpStatusCode.OK)] + public IActionResult GetBookmarkTags(bool showHidden = false) + { + var result = _bookmarkService.GetAllBookmarkTags(showHidden); + return Ok(_mapper.Map>(result)); + } + } } \ No newline at end of file diff --git a/YABA.API/Controllers/TagsController.cs b/YABA.API/Controllers/TagsController.cs index 6fa5c60..635041e 100644 --- a/YABA.API/Controllers/TagsController.cs +++ b/YABA.API/Controllers/TagsController.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using System.Net; using YABA.API.ViewModels.Tags; +using YABA.Common.DTOs.Tags; using YABA.Service.Interfaces; namespace YABA.API.Controllers @@ -24,10 +25,86 @@ namespace YABA.API.Controllers [HttpGet] [ProducesResponseType(typeof(IEnumerable), (int)HttpStatusCode.OK)] - public IActionResult GetTags() + [ProducesResponseType((int)HttpStatusCode.NotFound)] + public async Task GetAll() { - var result = _tagsService.GetAll(); + var result = await _tagsService.GetAll(); + + if (result == null) return NotFound(); + return Ok(_mapper.Map>(result)); } + + [HttpGet("{id}")] + [ProducesResponseType(typeof(TagResponse), (int)HttpStatusCode.OK)] + [ProducesResponseType((int)HttpStatusCode.NotFound)] + public async Task Get(int id) + { + var result = await _tagsService.Get(id); + + if (result == null) return NotFound(); + + return Ok(_mapper.Map(result)); + } + + [HttpPost] + [ProducesResponseType(typeof(IEnumerable), (int)HttpStatusCode.OK)] + [ProducesResponseType((int)HttpStatusCode.BadRequest)] + public async Task CreateTag([FromBody]CreateTagDTO request) + { + if (!ModelState.IsValid) return BadRequest(ModelState); + + var result = await _tagsService.CreateTag(request); + + if (result == null) return BadRequest(); + + return Ok(_mapper.Map(result)); + } + + [HttpPut("{id}")] + [ProducesResponseType(typeof(IEnumerable), (int)HttpStatusCode.OK)] + [ProducesResponseType((int)HttpStatusCode.NotFound)] + public async Task UpdateTag(int id, [FromBody]UpdateTagDTO request) + { + var result = await _tagsService.UpdateTag(id, request); + + if (result == null) return NotFound(); + + return Ok(_mapper.Map(result)); + } + + [HttpPatch("{id}")] + [ProducesResponseType(typeof(IEnumerable), (int)HttpStatusCode.OK)] + [ProducesResponseType((int)HttpStatusCode.NotFound)] + public async Task PatchTag(int id, [FromBody] UpdateTagDTO request) => await UpdateTag(id, request); + + + [HttpDelete] + [ProducesResponseType((int)HttpStatusCode.NoContent)] + [ProducesResponseType ((int)HttpStatusCode.NotFound)] + [ProducesResponseType((int)HttpStatusCode.BadRequest)] + public async Task DeleteTags([FromBody] DeleteTagsRequest request) + { + if(request.Ids == null || !request.Ids.Any()) return BadRequest(); + + var result = await _tagsService.DeleteTags(request.Ids); + if (result == null) return NotFound(); + + return NoContent(); + } + + [HttpPost("Hide")] + [ProducesResponseType((int)HttpStatusCode.NoContent)] + [ProducesResponseType((int)HttpStatusCode.NotFound)] + [ProducesResponseType((int)HttpStatusCode.BadRequest)] + public async Task HideTags([FromBody] HideTagsRequest request) + { + if (request.Ids == null || !request.Ids.Any()) return BadRequest(); + + var result = await _tagsService.HideTags(request.Ids); + if (result == null) return NotFound(); + + return NoContent(); + } } } diff --git a/YABA.API/Settings/AutoMapperProfile.cs b/YABA.API/Settings/AutoMapperProfile.cs index 98aee1e..a721a3f 100644 --- a/YABA.API/Settings/AutoMapperProfile.cs +++ b/YABA.API/Settings/AutoMapperProfile.cs @@ -13,11 +13,11 @@ namespace YABA.API.Settings public AutoMapperProfile() { CreateMap(); - CreateMap(); CreateMap(); CreateMap(); CreateMap(); CreateMap(); + CreateMap().ReverseMap(); } } } diff --git a/YABA.API/ViewModels/Tags/DeleteTagsRequest.cs b/YABA.API/ViewModels/Tags/DeleteTagsRequest.cs new file mode 100644 index 0000000..5eb9f20 --- /dev/null +++ b/YABA.API/ViewModels/Tags/DeleteTagsRequest.cs @@ -0,0 +1,7 @@ +namespace YABA.API.ViewModels.Tags +{ + public class DeleteTagsRequest + { + public IEnumerable Ids { get; set; } + } +} diff --git a/YABA.API/ViewModels/Tags/HideTagsRequest.cs b/YABA.API/ViewModels/Tags/HideTagsRequest.cs new file mode 100644 index 0000000..fa5e7f7 --- /dev/null +++ b/YABA.API/ViewModels/Tags/HideTagsRequest.cs @@ -0,0 +1,7 @@ +namespace YABA.API.ViewModels.Tags +{ + public class HideTagsRequest + { + public IEnumerable Ids { get; set; } + } +} diff --git a/YABA.API/ViewModels/Tags/TagResponse.cs b/YABA.API/ViewModels/Tags/TagResponse.cs index b8ad1cc..0ecf753 100644 --- a/YABA.API/ViewModels/Tags/TagResponse.cs +++ b/YABA.API/ViewModels/Tags/TagResponse.cs @@ -4,5 +4,6 @@ { public int Id { get; set; } public string Name { get; set; } + public bool IsHidden { get; set; } } } diff --git a/YABA.Common/DTOs/Bookmarks/BookmarkDTO.cs b/YABA.Common/DTOs/Bookmarks/BookmarkDTO.cs index bab6e9f..461716a 100644 --- a/YABA.Common/DTOs/Bookmarks/BookmarkDTO.cs +++ b/YABA.Common/DTOs/Bookmarks/BookmarkDTO.cs @@ -15,6 +15,6 @@ namespace YABA.Common.DTOs.Bookmarks public string? Note { get; set; } public bool IsHidden { get; set; } public string Url { get; set; } - public List? Tags { get; set; } = new List(); + public List? Tags { get; set; } = new List(); } } diff --git a/YABA.Common/DTOs/Tags/CreateTagDTO.cs b/YABA.Common/DTOs/Tags/CreateTagDTO.cs new file mode 100644 index 0000000..2e59cae --- /dev/null +++ b/YABA.Common/DTOs/Tags/CreateTagDTO.cs @@ -0,0 +1,11 @@ +using System.ComponentModel.DataAnnotations; + +namespace YABA.Common.DTOs.Tags +{ + public class CreateTagDTO + { + [Required] + public string Name { get; set; } + public bool IsHidden { get; set; } + } +} diff --git a/YABA.Common/DTOs/Tags/TagDTO.cs b/YABA.Common/DTOs/Tags/TagDTO.cs index 41b7d0e..3fb5e2a 100644 --- a/YABA.Common/DTOs/Tags/TagDTO.cs +++ b/YABA.Common/DTOs/Tags/TagDTO.cs @@ -3,7 +3,6 @@ public class TagDTO { public int Id { get; set; } - public bool IsDeleted { get; set; } public string Name { get; set; } public bool IsHidden { get; set; } } diff --git a/YABA.Common/DTOs/Tags/TagSummaryDTO.cs b/YABA.Common/DTOs/Tags/TagSummaryDTO.cs deleted file mode 100644 index ce38a9d..0000000 --- a/YABA.Common/DTOs/Tags/TagSummaryDTO.cs +++ /dev/null @@ -1,9 +0,0 @@ - -namespace YABA.Common.DTOs.Tags -{ - public class TagSummaryDTO - { - public int Id { get; set; } - public string Name { get; set; } - } -} diff --git a/YABA.Common/DTOs/Tags/UpdateTagDTO.cs b/YABA.Common/DTOs/Tags/UpdateTagDTO.cs new file mode 100644 index 0000000..e5c3af0 --- /dev/null +++ b/YABA.Common/DTOs/Tags/UpdateTagDTO.cs @@ -0,0 +1,10 @@ +using System.ComponentModel.DataAnnotations; + +namespace YABA.Common.DTOs.Tags +{ + public class UpdateTagDTO + { + public string? Name { get; set; } + public bool? IsHidden { get; set; } + } +} diff --git a/YABA.Service/BookmarkService.cs b/YABA.Service/BookmarkService.cs index bc69339..7c4d098 100644 --- a/YABA.Service/BookmarkService.cs +++ b/YABA.Service/BookmarkService.cs @@ -60,7 +60,7 @@ namespace YABA.Service { foreach (var bookmarkTag in bookmarkTags) { - var tagDTO = _mapper.Map(bookmarkTag.Tag); + var tagDTO = _mapper.Map(bookmarkTag.Tag); bookmarkDTO.Tags.Add(tagDTO); } } @@ -101,7 +101,7 @@ namespace YABA.Service var currentUserId = GetCurrentUserId(); var bookmark = _context.Bookmarks.FirstOrDefault(x => x.UserId == currentUserId && x.Id == id); - var tags = new List(); + var tags = new List(); if (request.Tags != null && request.Tags.Any()) tags = (await UpdateBookmarkTags(id, request.Tags)).ToList(); @@ -126,7 +126,7 @@ namespace YABA.Service return null; } - public async Task?> UpdateBookmarkTags(int id, IEnumerable tags) + public async Task?> UpdateBookmarkTags(int id, IEnumerable tags) { var currentUserId = GetCurrentUserId(); @@ -163,7 +163,7 @@ namespace YABA.Service .Where(x => x.BookmarkId == id) .Select(x => x.Tag); - return _mapper.Map>(updatedBookmarkTags); + return _mapper.Map>(updatedBookmarkTags); } return null; @@ -183,12 +183,12 @@ namespace YABA.Service .ToList(); var bookmarkDTO = _mapper.Map(bookmark); - bookmarkDTO.Tags = _mapper.Map>(bookmarkTags); + bookmarkDTO.Tags = _mapper.Map>(bookmarkTags); return bookmarkDTO; } - public IEnumerable? GetBookmarkTags(int id) + public IEnumerable? GetBookmarkTags(int id) { int.TryParse(_httpContextAccessor.HttpContext.User.Identity.GetUserId(), out int userId); if (!_roContext.Bookmarks.Any(x => x.Id == id && x.UserId == userId)) return null; @@ -199,7 +199,7 @@ namespace YABA.Service .Select(x => x.Tag) .ToList(); - return _mapper.Map>(bookmarkTags); + return _mapper.Map>(bookmarkTags); } public async Task DeleteBookmark(int id) @@ -236,6 +236,20 @@ namespace YABA.Service return entriesToHide.Select(x => x.Id); } + public IEnumerable GetAllBookmarkTags(bool showHidden = false) + { + var currentUserId = GetCurrentUserId(); + + var activeUserTags = _roContext.BookmarkTags + .Include(x => x.Tag) + .Where(x => x.Tag.UserId == currentUserId && x.Tag.IsHidden == showHidden) + .ToList() + .GroupBy(x => x.Tag.Id) + .Select(g => g.First()?.Tag); + + return _mapper.Map>(activeUserTags); + } + private int GetCurrentUserId() { int.TryParse(_httpContextAccessor.HttpContext.User.Identity.GetUserId(), out int userId); diff --git a/YABA.Service/Configuration/AutoMapperProfile.cs b/YABA.Service/Configuration/AutoMapperProfile.cs index 076b41c..e6f68ad 100644 --- a/YABA.Service/Configuration/AutoMapperProfile.cs +++ b/YABA.Service/Configuration/AutoMapperProfile.cs @@ -15,7 +15,8 @@ namespace YABA.Service.Configuration CreateMap(); CreateMap(); CreateMap(); - CreateMap(); + CreateMap() + .ForMember(dest => dest.Name, opt => opt.MapFrom(src => src.Name.ToLowerInvariant())); } } } diff --git a/YABA.Service/Interfaces/IBookmarkService.cs b/YABA.Service/Interfaces/IBookmarkService.cs index bf03689..b972429 100644 --- a/YABA.Service/Interfaces/IBookmarkService.cs +++ b/YABA.Service/Interfaces/IBookmarkService.cs @@ -1,6 +1,5 @@ using System.Collections.Generic; using System.Threading.Tasks; -using YABA.Common.DTOs; using YABA.Common.DTOs.Bookmarks; using YABA.Common.DTOs.Tags; @@ -10,13 +9,14 @@ namespace YABA.Service.Interfaces { Task CreateBookmark(CreateBookmarkRequestDTO request); Task UpdateBookmark(int id, UpdateBookmarkRequestDTO request); - Task?> UpdateBookmarkTags(int id, IEnumerable tags); + Task?> UpdateBookmarkTags(int id, IEnumerable tags); IEnumerable GetAll(bool isHidden = false); Task Get(int id); - IEnumerable? GetBookmarkTags(int id); + IEnumerable? GetBookmarkTags(int id); Task DeleteBookmark(int id); Task?> DeleteBookmarks(IEnumerable ids); Task?> HideBookmarks(IEnumerable ids); + IEnumerable GetAllBookmarkTags(bool showHidden = false); } } diff --git a/YABA.Service/Interfaces/ITagsService.cs b/YABA.Service/Interfaces/ITagsService.cs index cfa95c6..e898f44 100644 --- a/YABA.Service/Interfaces/ITagsService.cs +++ b/YABA.Service/Interfaces/ITagsService.cs @@ -1,10 +1,18 @@ using System.Collections.Generic; +using System.Threading.Tasks; using YABA.Common.DTOs.Tags; namespace YABA.Service.Interfaces { public interface ITagsService { - public IEnumerable GetAll(); + Task?> GetAll(); + Task Get(int id); + Task?> UpsertTags(IEnumerable tags); + Task UpsertTag(TagDTO tag); + Task?> DeleteTags(IEnumerable ids); + Task?> HideTags(IEnumerable ids); + Task CreateTag(CreateTagDTO request); + Task UpdateTag(int id, UpdateTagDTO request); } } diff --git a/YABA.Service/TagsService.cs b/YABA.Service/TagsService.cs index 4615ded..1f63492 100644 --- a/YABA.Service/TagsService.cs +++ b/YABA.Service/TagsService.cs @@ -1,11 +1,15 @@ using AutoMapper; using Microsoft.AspNetCore.Http; using Microsoft.EntityFrameworkCore; +using System; using System.Collections.Generic; using System.Linq; +using System.Threading.Tasks; using YABA.Common.DTOs.Tags; using YABA.Common.Extensions; using YABA.Data.Context; +using YABA.Data.Extensions; +using YABA.Models; using YABA.Service.Interfaces; namespace YABA.Service @@ -13,29 +17,111 @@ namespace YABA.Service public class TagsService : ITagsService { private readonly YABAReadOnlyContext _roContext; + private readonly YABAReadWriteContext _rwContext; private readonly IHttpContextAccessor _httpContextAccessor; private readonly IMapper _mapper; - public TagsService(YABAReadOnlyContext roContext, IHttpContextAccessor httpContextAccessor, IMapper mapper) + public TagsService( + YABAReadOnlyContext roContext, + YABAReadWriteContext rwContext, + IHttpContextAccessor httpContextAccessor, + IMapper mapper) { _roContext = roContext; + _rwContext = rwContext; _httpContextAccessor = httpContextAccessor; _mapper = mapper; } - public IEnumerable GetAll() + public async Task?> GetAll() + { + var currentUserId = GetCurrentUserId(); + if (!await _roContext.Users.UserExistsAsync(currentUserId)) return null; + + var activeUserTags = _roContext.Tags + .Where(x => x.UserId == currentUserId) + .ToList(); + + return _mapper.Map>(activeUserTags); + } + + public async Task Get(int id) + { + var currentUserId = GetCurrentUserId(); + if (!await _roContext.Users.UserExistsAsync(currentUserId)) return null; + + var bookmark = await _roContext.Tags.FirstOrDefaultAsync(x => x.Id == id && x.UserId == currentUserId); + return _mapper.Map(bookmark); + } + + public async Task?> UpsertTags(IEnumerable tags) + { + var currentUserId = GetCurrentUserId(); + if (!await _roContext.Users.UserExistsAsync(currentUserId)) return null; + + var tagsToUpsert = tags.Select(x => new Tag { Id = x.Id, Name = x.Name.ToLower(), IsHidden = x.IsHidden, UserId = currentUserId}); + _rwContext.Tags.UpsertRange(tagsToUpsert); + await _rwContext.SaveChangesAsync(); + + var newlyUpsertedTags = _roContext.Tags.Where(x => tagsToUpsert.Select(x => x.Name).Contains(x.Name)).ToList(); + return _mapper.Map>(newlyUpsertedTags); + } + + public async Task?> DeleteTags(IEnumerable ids) + { + var currentUserId = GetCurrentUserId(); + if (!await _roContext.Users.UserExistsAsync(currentUserId)) return null; + + var entriesToDelete = _rwContext.Tags.Where(x => x.UserId == currentUserId && ids.Contains(x.Id)).ToList(); + _rwContext.Tags.RemoveRange(entriesToDelete); + + if (await _rwContext.SaveChangesAsync() <= 0) return null; + + return entriesToDelete.Select(x => x.Id); + } + + public async Task?> HideTags(IEnumerable ids) + { + var currentUserId = GetCurrentUserId(); + if (!await _roContext.Users.UserExistsAsync(currentUserId)) return null; + + var entriesToHide = _rwContext.Tags.Where(x => x.UserId == currentUserId && ids.Contains(x.Id)).ToList(); + entriesToHide.ForEach((x) => { x.IsHidden = !x.IsHidden; }); + + if (await _rwContext.SaveChangesAsync() <= 0) return null; + + return entriesToHide.Select(x => x.Id); + } + + public async Task UpsertTag(TagDTO tag) => (await UpsertTags(new List() { tag }))?.FirstOrDefault(); + + public async Task CreateTag(CreateTagDTO request) { var currentUserId = GetCurrentUserId(); - var activeUserTags = _roContext.BookmarkTags - .Include(x => x.Tag) - .Where(x => x.Tag.UserId == currentUserId) - .ToList() - .GroupBy(x => x.Tag.Id) - .Select(g => g.First()?.Tag); + if(!(await _roContext.Users.UserExistsAsync(currentUserId)) + || await _roContext.Tags.AnyAsync(x => x.UserId == currentUserId && x.Name.ToLower() == request.Name.ToLower())) + return null; - return _mapper.Map>(activeUserTags); + var tag = _mapper.Map(request); + tag.UserId = currentUserId; + var newEntity = await _rwContext.Tags.AddAsync(tag); + + return await _rwContext.SaveChangesAsync() > 0 ? _mapper.Map(newEntity.Entity) : null; + } + + public async Task UpdateTag(int id, UpdateTagDTO request) + { + var currentUserId = GetCurrentUserId(); + var tag = await _rwContext.Tags.FirstOrDefaultAsync(x => x.UserId == currentUserId && x.Id == id); + + if (tag == null) return null; + + tag.Name = !string.IsNullOrEmpty(request.Name) ? request.Name.ToLower() : tag.Name; + tag.IsHidden = request.IsHidden.HasValue ? request.IsHidden.Value : tag.IsHidden; + + return await _rwContext.SaveChangesAsync() > 0 ? _mapper.Map(tag) : null; } private int GetCurrentUserId() diff --git a/yaba-web/src/App.js b/yaba-web/src/App.js index 001a0bb..2126b49 100644 --- a/yaba-web/src/App.js +++ b/yaba-web/src/App.js @@ -1,22 +1,23 @@ import React from "react"; import { Routes, Route } from 'react-router-dom'; import { Footer, Header, ProtectedRoute } from './components'; -import { BaseLayout, HomeView, RedirectView, BookmarksListView, BookmarkDetailView, TestView, NotFoundView } from './views'; +import { BaseLayout, HomeView, RedirectView, BookmarksListView, BookmarkDetailView, TestView, NotFoundView, TagsView } from './views'; import { isDev } from "./utils"; function App() { return (
- } content={ } footer={
}/>} /> + } content={ } footer={
}/> } /> + } /> + } /> + } /> + } /> + } />} /> + { isDev() && ( } content={ } footer={
}/>} /> )} + + + } + />
); diff --git a/yaba-web/src/api/index.js b/yaba-web/src/api/index.js index dfe0740..2a30a98 100644 --- a/yaba-web/src/api/index.js +++ b/yaba-web/src/api/index.js @@ -1,6 +1,6 @@ import { callExternalApi } from "./apiHelper"; import { getAllBookmarks, getBookmark, createNewBookmark, updateBookmark, deleteBookmark, deleteBookmarks, hideBookmarks } from "./v1/bookmarks"; -import { getAllTags } from "./v1/tags"; +import { getAllTags, createNewTag, updateTag, deleteTags, hideTags } from "./v1/tags"; import { getWebsiteMetaData } from "./v1/misc"; export { @@ -10,8 +10,12 @@ export { createNewBookmark, updateBookmark, getWebsiteMetaData, - getAllTags, deleteBookmark, deleteBookmarks, hideBookmarks, + getAllTags, + createNewTag, + updateTag, + deleteTags, + hideTags, }; \ No newline at end of file diff --git a/yaba-web/src/api/v1/tags.js b/yaba-web/src/api/v1/tags.js index efc8532..08abd3b 100644 --- a/yaba-web/src/api/v1/tags.js +++ b/yaba-web/src/api/v1/tags.js @@ -13,4 +13,64 @@ export const getAllTags = async(accessToken) => { }; return await callExternalApi({config}); -} \ No newline at end of file +}; + +export const createNewTag = async(accessToken, newTag) => { + const config = { + url: `${apiServerUrl}`, + method: "POST", + headers: { + "content-type": "application/json", + Authorization: `Bearer ${accessToken}` + }, + data: newTag + }; + + return await callExternalApi({config}); +}; + +export const updateTag = async(accessToken, id, updatedTagEntry) => { + const config = { + url: `${apiServerUrl}/${id}`, + method: "PUT", + headers: { + "content-type": "application/json", + Authorization: `Bearer ${accessToken}` + }, + data: updatedTagEntry + }; + + return await callExternalApi({config}); +}; + +export const deleteTags = async(accessToken, ids) => { + const config = { + url: `${apiServerUrl}`, + method: "DELETE", + headers: { + "content-type": "application/json", + Authorization: `Bearer ${accessToken}` + }, + data: { + ids: ids + } + }; + + return await callExternalApi({config}); +}; + +export const hideTags = async(accessToken, ids) => { + const config = { + url: `${apiServerUrl}/Hide`, + method: "POST", + headers: { + "content-type": "application/json", + Authorization: `Bearer ${accessToken}` + }, + data: { + ids: ids + } + }; + + return await callExternalApi({config}); +}; \ No newline at end of file diff --git a/yaba-web/src/components/external/index.js b/yaba-web/src/components/external/index.js index ac4173f..b2011d6 100644 --- a/yaba-web/src/components/external/index.js +++ b/yaba-web/src/components/external/index.js @@ -5,6 +5,9 @@ import Row from "react-bootstrap/Row"; import Form from 'react-bootstrap/Form'; import Button from "react-bootstrap/Button"; import Modal from 'react-bootstrap/Modal' +import { Table } from "react-bootstrap"; +import Dropdown from 'react-bootstrap/Dropdown'; +import DropdownButton from 'react-bootstrap/DropdownButton'; export { Alert, @@ -14,4 +17,7 @@ export { Form, Button, Modal, + Table, + Dropdown, + DropdownButton, }; \ No newline at end of file diff --git a/yaba-web/src/components/header.jsx b/yaba-web/src/components/header.jsx index bb533aa..b5ff55c 100644 --- a/yaba-web/src/components/header.jsx +++ b/yaba-web/src/components/header.jsx @@ -57,6 +57,7 @@ export function Header(props) { New + Tags Logout )} diff --git a/yaba-web/src/components/index.js b/yaba-web/src/components/index.js index 818cdc8..ae531a7 100644 --- a/yaba-web/src/components/index.js +++ b/yaba-web/src/components/index.js @@ -5,6 +5,8 @@ import { Auth0ProviderWithNavigate } from "./auth0ProviderWithNavigate"; import { ProtectedRoute } from "./protectedRoute"; import { SplashScreen } from "./shared/splashScreen"; import { SearchForm } from "./shared/searchForm"; +import { UpsertTagModal } from "./tags/upsertTagModal"; +import { InterceptModal } from "./shared/interceptModal"; export { Footer, @@ -13,5 +15,7 @@ export { Auth0ProviderWithNavigate, ProtectedRoute, SplashScreen, - SearchForm + SearchForm, + UpsertTagModal, + InterceptModal, }; \ No newline at end of file diff --git a/yaba-web/src/components/shared/interceptModal.jsx b/yaba-web/src/components/shared/interceptModal.jsx new file mode 100644 index 0000000..2d8cebe --- /dev/null +++ b/yaba-web/src/components/shared/interceptModal.jsx @@ -0,0 +1,32 @@ +import React from "react"; +import { Button, Form, Modal } from "../external"; + +export function InterceptModal(props) { + return ( + + + {props.title} + + {props.message} + + + + + + ); +}; diff --git a/yaba-web/src/components/tags/upsertTagModal.jsx b/yaba-web/src/components/tags/upsertTagModal.jsx new file mode 100644 index 0000000..264fc68 --- /dev/null +++ b/yaba-web/src/components/tags/upsertTagModal.jsx @@ -0,0 +1,73 @@ +import React from "react"; +import { Button, Form, Modal } from "../external"; +import { useFormik } from "formik"; +import * as Yup from "yup" + +export function UpsertTagModal(props) { + const formik = useFormik({ + initialValues: props.tag, + validationSchema: Yup.object({ + name: Yup.string().required("Name is required").lowercase() + }), + enableReinitialize: true, + onSubmit: async(values) => props.onSave(values) + }); + + return ( + +
+ + {props.tag.id > 0 ? "Edit Tag" : "New Tag"} + + + + Name: + + + {formik.errors.name} + + + + + + + + { props.tag.id > 0 && + } + + +
+
+ ); +} \ No newline at end of file diff --git a/yaba-web/src/views/bookmarksListView.jsx b/yaba-web/src/views/bookmarksListView.jsx index a75fac9..b87367c 100644 --- a/yaba-web/src/views/bookmarksListView.jsx +++ b/yaba-web/src/views/bookmarksListView.jsx @@ -1,5 +1,5 @@ import React, { useEffect, useReducer, useState } from "react"; -import { Alert, Col, Container, Row, Form, Button, Modal } from "../components/external"; +import { Alert, Col, Container, Row, Button, Modal } from "../components/external"; import { Bookmark } from "../components"; import { useAuth0 } from "@auth0/auth0-react"; import { deleteBookmarks, getAllBookmarks, getAllTags, hideBookmarks } from "../api"; diff --git a/yaba-web/src/views/index.js b/yaba-web/src/views/index.js index e17dc8e..96a3e66 100644 --- a/yaba-web/src/views/index.js +++ b/yaba-web/src/views/index.js @@ -5,6 +5,7 @@ import { BookmarksListView } from "./bookmarksListView"; import { BookmarkDetailView } from "./bookmarkDetailView"; import { TestView } from "./testView"; import { NotFoundView } from "./notFoundView"; +import { TagsView } from "./tagsView"; export { BaseLayout, @@ -13,5 +14,6 @@ export { BookmarksListView, BookmarkDetailView, TestView, - NotFoundView -} \ No newline at end of file + NotFoundView, + TagsView, +}; \ No newline at end of file diff --git a/yaba-web/src/views/tagsView.jsx b/yaba-web/src/views/tagsView.jsx new file mode 100644 index 0000000..f35f1cd --- /dev/null +++ b/yaba-web/src/views/tagsView.jsx @@ -0,0 +1,432 @@ +import React, { useEffect, useReducer, useState } from "react"; +import { useAuth0 } from "@auth0/auth0-react"; +import { containsSubstring } from "../utils"; +import { SplashScreen, SearchForm, UpsertTagModal, InterceptModal } from "../components"; +import { Alert, Button, Col, Container, Dropdown, DropdownButton, Form, Row, Table } from "../components/external"; +import { getAllTags, createNewTag, updateTag, deleteTags, hideTags } from "../api"; + +export function TagsView(props) { + const { getAccessTokenSilently } = useAuth0(); + + const [searchString, setSearchString] = useState(""); + const handleSearch = (e) => { + e.preventDefault(); + + if(!searchString) { + dispatchTagsState({type: "DISPLAY_ALL"}); + } else { + dispatchTagsState({type: "SEARCH"}); + } + }; + + const handleSearchStringChange = (e) => { + if(!e.target.value) { + dispatchTagsState({type: "DISPLAY_ALL"}); + } else { + setSearchString(e.target.value); + } + }; + + const [isAllTagsSelected, setAllTagsSelected] = useState(false); + + const initialSplashScreenState = { show: false, message: null }; + const splashScreenReducer = (state = initialSplashScreenState, action) => { + switch(action.type) { + case "SHOW_SPLASH_SCREEN": + return Object.assign({}, state, { + show: true, + message: action.payload.message + }); + case "HIDE_SPLASH_SCREEN": + default: + return Object.assign({}, state, { + show: false, + message: null + }); + } + }; + const [splashScreenState, dispatchSplashScreenState] = useReducer(splashScreenReducer, initialSplashScreenState); + + const alertMessageInitialState = { show: false, type: "primary", message: null}; + const alertReducer = (state = alertMessageInitialState, action) => { + switch(action.type) { + case "SHOW_ALERT": + return Object.assign({}, state, { + show: action.payload.show, + type: action.payload.alertType, + message: action.payload.message + }); + case "UPSERT_FAIL": + return { + show: true, + type: "danger", + message: action.payload.message + }; + case "UPSERT_SUCCEEDED": + return { + show: true, + type: "success", + message: action.payload.message + }; + case "NO_TAG_TO_DELETE": + return { + show: true, + type: "warning", + message: "No tag(s) to delete" + }; + case "DELETE_FAILED": + return { + show: true, + type: "danger", + message: action.payload.message + }; + case "DELETE_SUCCEEDED": + return { + show: true, + type: "success", + message: "Tag(s) deleted" + } + case "NO_TAG_TO_HIDE": + return { + show: true, + type: "warning", + message: "No tag(s) to hide/unhide" + } + case "HIDE_ALERT": + default: + return Object.assign({}, state, { + show: false + }); + } + }; + const [alertMessageState, dispatchAlertMessageState] = useReducer(alertReducer, alertMessageInitialState); + + const tagsReducer = (state = [], action) => { + let newState = []; + + switch(action.type) { + case "SET": + newState = action.payload.tags.map(x => ({...x, isSelected: false, isDisplayed: true})); + break; + case "ADD_SELECTED": + case "REMOVE_SELECTED": + newState = [...state]; + const selectedTagIndex = newState.findIndex(x => x.id === action.payload.selectedTagId); + newState[selectedTagIndex].isSelected = action.type === "ADD_SELECTED"; + break; + case "DELETE_SELECTED": + newState = state.filter((x) => !action.payload.selectedTagIds.includes(x.id)); + break; + case "SELECT_ALL": + newState = state.filter(x => x.isDisplayed).map(x => ({...x, isSelected: true})); + break; + case "UNSELECT_ALL": + newState = state.filter(x => x.isDisplayed).map(x => ({...x, isSelected: false})); + break; + case "DISPLAY_ALL": + newState = state.map(x => ({...x, isDisplayed: true})); + break; + case "SEARCH": + if(!searchString) { + dispatchTagsState({type: "DISPLAY_ALL"}); + } + + newState = state.map(x => ({...x, isDisplayed: containsSubstring(x, searchString)})); + break; + case "TAG_UPDATE": + newState = [...state]; + const updatedTagIndex = newState.findIndex(x => x.id == action.payload.tag.id); + newState.splice(updatedTagIndex, 1, Object.assign({}, newState[updatedTagIndex], action.payload.tag)); + break; + case "TAG_INSERT": + newState = [{...action.payload.tag, isDisplayed: true, isSelected: false}, ...state]; + break; + case "HIDE_SUCCEEDED": + newState = [...state.filter(x => !x.isSelected), ...state.filter(x => x.isSelected).map(x => Object.assign({}, x, {isHidden: !x.isHidden}))]; + break; + default: + newState = state; + break; + } + + setAllTagsSelected(newState.every(x => x.isSelected)); + return newState; + }; + const [tagsState, dispatchTagsState] = useReducer(tagsReducer, []); + const getTagsSelectedCount = () => tagsState.filter(x => x.isSelected).length; + const onTagSelected = (isTagSelected, tag) => dispatchTagsState({type: isTagSelected ? "ADD_SELECTED" : "REMOVE_SELECTED", payload: {selectedTagId: tag.id}}); + const getDisplayedTags = () => tagsState.filter(tag => tag.isDisplayed); + const getSelectedTags = () => tagsState.filter(tag => tag.isSelected); + const isAllSelectedHidden = () => getSelectedTags().every(tag => tag.isHidden); + const isAllSelectedNotHidden = () => getSelectedTags().every(tag => !tag.isHidden); + + const upsertTagModalInitialState = { show: false, tag: { id: 0, name: "", isHidden: false }}; + const upsertTagModalReducer = (state = upsertTagModalInitialState, action) => { + switch(action.type) { + case "SHOW_EDIT": + return {show: true, tag: action.payload.tag}; + case "SHOW_NEW": + return {...upsertTagModalInitialState, show: true}; + case "HIDE": + default: + return upsertTagModalInitialState; + } + } + const [upsertTagModalState, dispatchUpsertTagModal] = useReducer(upsertTagModalReducer, upsertTagModalInitialState); + + const initialInterceptModalState = { + show: false, + backdrop: "static", + title: null, + message: null, + secondaryAction: { + variant: "secondary", + text: "Cancel", + onClick: null, + }, + primaryAction: { + variant: "danger", + text: "Delete", + onClick: null, + }, + selectedTagIds: [] + }; + const interceptModalReducer = (state = initialInterceptModalState, action) => { + switch(action.type) { + case "SHOW_FOR_MULTIPLE_DELETE": + return { + ...state, + show: true, + title: "Delete Tags", + message: "Are you sure you want to delete selected tags?", + secondaryAction: { + variant: "secondary", + text: "Cancel", + onClick: null, + }, + primaryAction: { + variant: "danger", + text: "Delete", + onClick: null, + }, + selectedTagIds: action.payload.selectedTagIds, + }; + case "SHOW_FOR_DELETE": + return { + ...state, + show: true, + title: "Delete Tag", + message: "Are you sure you want to delete this tag?", + secondaryAction: { + variant: "secondary", + text: "Cancel", + onClick: null, + }, + primaryAction: { + variant: "danger", + text: "Delete", + onClick: null, + }, + selectedTagIds: action.payload.selectedTagIds, + }; + case "HIDE": + default: + return initialInterceptModalState; + } + }; + const [interceptModalState, dispatchInterceptModalState] = useReducer(interceptModalReducer, initialInterceptModalState); + + const fetchTags = async() => { + const accessToken = await getAccessTokenSilently(); + const { data, error } = await getAllTags(accessToken); + + if(error) { + dispatchAlertMessageState({type: "SHOW_ALERT", payload: {show: true, alertType: "danger", "message": `Error fetching tags: ${error.message}`}}); + } else { + dispatchTagsState({type: "SET", payload: {tags: data}}); + } + dispatchSplashScreenState({type: "HIDE_SPLASH_SCREEN", payload: {message: null}}); + }; + + const handleUpsertTag = async(tagEntry) => { + const isUpdate = tagEntry.id > 0; + dispatchAlertMessageState({ type: "CLOSE_ALERT"}); + dispatchUpsertTagModal({type: "HIDE"}); + dispatchSplashScreenState({type: "SHOW_SPLASH_SCREEN", payload: {message: isUpdate ? "Updating Bookmark entry" : "Creating new Bookmark"}}); + + const accessToken = await getAccessTokenSilently(); + const { data, error } = isUpdate ? + await updateTag(accessToken, tagEntry.id, tagEntry) : + await createNewTag(accessToken, tagEntry); + + if(error) { + dispatchAlertMessageState({type: "UPSERT_FAIL", payload: {message: `Error ${isUpdate ? "updating": "inserting new"} Tag record: ${error.message}`}}); + } else { + dispatchAlertMessageState({type: "UPSERT_SUCCEEDED", payload: {message: isUpdate ? "Tag updated" : "Tag created"}}); + dispatchTagsState({ type: isUpdate? "TAG_UPDATE" : "TAG_INSERT", payload: {tag: data}}); + } + + dispatchSplashScreenState({type: "HIDE_SPLASH_SCREEN", payload: {message: null}}); + }; + + const handleDeleteTags = async () => { + dispatchInterceptModalState({type: "HIDE"}); + dispatchUpsertTagModal({type: "HIDE"}) + const tagIdsToDelete = interceptModalState.selectedTagIds; + + if(tagIdsToDelete.length <= 0) { + dispatchAlertMessageState({type: "NO_TAG_TO_DELETE"}) + } else { + dispatchSplashScreenState({type: "SHOW_SPLASH_SCREEN", payload: {message: "Deleting Tag(s)"}}); + const accessToken = await getAccessTokenSilently(); + const { data, error } = await deleteTags(accessToken, tagIdsToDelete); + + if(error) { + dispatchAlertMessageState({type: "DELETE_FAILED", payload: {message: `Error deleting tag(s): ${error.message}`}}); + } else { + dispatchAlertMessageState({type: "DELETE_SUCCEEDED"}); + dispatchTagsState({type: "DELETE_SELECTED", payload: {selectedTagIds: tagIdsToDelete}}); + } + + dispatchSplashScreenState({TYPE: "HIDE_SPLASH_SCREEN", payload: {message: null}}); + } + }; + + const handleHidingTags = async () => { + const tagIdsToHide = getSelectedTags().map(x => x.id); + + if(tagIdsToHide.length <= 0) { + dispatchAlertMessageState({type: "NO_TAG_TO_HIDE"}) + } else { + dispatchSplashScreenState({type: "SHOW_SPLASH_SCREEN", payload: {message: "Hiding/Unhiding Tag(s)"}}); + const accessToken = await getAccessTokenSilently(); + const { data, error } = await hideTags(accessToken, tagIdsToHide); + + if(error) { + dispatchAlertMessageState({type: "UPSERT_FAILED", payload: {message: `Error hiding/unhiding tag(s): ${error.message}`}}); + } else { + dispatchAlertMessageState({type: "UPSERT_SUCCEEDED", payload: {message: `Tag(s) have been hidden/unhidden`}}); + dispatchTagsState({type: "HIDE_SUCCEEDED"}); + dispatchTagsState({type: "UNSELECT_ALL"}); + } + + dispatchSplashScreenState({TYPE: "HIDE_SPLASH_SCREEN", payload: {message: null}}); + } + }; + + useEffect(() => { + dispatchSplashScreenState({type: "SHOW_SPLASH_SCREEN", payload: {message: "Retrieving Tags..."}}); + fetchTags(); + }, []); + + return ( + <> + {splashScreenState.show && ( + + )} + + dispatchUpsertTagModal({type: "HIDE"})} + onHide={() => dispatchUpsertTagModal({type: "HIDE"})} + onSave={handleUpsertTag} + onDelete={() => dispatchInterceptModalState({type: "SHOW_FOR_DELETE", payload: {selectedTagIds: [upsertTagModalState.tag.id]}})} + /> + dispatchInterceptModalState({type: "HIDE"})} + backdrop={interceptModalState.backdrop} + title={interceptModalState.title} + message={interceptModalState.message} + secondaryAction={{...interceptModalState.secondaryAction, onClick: () => dispatchInterceptModalState({type: "HIDE"})}} + primaryAction={{...interceptModalState.primaryAction, onClick: () => handleDeleteTags()}} + /> + +
+ + { alertMessageState.show && dispatchAlertMessageState({type: "CLOSE"})} + dismissible + > + {alertMessageState.message} + + } + { tagsState.length <= 0 && !splashScreenState.show &&
No Tags found
} + { + tagsState.length > 0 && <> + + + + + +
+ + { getTagsSelectedCount() > 0 && + { isAllSelectedHidden() && Mark as Not Hidden} + { isAllSelectedNotHidden() && Mark as Hidden} + dispatchInterceptModalState({type: "SHOW_FOR_MULTIPLE_DELETE", payload: {selectedTagIds: getSelectedTags().map(x => x.id)}})}> + Delete + + + } +
+ +
+ + + + + + + + + + + + + { + getDisplayedTags().map(tag => { + return + + + + + + }) + } + +
+ dispatchTagsState({type: e.target.checked ? "SELECT_ALL" : "UNSELECT_ALL"})} + checked={isAllTagsSelected} + /> + TagIs Hidden?
+ onTagSelected(e.target.checked, tag)} + checked={tag.isSelected} + /> + {tag.name} + { tag.isHidden ? "Yes" : "No" } + + +
+ +
+ + } +
+
+ + ) +}; \ No newline at end of file