Added page and endpoints for managing Tags

This commit is contained in:
2023-02-19 20:51:31 -06:00
parent 1f3adf0932
commit c55b018c0d
27 changed files with 896 additions and 70 deletions

View File

@ -39,20 +39,6 @@ namespace YABA.API.Controllers
return CreatedAtAction(nameof(Create), _mapper.Map<BookmarkResponse>(result));
}
[HttpPost("{id}/Tags")]
[ProducesResponseType(typeof(IEnumerable<TagResponse>),(int)HttpStatusCode.OK)]
[ProducesResponseType((int)HttpStatusCode.NotFound)]
public async Task<IActionResult> 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<IEnumerable<TagResponse>>(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<BookmarkResponse>(result));
}
[HttpGet("{id}/Tags")]
[ProducesResponseType(typeof(IEnumerable<TagResponse>), (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<IEnumerable<TagResponse>>(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<IEnumerable<TagResponse>>(result));
}
}
}

View File

@ -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<TagResponse>), (int)HttpStatusCode.OK)]
public IActionResult GetTags()
[ProducesResponseType((int)HttpStatusCode.NotFound)]
public async Task<IActionResult> GetAll()
{
var result = _tagsService.GetAll();
var result = await _tagsService.GetAll();
if (result == null) return NotFound();
return Ok(_mapper.Map<IEnumerable<TagResponse>>(result));
}
[HttpGet("{id}")]
[ProducesResponseType(typeof(TagResponse), (int)HttpStatusCode.OK)]
[ProducesResponseType((int)HttpStatusCode.NotFound)]
public async Task<IActionResult> Get(int id)
{
var result = await _tagsService.Get(id);
if (result == null) return NotFound();
return Ok(_mapper.Map<TagResponse>(result));
}
[HttpPost]
[ProducesResponseType(typeof(IEnumerable<TagResponse>), (int)HttpStatusCode.OK)]
[ProducesResponseType((int)HttpStatusCode.BadRequest)]
public async Task<IActionResult> 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<TagResponse>(result));
}
[HttpPut("{id}")]
[ProducesResponseType(typeof(IEnumerable<TagResponse>), (int)HttpStatusCode.OK)]
[ProducesResponseType((int)HttpStatusCode.NotFound)]
public async Task<IActionResult> UpdateTag(int id, [FromBody]UpdateTagDTO request)
{
var result = await _tagsService.UpdateTag(id, request);
if (result == null) return NotFound();
return Ok(_mapper.Map<TagResponse>(result));
}
[HttpPatch("{id}")]
[ProducesResponseType(typeof(IEnumerable<TagResponse>), (int)HttpStatusCode.OK)]
[ProducesResponseType((int)HttpStatusCode.NotFound)]
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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();
}
}
}

View File

@ -13,11 +13,11 @@ namespace YABA.API.Settings
public AutoMapperProfile()
{
CreateMap<UserDTO, UserResponse>();
CreateMap<TagSummaryDTO, TagResponse>();
CreateMap<BookmarkDTO, BookmarkResponse>();
CreateMap<WebsiteMetaDataDTO, GetWebsiteMetaDataResponse>();
CreateMap<BookmarkDTO, PatchBookmarkRequest>();
CreateMap<PatchBookmarkRequest, UpdateBookmarkRequestDTO>();
CreateMap<TagDTO, TagResponse>().ReverseMap();
}
}
}

View File

@ -0,0 +1,7 @@
namespace YABA.API.ViewModels.Tags
{
public class DeleteTagsRequest
{
public IEnumerable<int> Ids { get; set; }
}
}

View File

@ -0,0 +1,7 @@
namespace YABA.API.ViewModels.Tags
{
public class HideTagsRequest
{
public IEnumerable<int> Ids { get; set; }
}
}

View File

@ -4,5 +4,6 @@
{
public int Id { get; set; }
public string Name { get; set; }
public bool IsHidden { get; set; }
}
}

View File

@ -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<TagSummaryDTO>? Tags { get; set; } = new List<TagSummaryDTO>();
public List<TagDTO>? Tags { get; set; } = new List<TagDTO>();
}
}

View File

@ -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; }
}
}

View File

@ -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; }
}

View File

@ -1,9 +0,0 @@

namespace YABA.Common.DTOs.Tags
{
public class TagSummaryDTO
{
public int Id { get; set; }
public string Name { get; set; }
}
}

View File

@ -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; }
}
}

View File

