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 (
+
+
+
+
+
+ )
+}
+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 &&
+ }
+
+
+ )
+}
+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;
+}