diff --git a/client/.env b/client/.env index 8a650d5c2b89fda5eedf34ed099d0bd0047b379f..43d8d21dc301e7952c4db794eb14aa38bd876921 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 fc79b6805264ad990dd41a6784c8ba4f39eb7b8c..8b1e0edca964f2659dba2de43c04ebb5e6515d53 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 1d319fbd739bab99b4e5b9635d00c8b4ccd475b5..02779d592aba328a3db80153d108819264062bb6 100644 --- a/client/src/components/Bookmarks/BookmarkTable/BookmarkTable.tsx +++ b/client/src/components/Bookmarks/BookmarkTable/BookmarkTable.tsx @@ -1,13 +1,25 @@ +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'; -import { connect } from 'react-redux'; -import { pinCategory, deleteCategory, deleteBookmark } from '../../../store/actions'; -import { KeyboardEvent } from 'react'; +// 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([]); + const [isCustomOrder, setIsCustomOrder] = useState(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 ( - - {props.categories.map((category: Category) => { - return ( - - - - - ) - })} -
{category.name} -
deleteCategoryHandler(category)} - onKeyDown={(e) => keyboardActionHandler(e, category, deleteCategoryHandler)} - tabIndex={0}> - -
-
props.updateHandler(category)} - // onKeyDown={(e) => keyboardActionHandler(e, app, props.updateAppHandler)} - tabIndex={0}> - -
-
props.pinCategory(category)} - onKeyDown={(e) => keyboardActionHandler(e, category, props.pinCategory)} - tabIndex={0}> - {category.isPinned - ? - : - } -
-
+ +
+ {isCustomOrder + ?

You can drag and drop single rows to reorder categories

+ :

Custom order is disabled. You can change it in settings

+ } +
+ + + {(provided) => ( + + {localCategories.map((category: Category, index): JSX.Element => { + return ( + + {(provided, snapshot) => { + const style = { + border: snapshot.isDragging ? '1px solid var(--color-accent)' : 'none', + borderRadius: '4px', + ...provided.draggableProps.style, + }; + + return ( + + + {!snapshot.isDragging && ( + + )} + + ) + }} + + ) + })} +
{category.name} +
deleteCategoryHandler(category)} + onKeyDown={(e) => keyboardActionHandler(e, category, deleteCategoryHandler)} + tabIndex={0}> + +
+
props.updateHandler(category)} + tabIndex={0}> + +
+
props.pinCategory(category)} + onKeyDown={(e) => keyboardActionHandler(e, category, props.pinCategory)} + tabIndex={0}> + {category.isPinned + ? + : + } +
+
+ )} +
+
+
) } else { const bookmarks: {bookmark: Bookmark, categoryName: string}[] = []; @@ -111,14 +196,12 @@ const BookmarkTable = (props: ComponentProps): JSX.Element => {
deleteBookmarkHandler(bookmark.bookmark)} - // onKeyDown={(e) => keyboardActionHandler(e, app, deleteAppHandler)} tabIndex={0}>
props.updateHandler(bookmark.bookmark)} - // onKeyDown={(e) => keyboardActionHandler(e, app, props.updateAppHandler)} tabIndex={0}>
@@ -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 21f37b37c511a61bdd5ae3ff756a7119c27541ba..7a2deb299bf420c15287f203b1b1ff927e266ab1 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 50b04b5d90ed2435db3d57263f24aa6e46f61aee..bba197d1d42aa5949a49e83c69c888d3c58f0cca 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 769bafaeed2f1180516136610981fc4b09a027e9..4324834bc9df7a359dbe32e1d6d070bd98d9e6dc 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 ebd66adf79a5dde4cc95717d3ce42b992b8a139a..97db1c706f93abfdf3e62d954a6df32095c397b3 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 9608ebc6e737c22fbb954d81f1e361b75f281610..0398bbb75ae1133bb3dc0c838950a9712648cc43 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(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(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>('/api/config/useOrdering'); + + dispatch({ + 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>('/api/categories/0/reorder', updateQuery); + + dispatch({ + 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 2c1d5f03178f8676800a8c68544555879f7f660d..a554d6e12e70299d63939c3ef4b35d87f0c69637 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(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 29d03b4e5dfa9b8d5a792b9bd765f306aad0fc03..15fe1eb4d14fdaecf132009a733776135af1f66e 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 b18b8f62b61fb2d5c318634fa0a7d0c19846f852..64067d7bef3ce90a172b6b3f1b1717d566e6ff0a 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