@ -60,7 +60,7 @@ namespace YABA.Service
{
foreach (var bookmarkTag in bookmarkTags)
{
var tagDTO = _mapper.Map<TagSummaryDTO>(bookmarkTag.Tag);
var tagDTO = _mapper.Map<TagDTO>(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<TagSummaryDTO>();
var tags = new List<TagDTO>();
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<IEnumerable<TagSummaryDTO>?> UpdateBookmarkTags(int id, IEnumerable<string> tags)
public async Task<IEnumerable<TagDTO>?> UpdateBookmarkTags(int id, IEnumerable<string> tags)
{
var currentUserId = GetCurrentUserId();
@ -163,7 +163,7 @@ namespace YABA.Service
.Where(x => x.BookmarkId == id)
.Select(x => x.Tag);
return _mapper.Map<IEnumerable<TagSummaryDTO>>(updatedBookmarkTags);
return _mapper.Map<IEnumerable<TagDTO>>(updatedBookmarkTags);
}
return null;
@ -183,12 +183,12 @@ namespace YABA.Service
.ToList();
var bookmarkDTO = _mapper.Map<BookmarkDTO>(bookmark);
bookmarkDTO.Tags = _mapper.Map<List<TagSummaryDTO>>(bookmarkTags);
bookmarkDTO.Tags = _mapper.Map<List<TagDTO>>(bookmarkTags);
return bookmarkDTO;
}
public IEnumerable<TagSummaryDTO>? GetBookmarkTags(int id)
public IEnumerable<TagDTO>? 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<IEnumerable<TagSummaryDTO>>(bookmarkTags);
return _mapper.Map<IEnumerable<TagDTO>>(bookmarkTags);
}
public async Task<int?> DeleteBookmark(int id)
@ -236,6 +236,20 @@ namespace YABA.Service
return entriesToHide.Select(x => x.Id);
}
public IEnumerable<TagDTO> 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<IEnumerable<TagDTO>>(activeUserTags);
}
private int GetCurrentUserId()
{
int.TryParse(_httpContextAccessor.HttpContext.User.Identity.GetUserId(), out int userId);

View File

@ -15,7 +15,8 @@ namespace YABA.Service.Configuration
CreateMap<Bookmark, BookmarkDTO>();
CreateMap<CreateBookmarkRequestDTO, Bookmark>();
CreateMap<Tag, TagDTO>();
CreateMap<Tag, TagSummaryDTO>();
CreateMap<CreateTagDTO, Tag>()
.ForMember(dest => dest.Name, opt => opt.MapFrom(src => src.Name.ToLowerInvariant()));
}
}
}

View File

@ -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<BookmarkDTO?> CreateBookmark(CreateBookmarkRequestDTO request);
Task<BookmarkDTO?> UpdateBookmark(int id, UpdateBookmarkRequestDTO request);
Task<IEnumerable<TagSummaryDTO>?> UpdateBookmarkTags(int id, IEnumerable<string> tags);
Task<IEnumerable<TagDTO>?> UpdateBookmarkTags(int id, IEnumerable<string> tags);
IEnumerable<BookmarkDTO> GetAll(bool isHidden = false);
Task<BookmarkDTO?> Get(int id);
IEnumerable<TagSummaryDTO>? GetBookmarkTags(int id);
IEnumerable<TagDTO>? GetBookmarkTags(int id);
Task<int?> DeleteBookmark(int id);
Task<IEnumerable<int>?> DeleteBookmarks(IEnumerable<int> ids);
Task<IEnumerable<int>?> HideBookmarks(IEnumerable<int> ids);
IEnumerable<TagDTO> GetAllBookmarkTags(bool showHidden = false);
}
}

View File

@ -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<TagSummaryDTO> GetAll();
Task<IEnumerable<TagDTO>?> GetAll();
Task<TagDTO?> Get(int id);
Task<IEnumerable<TagDTO>?> UpsertTags(IEnumerable<TagDTO> tags);
Task<TagDTO?> UpsertTag(TagDTO tag);
Task<IEnumerable<int>?> DeleteTags(IEnumerable<int> ids);
Task<IEnumerable<int>?> HideTags(IEnumerable<int> ids);
Task<TagDTO?> CreateTag(CreateTagDTO request);
Task<TagDTO?> UpdateTag(int id, UpdateTagDTO request);
}
}

View File

