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

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