diff --git a/.changeset/poor-pumas-rule.md b/.changeset/poor-pumas-rule.md new file mode 100644 index 0000000..3ca7e45 --- /dev/null +++ b/.changeset/poor-pumas-rule.md @@ -0,0 +1,6 @@ +--- +"frontend": patch +"@jspsych/metadata": patch +--- + +Updating frontend to be more user-friendly with major edits to UI, updating metadata to support this with more specific get methods diff --git a/package-lock.json b/package-lock.json index ead0b39..ada3cf1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9204,7 +9204,7 @@ "@types/jest": "^29.5.12", "husky": "^9.0.11", "ts-jest": "^29.1.4", - "typescript": "^5.5.3" + "typescript": "^5.5.4" } } } diff --git a/packages/frontend/src/App.css b/packages/frontend/src/App.css index f6c2b3f..10e1322 100644 --- a/packages/frontend/src/App.css +++ b/packages/frontend/src/App.css @@ -48,6 +48,19 @@ html, body { overflow-y: auto; /* Adds vertical scroll if content exceeds max-height */ } +.popup-content form { + display: flex; + flex-direction: column; + gap: 16px; /* Adjust as needed */ + align-items: center; + justify-content: center; +} + +.popup-submit { + flex: 0 1 auto; /* Size based on content */ + margin: auto; /* Centers the button horizontally */ +} + .delete-button { display: inline-flex; /* Makes the button only as wide as its content */ align-items: center; /* Centers the content vertically within the button */ @@ -158,17 +171,104 @@ html, body { /* .promptFormSurvey label { } */ -.promptFormSurvey input { +.promptFormSurvey input, .authorInput { margin-left: 10px; + padding: 5px; border: 1px solid #ccc; border-radius: 4px; } .promptFormSubmit { width: fit-content; +} + +.authorFormAddAuthors, .authorFormSubmit { + margin: 15px; +} +.authorLabel { + margin-left: 10px; } .pageDescription { margin-bottom: 32px; -} \ No newline at end of file +} + + +/* Preview page */ + +.preview-header { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; +} + +.previewButton { + margin: 10px; + padding: 0px; + width: 22px; + height: 22px; + border-radius: 5px; + background: none; +} + +/* Button Container */ + +.backSubmitButtonContainer { + display: flex; + flex-direction: row; + gap: 15px; +} + +/* HoverPopup */ +.hover-popup { + white-space: normal; /* Allows text to wrap */ + word-wrap: break-word; /* Ensures long words or URLs wrap to the next line */ +} + +.hover-popup-title-container { + padding: 5px; + display: flex; + flex-direction: row; +} + +.hover-popup-title-container button:first-of-type { + margin-right: 4px; /* Adjust the margin to make the gap smaller */ +} + +.hover-popup-title-container button:last-of-type { + margin-left: 4px; /* Optional: Adjust if needed for symmetry */ +} + +.delete-button-hover { + /* display: inline-flex; */ + padding: 5px 5px; + align-items: center; /* Centers the content vertically within the button */ + justify-content: center; /* Centers the content horizontally within the button */ +} + + +.delete-button-hover img { + width: 20px; /* Larger image size */ + height: 20px; /* Larger image size */ +} + +.expand-button { + padding-left: 12px; + padding-right: 12px; +} + +.variable-item-hover-popup, .field-item-hover-popup, .author-item-hover-popup { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + border: 1px solid #e0e0e0; /* Even lighter gray border */ + border-radius: 10px; /* Optional: add rounded corners */ + gap: 5px; + /* padding-left: 8px; + padding-right: 8px; */ + margin-bottom: 10px; +} + diff --git a/packages/frontend/src/App.tsx b/packages/frontend/src/App.tsx index d9764c4..10ee93c 100644 --- a/packages/frontend/src/App.tsx +++ b/packages/frontend/src/App.tsx @@ -8,7 +8,7 @@ import './App.css' function App() { const [jsPsychMetadata] = useState(new JsPsychMetadata()); // metadata objct const [ metadataString, setMetadataString ] = useState(JSON.stringify(jsPsychMetadata.getMetadata(), null, 2)); // this is the metadata string that willl keep track of metadata - const [ page, setPage ] = useState('upload'); // page logic + const [ page, setPage ] = useState('upload'); // page logic, change back to upload when done working with preview page // const [ fileList, setFileList ] = useState([]); -> this allows to download and save // whenever updates will just call pretty version @@ -21,8 +21,10 @@ function App() { switch (page) { case 'upload': return ; // NEED TO PASS IN UPDATE METADATA SO THAT WILL UPDATE STRING WHEN LOADING + case 'upload-data': + return ; // NEED TO PASS IN UPDATE METADATA SO THAT WILL UPDATE STRING WHEN LOADING case 'viewOptions': - return ; // NEED TO PASS SETPAGE ELEMENT + return ; // NEED TO PASS SETPAGE ELEMENT default: console.warn("uncaught page render:", page); return ; // NEED TO PASS IN UPDATE METADATA SO THAT WILL UPDATE STRING WHEN LOADING diff --git a/packages/frontend/src/assets/downarrow.svg b/packages/frontend/src/assets/downarrow.svg new file mode 100644 index 0000000..b2e3bb2 --- /dev/null +++ b/packages/frontend/src/assets/downarrow.svg @@ -0,0 +1,14 @@ + + + \ No newline at end of file diff --git a/packages/frontend/src/assets/plus.svg b/packages/frontend/src/assets/plus.svg new file mode 100644 index 0000000..d594686 --- /dev/null +++ b/packages/frontend/src/assets/plus.svg @@ -0,0 +1,14 @@ + + + + plus-circle + Created with Sketch Beta. + + + + + + + + + \ No newline at end of file diff --git a/packages/frontend/src/assets/uparrow.svg b/packages/frontend/src/assets/uparrow.svg new file mode 100644 index 0000000..7f7517c --- /dev/null +++ b/packages/frontend/src/assets/uparrow.svg @@ -0,0 +1,14 @@ + + + \ No newline at end of file diff --git a/packages/frontend/src/components/ListItems.tsx b/packages/frontend/src/components/ListItems.tsx new file mode 100644 index 0000000..29b50fa --- /dev/null +++ b/packages/frontend/src/components/ListItems.tsx @@ -0,0 +1,220 @@ +import JsPsychMetadata from '@jspsych/metadata'; +import React, { useState } from 'react'; +import Trash from '../assets/trash.svg'; +import UpArrow from '../assets/uparrow.svg'; +import DownArrow from '../assets/downarrow.svg'; + +type ListItemsProps = { + jsPsychMetadata: JsPsychMetadata; + updateMetadataString: () => void; + openPopup: (type: string, data?: any) => void; + data: Record; + updateState: () => void; +} + +type Author = { + "@type"?: string; + name: string; + givenName?: string; + familyName?: string; + identifier?: string; +} + +type VariableMeasured = { + "@type": string; + name: string; + description: string | Record; + value: string | boolean | number; + identifier?: string; + minValue?: number; + maxValue?: number; + levels?: string[]; + levelsOrdered?: boolean; + na?: boolean; + naValue?: string; + alternateName?: string; + privacy?: string; +} + +type Metadata = { + name: string; + schemaVersion: string; + "@context": string; + "@type": string; + description: string; + author: Author[]; + variableMeasured: VariableMeasured[]; + [key: string]: any; +} + +const ListItems: React.FC = ({ jsPsychMetadata, updateMetadataString, openPopup, data, updateState }) => { + const [expandedItems, setExpandedItems] = useState([]); + + const toggleExpand = (itemKey: string) => { + setExpandedItems((prevExpandedItems) => + prevExpandedItems.includes(itemKey) + ? prevExpandedItems.filter((key) => key !== itemKey) + : [...prevExpandedItems, itemKey] + ); + }; + + const generateButtons = (metadata: Metadata) => { + const res = []; + for (const key in metadata) { + const value = metadata[key]; + + if (key === "variableMeasured") { + for (const variable_key in value) { + const variable = value[variable_key]; + const isExpanded = expandedItems.includes("variable" + variable_key); + + res.push( +
+
+ + + +
+ + {isExpanded && ( +
+

{typeof variable.description === 'object' ? JSON.stringify(variable.description, null, 2) : variable.description}

+ {Object.entries(variable).map(([key, value]) => { + if (key === 'description' || value === '' || value === null || value === undefined || key === "@type" || key === "name") return null; + + const displayValue = (typeof value === 'object' || typeof value === 'function') + ? JSON.stringify(value, null, 2) + : String(value); + + return ( +

+ {key}: {displayValue} +

+ ); + })} +
+ )} +
+ ); + } + } else if (key === "author") { + for (const author_key in value) { + const author = value[author_key]; + const author_typing = typeof author === "string" ? { name: author } : author; + const isExpanded = expandedItems.includes("author" + author_key); + + res.push( +
+
+ + + +
+ {isExpanded && ( +
+ {Object.entries(author_typing).map(([key, value]) => { + if (value === '' || value === null || value === undefined || key === "name") return null; + + const displayValue = (typeof value === 'object' || typeof value === 'function') + ? JSON.stringify(value, null, 2) + : String(value); + + return ( +

+ {key}: {displayValue} +

+ ); + })} +
+ )} +
+ ); + } + } else { + const isExpanded = expandedItems.includes("field" + key); + + res.push( +
+
+ + + +
+ {isExpanded && ( +
+

{value}

+ {/* Add more information as needed */} +
+ )} +
+ ); + } + } + + return res; + }; + + const handleDelete = (name: string, type: string) => { + switch (type) { + case 'variable': + jsPsychMetadata.deleteVariable(name); + break; + case 'author': + jsPsychMetadata.deleteAuthor(name); + break; + case 'field': + jsPsychMetadata.deleteMetadataField(name); + break; + default: + console.warn('Unhandled type for deletion:', type); + } + + updateState(); + updateMetadataString(); + }; + + return ( +
+
+
+ {generateButtons(data as Metadata)} +
+
+
+ ); +}; + +export default ListItems; \ No newline at end of file diff --git a/packages/frontend/src/components/Preview.tsx b/packages/frontend/src/components/Preview.tsx index 21456f1..2301eae 100644 --- a/packages/frontend/src/components/Preview.tsx +++ b/packages/frontend/src/components/Preview.tsx @@ -1,18 +1,174 @@ -import JsPsychMetadata from '@jspsych/metadata'; +import JsPsychMetadata, {AuthorFields, VariableFields } from '@jspsych/metadata'; +import { useState } from 'react'; +import FieldPopup, { FieldFormData } from './popups/FieldPopup'; +import AuthorPopup, { AuthorFormData } from './popups/AuthorPopup'; +import VariablePopup, { VariableFormData } from './popups/VariablePopup'; +import ListPopup from '../components/popups/ListPopup'; +import ListItems from './ListItems'; +import Plus from '../assets/plus.svg'; interface PreviewProps { jsPsychMetadata: JsPsychMetadata; metadataString: string; + updateMetadataString: () => void; } -const Preview: React.FC = ( { metadataString } ) => { +const Preview: React.FC = ( { jsPsychMetadata, updateMetadataString } ) => { + const [ metadataFields, setMetadataFields ] = useState<{ [key: string]: any }>(jsPsychMetadata.getUserMetadataFields()); // fields + const [ authorsList, setAuthorsList ] = useState({author: jsPsychMetadata.getAuthorList()}); // authors + const [ variablesList, setVariablesList ] = useState({ variableMeasured: jsPsychMetadata.getVariableList()}); // variables + + const [isPopupOpen, setIsPopupOpen] = useState(false); + const [popupType, setPopupType] = useState(''); + const [popupData, setPopupData] = useState({}); // State to hold popup-specific data + + // can use this to replace setPopupType and setPopupData + const openPopup = (type: string, data?: any) => { + setPopupType(type); + setIsPopupOpen(true); + if (data) setPopupData(data); else {};// Set optional data to pass to child popups + }; + + const closePopup = () => { + setIsPopupOpen(false); + setPopupType(''); + setPopupData({}); + }; + + const filterEmptyFields = (obj: Record): Record => { + return Object.entries(obj).reduce((acc, [key, value]) => { + if (value !== undefined && value !== "" && (Array.isArray(value) ? value.length > 0 : true)) { + acc[key] = value; + } + return acc; + }, {} as Record); + } + + // THE AUTHOR single reference is not working as intended -> data not loaded correclty + // if there is an oldName, willl want to delete it and rewrite new + const handleSave = (formData: FieldFormData | AuthorFormData | VariableFormData, type: string, oldName?: string) => { + switch (type){ + case 'field': + const fieldData = formData as FieldFormData; // typecasting + + if ("fieldName" in fieldData && "fieldDescription" in fieldData && oldName && oldName !== ""){ + jsPsychMetadata.deleteMetadataField(oldName); + } + + jsPsychMetadata.setMetadataField(fieldData["fieldName"], fieldData["fieldDescription"]); + break; + case 'author': + const author = formData as AuthorFormData; // typecasting + const filteredAuthor = filterEmptyFields(author) as AuthorFields; + + // neeed to delete old reference + if ("name" in filteredAuthor && oldName && oldName !== "")jsPsychMetadata.deleteAuthor(oldName); + + jsPsychMetadata.setAuthor(filteredAuthor); // else case error? -> this else shouldn't be reachable so not sure how to handle + break; + case 'variable': + const variable = formData as VariableFormData; + + // Initialize levels as an empty array + let levels: string[] = []; + + // Convert levels to string if it's an array or keep it as a string + const levelsString = Array.isArray(variable.levels) ? JSON.stringify(variable.levels) : variable.levels || ''; + + try { + // Attempt to parse levels from JSON + levels = JSON.parse(levelsString); + } catch (error) { + // If parsing fails, fallback to splitting a comma-separated string + levels = (levelsString || '').split(',').map(item => item.trim()).filter(item => item !== ''); + } + + // Update variable with parsed levels array + const updatedVariable = { + ...variable, + levels: levels + }; + + const filteredVariable = filterEmptyFields(updatedVariable) as VariableFields; + + if ("name" in filteredVariable && oldName && oldName !== "") jsPsychMetadata.deleteVariable(oldName); + + jsPsychMetadata.setVariable(filteredVariable); // else case error? -> this else shouldn't be reachable so not sure how to handle + break; + default: + console.warn("Submitting form returning with undefined type:", type); + return; + } + + updateState(); + } + + const renderPopup = () => { + switch (popupType) { + case 'list': + return ; + case 'field': + return ; + case 'author': + return ; + case 'variables': + return ; + default: + return null; + } + }; + + const updateState = () => { + const newMetadataFields = jsPsychMetadata.getUserMetadataFields(); + const newAuthorsList = { author: jsPsychMetadata.getAuthorList() }; + const newVariablesList = { variableMeasured: jsPsychMetadata.getVariableList() }; + + if (JSON.stringify(metadataFields) !== JSON.stringify(newMetadataFields)) { + console.log("stringify metadatadatafields"); + setMetadataFields(newMetadataFields); + } + if (JSON.stringify(authorsList) !== JSON.stringify(newAuthorsList)) { + console.log("stringify author"); + + setAuthorsList(newAuthorsList); + } + if (JSON.stringify(variablesList) !== JSON.stringify(newVariablesList)) { + console.log("stringify var"); + setVariablesList(newVariablesList); + } + } + return (

Metadata preview

+
+

Fields

+ +
-          {metadataString}
+          
+        
+
+

Authors

+ +
+
+          
+        
+
+

Variables

+ +
+          
         
+ {isPopupOpen && renderPopup()}
) diff --git a/packages/frontend/src/components/popups/AuthorPopup.tsx b/packages/frontend/src/components/popups/AuthorPopup.tsx index a1c2807..6e8b19d 100644 --- a/packages/frontend/src/components/popups/AuthorPopup.tsx +++ b/packages/frontend/src/components/popups/AuthorPopup.tsx @@ -70,7 +70,7 @@ const AuthorPopup: React.FC = ({ onClose, onSave, currentPopup, set
- + = ({ onClose, onSave, currentPopup, set {nameError &&
{nameError}
}
- + = ({ onClose, onSave, currentPopup, set />
- + = ({ onClose, onSave, currentPopup, set />
- + void; // Update setPopupType to accept optional data setPopupData: (data: any) => void; updateMetadataString: () => void; + openPopup: (type: string, data?: any) => void; + data?: Record; } type Author = { @@ -50,52 +51,36 @@ type Metadata = { [key: string]: any; } -const ListPopup: React.FC = ({ jsPsychMetadata, onClose, setPopupType, setPopupData, updateMetadataString }) => { +const ListPopup: React.FC = ({ jsPsychMetadata, onClose, data, openPopup }) => { + const generateButtons = (metadata: Metadata) => { const res = []; for (const key in metadata) { const value = metadata[key]; - if (key === "variableMeasured"){ - for (const variable_key in value){ + if (key === "variableMeasured") { + for (const variable_key in value) { const variable = value[variable_key]; res.push(
- -
); } - } else if (key === "author"){ - for (const author_key in value){ + } else if (key === "author") { + for (const author_key in value) { const author = value[author_key]; + const author_typing = typeof author === "string" ? { name: author } : author; res.push(
- -
); @@ -103,19 +88,10 @@ const ListPopup: React.FC = ({ jsPsychMetadata, onClose, setPopupType } else { res.push(
- -
); } @@ -124,34 +100,14 @@ const ListPopup: React.FC = ({ jsPsychMetadata, onClose, setPopupType return res; } - const [buttons, setbuttons] = useState(generateButtons(jsPsychMetadata.getMetadata() as Metadata)); - - const handleDelete = (name: string, type: string) => { - switch (type) { - case 'variable': - jsPsychMetadata.deleteVariable(name); - break; - case 'author': - jsPsychMetadata.deleteAuthor(name); - break - case 'field': - jsPsychMetadata.deleteMetadataField(name); - break - } - - // updates the UI -> need update String - setbuttons(generateButtons(jsPsychMetadata.getMetadata() as Metadata)); - updateMetadataString(); - } - return (
-

This is the listPopup page

+

These are fields commonly added to Psych-DS metadata to describe datasets. Clicking on them will allow you to fill in variable information.

- {buttons} + {data ? generateButtons(data as Metadata) : generateButtons(jsPsychMetadata.getMetadata() as Metadata)}
diff --git a/packages/frontend/src/components/popups/VariablePopup.tsx b/packages/frontend/src/components/popups/VariablePopup.tsx index 1727a17..c893cd2 100644 --- a/packages/frontend/src/components/popups/VariablePopup.tsx +++ b/packages/frontend/src/components/popups/VariablePopup.tsx @@ -18,7 +18,7 @@ export type VariableFormData = { identifier: string; // identifier that distinguish across dataset (URL), confusing should check description minValue: number | undefined; maxValue: number | undefined; - levels: string[] | []; // technically property values in the other one but not sure how to format it + levels: string[] | [] | string; // string because of form data levelsOrdered: boolean | undefined; na: boolean | undefined; naValue: string; @@ -32,7 +32,7 @@ const VariablePopup: React.FC = ( { onClose, onSave, currentPopup const [formData, setFormData] = useState({ "@type": popupData["@type"] || "", name: popupData["name"] || "", // required - description: popupData["description"] || "", + description: (typeof popupData["description"] === 'object'? JSON.stringify(popupData["description"], null, 2): popupData["description"]) || "", value: popupData["value"] || "", // string, boolean, or number identifier: popupData["identifier"] || "", // identifier that distinguish across dataset (URL), confusing should check description minValue: popupData["minValue"] || undefined, @@ -91,7 +91,7 @@ const VariablePopup: React.FC = ( { onClose, onSave, currentPopup
- + = ( { onClose, onSave, currentPopup {nameError &&
{nameError}
}
- - description +