@ -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<TagSummaryDTO> GetAll()
public async Task<IEnumerable<TagDTO>?> 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<IEnumerable<TagDTO>>(activeUserTags);
}
public async Task<TagDTO?> 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<TagDTO>(bookmark);
}
public async Task<IEnumerable<TagDTO>?> UpsertTags(IEnumerable<TagDTO> 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<IEnumerable<TagDTO>>(newlyUpsertedTags);
}
public async Task<IEnumerable<int>?> DeleteTags(IEnumerable<int> 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<IEnumerable<int>?> HideTags(IEnumerable<int> 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<TagDTO?> UpsertTag(TagDTO tag) => (await UpsertTags(new List<TagDTO>() { tag }))?.FirstOrDefault();
public async Task<TagDTO?> 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<IEnumerable<TagSummaryDTO>>(activeUserTags);
var tag = _mapper.Map<Tag>(request);
tag.UserId = currentUserId;
var newEntity = await _rwContext.Tags.AddAsync(tag);
return await _rwContext.SaveChangesAsync() > 0 ? _mapper.Map<TagDTO>(newEntity.Entity) : null;
}
public async Task<TagDTO?> 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<TagDTO>(tag) : null;
}
private int GetCurrentUserId()

View File

@ -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 (
<div className="App" style={{minHeight: '100vh', display: 'flex', flexDirection: 'column'}}>
<Routes>
<Route
path="/"
element={<BaseLayout header={ <Header />} content={ <HomeView /> } footer={ <Footer />}/>}
/>
<Route
path="/redirect"
element={<BaseLayout header={ <Header />} content={ <RedirectView />} footer={ <Footer />}/> }
/>
<Route
path="/bookmarks"
element={
@ -29,6 +30,7 @@ function App() {
/>
}
/>
<Route
path="/bookmarks/hidden"
element={
@ -41,6 +43,7 @@ function App() {
/>
}
/>
<Route
path="/bookmarks/new"
element={
@ -52,6 +55,7 @@ function App() {
/>
}
/>
<Route
path="/bookmarks/:id"
element={
@ -63,16 +67,30 @@ function App() {
/>
}
/>
<Route
path="/404"
element={<BaseLayout content={<NotFoundView />} />}
/>
{ isDev() && (
<Route
path="/test"
element={<BaseLayout header={ <Header />} content={ <TestView /> } footer={ <Footer />}/>}
/>
)}
<Route
path="/tags"
element={
<ProtectedRoute
layout={BaseLayout}
header={Header}
footer={Footer}
view={TagsView}
/>
}
/>
</Routes>
</div>
);

View File

@ -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,
};

View File

@ -13,4 +13,64 @@ export const getAllTags = async(accessToken) => {
};
return await callExternalApi({config});
};
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});
};

View File

@ -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,
};

View File

@ -57,6 +57,7 @@ export function Header(props) {
<NavDropdown.Divider />
<NavDropdown.Item href="/bookmarks/new">New</NavDropdown.Item>
</NavDropdown>
<Nav.Link href="/tags">Tags</Nav.Link>
<Nav.Link onClick={handleLogout}>Logout</Nav.Link>
</>
)}

View File

@ -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,
};

View File

@ -0,0 +1,32 @@
import React from "react";
import { Button, Form, Modal } from "../external";
export function InterceptModal(props) {
return (
<Modal
show={props.show}
onHide={props.onHide}
keyboard={false}
backdrop={props.backdrop}
>
<Modal.Header closeButton>
<Modal.Title>{props.title}</Modal.Title>
</Modal.Header>
<Modal.Body>{props.message}</Modal.Body>
<Modal.Footer>
<Button
variant={props.secondaryAction.variant}
onClick={props.secondaryAction.onClick}
>
{props.secondaryAction.text}
</Button>
<Button
variant={props.primaryAction.variant}
onClick={props.primaryAction.onClick}
>
{props.primaryAction.text}
</Button>
</Modal.Footer>
</Modal>
);
};

View File

@ -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 (
<Modal
show={props.show}
onHide={props.onHide}
keyboard={false}
backdrop="static"
>
<Form onSubmit={formik.handleSubmit}>
<Modal.Header closeButton>
<Modal.Title>{props.tag.id > 0 ? "Edit Tag" : "New Tag"}</Modal.Title>
</Modal.Header>
<Modal.Body>
<Form.Group className="mb-3">
<Form.Label>Name:</Form.Label>
<Form.Control
id="name"
type="text"
placeholder="Enter tag name"
defaultValue={formik.values.name}
onBlur={formik.handleChange}
isInvalid={!!formik.errors.name}
/>
<Form.Control.Feedback
type="invalid"
className="d-flex justify-content-start"
>
{formik.errors.name}
</Form.Control.Feedback>
</Form.Group>
<Form.Group>
<Form.Check
id="isHidden"
type="switch"
label="Mark as hidden"
checked={formik.values.isHidden}
onChange={formik.handleChange}
/>
</Form.Group>
</Modal.Body>
<Modal.Footer>
{ props.tag.id > 0 && <Button
variant="danger"
onClick={props.onDelete}
>
Delete
</Button>
}
<Button
variant="primary"
type="submit"
>
Save
</Button>
</Modal.Footer>
</Form>
</Modal>
);
}

