diff --git a/client/.env b/client/.env index 8a650d5..43d8d21 100644 --- a/client/.env +++ b/client/.env @@ -1 +1 @@ -REACT_APP_VERSION=1.3.6 \ No newline at end of file +REACT_APP_VERSION=1.3.7 \ No newline at end of file diff --git a/client/src/components/Bookmarks/BookmarkTable/BookmarkTable.module.css b/client/src/components/Bookmarks/BookmarkTable/BookmarkTable.module.css index fc79b68..8b1e0ed 100644 --- a/client/src/components/Bookmarks/BookmarkTable/BookmarkTable.module.css +++ b/client/src/components/Bookmarks/BookmarkTable/BookmarkTable.module.css @@ -9,4 +9,21 @@ .TableAction:hover { cursor: pointer; +} + +.Message { + width: 100%; + display: flex; + justify-content: center; + align-items: baseline; + color: var(--color-primary); + margin-bottom: 20px; +} + +.Message a { + color: var(--color-accent); +} + +.Message a:hover { + cursor: pointer; } \ No newline at end of file diff --git a/client/src/components/Bookmarks/BookmarkTable/BookmarkTable.tsx b/client/src/components/Bookmarks/BookmarkTable/BookmarkTable.tsx index 1d319fb..02779d5 100644 --- a/client/src/components/Bookmarks/BookmarkTable/BookmarkTable.tsx +++ b/client/src/components/Bookmarks/BookmarkTable/BookmarkTable.tsx @@ -1,13 +1,25 @@ -import { ContentType } from '../Bookmarks'; -import classes from './BookmarkTable.module.css'; -import { connect } from 'react-redux'; -import { pinCategory, deleteCategory, deleteBookmark } from '../../../store/actions'; -import { KeyboardEvent } from 'react'; +import { KeyboardEvent, useState, useEffect, Fragment } from 'react'; +import { DragDropContext, Droppable, Draggable, DropResult } from 'react-beautiful-dnd'; +import { Link } from 'react-router-dom'; +// Redux +import { connect } from 'react-redux'; +import { pinCategory, deleteCategory, deleteBookmark, createNotification, reorderCategories } from '../../../store/actions'; + +// Typescript +import { Bookmark, Category, NewNotification } from '../../../interfaces'; +import { ContentType } from '../Bookmarks'; + +// CSS +import classes from './BookmarkTable.module.css'; + +// UI import Table from '../../UI/Table/Table'; -import { Bookmark, Category } from '../../../interfaces'; import Icon from '../../UI/Icons/Icon/Icon'; +// Utils +import { searchConfig } from '../../../utility'; + interface ComponentProps { contentType: ContentType; categories: Category[]; @@ -15,9 +27,28 @@ interface ComponentProps { deleteCategory: (id: number) => void; updateHandler: (data: Category | Bookmark) => void; deleteBookmark: (bookmarkId: number, categoryId: number) => void; + createNotification: (notification: NewNotification) => void; + reorderCategories: (categories: Category[]) => void; } const BookmarkTable = (props: ComponentProps): JSX.Element => { + const [localCategories, setLocalCategories] = useState<Category[]>([]); + const [isCustomOrder, setIsCustomOrder] = useState<boolean>(false); + + // Copy categories array + useEffect(() => { + setLocalCategories([...props.categories]); + }, [props.categories]) + + // Check ordering + useEffect(() => { + const order = searchConfig('useOrdering', ''); + + if (order === 'orderId') { + setIsCustomOrder(true); + } + }) + const deleteCategoryHandler = (category: Category): void => { const proceed = window.confirm(`Are you sure you want to delete ${category.name}? It will delete ALL assigned bookmarks`); @@ -40,46 +71,100 @@ const BookmarkTable = (props: ComponentProps): JSX.Element => { } } + const dragEndHanlder = (result: DropResult): void => { + if (!isCustomOrder) { + props.createNotification({ + title: 'Error', + message: 'Custom order is disabled' + }) + return; + } + + if (!result.destination) { + return; + } + + const tmpCategories = [...localCategories]; + const [movedApp] = tmpCategories.splice(result.source.index, 1); + tmpCategories.splice(result.destination.index, 0, movedApp); + + setLocalCategories(tmpCategories); + props.reorderCategories(tmpCategories); + } + if (props.contentType === ContentType.category) { return ( - <Table headers={[ - 'Name', - 'Actions' - ]}> - {props.categories.map((category: Category) => { - return ( - <tr key={category.id}> - <td>{category.name}</td> - <td className={classes.TableActions}> - <div - className={classes.TableAction} - onClick={() => deleteCategoryHandler(category)} - onKeyDown={(e) => keyboardActionHandler(e, category, deleteCategoryHandler)} - tabIndex={0}> - <Icon icon='mdiDelete' /> - </div> - <div - className={classes.TableAction} - onClick={() => props.updateHandler(category)} - // onKeyDown={(e) => keyboardActionHandler(e, app, props.updateAppHandler)} - tabIndex={0}> - <Icon icon='mdiPencil' /> - </div> - <div - className={classes.TableAction} - onClick={() => props.pinCategory(category)} - onKeyDown={(e) => keyboardActionHandler(e, category, props.pinCategory)} - tabIndex={0}> - {category.isPinned - ? <Icon icon='mdiPinOff' color='var(--color-accent)' /> - : <Icon icon='mdiPin' /> - } - </div> - </td> - </tr> - ) - })} - </Table> + <Fragment> + <div className={classes.Message}> + {isCustomOrder + ? <p>You can drag and drop single rows to reorder categories</p> + : <p>Custom order is disabled. You can change it in <Link to='/settings/other'>settings</Link></p> + } + </div> + <DragDropContext onDragEnd={dragEndHanlder}> + <Droppable droppableId='categories'> + {(provided) => ( + <Table headers={[ + 'Name', + 'Actions' + ]} + innerRef={provided.innerRef}> + {localCategories.map((category: Category, index): JSX.Element => { + return ( + <Draggable key={category.id} draggableId={category.id.toString()} index={index}> + {(provided, snapshot) => { + const style = { + border: snapshot.isDragging ? '1px solid var(--color-accent)' : 'none', + borderRadius: '4px', + ...provided.draggableProps.style, + }; + + return ( + <tr + {...provided.draggableProps} + {...provided.dragHandleProps} + ref={provided.innerRef} + style={style} + > + <td>{category.name}</td> + {!snapshot.isDragging && ( + <td className={classes.TableActions}> + <div + className={classes.TableAction} + onClick={() => deleteCategoryHandler(category)} + onKeyDown={(e) => keyboardActionHandler(e, category, deleteCategoryHandler)} + tabIndex={0}> + <Icon icon='mdiDelete' /> + </div> + <div + className={classes.TableAction} + onClick={() => props.updateHandler(category)} + tabIndex={0}> + <Icon icon='mdiPencil' /> + </div> + <div + className={classes.TableAction} + onClick={() => props.pinCategory(category)} + onKeyDown={(e) => keyboardActionHandler(e, category, props.pinCategory)} + tabIndex={0}> + {category.isPinned + ? <Icon icon='mdiPinOff' color='var(--color-accent)' /> + : <Icon icon='mdiPin' /> + } + </div> + </td> + )} + </tr> + ) + }} + </Draggable> + ) + })} + </Table> + )} + </Droppable> + </DragDropContext> + </Fragment> ) } else { const bookmarks: {bookmark: Bookmark, categoryName: string}[] = []; @@ -111,14 +196,12 @@ const BookmarkTable = (props: ComponentProps): JSX.Element => { <div className={classes.TableAction} onClick={() => deleteBookmarkHandler(bookmark.bookmark)} - // onKeyDown={(e) => keyboardActionHandler(e, app, deleteAppHandler)} tabIndex={0}> <Icon icon='mdiDelete' /> </div> <div className={classes.TableAction} onClick={() => props.updateHandler(bookmark.bookmark)} - // onKeyDown={(e) => keyboardActionHandler(e, app, props.updateAppHandler)} tabIndex={0}> <Icon icon='mdiPencil' /> </div> @@ -131,4 +214,12 @@ const BookmarkTable = (props: ComponentProps): JSX.Element => { } } -export default connect(null, { pinCategory, deleteCategory, deleteBookmark })(BookmarkTable); \ No newline at end of file +const actions = { + pinCategory, + deleteCategory, + deleteBookmark, + createNotification, + reorderCategories +} + +export default connect(null, actions)(BookmarkTable); \ No newline at end of file diff --git a/client/src/components/Bookmarks/Bookmarks.tsx b/client/src/components/Bookmarks/Bookmarks.tsx index 21f37b3..7a2deb2 100644 --- a/client/src/components/Bookmarks/Bookmarks.tsx +++ b/client/src/components/Bookmarks/Bookmarks.tsx @@ -43,6 +43,7 @@ const Bookmarks = (props: ComponentProps): JSX.Element => { name: '', id: -1, isPinned: false, + orderId: 0, bookmarks: [], createdAt: new Date(), updatedAt: new Date() diff --git a/client/src/components/Settings/OtherSettings/OtherSettings.tsx b/client/src/components/Settings/OtherSettings/OtherSettings.tsx index 50b04b5..bba197d 100644 --- a/client/src/components/Settings/OtherSettings/OtherSettings.tsx +++ b/client/src/components/Settings/OtherSettings/OtherSettings.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, ChangeEvent, FormEvent } from 'react'; // Redux import { connect } from 'react-redux'; -import { createNotification, updateConfig, sortApps } from '../../../store/actions'; +import { createNotification, updateConfig, sortApps, sortCategories } from '../../../store/actions'; // Typescript import { GlobalState, NewNotification, SettingsForm } from '../../../interfaces'; @@ -18,6 +18,7 @@ interface ComponentProps { createNotification: (notification: NewNotification) => void; updateConfig: (formData: SettingsForm) => void; sortApps: () => void; + sortCategories: () => void; loading: boolean; } @@ -52,8 +53,9 @@ const OtherSettings = (props: ComponentProps): JSX.Element => { // Update local page title document.title = formData.customTitle; - // Get sorted apps + // Sort apps and categories with new settings props.sortApps(); + props.sortCategories(); } // Input handler @@ -143,4 +145,11 @@ const mapStateToProps = (state: GlobalState) => { } } -export default connect(mapStateToProps, { createNotification, updateConfig, sortApps })(OtherSettings); \ No newline at end of file +const actions = { + createNotification, + updateConfig, + sortApps, + sortCategories +} + +export default connect(mapStateToProps, actions)(OtherSettings); \ No newline at end of file diff --git a/client/src/store/actions/actionTypes.ts b/client/src/store/actions/actionTypes.ts index 769bafa..4324834 100644 --- a/client/src/store/actions/actionTypes.ts +++ b/client/src/store/actions/actionTypes.ts @@ -15,6 +15,8 @@ import { PinCategoryAction, DeleteCategoryAction, UpdateCategoryAction, + SortCategoriesAction, + ReorderCategoriesAction, // Bookmarks AddBookmarkAction, DeleteBookmarkAction, @@ -49,6 +51,8 @@ export enum ActionTypes { pinCategory = 'PIN_CATEGORY', deleteCategory = 'DELETE_CATEGORY', updateCategory = 'UPDATE_CATEGORY', + sortCategories = 'SORT_CATEGORIES', + reorderCategories = 'REORDER_CATEGORIES', // Bookmarks addBookmark = 'ADD_BOOKMARK', deleteBookmark = 'DELETE_BOOKMARK', @@ -78,6 +82,8 @@ export type Action = PinCategoryAction | DeleteCategoryAction | UpdateCategoryAction | + SortCategoriesAction | + ReorderCategoriesAction | // Bookmarks AddBookmarkAction | DeleteBookmarkAction | diff --git a/client/src/store/actions/app.ts b/client/src/store/actions/app.ts index ebd66ad..97db1c7 100644 --- a/client/src/store/actions/app.ts +++ b/client/src/store/actions/app.ts @@ -174,7 +174,7 @@ export const reorderApps = (apps: App[]) => async (dispatch: Dispatch) => { export interface SortAppsAction { type: ActionTypes.sortApps; - payload: {}; + payload: string; } export const sortApps = () => async (dispatch: Dispatch) => { diff --git a/client/src/store/actions/bookmark.ts b/client/src/store/actions/bookmark.ts index 9608ebc..0398bbb 100644 --- a/client/src/store/actions/bookmark.ts +++ b/client/src/store/actions/bookmark.ts @@ -1,7 +1,7 @@ import axios from 'axios'; import { Dispatch } from 'redux'; import { ActionTypes } from './actionTypes'; -import { Category, ApiResponse, NewCategory, Bookmark, NewBookmark } from '../../interfaces'; +import { Category, ApiResponse, NewCategory, Bookmark, NewBookmark, Config } from '../../interfaces'; import { CreateNotificationAction } from './notification'; /** @@ -54,6 +54,8 @@ export const addCategory = (formData: NewCategory) => async (dispatch: Dispatch) type: ActionTypes.addCategory, payload: res.data.data }) + + dispatch<any>(sortCategories()); } catch (err) { console.log(err); } @@ -173,6 +175,8 @@ export const updateCategory = (id: number, formData: NewCategory) => async (disp type: ActionTypes.updateCategory, payload: res.data.data }) + + dispatch<any>(sortCategories()); } catch (err) { console.log(err); } @@ -261,4 +265,60 @@ export const updateBookmark = (bookmarkId: number, formData: NewBookmark, previo } catch (err) { console.log(err); } +} + +/** + * SORT CATEGORIES + */ +export interface SortCategoriesAction { + type: ActionTypes.sortCategories; + payload: string; +} + +export const sortCategories = () => async (dispatch: Dispatch) => { + try { + const res = await axios.get<ApiResponse<Config>>('/api/config/useOrdering'); + + dispatch<SortCategoriesAction>({ + type: ActionTypes.sortCategories, + payload: res.data.data.value + }) + } catch (err) { + console.log(err); + } +} + +/** + * REORDER CATEGORIES + */ +export interface ReorderCategoriesAction { + type: ActionTypes.reorderCategories; + payload: Category[]; +} + +interface ReorderQuery { + categories: { + id: number; + orderId: number; + }[] +} + +export const reorderCategories = (categories: Category[]) => async (dispatch: Dispatch) => { + try { + const updateQuery: ReorderQuery = { categories: [] } + + categories.forEach((category, index) => updateQuery.categories.push({ + id: category.id, + orderId: index + 1 + })) + + await axios.put<ApiResponse<{}>>('/api/categories/0/reorder', updateQuery); + + dispatch<ReorderCategoriesAction>({ + type: ActionTypes.reorderCategories, + payload: categories + }) + } catch (err) { + console.log(err); + } } \ No newline at end of file diff --git a/client/src/store/reducers/bookmark.ts b/client/src/store/reducers/bookmark.ts index 2c1d5f0..a554d6e 100644 --- a/client/src/store/reducers/bookmark.ts +++ b/client/src/store/reducers/bookmark.ts @@ -1,5 +1,6 @@ import { ActionTypes, Action } from '../actions'; import { Category, Bookmark } from '../../interfaces'; +import { sortData } from '../../utility'; export interface State { loading: boolean; @@ -141,6 +142,22 @@ const updateBookmark = (state: State, action: Action): State => { } } +const sortCategories = (state: State, action: Action): State => { + const sortedCategories = sortData<Category>(state.categories, action.payload); + + return { + ...state, + categories: sortedCategories + } +} + +const reorderCategories = (state: State, action: Action): State => { + return { + ...state, + categories: action.payload + } +} + const bookmarkReducer = (state = initialState, action: Action) => { switch (action.type) { case ActionTypes.getCategories: return getCategories(state, action); @@ -152,6 +169,8 @@ const bookmarkReducer = (state = initialState, action: Action) => { case ActionTypes.updateCategory: return updateCategory(state, action); case ActionTypes.deleteBookmark: return deleteBookmark(state, action); case ActionTypes.updateBookmark: return updateBookmark(state, action); + case ActionTypes.sortCategories: return sortCategories(state, action); + case ActionTypes.reorderCategories: return reorderCategories(state, action); default: return state; } } diff --git a/controllers/category.js b/controllers/category.js index 29d03b4..15fe1eb 100644 --- a/controllers/category.js +++ b/controllers/category.js @@ -37,14 +37,32 @@ exports.createCategory = asyncWrapper(async (req, res, next) => { // @route GET /api/categories // @access Public exports.getCategories = asyncWrapper(async (req, res, next) => { - const categories = await Category.findAll({ - include: [{ - model: Bookmark, - as: 'bookmarks' - }], - order: [[ Sequelize.fn('lower', Sequelize.col('Category.name')), 'ASC' ]] + // Get config from database + const useOrdering = await Config.findOne({ + where: { key: 'useOrdering' } }); + const orderType = useOrdering ? useOrdering.value : 'createdAt'; + let categories; + + if (orderType == 'name') { + categories = await Category.findAll({ + include: [{ + model: Bookmark, + as: 'bookmarks' + }], + order: [[ Sequelize.fn('lower', Sequelize.col('Category.name')), 'ASC' ]] + }); + } else { + categories = await Category.findAll({ + include: [{ + model: Bookmark, + as: 'bookmarks' + }], + order: [[ orderType, 'ASC' ]] + }); + } + res.status(200).json({ success: true, data: categories @@ -119,6 +137,22 @@ exports.deleteCategory = asyncWrapper(async (req, res, next) => { where: { id: req.params.id } }) + res.status(200).json({ + success: true, + data: {} + }) +}) + +// @desc Reorder categories +// @route PUT /api/categories/0/reorder +// @access Public +exports.reorderCategories = asyncWrapper(async (req, res, next) => { + req.body.categories.forEach(async ({ id, orderId }) => { + await Category.update({ orderId }, { + where: { id } + }) + }) + res.status(200).json({ success: true, data: {} diff --git a/routes/category.js b/routes/category.js index b18b8f6..64067d7 100644 --- a/routes/category.js +++ b/routes/category.js @@ -6,7 +6,8 @@ const { getCategories, getCategory, updateCategory, - deleteCategory + deleteCategory, + reorderCategories } = require('../controllers/category'); router @@ -20,4 +21,8 @@ router .put(updateCategory) .delete(deleteCategory); +router + .route('/0/reorder') + .put(reorderCategories); + module.exports = router; \ No newline at end of file