diff --git a/backend/models/core/Entity.js b/backend/models/core/Entity.js index 74d87558..ba12f690 100644 --- a/backend/models/core/Entity.js +++ b/backend/models/core/Entity.js @@ -506,6 +506,108 @@ class Entity { //TODO: end transaction return true; } + + // POST multiple entries to database + static async batchUpload(entry) { + const db = new DB(); + + const selectedProperties = ['slug', 'entity_type_id']; + + const entitiesBatchParams = entry.map((obj) => { + return selectedProperties.map((name) => { + const value = obj[name]; + const transformedValue = + typeof value === 'number' ? { longValue: value } : { stringValue: value }; + return { + name, + value: transformedValue + }; + }); + }); + + const insertEntitiesSQL = `INSERT into entities (slug, entity_type_id) + VALUES (:slug, :entity_type_id)`; + + await db.batchExecuteStatement(insertEntitiesSQL, entitiesBatchParams); + + const { + records: [[{ longValue: lastInsertedEntityID }]], + } = await db.executeStatement("SELECT LAST_INSERT_ID()"); + + //Attribute Introspection + const entityIntrospectionSQL = `SELECT id, name, type + FROM attributes + WHERE entity_type_id = :entity_type_id ORDER by \`order\``; + + const attributes = await db.executeStatement(entityIntrospectionSQL, [ + { name: "entity_type_id", value: { longValue: entry[0].entity_type_id } }, + ]); + + let valueBatchParams = []; + + entry.map((x, i) => { + const params = attributes.records.reduce((collection, record) => { + const [ + { longValue: attributeId }, + { stringValue: attributeName }, + { stringValue: attributeType }, + ] = record; + + return [ + ...collection, + [ + { name: "entity_id", value: { longValue: (lastInsertedEntityID + i) } }, + { name: "attribute_id", value: { longValue: attributeId } }, + //Refactor to encapsulate type switch + { + name: "value_string", + value: + attributeType == "text" || + attributeType == "image" || + attributeType == "link" || + attributeType === "video" + ? { stringValue: x[attributeName] } + : { isNull: true }, + }, + { + name: "value_long_string", + value: + attributeType == "textarea" || + attributeType === "gallery" || + attributeType === "rich-text" || + attributeType == "custom" + ? { stringValue: x[attributeName] } + : { isNull: true }, + }, + { + name: "value_double", + value: + attributeType == "float" && x[attributeName].trim() != '' + ? { doubleValue: x[attributeName] } + : { isNull: true }, + }, + { + name: "value_boolean", + value: + attributeType == "boolean" + ? { booleanValue: x[attributeName] } + : { isNull: true }, + }, + ], + ]; + }, []); + + valueBatchParams = valueBatchParams.concat(params); + }); + + const insertValuesBatchSQL = `INSERT INTO \`values\`(entity_id, attribute_id, + value_string, value_long_string, value_double, value_boolean) + VALUES (:entity_id, :attribute_id, :value_string, :value_long_string, :value_double, :value_boolean)`; + + await db.batchExecuteStatement(insertValuesBatchSQL, valueBatchParams); + + return true; + } } export default Entity; diff --git a/components/loading/LoadingScreen.js b/components/loading/LoadingScreen.js new file mode 100644 index 00000000..10e52d83 --- /dev/null +++ b/components/loading/LoadingScreen.js @@ -0,0 +1,11 @@ +const LoadingScreen = () => ( +
+
+
+
+
+
+
+); + +export default LoadingScreen; \ No newline at end of file diff --git a/components/modals/ErrorModal.js b/components/modals/ErrorModal.js new file mode 100644 index 00000000..8bdb4c7d --- /dev/null +++ b/components/modals/ErrorModal.js @@ -0,0 +1,38 @@ +import Modal from 'react-bootstrap/Modal'; +import { Poppins } from '@next/font/google'; +import { FaTimes } from 'react-icons/fa'; +import { IoIosCloseCircleOutline } from 'react-icons/io'; +import cx from "classnames"; + +const poppins = Poppins({ + weight: ['300', '400', '500', '600', '700'], + subsets: ['latin'], + display: 'swap', +}) + +const ErrorModal = ({ show, message, hide }) => { + return ( + + +
+
+
+
+ +
+
+ +
+
Ooops!
+ {message} +
+
+
+ ) +} +export default ErrorModal; \ No newline at end of file diff --git a/components/modals/LoadingModal.js b/components/modals/LoadingModal.js new file mode 100644 index 00000000..c45ffb5b --- /dev/null +++ b/components/modals/LoadingModal.js @@ -0,0 +1,41 @@ +import Modal from 'react-bootstrap/Modal'; +import LoadingScreen from '../loading/LoadingScreen'; +import { Poppins } from '@next/font/google'; +import { FaTimes } from 'react-icons/fa'; +import { IoIosCheckmarkCircleOutline } from 'react-icons/io'; +import cx from "classnames"; + +const poppins = Poppins({ + weight: ['300', '400', '500', '600', '700'], + subsets: ['latin'], + display: 'swap', +}) + +const LoadingModal = ({ show, loading, message, hide }) => { + return ( + hide() : null} + > + + {loading && } + {!loading && +
+
+
+
+ +
+
+ +
+
Great!
+ {message} +
} +
+
+ ) +} +export default LoadingModal; \ No newline at end of file diff --git a/components/reducers/contentManagerReducer.js b/components/reducers/contentManagerReducer.js index 89dcb956..4af24b6e 100644 --- a/components/reducers/contentManagerReducer.js +++ b/components/reducers/contentManagerReducer.js @@ -17,6 +17,7 @@ import { SET_MODAL_CONTENT, SET_DATA, SET_METADATA, + SET_IMPORT_CSV_MODAL } from "@/lib/actions"; export const initialState = { @@ -36,10 +37,13 @@ export const initialState = { modalContent: null, view: 'list', data: {}, - metadata: {} + metadata: {}, + importCSVModal: { + show: false, + loading: false, + } }; - export const contentManagerReducer = (state, action) => { switch (action.type) { case LOADING: @@ -141,5 +145,10 @@ export const contentManagerReducer = (state, action) => { ...state, metadata: action.payload } + case SET_IMPORT_CSV_MODAL: + return { + ...state, + importCSVModal: action.payload + }; } }; diff --git a/db/migrations/202307040350120_insert_download_csv_capability.json b/db/migrations/202307040350120_insert_download_csv_capability.json new file mode 100644 index 00000000..e908108b --- /dev/null +++ b/db/migrations/202307040350120_insert_download_csv_capability.json @@ -0,0 +1,10 @@ +{ + "up": [ + "REPLACE INTO capabilities (name, description, is_system_supplied) VALUES", + "('import_csv', 'Can import CSV file', true)" + ], + "down":[ + "DELETE FROM capabilities WHERE name = 'import_csv'" + ] +} + \ No newline at end of file diff --git a/db/migrations/202307043501210_add_download_csv_capability_to_super_admin.json b/db/migrations/202307043501210_add_download_csv_capability_to_super_admin.json new file mode 100644 index 00000000..b2f66b7e --- /dev/null +++ b/db/migrations/202307043501210_add_download_csv_capability_to_super_admin.json @@ -0,0 +1,11 @@ +{ + "up": [ + "REPLACE INTO group_capabilities (group_id, capabilities_id) VALUES", + "(1, (SELECT id FROM capabilities WHERE name = 'import_csv' AND is_system_supplied = true))" + ], + "down":[ + "DELETE FROM group_capabilities WHERE group_id IN = 1", + "AND capabilities_id = (SELECT id FROM capabilities WHERE name = 'import_csv' AND is_system_supplied = true);" + ] +} + \ No newline at end of file diff --git a/lib/Constants.js b/lib/Constants.js index 47643411..e19a0096 100644 --- a/lib/Constants.js +++ b/lib/Constants.js @@ -29,6 +29,7 @@ export const rejectUsers = 'reject_users'; export const changeUserPassword = 'change_user_password'; export const deleteUsers = 'delete_users'; export const downloadCSV = 'download_csv'; +export const importCSV = 'import_csv'; // password creation diff --git a/lib/actions.js b/lib/actions.js index 91efee69..38521200 100644 --- a/lib/actions.js +++ b/lib/actions.js @@ -43,3 +43,4 @@ export const SET_CURRENT_ICON = 'SET_CURRENT_ICON'; export const SET_GROUPS = 'SET_GROUPS'; export const SET_DATA = 'SET_DATA'; export const SET_METADATA = 'SET_METADATA'; +export const SET_IMPORT_CSV_MODAL = 'SET_IMPORT_CSV_MODAL'; diff --git a/pages/admin/content-manager/[entity_type_slug]/index.js b/pages/admin/content-manager/[entity_type_slug]/index.js index 8442f19c..dae8a50a 100644 --- a/pages/admin/content-manager/[entity_type_slug]/index.js +++ b/pages/admin/content-manager/[entity_type_slug]/index.js @@ -17,7 +17,8 @@ import ContentManagerLayout from "components/layouts/ContentManagerLayout"; import { FaList, FaTh, - FaDownload + FaDownload, + FaUpload } from "react-icons/fa"; import { @@ -36,10 +37,11 @@ import { PAGE_SETS_RENDERER, TOGGLE_VIEW, SET_DATA, - SET_METADATA + SET_METADATA, + SET_IMPORT_CSV_MODAL } from "@/lib/actions"; import AppContentPagination from "components/klaudsolcms/pagination/AppContentPagination"; -import { defaultPageRender, maximumNumberOfPage, EntryValues, writeContents, downloadCSV } from "lib/Constants" +import { defaultPageRender, maximumNumberOfPage, EntryValues, writeContents, downloadCSV, importCSV } from "lib/Constants" import { getSessionCache } from "@klaudsol/commons/lib/Session"; import { useClientErrorHandler } from "@/components/hooks" @@ -47,6 +49,8 @@ import { handleDownloadCsv } from "@/lib/downloadCSV"; import { Spinner } from "react-bootstrap"; import { defaultEntityTypeVariant, entityTypeVariantsEnum } from "@/constants"; import SingleType from "@/components/entity_types/SingleType"; +import LoadingModal from "@/components/modals/LoadingModal"; +import ErrorModal from "@/components/modals/ErrorModal"; export default function ContentManager({ cache }) { const router = useRouter(); @@ -64,6 +68,7 @@ export default function ContentManager({ cache }) { const [variant, setVariant] = useState(defaultEntityTypeVariant); const [attributes, setAttributes] = useState({}); const [entityTypeId, setEntityTypeId] = useState(0); + const [errorModal, setErrorModal] = useState(false); /*** Entity Types List ***/ useEffect(() => { @@ -124,7 +129,6 @@ export default function ContentManager({ cache }) { })(); }, [entity_type_slug, state.page, state.entry, state.setsRenderer]); - useEffect(() => { dispatch({type: SET_PAGE,payload: defaultPageRender}); dispatch({type: PAGE_SETS_RENDERER,payload: defaultPageRender}); @@ -134,6 +138,121 @@ export default function ContentManager({ cache }) { dispatch({ type: TOGGLE_VIEW, payload: view }) } + const generateUniqueSlug = () => { + // Get current timestamp + const timestamp = Date.now(); + + // Introduce a small delay to ensure unique timestamp + while (Date.now() === timestamp) {} + + const randomString = Math.random().toString(36).substr(2, 9); + const slug = `${timestamp}-${randomString}`; + + return slug; + } + + // At the moment, the function can only retrieve CSV files + // TODO: also consider .xlsx and .xls files + const handleFileUpload = (event) => { + event.preventDefault(); + const file = event.target.files[0]; + + const reader = new FileReader(); + reader.onload = (e) => { + const contents = e.target.result; + // Check if CSV file has the same fields with the content type + const validCSV = validateCSV(contents); + + // If CSV is the same + // Process the CSV/Excel contents here + if (validCSV) { + let res = convertCSVToArray(contents); + res = res.map(x => { + return { + ...x, + slug: generateUniqueSlug(), + entity_type_id: state.metadata.entity_type_id, + status: "draft" + } + }) + onCSVUpload(res); + } else { + setErrorModal(true); + } + + // Reset the value of the file input element + event.target.value = null; + }; + + if (file) { + reader.readAsText(file); + } + }; + + // Function that checks if CSV file has the same fields with the content type + const validateCSV = (csv) => { + const lines = csv.split('\n'); + const headers = lines[0].split(','); + + const updatedArray = headers.map((element) => element.trim()); + const filteredArray = state.columns.filter(obj => obj.accessor !== 'id' && obj.accessor !== 'status' && obj.accessor !== 'slug'); + const filteredAccessorArray = filteredArray.map(obj => obj.accessor); + + const isSameArray = updatedArray.every(element => filteredAccessorArray.includes(element)); + + console.log(filteredAccessorArray); + console.log(filteredArray); + console.log(isSameArray); + + return isSameArray; + } + + // Converts the CSV file to an array + const convertCSVToArray = (csv) => { + const lines = csv.split('\n'); + const headers = lines[0].split(','); + + const result = lines.slice(1).map((line) => { + const row = line.split(','); + + const obj = headers.reduce((acc, header, index) => { + const value = row[index].trim(); // Remove leading/trailing whitespace + const modifiedHeader = index === headers.length - 1 ? header.trim().replace(' ', '') : header; + acc[modifiedHeader] = value; + return acc; + }, {}); + + return obj; + }); + + return result; + }; + + // handles upload function + const onCSVUpload = async (values) => { + try { + dispatch({ type: SET_IMPORT_CSV_MODAL, payload: { show: true, loading: true } }); + const responseRaw = await slsFetch(`/api/uploadCsv`, { + method: "POST", + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(values), + }); + const response = await responseRaw.json(); + } catch (ex) { + errorHandler(ex); + } finally { + dispatch({ type: SET_IMPORT_CSV_MODAL, payload: { show: true, loading: false, message: '' } }); + } + }; + + // Function to be called when loading modal is closed + const handleCloseLoadingModal = () => { + dispatch({ type: SET_IMPORT_CSV_MODAL, payload: { loading: false, show: false }}); + router.reload(); + } + return (
@@ -170,6 +289,17 @@ export default function ContentManager({ cache }) { } Download as CSV } + {capabilities.includes(importCSV) && + variant === entityTypeVariantsEnum.collection && + <> + + + }
@@ -257,6 +387,19 @@ export default function ContentManager({ cache }) {
*/} + + handleCloseLoadingModal()} + /> + + setErrorModal(false)} + message="The CSV file does not match. Please try again." + /> diff --git a/pages/api/uploadCsv.js b/pages/api/uploadCsv.js new file mode 100644 index 00000000..f838eb12 --- /dev/null +++ b/pages/api/uploadCsv.js @@ -0,0 +1,40 @@ +/** + * MIT License + +Copyright (c) 2022 KlaudSol Philippines, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +**/ + +import Entity from "@/backend/models/core/Entity"; +import { withSession } from "@klaudsol/commons/lib/Session"; +import { OK } from "@klaudsol/commons/lib/HttpStatuses"; +import { handleRequests } from "@klaudsol/commons/lib/API"; +import { assertUserCan } from '@klaudsol/commons/lib/Permissions'; +import { importCSV } from '@/lib/Constants'; + +export default withSession(handleRequests({ post })); +async function post(req, res) { + await assertUserCan(importCSV, req); + const entry = req.body; + await Entity.batchUpload(entry); + + res.status(OK).json({ message: 'Successfully imported CSV file' }) +} diff --git a/styles/general.scss b/styles/general.scss index d7d4ba98..a9ce4f36 100644 --- a/styles/general.scss +++ b/styles/general.scss @@ -271,6 +271,100 @@ background-color: lightgray !important; } } + + &-error { + @include flex(center, center, column); + @include font-light(16px); + @include padding-tb-lr(0px, 0px); + padding-bottom: 20px; + gap: 10px; + + &-body { + padding: 0 !important; + + &-default { + padding: 15px; + } + } + + &-header { + @include flex(flex-end, center, row); + width: 100%; + + &-icon { + cursor: pointer; + color: white; + font-size: 12px; + } + } + + &-title { + @include font-semi-bold(24px); + } + + &-icon { + font-size: 100px; + color: white; + margin-top: 20px; + + &-container { + @include flex(center, center, column); + @include padding-tb-lr(5px, 10px); + padding-bottom: 30px; + background-color: #e85e6c; + width: 100%; + border-top-left-radius: 7px; + border-top-right-radius: 7px; + } + } + } + + &-loading { + @include flex(center, center, column); + @include font-light(16px); + @include padding-tb-lr(0px, 0px); + padding-bottom: 20px; + gap: 10px; + + &-body { + padding: 0 !important; + + &-default { + padding: 15px; + } + } + + &-header { + @include flex(flex-end, center, row); + width: 100%; + + &-icon { + cursor: pointer; + color: white; + font-size: 12px; + } + } + + &-title { + @include font-semi-bold(24px); + } + + &-icon { + font-size: 100px; + color: white; + margin-top: 20px; + + &-container { + @include flex(center, center, column); + @include padding-tb-lr(5px, 10px); + padding-bottom: 30px; + background-color: #467286; + width: 100%; + border-top-left-radius: 7px; + border-top-right-radius: 7px; + } + } + } } } diff --git a/styles/globals.scss b/styles/globals.scss index f51e81af..cdec02fc 100644 --- a/styles/globals.scss +++ b/styles/globals.scss @@ -203,3 +203,83 @@ div.outer { /* TIME TRACKING CSS */ /* CUSTOM STYLE STARTS HERE */ + +#import-csv { + display: none; +} + +.loading-screen { + display: flex; + justify-content: center; + align-items: center; + width: 100%; + height: auto; + font-size: 2rem; +} + +.loading-screen .dot { + display: flex; + justify-content: center; + align-items: center; + position: relative; + width: 1rem; + height: 1rem; + margin: 0.8em; + border-radius: 50%; +} + +.loading-screen .dot::before { + position: absolute; + content: ""; + width: 100%; + height: 100%; + background: inherit; + border-radius: inherit; + animation: anime 2s ease-out infinite; +} + +@keyframes anime { + 50%, + 75% { + transform: scale(2.5); + } + 80%, + 100% { + opacity: 0; + } +} + +.loading-screen .dot:nth-child(1) { + background-color: #455a64; // Blue +} +.loading-screen .dot:nth-child(2) { + background-color: #516d79; // Red +} + +.loading-screen .dot:nth-child(3) { + background-color: #537584; // Yellow +} + +.loading-screen .dot:nth-child(4) { + background-color: #66818e; // Green +} + +.loading-screen .dot:nth-child(5) { + background-color: #90a4ad; // Purple +} + +.loading-screen .dot:nth-child(1)::before { + animation-delay: 0.2s; +} +.loading-screen .dot:nth-child(2)::before { + animation-delay: 0.4s; +} +.loading-screen .dot:nth-child(3)::before { + animation-delay: 0.6s; +} +.loading-screen .dot:nth-child(4)::before { + animation-delay: 0.8s; +} +.loading-screen .dot:nth-child(5)::before { + animation-delay: 1s; +}