View File

@ -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";

View File

@ -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
}
NotFoundView,
TagsView,
};

View File

@ -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 && (
<SplashScreen
message={splashScreenState.message}
/>
)}
<UpsertTagModal
show={upsertTagModalState.show}
tag={upsertTagModalState.tag}
onCancel={() => dispatchUpsertTagModal({type: "HIDE"})}
onHide={() => dispatchUpsertTagModal({type: "HIDE"})}
onSave={handleUpsertTag}
onDelete={() => dispatchInterceptModalState({type: "SHOW_FOR_DELETE", payload: {selectedTagIds: [upsertTagModalState.tag.id]}})}
/>
<InterceptModal
show={interceptModalState.show}
onHide={() => dispatchInterceptModalState({type: "HIDE"})}
backdrop={interceptModalState.backdrop}
title={interceptModalState.title}
message={interceptModalState.message}
secondaryAction={{...interceptModalState.secondaryAction, onClick: () => dispatchInterceptModalState({type: "HIDE"})}}
primaryAction={{...interceptModalState.primaryAction, onClick: () => handleDeleteTags()}}
/>
<div style={{flexGrow: 1}}>
<Container>
{ alertMessageState.show && <Alert
variant={alertMessageState.type}
onClose={() => dispatchAlertMessageState({type: "CLOSE"})}
dismissible
>
{alertMessageState.message}
</Alert>
}
{ tagsState.length <= 0 && !splashScreenState.show && <div className="text-center fs-1">No Tags found</div> }
{
tagsState.length > 0 && <>
<Row>
<Col xs="5">
<SearchForm
onHandleSearch={handleSearch}
onSearchStringChange={handleSearchStringChange}
onBlur={handleSearchStringChange}
searchString={searchString}
/>
</Col>
<Col xs="4">
<div className="d-flex justify-content-end align-items-center">
<Button variant="primary" className="me-2" onClick={() => dispatchUpsertTagModal({type: "SHOW_NEW"})}>New</Button>
{ getTagsSelectedCount() > 0 && <DropdownButton variant="secondary" title={`${getTagsSelectedCount()} selected`}>
{ isAllSelectedHidden() && <Dropdown.Item onClick={handleHidingTags}>Mark as Not Hidden</Dropdown.Item>}
{ isAllSelectedNotHidden() && <Dropdown.Item onClick={handleHidingTags}>Mark as Hidden</Dropdown.Item>}
<Dropdown.Item onClick={() => dispatchInterceptModalState({type: "SHOW_FOR_MULTIPLE_DELETE", payload: {selectedTagIds: getSelectedTags().map(x => x.id)}})}>
<span className="text-danger">Delete</span>
</Dropdown.Item>
</DropdownButton>
}
</div>
</Col>
</Row>
<Row>
<Col xs="9">
<Table>
<thead>
<tr>
<th>
<Form.Check
type="checkbox"
onChange={(e) => dispatchTagsState({type: e.target.checked ? "SELECT_ALL" : "UNSELECT_ALL"})}
checked={isAllTagsSelected}
/>
</th>
<th>Tag</th>
<th>Is Hidden?</th>
<th></th>
</tr>
</thead>
<tbody>
{
getDisplayedTags().map(tag => {
return <tr key={tag.id}>
<td>
<Form.Check
type="checkbox"
onChange={(e) => onTagSelected(e.target.checked, tag)}
checked={tag.isSelected}
/>
</td>
<td>{tag.name}</td>
<td>
<span className={tag.isHidden ? "text-danger" : "text-dark"}>{ tag.isHidden ? "Yes" : "No" }</span>
</td>
<td>
<Button variant="link" className="py-0" onClick={() => dispatchUpsertTagModal({type: "SHOW_EDIT", payload: {tag: tag}})}>Edit</Button>
</td>
</tr>
})
}
</tbody>
</Table>
</Col>
</Row>
</>
}
</Container>
</div>
</>
)
};