Files
YABA/yaba-web/src/views/bookmarksListView.jsx
Carl Tibule b0cb791bc2 Add auto-registration mechanism
- Add User registration mechanism when adding auth provider id to claims, if no User Id corresponds to the auth provider Id
- Swallow unique constraint violation for unique auth provider id on user table, in case of duplicate requests
- Add Serilog logging
- Add no bookmarks and no tags message when none is found on Bookmark List page
2023-03-03 23:35:53 -06:00

349 lines
17 KiB
JavaScript

import React, { useEffect, useReducer, useState } from "react";
import { Alert, Col, Container, Row, Button, Modal, Dropdown, DropdownButton } from "../components/external";
import { Bookmark } from "../components";
import { useAuth0 } from "@auth0/auth0-react";
import { deleteBookmarks, getAllBookmarks, hideBookmarks } from "../api";
import { SplashScreen, SearchForm, Tag } from "../components";
import { getTagGroups, isSubset, containsSubstring, flattenTagArrays } from "../utils";
export function BookmarksListView(props) {
const { getAccessTokenSilently } = useAuth0();
const [searchString, setSearchString] = useState("");
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 "HIDE_ALERT":
default:
return Object.assign({}, state, {
show: false
});
}
};
const [alertMessageState, dispatchAlertMessageState] = useReducer(alertReducer, alertMessageInitialState);
const tagsInitialState = [];
const tagsReducer = (state = tagsInitialState, action) => {
switch(action.type) {
case "SET":
return action.payload.tags.map(x => ({...x, isSelected: false, isDisplayed: true }));
case "ADD_SELECTED":
case "REMOVE_SELECTED":
const modifiedTags = [...state];
const selectedTagIndex = modifiedTags.findIndex((x) => x.id === action.payload.selectedTagId);
modifiedTags[selectedTagIndex].isSelected = action.type === "ADD_SELECTED";
return modifiedTags;
default:
return state;
}
}
const [tagsState, dispatchTagsState] = useReducer(tagsReducer, tagsInitialState);
const getSelectedTags = () => tagsState.filter((x) => x.isSelected);
const getNotSelectedTags = () => tagsState.filter((x) => !x.isSelected);
const bookmarksInitialState = [];
const bookmarksReducer = (state = bookmarksInitialState, action) => {
switch(action.type) {
case "SET":
return action.payload.bookmarks.map(x => ({...x, isSelected: false, isDisplayed: true}));
case "ADD_SELECTED":
case "REMOVE_SELECTED":
const modifiedBookmarks = [...state];
const modifiedBookmarkIndex = modifiedBookmarks.findIndex((x) => x.id === action.payload.selectedBookmarkId);
modifiedBookmarks[modifiedBookmarkIndex].isSelected = action.type === "ADD_SELECTED";
return modifiedBookmarks;
case "DELETE_SELECTED":
case "HIDE_SELECTED":
return state.filter((x) => !action.payload.selectedBookmarkIds.includes(x.id));
case "SELECT_ALL":
return state.map(x => ({...x, isSelected: x.isDisplayed}));
case "UNSELECT_ALL":
return state.map(x => ({...x, isSelected: false}));
case "DISPLAY_ALL":
return state.map(x => ({...x, isDisplayed: true}));
case "SEARCH":
return state.map(x => ({...x, isDisplayed: (getSelectedTags().length <= 0 || isSubset(x.tags.map(x => x.id), getSelectedTags().map(x => x.id)))
&& (!searchString || containsSubstring(x, searchString))}));
default:
return state;
}
};
const [bookmarksState, dispatchBookmarksState] = useReducer(bookmarksReducer, []);
const onBookmarkSelected = (isBookmarkSelected, bookmark) => dispatchBookmarksState({type: isBookmarkSelected ? "ADD_SELECTED" : "REMOVE_SELECTED", payload: {selectedBookmarkId: bookmark.id}});
const getDisplayedBookmarksCount = () => bookmarksState.filter(x => x.isDisplayed).length;
const getSelectedBookmarksCount = () => bookmarksState.filter(x => x.isSelected && x.isDisplayed).length;
const getSelectedBookmarks = () => bookmarksState.filter((x) => x.isSelected && x.isDisplayed);
const getAreAllBookmarksSelected = () => bookmarksState.filter(x => x.isDisplayed).every(x => x.isSelected);
const getFilteredBookmarks = () => bookmarksState.filter(x => x.isDisplayed);
const onDeleteSelectedBookmarks = async (ids) => {
if(ids.length <= 0) {
dispatchAlertMessageState({type: "SHOW_ALERT", payload: {show: true, alertType: "warning", message: "No bookmark(s) to delete"}});
} else {
dispatchSplashScreenState({type: "SHOW_SPLASH_SCREEN", payload: {message: "Deleting bookmark(s)"}});
const accessToken = await getAccessTokenSilently();
const { data, error } = await deleteBookmarks(accessToken, ids);
if(error) {
dispatchAlertMessageState({type: "SHOW_ALERT", payload: {show: true, alertType: "danger", message: `An error has occurred while deleting bookmark(s): ${error.message}`}})
} else {
dispatchBookmarksState({type: "DELETE_SELECTED", payload: {selectedBookmarkIds: ids}});
}
dispatchSplashScreenState({type: "HIDE_SPLASH_SCREEN", payload: {message: null }});
}
};
const initialConfirmModalState = {show: false, title: null, message: null, selectedBookmarkId: null};
const confirmModalReducer = (state = initialConfirmModalState, action) => {
switch(action.type) {
case "SHOW_FOR_MULTIPLE_BOOKMARK_DELETE":
return {
...state,
show: action.payload.show,
title: action.payload.title,
message: action.payload.message
};
case "SHOW_FOR_BOOKMARK_DELETE":
return {
...state,
show: action.payload.show,
title: action.payload.title,
message: action.payload.message,
selectedBookmarkId: action.payload.selectedBookmarkId
};
case "HIDE":
default:
return initialConfirmModalState;
}
};
const [confirmModalState, dispatchConfirmModalState] = useReducer(confirmModalReducer, initialConfirmModalState);
const handleDeleteMultipleBookmarks = () => dispatchConfirmModalState({"type": "SHOW_FOR_MULTIPLE_BOOKMARK_DELETE", payload: {show: true, title: "Delete selected bookmarks", "message": "Are you sure you want to delete selected bookmarks?"}});
const handleDeleteBookmark = (bookmarkId) => dispatchConfirmModalState({type: "SHOW_FOR_BOOKMARK_DELETE", payload: { show: true, title: "Delete bookmark", message: "Are you sure you want to delete this bookmark?", selectedBookmarkId: bookmarkId}});
const onHandleCloseConfirmModal = async () => {
const bookmarkIdsToDelete = confirmModalState.selectedBookmarkId ? [confirmModalState.selectedBookmarkId] : getSelectedBookmarks().map((x) => x.id);
dispatchConfirmModalState({type: "HIDE"});
await onDeleteSelectedBookmarks(bookmarkIdsToDelete);
};
const handleHideBookmarks = async(ids) => {
if(ids.length <= 0) {
dispatchAlertMessageState({type: "SHOW_ALERT", payload: {show: true, alertType: "warning", message: "No bookmark(s) to hide"}});
} else {
dispatchSplashScreenState({type: "SHOW_SPLASH_SCREEN", payload: {message: "Hiding bookmark(s)"}});
const accessToken = await getAccessTokenSilently();
const { data, error } = await hideBookmarks(accessToken, ids);
if(error) {
dispatchAlertMessageState({type: "SHOW_ALERT", payload: {show: true, alertType: "danger", message: `An error has occurred while hiding bookmark(s): ${error.message}`}})
} else {
dispatchBookmarksState({type: "HIDE_SELECTED", payload: {selectedBookmarkIds: ids}});
}
dispatchSplashScreenState({type: "HIDE_SPLASH_SCREEN", payload: {message: null }});
}
};
const onTagSelected = (isTagSelected, tag) => {
dispatchTagsState({type: isTagSelected ? "ADD_SELECTED" : "REMOVE_SELECTED", payload: {selectedTagId: tag.id}});
dispatchBookmarksState({type: "SEARCH"});
};
const handleSearch = (e) => {
e.preventDefault();
setSearchString(e.target[0].value);
dispatchBookmarksState({type: "SEARCH"});
};
const handleSearchStringChange = (e) => {
setSearchString(e.target.value);
dispatchBookmarksState({type: "SEARCH"});
};
useEffect(() => {
dispatchSplashScreenState({type: "SHOW_SPLASH_SCREEN", payload: {message: "Retrieving Bookmarks..."}});
const fetchBookmarks = async() => {
const accessToken = await getAccessTokenSilently();
const { data, error } = await getAllBookmarks(accessToken, props.showHidden);
if(error) {
dispatchAlertMessageState({type: "SHOW_ALERT", payload: {show: true, alertType: "danger", "message": `Error fetching bookmarks: ${error.message}`}});
} else {
dispatchBookmarksState({type: "SET", payload: {bookmarks: data}});
dispatchTagsState({type: "SET", payload: {tags: flattenTagArrays(data.map(x => x.tags))}});
}
dispatchSplashScreenState({type: "HIDE_SPLASH_SCREEN", payload: {message: null}});
}
fetchBookmarks();
}, []);
return(
<React.Fragment>
{splashScreenState.show && (
<SplashScreen
message={splashScreenState.message}
/>
)}
<div style={{flexGrow: 1}}>
<Container>
{alertMessageState.show && (
<Alert
variant={alertMessageState.type}
onClose={() => dispatchAlertMessageState({type: "CLOSE"})}
dismissible
>
{alertMessageState.message}
</Alert>
)}
<Modal
show={confirmModalState.show}
onHide={() => {dispatchConfirmModalState({type: "HIDE"})}}
keyboard={false}
>
<Modal.Header closeButton>
<Modal.Title>{confirmModalState.title}</Modal.Title>
</Modal.Header>
<Modal.Body>{confirmModalState.message}</Modal.Body>
<Modal.Footer>
<Button
variant="secondary"
onClick={() => {dispatchConfirmModalState({type: "HIDE"})}}
>
Cancel
</Button>
<Button
variant="danger"
onClick={onHandleCloseConfirmModal}
>
Delete
</Button>
</Modal.Footer>
</Modal>
<Row>
<Col xs="9">
<Row>
<Col xs="7">
<span className="fs-4">{props.showHidden ? "Hidden Bookmarks" : "Bookmarks"}</span>
</Col>
<Col xs="5" className="d-flex justify-content-end">
<SearchForm
onHandleSearch={handleSearch}
onSearchStringChange={handleSearchStringChange}
searchString={searchString}
/>
</Col>
</Row>
<hr className="mt-1" />
</Col>
<Col xs="3">
<span className="fs-4">Tags</span>
<hr className="mt-1" />
</Col>
</Row>
{
(bookmarksState.length > 0 || getSelectedBookmarksCount() > 0) && <Row>
<Col xs="2" className="mb-3">
{
bookmarksState.length > 0 &&
<Button variant="primary" onClick={() => dispatchBookmarksState({type: getAreAllBookmarksSelected() ? "UNSELECT_ALL" : "SELECT_ALL"})}>{getAreAllBookmarksSelected() ? "Unselect All" : "Select All" }</Button>
}
</Col>
<Col xs="7" className="mb-3">
{
getSelectedBookmarksCount() > 0 &&
<div className="d-flex justify-content-end align-items-center">
<span className="fs-5 me-2"> {getSelectedBookmarksCount()} selected</span>
<Button variant="primary" className="me-2" onClick={() => handleHideBookmarks(getSelectedBookmarks().map(x => x.id))}>{props.showHidden ? "Unhide" : "Hide"}</Button>
<Button
variant="danger"
onClick={handleDeleteMultipleBookmarks}
>
Delete
</Button>
</div>
}
</Col>
<Col xs="3" className="mb-3">
{
getSelectedTags().length > 0 && (
getSelectedTags().map((tag) => {
return <Button key={tag.id} variant="primary" style={{textDecoration: "none", cursor: "pointer" }} className="badge rounded-pill text-bg-primary me-2" onClick={() => onTagSelected(false, tag)}>#{tag.name} | x</Button>
})
)
}
</Col>
</Row>
}
<Row>
<Col xs="9">
{ getFilteredBookmarks().length <= 0 && <div className="fs-3">No Bookmarks found</div> }
{
getFilteredBookmarks().map((bookmark) => {
return <Bookmark
key={bookmark.id}
bookmark={bookmark}
onBookmarkSelected={(selected) => onBookmarkSelected(selected, bookmark)}
onDeleteClicked={() => handleDeleteBookmark(bookmark.id)}
onHideClicked={() => handleHideBookmarks([bookmark.id])}
searchString={searchString}
/>
})
}
</Col>
<Col xs="3">
{ getTagGroups(getNotSelectedTags()).length <= 0 && <div className="fs-3">No Tags gound</div> }
{
getTagGroups(getNotSelectedTags()).map((group) => {
return <div key={group.name} className="mb-3">
<span key={group.name} className="text-primary fw-bold">{group.name}</span>
<br />
{
group.tags.map((tag) => <Tag key={tag.id} tag={tag} onClick={() => onTagSelected(true, tag)} />)
}
</div>
})
}
</Col>
</Row>
</Container>
</div>
</React.Fragment>
);
}