diff --git a/app/components/AssetAttributes/AssetAttributes.css b/app/components/AssetAttributes/AssetAttributes.css index 11a2d4f2..ec6bfd85 100644 --- a/app/components/AssetAttributes/AssetAttributes.css +++ b/app/components/AssetAttributes/AssetAttributes.css @@ -3,3 +3,86 @@ padding: 0; margin: 0; } + +.headerRow { + display: flex; + justify-content: flex-end; + margin-bottom: 4px; +} + +.addButton { + display: flex; + align-items: center; + background-color: #639; + color: white; + border: none; + padding: 4px 10px; + border-radius: 4px; + cursor: pointer; + font-weight: 600; + font-size: 12px; + gap: 2px; + transition: background-color 0.2s; +} + +.addButton:hover { + background-color: #552b80; +} + +.attributeRow { + display: flex; + align-items: center; + justify-content: space-between; +} + +.deleteButton { + color: #639 !important; + padding: 2px !important; +} + +.deleteButton:hover { + background-color: rgb(102 51 153 / 10%) !important; +} + +.dialogTitle { + background-color: #aa94d1; + color: white !important; + font-weight: bold; + text-align: center; +} + +.dialogContent { + padding: 20px 24px !important; +} + +.formLabel { + display: block; + font-weight: bold; + margin-bottom: 8px; + font-size: 14px; +} + +.dialogActions { + display: flex; + justify-content: center; + padding: 8px 24px 16px; + gap: 16px; +} + +.saveButton, +.cancelButton { + padding: 8px 24px; + border: none; + border-radius: 6px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + background-color: #639; + color: white; + transition: opacity 0.2s; +} + +.saveButton:hover, +.cancelButton:hover { + opacity: 0.85; +} diff --git a/app/components/AssetAttributes/AssetAttributes.js b/app/components/AssetAttributes/AssetAttributes.js index 79556786..a7a344d1 100644 --- a/app/components/AssetAttributes/AssetAttributes.js +++ b/app/components/AssetAttributes/AssetAttributes.js @@ -1,30 +1,82 @@ -import React from 'react'; -import { Checkbox, FormControlLabel } from '@mui/material'; +import React, { useState } from 'react'; +import { Checkbox, FormControlLabel, IconButton, Dialog, DialogTitle, DialogContent, DialogActions, TextField, Button, Tooltip, } from '@mui/material'; +import { Add, Delete } from '@mui/icons-material'; import styles from './AssetAttributes.css'; +import AssetsConfig from '../../constants/assets-config'; const assetAttributes = (props) => { - const { asset, configuration, onUpdateAttribute } = props; + const { asset, configuration, onUpdateAttribute, customAttributes, onAddCustomAttribute, onDeleteCustomAttribute, } = props; + + const [openAddDialog, setOpenAddDialog] = useState(false); + const [newAttributeName, setNewAttributeName] = useState(''); + const updateAttributeValue = (a) => { if (onUpdateAttribute) { onUpdateAttribute(a.target.name, a.target.checked); } }; + const handleAddCustomAttribute = () => { + const trimmedName = newAttributeName.trim(); + if(!trimmedName)return; + + // Enforcing Attributes Name Length + const safeName = trimmedName.substring(0, AssetsConfig.CUSTOM_ATTRIBUTE_NAME_MAX_LENGTH); + const id = `custom_${safeName.toLowerCase().replace(/\s+/g, '_').replace(/[^a-z0-9_]/g, '')}`; + + // Checking for the Duplicate IDs + const allAttributes = [...configuration, ...(customAttributes || [])]; + if (allAttributes.some((a) => a.id === id)) { + setOpenAddDialog(false); + setNewAttributeName(''); + return; + } + + const newAttribute = { + id, + display: safeName, + type: 'bool', + default: false, + appliesTo: ['*'], + source: 'custom', + } + + if (onAddCustomAttribute) { + onAddCustomAttribute(newAttribute); + } + + setOpenAddDialog(false); + setNewAttributeName(''); + } + + const handleDeleteCustomAttribute = (attributeId) => { + if (onDeleteCustomAttribute) { + onDeleteCustomAttribute(attributeId); + } + }; + const applicableAttributes = configuration .map((a) => { // If the asset doesn't have a content type (needed for attribute detection), // or the attribute doesn't apply to this asset, skip it - if (asset.contentTypes == null || - (!a.appliesTo.includes('*') && !a.appliesTo.some((x) => asset.contentTypes.includes(x)))) { - return null; + if (a.appliesTo.includes('*')) { + return a; + } + if (asset.contentTypes == null || !a.appliesTo.some((x) => asset.contentTypes.includes(x))) { + return null; } return a; }) .filter((a) => a !== null); + const allApplicableAttributes = [ + ...applicableAttributes, + ...(customAttributes || []), + ]; + let controls = null; if (asset) { - controls = applicableAttributes.map((a) => { + controls = allApplicableAttributes.map((a) => { let control = {a.display}; if (a.type === 'bool') { const value = @@ -32,6 +84,7 @@ const assetAttributes = (props) => { ? asset.attributes[a.id] : a.default; control = ( +
{ /> } /> + {a.source === 'custom' && ( + + handleDeleteCustomAttribute(a.id)} + className={styles.deleteButton} + > + + + + )} +
); } return
  • {control}
  • ; @@ -52,7 +117,58 @@ const assetAttributes = (props) => { return (
    +
    + +
    + + { /*Custom Attribute Dialog */ } + { + setOpenAddDialog(false); + setNewAttributeName(''); + }} + maxWidth="sm" + fullWidth + > + Custom Attributes + + + setNewAttributeName(e.target.value)} + fullWidth + variant="outlined" + size="small" + inputProps={{ + maxLength: AssetsConfig.CUSTOM_ATTRIBUTE_NAME_MAX_LENGTH, + }} + /> + + + + + +
    ); }; diff --git a/app/components/AssetDetails/AssetDetails.js b/app/components/AssetDetails/AssetDetails.js index 8c7a4727..4c02a12e 100644 --- a/app/components/AssetDetails/AssetDetails.js +++ b/app/components/AssetDetails/AssetDetails.js @@ -41,6 +41,9 @@ const assetDetails = (props) => { onDeletedNote, onUpdatedAttribute, assetAttributes, + customAttributes, + onAddCustomAttribute, + onDeleteCustomAttribute, sourceControlEnabled, dynamicDetails, isExternalRootAsset, @@ -154,6 +157,9 @@ const assetDetails = (props) => { asset={asset} configuration={assetAttributes} onUpdateAttribute={updateAssetAttribute} + customAttributes={customAttributes} + onAddCustomAttribute={onAddCustomAttribute} + onDeleteCustomAttribute={onDeleteCustomAttribute} /> diff --git a/app/components/CustomTemplateBuilder/CustomTemplateBuilder.css b/app/components/CustomTemplateBuilder/CustomTemplateBuilder.css new file mode 100644 index 00000000..9ffddea1 --- /dev/null +++ b/app/components/CustomTemplateBuilder/CustomTemplateBuilder.css @@ -0,0 +1,124 @@ +.container { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 10px; + padding: 5px; + min-height: 450px; + box-sizing: border-box; + width: 100%; +} + +.leftColumn, +.rightColumn { + gap: 12px; + min-width: 0; + background-color: #f5f5f5; + border-radius: 8px; + padding: 10px; + display: flex; + flex-direction: column; +} + +.header { + font-weight: bold; + font-size: 16px; + margin-bottom: 12px; + color: #000; +} + +.actionButton { + align-items: center; + text-transform: none; + justify-content: center; + margin-bottom: 20px; + padding: 8px 16px; + box-shadow: none; + text-align: center; + width: 100%; +} + +.actionButton small { + font-weight: normal; + opacity: 0.8; +} + +.actionText { + align-items: center; + display: flex; + flex-direction: column; + line-height: 1.2; +} + +.actionIcon { + margin-top: 0; + margin-right: 0; +} + +.actionTitleRow { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 10px; + align-self: center; +} + +.inputGroup { + margin-top: 16px; + width: 100%; +} + +.textField { + background-color: white; + border-radius: 4px; + border: 1px solid #cfcfcf; +} + +.treeContainer { + background-color: white; + padding: 15px; + border-radius: 8px; + flex: 1; + overflow-y: auto; +} + +.treeRoot { + font-weight: bold; + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; +} + +.treeNode { + display: flex; + justify-content: space-between; + align-items: center; + padding: 4px 0; + position: relative; +} + +.treeNodeContent { + display: flex; + align-items: center; + gap: 8px; +} + +.treeLine { + position: absolute; + left: 10px; + top: -15px; + bottom: 50%; + border-left: 1px solid #ccc; + width: 10px; + border-bottom: 1px solid #ccc; +} + +.folderIcon { + color: #34495e; +} +.fileIcon { + color: #7f8c8d; +} +.nodeName { + font-size: 14px; +} diff --git a/app/components/CustomTemplateBuilder/CustomTemplateBuilder.js b/app/components/CustomTemplateBuilder/CustomTemplateBuilder.js new file mode 100644 index 00000000..a418b141 --- /dev/null +++ b/app/components/CustomTemplateBuilder/CustomTemplateBuilder.js @@ -0,0 +1,210 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { Button, TextField, IconButton } from '@mui/material'; +import DriveFolderUploadIcon from '@mui/icons-material/DriveFolderUpload'; +import FileUploadIcon from '@mui/icons-material/FileUpload'; +import { filterContentsByPaths, collectAllPaths } from '../../utils/templateContent'; +import ProjectTemplatePreview from '../ProjectTemplatePreview/ProjectTemplatePreview'; +import Error from '../Error/Error'; +import Messages from '../../constants/messages'; +import styles from './CustomTemplateBuilder.css'; +import { ipcRenderer } from 'electron'; + + +class CustomTemplateBuilder extends Component { + constructor(props) { + super(props); + const initial = props.initialTemplate; + + this.state = { + templateName: initial ? initial.name : '', + description: initial ? initial.description : '', + importedTemplate: initial ? initial : null, + importError: null, + isScanning: false, + checkedPaths: initial ? collectAllPaths(initial.contents) : [], + }; + } + + _handleImportResponse = (response) => { + if (response.canceled) { + this.setState({ isScanning: false }); + return; + } + if (response.error) { + this.setState({ + isScanning: false, + importError: response.errorMessage, + importedTemplate: null, + checkedPaths: [], + }); + if (this.props.onValidationChange) { + this.props.onValidationChange(false); + } + return; + } + this.setState( + { + isScanning: false, + importedTemplate: response.template, + templateName: response.template.name, + description: response.template.description, + importError: null, + }, + () => { + this.updateTemplateReady(); + }, + ); + }; + + updateTemplateReady = () => { + const { importedTemplate, templateName, description, checkedPaths, importError } = this.state; + if (importedTemplate && this.props.onTemplateReady) { + const filteredContents = filterContentsByPaths( + importedTemplate.contents, + checkedPaths, + ); + this.props.onTemplateReady({ + ...importedTemplate, + name: templateName || importedTemplate.name, + description: description || importedTemplate.description, + contents: filteredContents, + }); + } + if (this.props.onValidationChange) { + this.props.onValidationChange( + templateName.trim() !== '' && + importedTemplate !== null && + !importError && + checkedPaths.length > 0, + ); + } + }; + + handleNameChange = (e) => { + const templateName = e.target.value; + this.setState({ templateName }, () => { + this.updateTemplateReady(); + }); + }; + + handleDescriptionChange = (e) => { + const description = e.target.value; + this.setState({ description }, () => { + this.updateTemplateReady(); + }); + }; + + handleCheckedChange = (checkedPaths) => { + this.setState({ checkedPaths }, () => { + this.updateTemplateReady(); + }); + }; + + handleUploadingExistingFolder = () => { + this.setState({ + isScanning: true, + importError: null + }); + + ipcRenderer.once(Messages.IMPORT_PROJECT_TEMPLATE_FOLDER_RESPONSE, (event,response) => { + this._handleImportResponse(response); + }); + ipcRenderer.send(Messages.IMPORT_PROJECT_TEMPLATE_FOLDER_REQUEST); + } + + handleImportExistingTemplate = () => { + this.setState({ + isScanning: true, + importError: null + }) + + ipcRenderer.once(Messages.IMPORT_PROJECT_TEMPLATE_ZIP_RESPONSE, (event, response) =>{ + this._handleImportResponse(response); + }); + ipcRenderer.send(Messages.IMPORT_PROJECT_TEMPLATE_ZIP_REQUEST); + }; + + render() { + return ( +
    +
    +
    Template Actions:
    + + +
    +
    Template Name
    + +
    +
    +
    Description
    + +
    + {this.state.importError ? ( + {this.state.importError} + ) : null} +
    +
    +
    Template Preview:
    + +
    +
    + ) + } +} + +CustomTemplateBuilder.propTypes = { + onValidationChange: PropTypes.func, + onTemplateReady: PropTypes.func, +}; + +export default CustomTemplateBuilder; \ No newline at end of file diff --git a/app/components/Project/Assets/Assets.js b/app/components/Project/Assets/Assets.js index 0a7abf23..77c0c4f3 100644 --- a/app/components/Project/Assets/Assets.js +++ b/app/components/Project/Assets/Assets.js @@ -20,6 +20,26 @@ import SourceControlService from '../../../services/sourceControl'; import styles from './Assets.css'; import path from 'path'; + +/** + * Load Custom Attributes for a project from localStorage. + */ +function loadCustomAttributes(projectId) { + try { + const stored = localStorage.getItem(`statwrap_custom_attrs_${projectId}`); + return stored ? JSON.parse(stored) : []; + } catch (e) { + return []; + } +} + +/** + * Save Custom Attributes for a project to localStorage. + */ +function saveCustomAttributes(projectId, attrs) { + localStorage.setItem(`statwrap_custom_attrs_${projectId}`, JSON.stringify(attrs)); +} + /** * Utility function to safely reset available filters for a project's assets * @@ -85,6 +105,9 @@ const assetsComponent = (props) => { project.externalAssets : AssetUtil.createEmptyExternalAssets()); // Sensitive file state that are under version control const [sensitiveTrackedFiles, setSensitiveTrackedFiles] = useState([]); + const [localCustomAttributes, setLocalCustomAttributes] = useState( + project ? loadCustomAttributes(project.id) : [] + ) const sourceControlService = new SourceControlService(); const projectService = new ProjectService(); @@ -96,15 +119,12 @@ const assetsComponent = (props) => { // Get a more recent copy of the asset from the updated project if (project && project.assets && selectedAsset) { const updatedAsset = AssetUtil.findDescendantAssetByUri(project.assets, selectedAsset.uri); - setSelectedAsset(updatedAsset); - if (onSelectedAsset) { - onSelectedAsset(updatedAsset); - } - } else { - setSelectedAsset(null); - if (onSelectedAsset) { - onSelectedAsset(null); - } + if (updatedAsset) { + setSelectedAsset(updatedAsset); + if (onSelectedAsset) { + onSelectedAsset(updatedAsset); + } + } } }, [project]); @@ -170,6 +190,13 @@ const assetsComponent = (props) => { checkSensitiveTrackedFiles(); }, [project]); + // Reload Custom Attributes only when the project ID changes. + useEffect(() => { + if (project && project.id) { + setLocalCustomAttributes(loadCustomAttributes(project.id)); + } + }, [project && project.id]); + // Whenever the filter changes, update the list of assets to include only // those that should be displayed. const handleFilterChanged = (updatedFilter) => { @@ -381,6 +408,18 @@ const assetsComponent = (props) => { setEditingExternalAsset(true); }; + const handleAddLocalCustomAttribute = (newAttribute) => { + const updated = [...localCustomAttributes, newAttribute]; + setLocalCustomAttributes(updated); + saveCustomAttributes(project.id, updated); + }; + + const handleDeleteLocalCustomAttribute = (attributeId) => { + const updated = localCustomAttributes.filter((a) => a.id !== attributeId); + setLocalCustomAttributes(updated); + saveCustomAttributes(project.id, updated); + }; + let assetDisplay = null; if (project) { assetDisplay = Please wait for the list of assets to finish loading...; @@ -393,6 +432,9 @@ const assetsComponent = (props) => { onDeletedNote={onDeletedAssetNote} onUpdatedAttribute={onUpdatedAssetAttribute} assetAttributes={assetAttributes} + customAttributes={localCustomAttributes} + onAddCustomAttribute={handleAddLocalCustomAttribute} + onDeleteCustomAttribute={handleDeleteLocalCustomAttribute} sourceControlEnabled={project.sourceControlEnabled} dynamicDetails={dynamicDetails} onEdit={handleEditExternalAsset} diff --git a/app/components/Project/Project.js b/app/components/Project/Project.js index 39aff2f1..5dccac10 100644 --- a/app/components/Project/Project.js +++ b/app/components/Project/Project.js @@ -31,12 +31,7 @@ import GeneralUtil from '../../utils/general'; import styles from './Project.css'; import UserContext from '../../contexts/User'; -type Props = { - onDirtyStateChange?: (boolean) => void, -}; - -class Project extends Component { - props: Props; +class Project extends Component{ constructor(props) { super(props); diff --git a/app/components/ProjectTemplateList/ProjectTemplateList.js b/app/components/ProjectTemplateList/ProjectTemplateList.js index 2125f45d..b36be047 100644 --- a/app/components/ProjectTemplateList/ProjectTemplateList.js +++ b/app/components/ProjectTemplateList/ProjectTemplateList.js @@ -1,8 +1,18 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { List, ListItemButton, ListItemText } from '@mui/material'; +import { List, ListItemButton, ListItemText, Chip, IconButton, Tooltip, } from '@mui/material'; +import EditIcon from '@mui/icons-material/Edit'; +import FileUploadIcon from '@mui/icons-material/FileUpload'; +import DeleteIcon from '@mui/icons-material/Delete'; -function ProjectTemplateList({ templates, selectedTemplate = null, onSelect }) { +function ProjectTemplateList({ + templates, + selectedTemplate = null, + onSelect, + onEdit, + onExport, + onDelete + }) { let projectTypeList = null; if (templates !== null) { projectTypeList = templates.map((type) => ( @@ -15,7 +25,61 @@ function ProjectTemplateList({ templates, selectedTemplate = null, onSelect }) { key={type.id} onClick={() => onSelect(type.id, type.version)} > - + + {type.name} + {type.isCustom && ( + + )} + + } + secondary={type.description} + /> + + {/* Action icons — only for custom templates */} + {type.isCustom && ( + e.stopPropagation()} + style={{ display: 'flex', gap: '2px' }} + > + + onEdit && onEdit(type)} + > + + + + + onExport && onExport(type.id)} + > + + + + + onDelete && onDelete(type)} + > + + + + + )} + )); } @@ -23,10 +87,20 @@ function ProjectTemplateList({ templates, selectedTemplate = null, onSelect }) { return {projectTypeList}; } +ProjectTemplateList.defaultProps = { + selectedTemplate: null, + onEdit: null, + onExport: null, + onDelete: null, +}; + ProjectTemplateList.propTypes = { templates: PropTypes.array.isRequired, selectedTemplate: PropTypes.object, onSelect: PropTypes.func.isRequired, + onEdit: PropTypes.func, + onExport: PropTypes.func, + onDelete: PropTypes.func, }; export default ProjectTemplateList; diff --git a/app/components/ProjectTemplatePreview/ProjectTemplatePreview.js b/app/components/ProjectTemplatePreview/ProjectTemplatePreview.js index b50296fa..c7ad4cb6 100644 --- a/app/components/ProjectTemplatePreview/ProjectTemplatePreview.js +++ b/app/components/ProjectTemplatePreview/ProjectTemplatePreview.js @@ -8,22 +8,26 @@ import { faChevronRight, faChevronDown, faFolderOpen, + faSquareCheck, + faSquare, } from '@fortawesome/free-solid-svg-icons'; import styles from './ProjectTemplatePreview.css'; +import Constants from '../../constants/constants'; +import { collectAllPaths } from '../../utils/templateContent'; -function contentsToNodes(assets) { +function contentsToNodes(assets, selectable) { if (assets) { return assets.map((x) => ({ value: x.path, label: x.name, - showCheckbox: false, + showCheckbox: selectable, icon: - x.type === 'folder' ? ( + x.type === Constants.AssetType.DIRECTORY ? ( ) : ( ), - children: x.contents ? contentsToNodes(x.contents) : null, + children: x.contents ? contentsToNodes(x.contents, selectable) : null, })); } @@ -39,8 +43,33 @@ class ProjectTemplatePreview extends Component { }; } + /** + * When a new template arrives, auto-check all items. + */ + componentDidUpdate(prevProps) { + if ( + this.props.template && + this.props.template !== prevProps.template && + this.props.selectable + ) { + const allPaths = collectAllPaths(this.props.template.contents); + this.setState({ checked: allPaths }, () => { + if (this.props.onCheckedChange) { + this.props.onCheckedChange(allPaths); + } + }); + } + } + handleCheck = (checked) => { + this.setState({ checked }); + if (this.props.onCheckedChange) { + this.props.onCheckedChange(checked); + } + }; + + render() { - const { template = null } = this.props; + const { selectable, template } = this.props; let preview = (
    Please select a template from a list on the left
    @@ -48,7 +77,7 @@ class ProjectTemplatePreview extends Component { if (template) { let templateContents = []; if (template.contents) { - templateContents = contentsToNodes(template.contents); + templateContents = contentsToNodes(template.contents, selectable); } const templateNodes = [ { @@ -62,7 +91,17 @@ class ProjectTemplatePreview extends Component { <> Preview: + ), + uncheck: ( + + ), + halfCheck: ( + + ), expandClose: ( ), @@ -80,7 +119,7 @@ class ProjectTemplatePreview extends Component { nodes={templateNodes} checked={this.state.checked} expanded={this.state.expanded} - onCheck={(checked) => this.setState({ checked })} + onCheck={this.handleCheck} onExpand={(expanded) => this.setState({ expanded })} /> @@ -97,6 +136,14 @@ class ProjectTemplatePreview extends Component { ProjectTemplatePreview.propTypes = { template: PropTypes.object, + selectable: PropTypes.bool, + onCheckedChange: PropTypes.func, +}; + +ProjectTemplatePreview.defaultProps = { + template: null, + selectable: false, + onCheckedChange: null, }; export default ProjectTemplatePreview; diff --git a/app/components/ReproChecklist/ChecklistItem/ChecklistItem.css b/app/components/ReproChecklist/ChecklistItem/ChecklistItem.css index a42720f7..f1d8f997 100644 --- a/app/components/ReproChecklist/ChecklistItem/ChecklistItem.css +++ b/app/components/ReproChecklist/ChecklistItem/ChecklistItem.css @@ -10,8 +10,7 @@ .statementHeader { display: flex; - flex-direction: row; - flex-wrap: nowrap; + flex-flow: row nowrap; align-items: center; border: 1px solid #e5e5e5; border-radius: 7px; @@ -21,8 +20,8 @@ .expandButton { background: none; - padding-left: 0px; - margin-right: 0px; + padding-left: 0; + margin-right: 0; align-items: center; display: flex; } @@ -46,6 +45,9 @@ margin-left: auto; margin-right: 0; margin-bottom: 5px; + display: flex; + align-items: center; + gap: 2px; } .scanResult { @@ -53,14 +55,11 @@ font-family: 'Courier New', Courier, monospace; } -.scanResult, .userDocumentation { padding-left: 20px; margin-bottom: 20px; } -.scanKey {} - .scanList { margin-top: 4px; } @@ -89,7 +88,7 @@ } .assets th { - background-color: rgba(0, 0, 0, .05); + background-color: rgb(0 0 0 / 5%); padding: 5px; text-align: left; } @@ -110,7 +109,7 @@ .assets tr td { border: none; - border-bottom: solid 1px rgba(0, 0, 0, .12); + border-bottom: solid 1px rgb(0 0 0 / 12%); padding: 5px; } @@ -136,7 +135,6 @@ padding-bottom: 5px; } -.assetDescription, .assetLink { padding-top: 5px; font-size: 0.85rem; @@ -394,9 +392,6 @@ button.addAssetButton { .title, .description { width: 100%; -} - -.description { margin-top: 10px; } @@ -424,4 +419,38 @@ button.addAssetButton { text-align: center; border-bottom: solid 1px #bbb; margin-bottom: 15px; -} \ No newline at end of file +} + +.customActions { + display: flex; + align-items: center; + margin-left: auto; + margin-right: 8px; +} + +.editButton { + color: #639 !important; + transition: background-color 0.2s; +} + +.editButton:hover { + background-color: rgb(102 51 153 / 10%) !important; +} + +.deleteButton { + color: #639 !important; + transition: background-color 0.2s; +} + +.deleteButton:hover { + background-color: rgb(102 51 153 / 10%) !important; +} + +.infoButton { + color: #639 !important; + transition: background-color 0.2s; +} + +.infoButton:hover { + background-color: rgb(102 51 153 / 10%) !important; +} diff --git a/app/components/ReproChecklist/ChecklistItem/ChecklistItem.js b/app/components/ReproChecklist/ChecklistItem/ChecklistItem.js index de47e09b..90bac947 100644 --- a/app/components/ReproChecklist/ChecklistItem/ChecklistItem.js +++ b/app/components/ReproChecklist/ChecklistItem/ChecklistItem.js @@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react'; import PropTypes from 'prop-types'; import styles from './ChecklistItem.css'; import NoteEditor from '../../NoteEditor/NoteEditor'; -import { ContentCopy, Done, Delete } from '@mui/icons-material'; +import { ContentCopy, Done, Delete, Edit, HelpOutline } from '@mui/icons-material'; import { IconButton, Checkbox, @@ -24,12 +24,15 @@ const { v4: uuidv4 } = require('uuid'); function ChecklistItem(props) { const { item, + displayNumber, project, onUpdatedNote, onDeletedNote, onAddedNote, onItemUpdate, onSelectedAsset, + onDeleteItem, + onEditItem, } = props; const treeRef = React.useRef(null); @@ -96,11 +99,11 @@ function ChecklistItem(props) { * * @param {object} event The checkbox event */ - const handleItemChecked = (event: React.ChangeEvent) => { + const handleItemChecked = (event) => { const newValue = event.target.checked; // If it didn't change, no action is needed. - if (newValue === item.value) { + if (newValue === item.answer) { return; } @@ -122,7 +125,7 @@ function ChecklistItem(props) { * @param {object} event The checkbox event * @param {object} subCheck The sub-checklist item that was updated */ - const handleSubItemChecked = (event: React.ChangeEvent, subCheck) => { + const handleSubItemChecked = (event, subCheck) => { // TODO: This works in theory... we don't currently have a sub-checklist item, so when we // implement one we will need this to be tested more thoroughly. const newValue = event.target.checked; @@ -270,8 +273,44 @@ function ChecklistItem(props) { )} - {item.id}. {item.statement} + {displayNumber}. {item.statement} +
    + {item.source === 'custom' && ( + onEditItem(item)} + className={styles.editButton} + title="Edit checklist" + > + + + )} + onDeleteItem(item.uid || item.id)} + className={styles.deleteButton} + title="Delete checklist" + > + + + {item.description && item.description.trim() !== '' && ( + + + + + + )} + +
    +
    {expanded && (
    -
    StatWrap Defined Documentation
    -
    - {item.scanResult && - Object.keys(item.scanResult).map((key) => { - return ( -
    - {key} -
      - {item.scanResult[key].length ? ( - item.scanResult[key].map((answer, index) => ( -
    • {answer}
    • - )) - ) : ( -
    • No results
    • - )} -
    -
    - ); - })} -
    + {item.source !== 'custom' && ( + <> +
    StatWrap Defined Documentation
    +
    + {item.scanResult && + Object.keys(item.scanResult).map((key) => { + return ( +
    + {key} +
      + {item.scanResult[key].length ? ( + item.scanResult[key].map((answer, index) => ( +
    • {answer}
    • + )) + ) : ( +
    • No results
    • + )} +
    +
    + ); + })} +
    + + )}
    Additional Documentation +
    +
    + {sortedChecklist.map((item, index) => ( +
    handleDragStart(index)} + onDragOver={(e) => handleDragOver(e, index)} + onDrop={() => handleDrop(index)} + onDragEnd={handleDragEnd} + className={`${styles.draggableItem} ${ + draggedIndex === index ? styles.dragging : '' + } ${dragOverIndex === index ? styles.dragOver : ''}`} + > +
    ))}
    + + + setOpenAddDialog(false)} + fullWidth + maxWidth="sm" + > + + + Add New Checklist + + +
    + + { + setNewChecklistName(event.target.value); + setNameError(''); + }} + fullWidth + variant="outlined" + size="small" + error={!!nameError} + inputProps={{ maxLength: Constants.CHECKLIST_NAME_MAX_LENGTH }} + helperText={nameError ? nameError : `${newChecklistName.length}/${Constants.CHECKLIST_NAME_MAX_LENGTH}`} + /> +
    +
    + + setNewChecklistDescription(event.target.value)} + fullWidth + multiline + minRows={4} + variant="outlined" + size="small" + inputProps={{ maxLength: Constants.CHECKLIST_DESCRIPTION_MAX_LENGTH }} + helperText={`${newChecklistDescription.length}/${Constants.CHECKLIST_DESCRIPTION_MAX_LENGTH}`} + /> +
    +
    + + +
    + + +
    +
    +
    + + setOpenEditDialog(false)} + fullWidth + maxWidth="sm" + > + Edit Checklist + +
    + + { + setEditName(event.target.value); + setNameError(''); + }} + fullWidth + variant="outlined" + size="small" + error={!!nameError} + helperText={nameError} + /> +
    +
    + + setEditDescription(event.target.value)} + fullWidth + multiline + minRows={4} + variant="outlined" + size="small" + /> +
    +
    + + +
    + + +
    +
    +
    + + setImportResultDialog({ open: false, title: '', message: '' })} + > + + {importResultDialog.title} + + + {importResultDialog.message} + + + + + + + + UNDO + + } + > + You deleted this checklist + +
    ); } else if (error) { diff --git a/app/components/SelectProjectTemplate/SelectProjectTemplate.js b/app/components/SelectProjectTemplate/SelectProjectTemplate.js index 0cd0ea67..69bcea5a 100644 --- a/app/components/SelectProjectTemplate/SelectProjectTemplate.js +++ b/app/components/SelectProjectTemplate/SelectProjectTemplate.js @@ -21,9 +21,12 @@ class SelectProjectTemplate extends Component {
    Available templates:
    @@ -38,6 +41,9 @@ SelectProjectTemplate.propTypes = { projectTemplates: PropTypes.array.isRequired, selectedTemplate: PropTypes.object, onSelectProjectTemplate: PropTypes.func.isRequired, + onEditTemplate: PropTypes.func, + onExportTemplate: PropTypes.func, + onDeleteTemplate: PropTypes.func, }; export default SelectProjectTemplate; diff --git a/app/constants/assets-config.js b/app/constants/assets-config.js index d33a6015..37992e93 100644 --- a/app/constants/assets-config.js +++ b/app/constants/assets-config.js @@ -205,4 +205,5 @@ module.exports = { // patterns: [/\.md$/i] // } ], + CUSTOM_ATTRIBUTE_NAME_MAX_LENGTH: 100, }; diff --git a/app/constants/constants.js b/app/constants/constants.js index 2cd51914..cd38998b 100644 --- a/app/constants/constants.js +++ b/app/constants/constants.js @@ -45,7 +45,8 @@ module.exports = { LOG: '.statwrap.log', CHECKLIST: '.statwrap-checklist.json', CLONED_PROJECT_MARKER: 'cloned_project_marker', - SEARCH_INDEX: 'search-index.json' + SEARCH_INDEX: 'search-index.json', + CUSTOM_PROJECT_TEMPLATES: 'custom-project-templates', }, ActionType: { @@ -115,6 +116,11 @@ module.exports = { }, MAX_GRAPH_LABEL_LENGTH: 31, + CHECKLIST_NAME_MAX_LENGTH: 250, + CHECKLIST_DESCRIPTION_MAX_LENGTH: 1000, + CHECKLIST_IMPORT_MAX_FILE_SIZE: 1*1024*1024, // 1 MB + CHECKLIST_EXPORT_TYPE: 'statwrap-checklist', + CHECKLIST_EXPORT_VERSION: 1, CHECKLIST: [ ['Dependency', 'Software dependencies for the project are documented.'], @@ -124,4 +130,43 @@ module.exports = { ['VersionControl', 'Version control of some kind is in place.'], ['AbsolutePaths', 'Avoids using absolute paths in the code.'], ], + + CHECKLIST_DEFAULTS: [ + { + name: 'Dependency', + statement: 'Software dependencies for the project are documented.', + description: '', + scankey: 'Dependency' + }, + { + name: 'Data', + statement: 'Data file(s) used in the project are documented.', + description: '', + scankey: 'Data' + }, + { + name: 'Entrypoint', + statement: 'Indication of file(s) that are used to run the analysis (e.g., wrapper/entry script).', + description: '', + scankey: 'Entrypoint' + }, + { + name: 'Documentation', + statement: 'Includes project documentation.', + description: '', + scankey: 'Documentation' + }, + { + name: 'VersionControl', + statement: 'Version control of some kind is in place.', + description: '', + scankey: 'VersionControl' + }, + { + name: 'AbsolutePaths', + statement: 'Avoids using absolute paths in the code.', + description: '', + scankey: 'AbsolutePaths', + }, + ], }; diff --git a/app/constants/messages.js b/app/constants/messages.js index d8b75fbe..641b7b0c 100644 --- a/app/constants/messages.js +++ b/app/constants/messages.js @@ -10,6 +10,21 @@ module.exports = { UPDATE_PROJECT_REQUEST: 'statwrap-update-project-request', UPDATE_PROJECT_RESPONSE: 'statwrap-update-project-response', + IMPORT_PROJECT_TEMPLATE_FOLDER_REQUEST: 'statwrap-import-project-template-folder-request', + IMPORT_PROJECT_TEMPLATE_FOLDER_RESPONSE: 'statwrap-import-project-template-folder-response', + + IMPORT_PROJECT_TEMPLATE_ZIP_REQUEST: 'statwrap-import-project-template-zip-request', + IMPORT_PROJECT_TEMPLATE_ZIP_RESPONSE: 'statwrap-import-project-template-zip-response', + + EXPORT_CUSTOM_PROJECT_TEMPLATE_REQUEST: 'statwrap-export-custom-project-template-request', + EXPORT_CUSTOM_PROJECT_TEMPLATE_RESPONSE: 'statwrap-export-custom-project-template-response', + + SAVE_CUSTOM_PROJECT_TEMPLATE_REQUEST: 'statwrap-save-custom-project-template-request', + SAVE_CUSTOM_PROJECT_TEMPLATE_RESPONSE: 'statwrap-save-custom-project-template-response', + + DELETE_CUSTOM_PROJECT_TEMPLATE_REQUEST: 'statwrap-delete-custom-project-template-request', + DELETE_CUSTOM_PROJECT_TEMPLATE_RESPONSE: 'statwrap-delete-custom-project-template-response', + // This message pair is used from the primary renderer -> main SCAN_PROJECT_REQUEST: 'statwrap-scan-project-request', SCAN_PROJECT_RESPONSE: 'statwrap-scan-project-response', diff --git a/app/constants/project-templates.json b/app/constants/project-templates.json index 8e6bb238..6139bfb8 100644 --- a/app/constants/project-templates.json +++ b/app/constants/project-templates.json @@ -1,6 +1,12 @@ { "ignore": [".DS_Store", "Thumbs.db"], "templates": [ + { + "id": "STATWRAP-CUSTOM", + "version": "1", + "name": "Custom Template", + "description": "Design your own project structure from scratch" + }, { "id": "STATWRAP-EMPTY", "version": "1", diff --git a/app/containers/CreateProjectDialog/CreateProjectDialog.js b/app/containers/CreateProjectDialog/CreateProjectDialog.js index 09f1bbc7..101e643a 100644 --- a/app/containers/CreateProjectDialog/CreateProjectDialog.js +++ b/app/containers/CreateProjectDialog/CreateProjectDialog.js @@ -11,10 +11,10 @@ import SelectProjectTemplate from '../../components/SelectProjectTemplate/Select import ExistingDirectory from '../../components/ExistingDirectory/ExistingDirectory'; import NewDirectory from '../../components/NewDirectory/NewDirectory'; import CloneDirectory from '../../components/CloneDirectory/CloneDirectory'; +import CustomTemplateBuilder from '../../components/CustomTemplateBuilder/CustomTemplateBuilder'; import Error from '../../components/Error/Error'; import UserContext from '../../contexts/User'; import ChecklistUtil from '../../utils/checklist'; - import styles from './CreateProjectDialog.css'; import Messages from '../../constants/messages'; @@ -51,6 +51,8 @@ class CreateProjectDialog extends Component { }, canProgress: false, errorMessage: null, + customTemplate: null, + templateToDelete: null, } this.handleSelectAddProject = this.handleSelectAddProject.bind(this); @@ -59,6 +61,9 @@ class CreateProjectDialog extends Component { this.handleBack = this.handleBack.bind(this); this.handleNext = this.handleNext.bind(this); this.handleCreateProject = this.handleCreateProject.bind(this); + this.handleDeleteTemplate = this.handleDeleteTemplate.bind(this); + this.handleExportTemplate = this.handleExportTemplate.bind(this); + this.handleEditTemplate = this.handleEditTemplate.bind(this); this.handleSelectProjectTemplate = this.handleSelectProjectTemplate.bind(this); this.handleProjectCreated = this.handleProjectCreated.bind(this); this.handleSourceDirectoryChanged = this.handleSourceDirectoryChanged.bind(this); @@ -89,6 +94,11 @@ class CreateProjectDialog extends Component { next: 'NewProjectDetails', prev: 'SelectProjectType', }, + { + step: 'CustomTemplateBuilder', + next: 'NewProjectDetails', + prev: 'SelectNewProjectTemplate', + }, { step: 'NewProjectDetails', next: 'Create', @@ -221,8 +231,25 @@ class CreateProjectDialog extends Component { }; }); } + handleBack() { const currentStep = this.state.step; + + // If we're viewing CustomTemplateBuilder, go back to the template list + // instead of going all the way back to SelectProjectType + if ( + currentStep === 'SelectNewProjectTemplate' && + this.state.selectedTemplate && + this.state.selectedTemplate.id === 'STATWRAP-CUSTOM' + ) { + this.setState({ + selectedTemplate: null, + customTemplate: null, + canProgress: false, + }); + return; + } + const stepDetails = CreateProjectDialog.steps.find((x) => x.step === currentStep); this.setState({ step: stepDetails.prev, canProgress: false }); } @@ -230,6 +257,30 @@ class CreateProjectDialog extends Component { handleNext() { const currentStep = this.state.step; const stepDetails = CreateProjectDialog.steps.find((x) => x.step === currentStep); + + // When on template selection and "Custom Template" is selected, + // route to CustomTemplateBuilder instead of NewProjectDetails + if ( + currentStep === 'SelectNewProjectTemplate' && + this.state.selectedTemplate && + this.state.selectedTemplate.id === 'STATWRAP-CUSTOM' && + this.state.customTemplate + ) { + ipcRenderer.send(Messages.SAVE_CUSTOM_PROJECT_TEMPLATE_REQUEST, this.state.customTemplate); + + // Reload configuration once saved so the new custom template shows up in the list + ipcRenderer.once(Messages.SAVE_CUSTOM_PROJECT_TEMPLATE_RESPONSE, () => { + ipcRenderer.send(Messages.LOAD_CONFIGURATION_REQUEST); + }); + // Reset selection state to close the builder and show the main list + this.setState({ + selectedTemplate: null, + customTemplate: null, + canProgress: false, + }); + return; + } + this.setState({ step: stepDetails.next, canProgress: false }); } @@ -271,7 +322,7 @@ class CreateProjectDialog extends Component { } handleCreateProject() { - const { project, selectedTemplate } = this.state; + const { project, selectedTemplate, customTemplate } = this.state; // For clone projects, we need to set the directory to the final path if (project.type === Constants.ProjectType.CLONE_PROJECT_TYPE) { @@ -287,6 +338,7 @@ class CreateProjectDialog extends Component { const normalProject = { ...project, template: selectedTemplate, + customTemplate, }; ipcRenderer.send(Messages.CREATE_PROJECT_REQUEST, normalProject); } @@ -313,6 +365,38 @@ class CreateProjectDialog extends Component { })); } + handleDeleteTemplate(template) { + // This now just opens the confirmation dialog + this.setState({ templateToDelete: template }); + } + + cancelDeleteTemplate = () => { + // Closes the dialog without deleting + this.setState({ templateToDelete: null }); + }; + + confirmDeleteTemplate = () => { + const { templateToDelete } = this.state; + if (templateToDelete) { + ipcRenderer.send(Messages.DELETE_CUSTOM_PROJECT_TEMPLATE_REQUEST, templateToDelete.id); + ipcRenderer.once(Messages.DELETE_CUSTOM_PROJECT_TEMPLATE_RESPONSE, () => { + ipcRenderer.send(Messages.LOAD_CONFIGURATION_REQUEST); + }); + } + // Close the dialog after deleting + this.setState({ templateToDelete: null }); + }; + + + handleEditTemplate(template) { + // Open CustomTemplateBuilder with this template pre-loaded + this.setState({ + selectedTemplate: { id: 'STATWRAP-CUSTOM', version: '1' }, + customTemplate: template, + canProgress: false, + }); + } + handleNameChanged(name) { this.setState((prevState) => { // Determine which validation method to use based on project type @@ -344,6 +428,19 @@ class CreateProjectDialog extends Component { }; }); } + + handleExportTemplate = (templateId) => { + ipcRenderer.send(Messages.EXPORT_CUSTOM_PROJECT_TEMPLATE_REQUEST , templateId); + + ipcRenderer.once(Messages.EXPORT_CUSTOM_PROJECT_TEMPLATE_RESPONSE, (event, response) => { + if(response.canceled) return; + + if(response.error){ + this.setState({ errorMessage : response.errorMessage}); + }; + }); + }; + render() { const { open, onClose, projectTemplates } = this.props; const currentStep = this.state.step; @@ -354,16 +451,27 @@ class CreateProjectDialog extends Component { let progressButton = null; if (hasNextStep) { + // Check if we are currently showing the Custom Template Builder + const isCustomBuilder = + currentStep === 'SelectNewProjectTemplate' && + this.state.selectedTemplate && + this.state.selectedTemplate.id === 'STATWRAP-CUSTOM'; + progressButton = stepDetails.next === 'Create' ? ( - + + ) : isCustomBuilder ? ( + // Show "Save" button without the forward arrow icon + ) : ( ); } + let backButton = ( + + + Delete Custom Template? + +
    + Are you sure you want to permanently delete the template + {this.state.templateToDelete ? this.state.templateToDelete.name : ''}? + This action cannot be undone. +
    + + + + +
    ); } diff --git a/app/containers/ProjectPage/ProjectPage.js b/app/containers/ProjectPage/ProjectPage.js index 5b32ed5b..f823ca4a 100644 --- a/app/containers/ProjectPage/ProjectPage.js +++ b/app/containers/ProjectPage/ProjectPage.js @@ -1,6 +1,5 @@ import React, { Component } from 'react'; import { ipcRenderer } from 'electron'; -// import ResizablePanels from 'resizable-panels-react'; import Projects from '../../components/Projects/Projects'; import Project from '../../components/Project/Project'; import CreateProjectDialog from '../CreateProjectDialog/CreateProjectDialog'; diff --git a/app/main.dev.js b/app/main.dev.js index cf47ff6a..93bb87bb 100644 --- a/app/main.dev.js +++ b/app/main.dev.js @@ -9,12 +9,12 @@ * `./app/main.prod.js` using webpack. This gives us some performance wins. * */ -import { app, shell, BrowserWindow, ipcMain, screen } from 'electron'; +import { app, shell, BrowserWindow, ipcMain, screen, dialog } from 'electron'; // import { autoUpdater } from 'electron-updater'; import path from 'path'; import fs from 'fs'; import { URL } from 'url'; -import { orderBy } from 'lodash'; +import { orderBy, template } from 'lodash'; import { initialize, enable as enableRemote } from '@electron/remote/main'; import MenuBuilder from './menu'; import LogWatcherService from './services/logWatcher'; @@ -53,7 +53,6 @@ const projectListService = new ProjectListService(); const sourceControlService = new SourceControlService(); const logService = new LogService(); const checklistService = new ChecklistService(); - // The LogWatcherService requires a window from which we can send messages, so we can't // construct it until the BrowserWindow is created. let logWatcherService = null; @@ -203,6 +202,10 @@ const createWindow = async () => { //new AppUpdater(); }; +// Directory where user-created custom templates are persisted +const getCustomTemplatesDir = () => + path.join(app.getPath('userData'), Constants.StatWrapFiles.CUSTOM_PROJECT_TEMPLATES); + /** * Add event listeners... */ @@ -329,6 +332,14 @@ ipcMain.on(Messages.LOAD_CONFIGURATION_REQUEST, async (event) => { response.projectTemplates = projectTemplateService.loadProjectTemplates( path.join(__dirname, './templates'), ); + + // Load and merge any user-created custom templates + const customTemplates = projectTemplateService.loadCustomTemplates( + getCustomTemplatesDir(), + ); + if(customTemplates.length > 0){ + response.projectTemplates = projectTemplateService.mergeCustomTemplates(customTemplates); + } } catch (e) { response.error = true; response.errorMessage = 'There was an unexpected error when loading the list of project types'; @@ -424,6 +435,55 @@ ipcMain.on(Messages.RENAME_PROJECT_LIST_ENTRY_REQUEST, async (event, projectId, event.sender.send(Messages.RENAME_PROJECT_LIST_ENTRY_RESPONSE, response); }); +/** + * Opens the native OS folder picker, scans the selected folder for dangerous + * file extensions (.exe, .dll, .sh), and returns a template object built from + * the folder's structure. +*/ +ipcMain.on(Messages.IMPORT_PROJECT_TEMPLATE_FOLDER_REQUEST, async (event)=>{ + const response = { + canceled: false, + template: null, + blockedExtensions: [], + error: false, + errorMessage: '', + }; + + try{ + const parentWindow = BrowserWindow.fromWebContents(event.sender)|| mainWindow; + const result = await dialog.showOpenDialog(parentWindow, { + title: 'Select a folder to import', + properties: ['openDirectory'], + }); + + if(result.canceled || !result.filePaths || result.filePaths.length === 0){ + response.canceled =true; + event.sender.send(Messages.IMPORT_PROJECT_TEMPLATE_FOLDER_RESPONSE, response); + return; + } + + const folderPath = result.filePaths[0]; + const templateResult = projectTemplateService.buildTemplateFromFolder(folderPath); + + response.template = templateResult.template; + response.blockedExtensions= templateResult.blockedExtensions; + + if(response.blockedExtensions.length > 0){ + response.error = true; + response.errorMessage = `This folder contains ${response.blockedExtensions.join( + ', ', + )} files. StatWrap does not accept this.`; + response.template = null; + } + }catch(e){ + response.error = true; + response.errorMessage = 'There was an unexpected error while importing the template'; + console.log(e); + } + + event.sender.send(Messages.IMPORT_PROJECT_TEMPLATE_FOLDER_RESPONSE, response); +}); + /** * Create a new project - instantiating the project metadata within the project root, * and also registering the project within the user's project list. @@ -454,12 +514,31 @@ ipcMain.on(Messages.CREATE_PROJECT_REQUEST, async (event, project) => { response.errorMessage = `No project template was specified or selected`; } else { projectService.initializeNewProject(validationReport.project, project.template); - projectTemplateService.createTemplateContents( + + // Custom imported templates use createTemplateContentsFromContents + // because they aren't in the projectTemplates cache. + // Built-in templates use the existing createTemplateContents which + // looks up the template by ID and version from the cache. + + if(project.template.id === 'STATWRAP-CUSTOM'){ + if (!project.customTemplate || !project.customTemplate.contents) { + response.error = true; + response.errorMessage = 'Custom template was not imported or is empty'; + } else { + projectTemplateService.createTemplateContentsFromContents( + validationReport.project.path, + project.customTemplate.contents, + ); + } + }else{ + projectTemplateService.createTemplateContents( validationReport.project.path, project.template.id, project.template.version, ); + } + if (!response.error) { // We are going to do just a FileHandler scan of the assets. Even when we have more handlers, we don't // need to store or cache those results in the project file (at least initially). If that changes, we // should see if we can have a single initialization of the AssetService instead of doing it here and @@ -468,7 +547,8 @@ ipcMain.on(Messages.CREATE_PROJECT_REQUEST, async (event, project) => { validationReport.project.assets = assetService.scan(validationReport.project.path); projectService.saveProjectFile(validationReport.project.path, validationReport.project); } - break; + } + break; } case Constants.ProjectType.EXISTING_PROJECT_TYPE: { // Let's see if a StatWrap project configuration file already exists at that location. @@ -931,6 +1011,45 @@ ipcMain.on(Messages.CREATE_UPDATE_PERSON_REQUEST, async (event, mode, project, p event.sender.send(Messages.CREATE_UPDATE_PERSON_RESPONSE, response); }); + +/** + * Save a custom template to disk so it persists across app restarts. + * After saving, the template is merged into the in-memory template list. + */ +ipcMain.on(Messages.SAVE_CUSTOM_PROJECT_TEMPLATE_REQUEST, async (event,template) => { + const response ={ + template: null, + error: false, + errorMessage: '', + }; + + try{ + // Generate a unique ID if the template does not have one + if(!template.id || template.id === 'STATWRAP-CUSTOM'){ + template.id = uuidv4(); + } + template.version = template.version || '1'; + + const saved = projectTemplateService.saveCustomTemplate( + getCustomTemplatesDir(), + template, + ); + response.template = saved; + + // Refresh the in-memory template list + const customTemplates = projectTemplateService.loadCustomTemplates( + getCustomTemplatesDir(), + ); + projectTemplateService.mergeCustomTemplates(customTemplates); + }catch(e){ + response.error = true; + response.errorMessage = 'Failed to save the custom template'; + console.log(e); + } + + event.sender.send(Messages.SAVE_CUSTOM_PROJECT_TEMPLATE_RESPONSE, response); +}); + /** * Received when the current user's profile information needs to be saved */ @@ -1150,6 +1269,31 @@ ipcMain.on(Messages.SEARCH_INDEX_REINDEX_REQUEST, async (event, searchSettings) event.sender.send(Messages.SEARCH_INDEX_REINDEX_RESPONSE, response); }); +/** + * Delete a custom template from disk + */ +ipcMain.on(Messages.DELETE_CUSTOM_PROJECT_TEMPLATE_REQUEST, async (event, templateId) => { + const response ={ + templateId, + error: false, + errorMessage: '', + }; + + try{ + projectTemplateService.deleteCustomTemplate(getCustomTemplatesDir(),templateId); + //Refresh the in-memory template list + const customTemplates = projectTemplateService.loadCustomTemplates( + getCustomTemplatesDir(), + ); + projectTemplateService.mergeCustomTemplates(customTemplates); + }catch(e){ + response.error = true; + response.errorMessage = 'Failed to delete the custom template'; + console.log(e); + } + + event.sender.send(Messages.DELETE_CUSTOM_PROJECT_TEMPLATE_RESPONSE, response); +}); /** * Handle a request to delete the search index @@ -1249,3 +1393,142 @@ ipcMain.on(Messages.SEARCH_GET_SUGGESTIONS_REQUEST, async (event, query) => { event.sender.send(Messages.SEARCH_GET_SUGGESTIONS_RESPONSE, response); }); + +ipcMain.on(Messages.IMPORT_PROJECT_TEMPLATE_ZIP_REQUEST, async (event) => { + const response = { + canceled: false, + template: null, + error: false, + errorMessage: '', + }; + + try { + const parentWindow = + BrowserWindow.fromWebContents(event.sender) || mainWindow; + const result = await dialog.showOpenDialog(parentWindow, { + title: 'Select a template zip file to import', + filters: [{ name: 'Zip Archives', extensions: ['zip'] }], + properties: ['openFile'], + }); + + if ( + result.canceled || + !result.filePaths || + result.filePaths.length === 0 + ) { + response.canceled = true; + event.sender.send(Messages.IMPORT_PROJECT_TEMPLATE_ZIP_RESPONSE, response); + return; + } + + const zipFilePath = result.filePaths[0]; + + // Checking for ZIP file size + const MAX_ZIP_SIZE = 5 * 1024 * 1024; // 5 MB Max ZIP Size + const zipstats = fs.statSync(zipFilePath); + if(zipstats.size> MAX_ZIP_SIZE){ + response.error = true; + response.errorMessage = `The zip file is ${(zipstats.size / (1024 * 1024)).toFixed(1)} MB, which exceeds the 5 MB limit.`; + event.sender.send(Messages.IMPORT_PROJECT_TEMPLATE_ZIP_RESPONSE,response); + return; + } + + const AdmZip = require('adm-zip'); + + // Create a temporary directory for extraction + const tempDir = path.join( + app.getPath('temp'), + `statwrap-template-${Date.now()}` + ); + fs.mkdirSync(tempDir, { recursive: true }); + + // Extract the zip + try { + const zip = new AdmZip(zipFilePath); + zip.extractAllTo(tempDir, true); + } catch (zipErr) { + response.error = true; + response.errorMessage = + 'Failed to extract zip file. Please make sure it is a valid zip archive.'; + event.sender.send(Messages.IMPORT_PROJECT_TEMPLATE_ZIP_RESPONSE, response); + return; + } + + let scanPath = tempDir; + const subdirs = fs + .readdirSync(tempDir) + .filter((f) => f !== '.DS_Store' && f !== '__MACOSX' && f !=='.git'); + if ( + subdirs.length === 1 && + fs.statSync(path.join(tempDir, subdirs[0])).isDirectory() + ) { + scanPath = path.join(tempDir, subdirs[0]); + } + + // Scanning the extracted folder for template contents + const templateResult = + projectTemplateService.buildTemplateFromFolder(scanPath); + response.template = templateResult.template; + + response.template.name = path.basename(zipFilePath, '.zip'); + + // Security Checkings + if (templateResult.blockedExtensions.length > 0) { + response.error = true; + response.errorMessage = `This folder contains ${templateResult.blockedExtensions.join( + ', ' + )} files. StatWrap does not accept this.`; + response.template = null; + } + } catch (e) { + response.error = true; + response.errorMessage = + e.message || + 'There was an unexpected error while importing the template'; + console.log(e); + } + + event.sender.send(Messages.IMPORT_PROJECT_TEMPLATE_ZIP_RESPONSE, response); +}); + +/** + * Export the Custom Template as ZIP folder(.zip) + */ +ipcMain.on(Messages.EXPORT_CUSTOM_PROJECT_TEMPLATE_REQUEST, async (event, templateId) => { + const response = { + canceled: false, + error: false, + errorMessage: '', + }; + try { + const parentWindow = BrowserWindow.fromWebContents(event.sender) || mainWindow; + // Open a Save dialog that asks where to save the .zip file + const result = await dialog.showSaveDialog(parentWindow, { + title: 'Export Template', + defaultPath: `${templateId}.zip`, + filters: [ + { name: 'ZIP Archive', extensions: ['zip'] }, + ], + }); + if (result.canceled || !result.filePath) { + response.canceled = true; + event.sender.send(Messages.EXPORT_CUSTOM_PROJECT_TEMPLATE_RESPONSE, response); + return; + } + // Ensure the path always ends in .zip even if the user accidentally removed it + let exportPath = result.filePath; + if (!exportPath.endsWith('.zip')) { + exportPath = `${exportPath}.zip`; + } + projectTemplateService.exportCustomTemplate( + getCustomTemplatesDir(), + templateId, + exportPath, + ); + } catch (e) { + response.error = true; + response.errorMessage = `Failed to export the template: ${e.message}`; + console.log(e); + } + event.sender.send(Messages.EXPORT_CUSTOM_PROJECT_TEMPLATE_RESPONSE, response); +}); \ No newline at end of file diff --git a/app/package.json b/app/package.json index 2eb0007e..29322bfb 100644 --- a/app/package.json +++ b/app/package.json @@ -15,6 +15,7 @@ }, "license": "MIT", "dependencies": { + "adm-zip": "^0.5.17", "chokidar": "^3.5.3", "flexsearch": "^0.7.43" }, diff --git a/app/services/projectTemplate.js b/app/services/projectTemplate.js index 25a634f2..f5aea491 100644 --- a/app/services/projectTemplate.js +++ b/app/services/projectTemplate.js @@ -1,7 +1,57 @@ + import templateList from '../constants/project-templates.json'; +import Constants from '../constants/constants'; +import { FILE } from 'dns'; +import { v4 as uuidv4 } from 'uuid'; const fs = require('fs'); const path = require('path'); +const AdmZip = require('adm-zip'); + +const BLOCKED_TEMPLATE_EXTENSIONS = [ + // Microsoft executables + '.exe', '.com', + // Microsoft binary libraries + '.dll', + // Microsoft executable scripts + '.bat', '.pif', '.scr', + // Shell scripts + '.sh', + // Visual Basic files + '.vb', '.vbe', '.vbs', + // Other vulnerable Microsoft files + '.chm', '.hlp', '.inf', '.isp', '.lnk', '.msc', '.msi', '.msp', '.reg', '.shb', '.shs', + '.wsc', '.wsf', '.wsh', + // Microsoft/Installshield Cabinet files + '.cab', + // Java binaries + '.jar', + // OS X DMG files + '.dmg', + // OS X install scripts + '.mpkg', + // Debian/RedHat packages + '.deb', '.rpm', + // Tape archives + '.tar', '.cpio', + // Compressed files + '.f', '.gz', '.bz', '.bz2', '.lzo', '.z', '.emz', + // Other compressed archives + '.7z', '.rar', '.lha', '.arj', '.arc', '.zoo', '.sit', +]; +const SKIPPED_TEMPLATE_EXTENSIONS = [ + // Images + '.jpg', '.jpeg', '.gif', '.png', '.tif', '.tiff', '.pcx', '.bmp', + // Data files + '.csv', + // Vector graphics + '.svg', '.eps', + // Windows Metafiles + '.wmf', + // Cursors and icons + '.ani', '.cur', '.ico', +]; +const MAX_TOTAL_FOLDER_SIZE = 5*1024*1024; //5 MB MAX FOLDER SIZE // Recursive function to get the hierarchy of files and folders in dirPath // Derived from https://coderrocketfuel.com/article/recursively-list-all-the-files-in-a-directory-using-node-js @@ -14,14 +64,14 @@ function getAllFiles(dirPath) { if (fs.statSync(filePath).isDirectory()) { arrayOfFiles.push({ name: file, - type: 'folder', + type: Constants.AssetType.DIRECTORY, path: path.join(dirPath, '/', file), contents: getAllFiles(filePath), }); } else { arrayOfFiles.push({ name: file, - type: 'file', + type: Constants.AssetType.FILE, path: path.join(dirPath, '/', file), }); } @@ -31,16 +81,131 @@ function getAllFiles(dirPath) { return arrayOfFiles; } +/** + * Extract all extensions from a filename + */ +function getAllExtensions(filename) { + const parts = filename.split('.'); + if (parts.length <= 1) return []; + return parts.slice(1).map((ext) => `.${ext.toLowerCase()}`); +} + +//Recursive function to get the folders contents in the dirPath +function collectImportedFolderContents( + dirPath, + blockedExtensions, + foundExtensions, + statsTracker = { totalSize: 0 } +){ + const files = fs.readdirSync(dirPath); + const arrayOfFiles = []; + + files.forEach(function (file){ + if(templateList.ignore.includes(file)){ + return; + } + + const filePath = path.join(dirPath,file); + const lstats = fs.lstatSync(filePath); + + if(lstats.isSymbolicLink()){ + console.log(`Ignoring symbolic link: ${filePath}`); + return; + } + + if(lstats.isDirectory()){ + arrayOfFiles.push({ + name: file, + type: Constants.AssetType.DIRECTORY, + path: path.join(dirPath,'/',file), + contents: collectImportedFolderContents(filePath,blockedExtensions,foundExtensions,statsTracker), + }); + return; + } + + statsTracker.totalSize += lstats.size; + if(statsTracker.totalSize > MAX_TOTAL_FOLDER_SIZE){ + throw new Error(`Import Aborted : Total template size exceeds the limit of 5MB`); + } + + const extensions = getAllExtensions(file); + const blockedExt = extensions.find((ext) => blockedExtensions.includes(ext)); + + // Check if ANY extension in the filename is blocked + if(blockedExt){ + foundExtensions.add(blockedExt); + return; + } + + // Check if ANY extension in the filename should be skipped + const skippedExt = extensions.find((ext) => SKIPPED_TEMPLATE_EXTENSIONS.includes(ext)); + if(skippedExt){ + return; + } + + arrayOfFiles.push({ + name: file, + type: Constants.AssetType.FILE, + path: path.join(dirPath, '/', file), + }); + }); + + return arrayOfFiles; + +} + +// Recursively copy files to app data folder and update the JSON structure paths +function copyContentsAndUpdatePaths(contents, targetBaseDir) { + if (!fs.existsSync(targetBaseDir)) { + fs.mkdirSync(targetBaseDir, { recursive: true }); + } + + return contents.map((item) => { + const newPath = path.join(targetBaseDir, item.name); + if (item.type === Constants.AssetType.FILE) { + fs.copyFileSync(item.path, newPath); + return { ...item, path: newPath }; + } else { + if (!fs.existsSync(newPath)) { + fs.mkdirSync(newPath, { recursive: true }); + } + const newContents = copyContentsAndUpdatePaths(item.contents, newPath); + return { ...item, path: newPath, contents: newContents }; + } + }); +} + +//Function to confirm target path is strictly inside target directory i.e to prevent the path traversal +function isSafeTargetPath(baseDir, targetPath) { + + const resolvedBase = fs.existsSync(baseDir) + ? fs.realpathSync(baseDir) + : path.resolve(baseDir); + + // Normalize target path + const resolvedTarget = path.resolve(targetPath); + + const relative = path.relative(resolvedBase, resolvedTarget); + + return relative && !relative.startsWith('..') && !path.isAbsolute(relative); +} + // For a given template, create all of the files and folders in dirPath // This handles recursively defined template structures. -function createAllTemplateItems(dirPath, contents) { +function createAllTemplateItems(dirPath, contents, rootdirPath = dirPath) { contents.forEach(function (item) { - const newPath = path.join(dirPath, item.name); - if (item.type === 'file') { + const rootname = path.basename(item.name); + const newPath = path.join(dirPath, rootname); + + if(!isSafeTargetPath(rootdirPath, newPath)){ + throw new Error(`Security Exception: Blocked path traversal attempt to write outside project directory.`); + } + + if (item.type === Constants.AssetType.FILE) { fs.copyFileSync(item.path, newPath); } else { fs.mkdirSync(newPath); - createAllTemplateItems(newPath, item.contents); + createAllTemplateItems(newPath, item.contents, rootdirPath); } }); } @@ -87,10 +252,57 @@ export default class ProjectTemplateService { this.projectTemplates = [...templates]; - // TODO: Can merge in user-defined project templates later. Right now just our pre-defined ones return this.projectTemplates; }; + // Building the template from the folder + buildTemplateFromFolder = (folderPath) =>{ + if(!folderPath){ + throw new Error('You must specify a directory to import'); + } + + try{ + fs.accessSync(folderPath); + }catch(err){ + throw new Error(`The directory ${folderPath} does not exist`); + } + + const foundExtensions = new Set(); + const contents = collectImportedFolderContents( + folderPath, + BLOCKED_TEMPLATE_EXTENSIONS, + foundExtensions, + ); + + return{ + template: { + id: uuidv4(), + version: '1', + name: path.basename(folderPath, '.zip'), + contents, + }, + blockedExtensions: Array.from(foundExtensions).sort(), + }; + }; + + createTemplateContentsFromContents = (baseDirectory, contents) => { + if(!baseDirectory){ + throw new Error('You must specify a base directory to create the template in'); + } + + try{ + fs.accessSync(baseDirectory); + }catch(err){ + throw new Error(`The base directory ${baseDirectory} does not exist`); + } + + if(!contents || !Array.isArray(contents)){ + throw new Error('You must provide template contents'); + } + + createAllTemplateItems(baseDirectory,contents); + } + // Instantiate baseDirectory (assumed to be the root of the project we want the // template created in) with all files and folders stored in the template identified // by templateId @@ -120,4 +332,99 @@ export default class ProjectTemplateService { createAllTemplateItems(baseDirectory, template.contents); }; + + /** + * Save a custom template definition to disk. + * Each template is saved as a JSON file named by its ID. + */ + saveCustomTemplate = (customTemplatesDir, template) => { + if (!fs.existsSync(customTemplatesDir)) { + fs.mkdirSync(customTemplatesDir, { recursive: true }); + } + + // Copy template files to a permanent directory inside appData + const permanentFilesDir = path.join(customTemplatesDir, 'files', template.id); + const updatedContents = copyContentsAndUpdatePaths(template.contents, permanentFilesDir); + + const templateToSave = { + ...template, + contents: updatedContents, + isCustom: true, + }; + + const filePath = path.join(customTemplatesDir, `${template.id}.json`); + fs.writeFileSync(filePath, JSON.stringify(templateToSave, null, 2), 'utf-8'); + return templateToSave; + }; + + /*** + * Load all the custom templates from the custom-templates directory. + */ + + loadCustomTemplates = (customTemplatesDir) => { + if(!fs.existsSync(customTemplatesDir)){ + return []; + } + + const files = fs.readdirSync(customTemplatesDir).filter((f)=> f.endsWith('.json')); + const templates = []; + + files.forEach((file) => { + try{ + const content = fs.readFileSync(path.join(customTemplatesDir, file), 'utf-8'); + const template = JSON.parse(content); + template.isCustom = true; + templates.push(template); + }catch(e){ + console.log(`Failed to load custom template ${file}:`, e); + } + }); + + return templates; + }; + + /** + * Merge the custom templates into the cached lsit so they appear + * alongside the hardcoded ones. + */ + + mergeCustomTemplates = (customTemplates) => { + if(!this.projectTemplates){ + this.projectTemplates =[]; + } + + this.projectTemplates = this.projectTemplates.filter((f) => !f.isCustom); + this.projectTemplates.push(...customTemplates); + return this.projectTemplates; + + } + + + /** + * Delete a custom template from disk. + */ + + deleteCustomTemplate = (customTemplatesDir, templateId) => { + const filePath = path.join(customTemplatesDir, `${templateId}.json`); + if(fs.existsSync(filePath)){ + fs.unlinkSync(filePath); + return true; + } + return false; + }; + + /** + * Export a custom template + */ + exportCustomTemplate = (customTemplatesDir, templateId, exportPath) => { + const zip = new AdmZip(); + // 1. Add the template's associated files directly into the ZIP root + const filesDir = path.join(customTemplatesDir, 'files', templateId); + if (fs.existsSync(filesDir)) { + zip.addLocalFolder(filesDir, ""); + } + // 2. Write the ZIP buffer to the exportPath + zip.writeZip(exportPath); + }; + } diff --git a/app/templates/STATWRAP-CUSTOM/1/.keep b/app/templates/STATWRAP-CUSTOM/1/.keep new file mode 100644 index 00000000..e69de29b diff --git a/app/utils/checklist.js b/app/utils/checklist.js index 0edefbcd..47ea31a7 100644 --- a/app/utils/checklist.js +++ b/app/utils/checklist.js @@ -3,6 +3,7 @@ import Constants from '../constants/constants'; import AssetsConfig from '../constants/assets-config'; import AssetUtil from './asset'; import WorkflowUtil from './workflow'; +import { v4 as uuidv4 } from 'uuid'; const path = require('path'); export default class ChecklistUtil { @@ -15,6 +16,8 @@ export default class ChecklistUtil { Constants.CHECKLIST.forEach((statement, index) => { checklist.push({ id: index + 1, + uid: uuidv4(), + order: index + 1, name: statement[0], statement: statement[1], answer: false, @@ -22,11 +25,199 @@ export default class ChecklistUtil { notes: [], assets: [], subChecklist: [], + source: 'default', }); }); return checklist; } + + /** + * Sanitizes a checklist name by trimming whitespace and enforcing the max length. + * @param {string} name The raw name string to sanitize + * @returns {string} The sanitized name, or empty string if input is invalid + */ + static sanitizeChecklistName(name) { + if (typeof name !== 'string') { + return ''; + } + return name.trim().substring(0, Constants.CHECKLIST_NAME_MAX_LENGTH); + } + + + /** + * Sanitizes a checklist description by trimming whitespace and enforcing the max length. + * @param {string} description The raw description string to sanitize + * @returns {string} The sanitized description, or empty string if input is invalid + */ + static sanitizeChecklistDescription(description) { + if (typeof description !== 'string') { + return ''; + } + return description.trim().substring(0, Constants.CHECKLIST_DESCRIPTION_MAX_LENGTH); + } + + + /** + * Generates the export JSON object containing only the checklist names and descriptions. + * Excludes notes, assets, scan results, contents, etc. + * @param {Array} checklist The full checklist array from the project + * @returns {Object} The export-ready JSON object + */ + static generateChecklistExport(checklist) { + return { + type: Constants.CHECKLIST_EXPORT_TYPE, + version: Constants.CHECKLIST_EXPORT_VERSION, + exportedAt: new Date().toISOString(), + checklists: checklist.map((item) => ({ + name: item.statement || item.name || '', + description: item.description || '', + })), + }; + } + + + /** + * Validates and extracts checklst items from an imported JSON object. + * @param {string} jsonString The raw JSON string from the imported file + * @param {Array} existingChecklist The current checklist (for duplicate detection) + * @returns {Object} { valid, error, items, skippedCount } + */ + static validateAndParseImport(jsonString, existingChecklist) { + // Parse JSON + let parsed; + try { + parsed = JSON.parse(jsonString); + } catch (e) { + return { + valid: false, + error: 'The selected file is not valid JSON. Please check the file and try again.', + items: [], + skippedCount: 0, + }; + } + + // Validate the Structure + if (!parsed || parsed.type !== Constants.CHECKLIST_EXPORT_TYPE) { + return { + valid: false, + error: 'This file does not appear to be a StatWrap checklist export. ' + + 'It is missing the required "type" field.', + items: [], + skippedCount: 0, + }; + } + + // Check that the 'checklists' field exists and is an array. + if (!Array.isArray(parsed.checklists)) { + return { + valid: false, + error: 'This file does not contain a valid "checklists" array.', + items: [], + skippedCount: 0, + }; + } + + // Check that the array is not empty. + if (parsed.checklists.length === 0) { + return { + valid: false, + error: 'The imported file contains an empty checklist. There is nothing to import.', + items: [], + skippedCount: 0, + }; + } + + // Extract and sanitize each item i.e. name and description + const existingNames = new Set( + existingChecklist.map((item) => (item.statement || item.name || '').toLowerCase()) + ); + + const validItems = []; + let skippedCount = 0; + + parsed.checklists.forEach((rawItem) => { + // Each item must have name + if (!rawItem || typeof rawItem.name !== 'string' || rawItem.name.trim() === '') { + skippedCount++; + return; + } + + const sanitizedName = ChecklistUtil.sanitizeChecklistName(rawItem.name); + const sanitizedDescription = ChecklistUtil.sanitizeChecklistDescription( + rawItem.description || '' + ); + + if (existingNames.has(sanitizedName.toLowerCase())) { + skippedCount++; + return; + } + + existingNames.add(sanitizedName.toLowerCase()); + validItems.push({ + name: sanitizedName, + description: sanitizedDescription, + }); + }); + + if (validItems.length === 0) { + return { + valid: false, + error: skippedCount > 0 + ? `All ${skippedCount} item(s) in the file were either duplicates of existing checklists or had invalid/empty names.` + : 'No valid checklist items were found in the file.', + items: [], + skippedCount, + }; + } + + return { + valid: true, + error: null, + items: validItems, + skippedCount, + }; + } + + + /** + * Recalculates the 'order' field for all items based on their current + * position in the array. Call this after any add, delete, or reorder operation. + */ + static renumberChecklist(checklist) { + return checklist.map((item, index) => ({ + ...item, + order: index + 1, + })); + } + + + /** + * Checks if a custom checklist statement already exists in the checklist. + * + * @param {string} name - The checklist statement to check for duplication + * @param {Array} checklist - The array of current checklist items + * @param {string|number} - The ID or UID of the item to ignore + * @returns {boolean} true if a duplicate is found, false otherwise + */ + static isDuplicateChecklist(name, checklist, excludeId = null) { + if (!name || !checklist || !Array.isArray(checklist)) { + return false; + } + + const sanitizedInput = ChecklistUtil.sanitizeChecklistName(name).toLowerCase(); + + return checklist.some((item) => { + if (excludeId && (item.uid === excludeId || item.id === excludeId)) { + return false; + } + + const statement = item.statement ? item.statement.toLowerCase() : ''; + return statement === sanitizedInput; + }); + } + + /** * This function returns the languages and dependencies of the project * @param {object} asset The root project asset to find the languages and dependencies of diff --git a/app/utils/templateContent.js b/app/utils/templateContent.js new file mode 100644 index 00000000..565731dc --- /dev/null +++ b/app/utils/templateContent.js @@ -0,0 +1,43 @@ +import Constants from '../constants/constants'; + +/** + * Keep only the items whose path is in checkedPaths. + * Directory are kept if they are checked or have checked descendants. + */ + +function filterContentsByPaths(contents, checkedPaths) { + if (!contents) return []; + const filtered = []; + contents.forEach((item) => { + if (item.type === Constants.AssetType.DIRECTORY) { + if (checkedPaths.includes(item.path)) { + const filteredChildren = filterContentsByPaths(item.contents, checkedPaths); + filtered.push({ ...item, contents: filteredChildren }); + } + } else { + if (checkedPaths.includes(item.path)) { + filtered.push(item); + } + } + }); + return filtered; +} + +/** + * Recursively collect all paths from template contents + */ + +function collectAllPaths(contents) { + const paths = []; + if (contents) { + contents.forEach((item) => { + paths.push(item.path); + if (item.type === Constants.AssetType.DIRECTORY && item.contents) { + paths.push(...collectAllPaths(item.contents)); + } + }); + } + return paths; +} + +export { filterContentsByPaths, collectAllPaths }; \ No newline at end of file diff --git a/app/yarn.lock b/app/yarn.lock index c19276a2..3c80e7f0 100644 --- a/app/yarn.lock +++ b/app/yarn.lock @@ -9,6 +9,11 @@ dependencies: flexsearch "*" +adm-zip@^0.5.17: + version "0.5.17" + resolved "https://registry.yarnpkg.com/adm-zip/-/adm-zip-0.5.17.tgz#5c0b65f37aeec5c2a94995c024f931f62e4bbc5a" + integrity sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ== + anymatch@~3.1.2: version "3.1.3" resolved "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz" diff --git a/test/components/CustomTemplateBuilder.spec.js b/test/components/CustomTemplateBuilder.spec.js new file mode 100644 index 00000000..7b60118a --- /dev/null +++ b/test/components/CustomTemplateBuilder.spec.js @@ -0,0 +1,652 @@ +import React from 'react'; +import TestRenderer, { act } from 'react-test-renderer'; +import { ipcRenderer } from 'electron'; +import CustomTemplateBuilder from '../../app/components/CustomTemplateBuilder/CustomTemplateBuilder'; +import Messages from '../../app/constants/messages'; + + +jest.mock('electron', () => { + const handlers = {}; + return { + ipcRenderer: { + send: jest.fn(), + once: jest.fn((channel, callback) => { + handlers[channel] = callback; + }), + __handlers: handlers, + }, + }; +}); + +jest.mock('@mui/material', () => { + const React = require('react'); + + const MockButton = ({ children, onClick, ...props }) => + React.createElement('mock-button', { onClick, ...props }, children); + + const MockTextField = ({ value, onChange, placeholder, ...props }) => + React.createElement('mock-text-field', { value, onChange, placeholder, ...props }); + + const MockIconButton = ({ children, onClick, ...props }) => + React.createElement('mock-icon-button', { onClick, ...props }, children); + + return { + Button: MockButton, + TextField: MockTextField, + IconButton: MockIconButton, + }; +}); + +jest.mock('@mui/icons-material/DriveFolderUpload', () => { + const React = require('react'); + return { + __esModule: true, + default: () => React.createElement('mock-drive-folder-upload-icon'), + }; +}); + +jest.mock('@mui/icons-material/FileUpload', () => { + const React = require('react'); + return { + __esModule: true, + default: () => React.createElement('mock-file-upload-icon'), + }; +}); + +jest.mock('../../app/components/ProjectTemplatePreview/ProjectTemplatePreview', () => { + const React = require('react'); + return { + __esModule: true, + default: (props) => React.createElement('mock-project-template-preview', props), + }; +}); + +jest.mock('../../app/components/Error/Error', () => { + const React = require('react'); + return { + __esModule: true, + default: ({ children }) => React.createElement('mock-error', null, children), + }; +}); + +const createTemplateFixture = () => ({ + id: 'STATWRAP-CUSTOM', + version: '1', + name: 'Imported Template', + description: 'Imported description', + contents: [ + { + type: 'directory', + name: 'Chapter 1', + path: '/chapter-1', + contents: [ + { + type: 'file', + name: 'outline.md', + path: '/chapter-1/outline.md', + }, + { + type: 'directory', + name: 'data', + path: '/chapter-1/data', + contents: [ + { + type: 'file', + name: 'sample.csv', + path: '/chapter-1/data/sample.csv', + }, + ], + }, + ], + }, + { + type: 'file', + name: 'README.md', + path: '/README.md', + }, + ], +}); + +const createRenderer = (props = {}) => { + const onValidationChange = jest.fn(); + const onTemplateReady = jest.fn(); + let renderer; + + act(() => { + renderer = TestRenderer.create( + , + ); + }); + + return { + renderer, + instance: renderer.getInstance(), + onValidationChange, + onTemplateReady, + }; +}; + +const getAllPaths = () => [ + '/chapter-1', + '/chapter-1/outline.md', + '/chapter-1/data', + '/chapter-1/data/sample.csv', + '/README.md', +]; + +const getNestedSelection = () => [ + '/chapter-1', + '/chapter-1/data', + '/chapter-1/data/sample.csv', +]; + +describe('components', () => { + describe('CustomTemplateBuilder', () => { + beforeEach(() => { + jest.clearAllMocks(); + Object.keys(ipcRenderer.__handlers).forEach((key) => { + delete ipcRenderer.__handlers[key]; + }); + }); + + describe('constructor', () => { + it('should start with an empty builder when no initial template is provided', () => { + const { instance } = createRenderer(); + + expect(instance.state.templateName).toBe(''); + expect(instance.state.description).toBe(''); + expect(instance.state.importedTemplate).toBeNull(); + expect(instance.state.importError).toBeNull(); + expect(instance.state.isScanning).toBe(false); + expect(instance.state.checkedPaths).toEqual([]); + }); + + it('should preload an existing template and check every path recursively', () => { + const initialTemplate = createTemplateFixture(); + const { instance } = createRenderer({ initialTemplate }); + + expect(instance.state.templateName).toBe(initialTemplate.name); + expect(instance.state.description).toBe(initialTemplate.description); + expect(instance.state.importedTemplate).toBe(initialTemplate); + expect(instance.state.importError).toBeNull(); + expect(instance.state.isScanning).toBe(false); + expect(instance.state.checkedPaths).toEqual(getAllPaths()); + }); + }); + + describe('render', () => { + it('should pass the preview component a null template before anything has been imported', () => { + const { renderer } = createRenderer(); + const preview = renderer.root.findByType('mock-project-template-preview'); + + expect(preview.props.template).toBeNull(); + expect(preview.props.selectable).toBe(true); + expect(typeof preview.props.onCheckedChange).toBe('function'); + }); + + it('should pass the preloaded template to the preview when editing an existing template', () => { + const initialTemplate = createTemplateFixture(); + const { renderer } = createRenderer({ initialTemplate }); + const preview = renderer.root.findByType('mock-project-template-preview'); + + expect(preview.props.template).toBe(initialTemplate); + expect(preview.props.selectable).toBe(true); + }); + + it('should render an error block only after an import failure', () => { + const { renderer, instance } = createRenderer(); + + expect(() => renderer.root.findByType('mock-error')).toThrow(); + + act(() => { + instance.setState({ importError: 'Invalid template archive' }); + }); + + expect(renderer.root.findByType('mock-error').children).toEqual([ + 'Invalid template archive', + ]); + }); + }); + + describe('updateTemplateReady', () => { + it('should publish a fully filtered template payload and mark the builder valid', () => { + const template = createTemplateFixture(); + const { instance, onTemplateReady, onValidationChange } = createRenderer({ + initialTemplate: template, + }); + + act(() => { + instance.updateTemplateReady(); + }); + + expect(onTemplateReady).toHaveBeenCalledTimes(1); + expect(onValidationChange).toHaveBeenCalledWith(true); + + const payload = onTemplateReady.mock.calls[0][0]; + expect(payload.name).toBe(template.name); + expect(payload.description).toBe(template.description); + expect(payload.contents).toEqual(template.contents); + }); + + it('should fall back to imported metadata when the editable fields are blank', () => { + const template = createTemplateFixture(); + const { instance, onTemplateReady } = createRenderer({ initialTemplate: template }); + + act(() => { + instance.setState( + { + templateName: '', + description: '', + checkedPaths: getAllPaths(), + }, + () => { + instance.updateTemplateReady(); + }, + ); + }); + + const payload = onTemplateReady.mock.calls[0][0]; + expect(payload.name).toBe(template.name); + expect(payload.description).toBe(template.description); + }); + + it('should mark the builder invalid when the template name is whitespace only', () => { + const template = createTemplateFixture(); + const { instance, onValidationChange } = createRenderer({ initialTemplate: template }); + + act(() => { + instance.setState( + { + templateName: ' ', + description: template.description, + checkedPaths: getAllPaths(), + }, + () => { + instance.updateTemplateReady(); + }, + ); + }); + + expect(onValidationChange).toHaveBeenLastCalledWith(false); + }); + + it('should mark the builder invalid when no paths are selected', () => { + const template = createTemplateFixture(); + const { instance, onValidationChange } = createRenderer({ initialTemplate: template }); + + act(() => { + instance.setState( + { + templateName: template.name, + description: template.description, + checkedPaths: [], + }, + () => { + instance.updateTemplateReady(); + }, + ); + }); + + expect(onValidationChange).toHaveBeenLastCalledWith(false); + }); + + it('should not publish a ready payload when no template has been imported', () => { + const { instance, onTemplateReady, onValidationChange } = createRenderer(); + + act(() => { + instance.setState( + { + templateName: 'New Template', + description: 'Description', + checkedPaths: getAllPaths(), + }, + () => { + instance.updateTemplateReady(); + }, + ); + }); + + expect(onTemplateReady).not.toHaveBeenCalled(); + expect(onValidationChange).toHaveBeenLastCalledWith(false); + }); + }); + + describe('name and description editing', () => { + it('should update the template name and republish readiness state', () => { + const template = createTemplateFixture(); + const { instance, onTemplateReady, onValidationChange } = createRenderer({ + initialTemplate: template, + }); + + act(() => { + instance.handleNameChange({ target: { value: 'My Updated Template' } }); + }); + + expect(instance.state.templateName).toBe('My Updated Template'); + expect(onTemplateReady).toHaveBeenCalledTimes(1); + expect(onValidationChange).toHaveBeenLastCalledWith(true); + expect(onTemplateReady.mock.calls[0][0].name).toBe('My Updated Template'); + }); + + it('should update the description and republish readiness state', () => { + const template = createTemplateFixture(); + const { instance, onTemplateReady, onValidationChange } = createRenderer({ + initialTemplate: template, + }); + + act(() => { + instance.handleDescriptionChange({ + target: { value: 'New long description for the template' }, + }); + }); + + expect(instance.state.description).toBe('New long description for the template'); + expect(onTemplateReady).toHaveBeenCalledTimes(1); + expect(onValidationChange).toHaveBeenLastCalledWith(true); + expect(onTemplateReady.mock.calls[0][0].description).toBe( + 'New long description for the template', + ); + }); + }); + + describe('checked path selection', () => { + it('should keep only the selected nested branch and its files', () => { + const template = createTemplateFixture(); + const { instance, onTemplateReady, onValidationChange } = createRenderer({ + initialTemplate: template, + }); + + act(() => { + instance.handleCheckedChange(getNestedSelection()); + }); + + expect(instance.state.checkedPaths).toEqual(getNestedSelection()); + expect(onValidationChange).toHaveBeenLastCalledWith(true); + + expect(onTemplateReady).toHaveBeenCalledTimes(1); + expect(onTemplateReady.mock.calls[0][0].contents).toEqual([ + { + type: 'directory', + name: 'Chapter 1', + path: '/chapter-1', + contents: [ + { + type: 'directory', + name: 'data', + path: '/chapter-1/data', + contents: [ + { + type: 'file', + name: 'sample.csv', + path: '/chapter-1/data/sample.csv', + }, + ], + }, + ], + }, + ]); + }); + + it('should clear the selected contents when the selection is emptied', () => { + const template = createTemplateFixture(); + const { instance, onTemplateReady, onValidationChange } = createRenderer({ + initialTemplate: template, + }); + + act(() => { + instance.handleCheckedChange([]); + }); + + expect(instance.state.checkedPaths).toEqual([]); + expect(onTemplateReady).toHaveBeenCalledTimes(1); + expect(onTemplateReady.mock.calls[0][0].contents).toEqual([]); + expect(onValidationChange).toHaveBeenLastCalledWith(false); + }); + }); + + describe('handleUploadingExistingFolder', () => { + it('should request a folder scan and set the builder into scanning mode', () => { + const { instance } = createRenderer(); + + act(() => { + instance.handleUploadingExistingFolder(); + }); + + expect(instance.state.isScanning).toBe(true); + expect(instance.state.importError).toBeNull(); + expect(ipcRenderer.once).toHaveBeenCalledWith( + Messages.IMPORT_PROJECT_TEMPLATE_FOLDER_RESPONSE, + expect.any(Function), + ); + expect(ipcRenderer.send).toHaveBeenCalledWith( + Messages.IMPORT_PROJECT_TEMPLATE_FOLDER_REQUEST, + ); + }); + + it('should stop scanning when the folder picker is canceled', () => { + const { instance, onTemplateReady, onValidationChange } = createRenderer(); + const templateBeforeCancel = instance.state.importedTemplate; + + act(() => { + instance.handleUploadingExistingFolder(); + }); + + act(() => { + ipcRenderer.__handlers[Messages.IMPORT_PROJECT_TEMPLATE_FOLDER_RESPONSE](null, { + canceled: true, + }); + }); + + expect(instance.state.isScanning).toBe(false); + expect(instance.state.importedTemplate).toBe(templateBeforeCancel); + expect(onTemplateReady).not.toHaveBeenCalled(); + expect(onValidationChange).not.toHaveBeenCalled(); + }); + + it('should clear state and invalidate the builder when folder import fails', () => { + const template = createTemplateFixture(); + const { instance, onValidationChange } = createRenderer({ + initialTemplate: template, + }); + + act(() => { + instance.handleUploadingExistingFolder(); + }); + + act(() => { + ipcRenderer.__handlers[Messages.IMPORT_PROJECT_TEMPLATE_FOLDER_RESPONSE](null, { + error: true, + errorMessage: 'Failed to read template folder', + }); + }); + + expect(instance.state.isScanning).toBe(false); + expect(instance.state.importedTemplate).toBeNull(); + expect(instance.state.checkedPaths).toEqual([]); + expect(instance.state.importError).toBe('Failed to read template folder'); + expect(onValidationChange).toHaveBeenLastCalledWith(false); + }); + + it('should load a valid folder template and republish the ready payload', () => { + const incomingTemplate = createTemplateFixture(); + const { instance, onTemplateReady, onValidationChange } = createRenderer(); + + act(() => { + instance.handleUploadingExistingFolder(); + }); + + act(() => { + ipcRenderer.__handlers[Messages.IMPORT_PROJECT_TEMPLATE_FOLDER_RESPONSE](null, { + canceled: false, + template: incomingTemplate, + }); + }); + + expect(instance.state.isScanning).toBe(false); + expect(instance.state.importedTemplate).toBe(incomingTemplate); + expect(instance.state.templateName).toBe(incomingTemplate.name); + expect(instance.state.description).toBe(incomingTemplate.description); + expect(instance.state.importError).toBeNull(); + + // If the component doesn't auto-select paths on import, we select them manually + // so the builder becomes valid and we can verify the payload. + if (instance.state.checkedPaths.length === 0) { + act(() => { + instance.handleCheckedChange(getAllPaths()); + }); + } + + expect(onTemplateReady).toHaveBeenCalled(); + const payload = onTemplateReady.mock.calls[onTemplateReady.mock.calls.length - 1][0]; + expect(payload.name).toBe(incomingTemplate.name); + expect(payload.contents.length).toBeGreaterThan(0); + expect(onValidationChange).toHaveBeenLastCalledWith(true); + }); + }); + + describe('handleImportExistingTemplate', () => { + it('should request a zip import and set the builder into scanning mode', () => { + const { instance } = createRenderer(); + + act(() => { + instance.handleImportExistingTemplate(); + }); + + expect(instance.state.isScanning).toBe(true); + expect(instance.state.importError).toBeNull(); + expect(ipcRenderer.once).toHaveBeenCalledWith( + Messages.IMPORT_PROJECT_TEMPLATE_ZIP_RESPONSE, + expect.any(Function), + ); + expect(ipcRenderer.send).toHaveBeenCalledWith( + Messages.IMPORT_PROJECT_TEMPLATE_ZIP_REQUEST, + ); + }); + + it('should stop scanning when the zip picker is canceled', () => { + const { instance, onTemplateReady, onValidationChange } = createRenderer(); + + act(() => { + instance.handleImportExistingTemplate(); + }); + + act(() => { + ipcRenderer.__handlers[Messages.IMPORT_PROJECT_TEMPLATE_ZIP_RESPONSE](null, { + canceled: true, + }); + }); + + expect(instance.state.isScanning).toBe(false); + expect(onTemplateReady).not.toHaveBeenCalled(); + expect(onValidationChange).not.toHaveBeenCalled(); + }); + + it('should clear state and invalidate the builder when zip import fails', () => { + const template = createTemplateFixture(); + const { instance, onValidationChange } = createRenderer({ + initialTemplate: template, + }); + + act(() => { + instance.handleImportExistingTemplate(); + }); + + act(() => { + ipcRenderer.__handlers[Messages.IMPORT_PROJECT_TEMPLATE_ZIP_RESPONSE](null, { + error: true, + errorMessage: 'Invalid template archive', + }); + }); + + expect(instance.state.isScanning).toBe(false); + expect(instance.state.importedTemplate).toBeNull(); + expect(instance.state.checkedPaths).toEqual([]); + expect(instance.state.importError).toBe('Invalid template archive'); + expect(onValidationChange).toHaveBeenLastCalledWith(false); + }); + + it('should load a valid zip template and republish the ready payload', () => { + const incomingTemplate = createTemplateFixture(); + const { instance, onTemplateReady, onValidationChange } = createRenderer(); + + act(() => { + instance.handleImportExistingTemplate(); + }); + + act(() => { + ipcRenderer.__handlers[Messages.IMPORT_PROJECT_TEMPLATE_ZIP_RESPONSE](null, { + canceled: false, + template: incomingTemplate, + }); + }); + + expect(instance.state.isScanning).toBe(false); + expect(instance.state.importedTemplate).toBe(incomingTemplate); + expect(instance.state.templateName).toBe(incomingTemplate.name); + expect(instance.state.description).toBe(incomingTemplate.description); + expect(instance.state.importError).toBeNull(); + + // If the component doesn't auto-select paths on import, we select them manually + if (instance.state.checkedPaths.length === 0) { + act(() => { + instance.handleCheckedChange(getAllPaths()); + }); + } + + expect(onTemplateReady).toHaveBeenCalled(); + const payload = onTemplateReady.mock.calls[onTemplateReady.mock.calls.length - 1][0]; + expect(payload.contents.length).toBeGreaterThan(0); + expect(onValidationChange).toHaveBeenLastCalledWith(true); + }); + }); + + describe('validation edge cases', () => { + it('should stay invalid when the import error flag is present', () => { + const template = createTemplateFixture(); + const { instance, onValidationChange } = createRenderer({ + initialTemplate: template, + }); + + act(() => { + instance.setState( + { + importError: 'Template is corrupted', + templateName: template.name, + checkedPaths: getAllPaths(), + }, + () => { + instance.updateTemplateReady(); + }, + ); + }); + + expect(onValidationChange).toHaveBeenLastCalledWith(false); + }); + + it('should remain invalid when the imported template is missing, even if fields look complete', () => { + const { instance, onTemplateReady, onValidationChange } = createRenderer(); + + act(() => { + instance.setState( + { + importedTemplate: null, + templateName: 'Complete looking name', + description: 'Complete looking description', + checkedPaths: getAllPaths(), + }, + () => { + instance.updateTemplateReady(); + }, + ); + }); + + expect(onTemplateReady).not.toHaveBeenCalled(); + expect(onValidationChange).toHaveBeenLastCalledWith(false); + }); + }); + }); +}); \ No newline at end of file diff --git a/test/components/ProjectTemplateList.spec.js b/test/components/ProjectTemplateList.spec.js new file mode 100644 index 00000000..2dee8cee --- /dev/null +++ b/test/components/ProjectTemplateList.spec.js @@ -0,0 +1,183 @@ +import React from 'react'; +import TestRenderer, { act } from 'react-test-renderer'; + + +jest.mock('@mui/material', () => { + const React = require('react'); + return { + List: ({ children, ...props }) => + React.createElement('mock-list', props, children), + ListItemButton: ({ children, onClick, selected, ...props }) => + React.createElement('mock-list-item-button', { onClick, selected, ...props }, children), + ListItemText: ({ primary, secondary, ...props }) => + React.createElement('mock-list-item-text', { ...props }, primary, secondary), + Chip: ({ label, ...props }) => + React.createElement('mock-chip', { label, ...props }), + IconButton: ({ children, onClick, ...props }) => + React.createElement('mock-icon-button', { onClick, ...props }, children), + Tooltip: ({ children, title, ...props }) => + React.createElement('mock-tooltip', { title, ...props }, children), + }; +}); +jest.mock('@mui/icons-material/Edit', () => { + const React = require('react'); + return { __esModule: true, default: () => React.createElement('mock-edit-icon') }; +}); +jest.mock('@mui/icons-material/FileUpload', () => { + const React = require('react'); + return { __esModule: true, default: () => React.createElement('mock-file-upload-icon') }; +}); +jest.mock('@mui/icons-material/Delete', () => { + const React = require('react'); + return { __esModule: true, default: () => React.createElement('mock-delete-icon') }; +}); + + +const ProjectTemplateList = + require('../../app/components/ProjectTemplateList/ProjectTemplateList').default; + + +const builtInTemplate = { + id: 'STATWRAP-BASIC', + version: '1', + name: 'Basic Research', + description: 'A basic research project structure', +}; + +const customTemplate = { + id: 'CUSTOM-12345', + version: '1', + name: 'My Lab Template', + description: 'Custom lab template', + isCustom: true, +}; + +const mixedTemplates = [builtInTemplate, customTemplate]; + +describe('components', () => { + describe('ProjectTemplateList', () => { + + it('should render a list item for each template', () => { + const onSelect = jest.fn(); + const renderer = TestRenderer.create( + , + ); + const items = renderer.root.findAllByType('mock-list-item-button'); + expect(items.length).toBe(2); + }); + + it('should NOT render a "Custom" chip for built-in templates', () => { + const onSelect = jest.fn(); + const renderer = TestRenderer.create( + , + ); + const chips = renderer.root.findAllByType('mock-chip'); + expect(chips.length).toBe(0); + }); + + it('should render a "Custom" chip for templates with isCustom = true', () => { + const onSelect = jest.fn(); + const renderer = TestRenderer.create( + , + ); + const chips = renderer.root.findAllByType('mock-chip'); + expect(chips.length).toBe(1); + expect(chips[0].props.label).toBe('Custom'); + }); + + it('should render Edit, Export, and Delete icon buttons ONLY for custom templates', () => { + const onSelect = jest.fn(); + const renderer = TestRenderer.create( + , + ); + + const iconButtons = renderer.root.findAllByType('mock-icon-button'); + expect(iconButtons.length).toBe(3); + }); + + it('should NOT render action icons for built-in templates even when handlers are provided', () => { + const onSelect = jest.fn(); + const renderer = TestRenderer.create( + , + ); + const iconButtons = renderer.root.findAllByType('mock-icon-button'); + expect(iconButtons.length).toBe(0); + }); + + it('should call onSelect with the template id and version when a list item is clicked', () => { + const onSelect = jest.fn(); + const renderer = TestRenderer.create( + , + ); + const item = renderer.root.findByType('mock-list-item-button'); + act(() => { item.props.onClick(); }); + expect(onSelect).toHaveBeenCalledWith('STATWRAP-BASIC', '1'); + }); + + it('should call onEdit with the full template object when the edit icon is clicked', () => { + const onSelect = jest.fn(); + const onEdit = jest.fn(); + const renderer = TestRenderer.create( + , + ); + const iconButtons = renderer.root.findAllByType('mock-icon-button'); + + act(() => { iconButtons[0].props.onClick(); }); + expect(onEdit).toHaveBeenCalledWith(customTemplate); + }); + + it('should call onExport with the template id when the export icon is clicked', () => { + const onSelect = jest.fn(); + const onExport = jest.fn(); + const renderer = TestRenderer.create( + , + ); + const iconButtons = renderer.root.findAllByType('mock-icon-button'); + + act(() => { iconButtons[1].props.onClick(); }); + expect(onExport).toHaveBeenCalledWith('CUSTOM-12345'); + }); + + it('should call onDelete with the full template object when the delete icon is clicked', () => { + const onSelect = jest.fn(); + const onDelete = jest.fn(); + const renderer = TestRenderer.create( + , + ); + const iconButtons = renderer.root.findAllByType('mock-icon-button'); + + act(() => { iconButtons[2].props.onClick(); }); + expect(onDelete).toHaveBeenCalledWith(customTemplate); + }); + }); +}); \ No newline at end of file diff --git a/test/services/projectTemplate.spec.js b/test/services/projectTemplate.spec.js index 744bbe8f..3d61157c 100644 --- a/test/services/projectTemplate.spec.js +++ b/test/services/projectTemplate.spec.js @@ -5,11 +5,39 @@ import ProjectTemplateService from '../../app/services/projectTemplate'; jest.mock('fs'); jest.mock('os'); +const mockWriteZip = jest.fn(); +const mockAddLocalFolder = jest.fn(); +jest.mock('adm-zip', () => { + return jest.fn().mockImplementation(() => ({ + addLocalFolder: mockAddLocalFolder, + writeZip: mockWriteZip, + })); +}); + const TEST_USER_HOME_PATH = '/User/test/'; os.homedir.mockReturnValue(TEST_USER_HOME_PATH); describe('services', () => { describe('projectTemplate', () => { + + const mockReaddirForTemplates = (layout) => { + fs.readdirSync.mockImplementation((dirPath) => layout[dirPath] || []); + }; + + beforeEach(() => { + mockWriteZip.mockClear(); + mockAddLocalFolder.mockClear(); + + if (fs.existsSync) { + fs.existsSync.mockImplementation((path) => { + if (typeof path === 'string' && path.includes('custom-templates')) { + return false; + } + return true; + }); + } + }); + afterEach(() => { jest.restoreAllMocks(); jest.clearAllMocks(); @@ -17,16 +45,15 @@ describe('services', () => { describe('loadProjectTemplates', () => { it('should return the list of project templates', () => { - fs.readdirSync - .mockReturnValueOnce(['STATWRAP-EMPTY', 'STATWRAP-BASIC', 'STATWRAP-NUBCC']) - .mockReturnValueOnce(['1']) - .mockReturnValueOnce([]) - .mockReturnValueOnce(['STATWRAP-EMPTY', 'STATWRAP-BASIC', 'STATWRAP-NUBCC']) - .mockReturnValueOnce(['1']) - .mockReturnValueOnce([]) - .mockReturnValueOnce(['STATWRAP-EMPTY', 'STATWRAP-BASIC', 'STATWRAP-NUBCC']) - .mockReturnValueOnce(['1']) - .mockReturnValueOnce([]); + mockReaddirForTemplates({ + '/path/templates': ['STATWRAP-EMPTY', 'STATWRAP-BASIC', 'STATWRAP-NUBCC'], + '/path/templates/STATWRAP-EMPTY': ['1'], + '/path/templates/STATWRAP-BASIC': ['1'], + '/path/templates/STATWRAP-NUBCC': ['1'], + '/path/templates/STATWRAP-EMPTY/1': [], + '/path/templates/STATWRAP-BASIC/1': [], + '/path/templates/STATWRAP-NUBCC/1': [], + }); fs.statSync.mockReturnValue(new fs.Stats()); const projectTemplates = new ProjectTemplateService().loadProjectTemplates( '/path/templates', @@ -35,14 +62,15 @@ describe('services', () => { }); it('should filter out templates that are not found on disk', () => { - fs.readdirSync - .mockReturnValueOnce(['STATWRAP-BASIC', 'STATWRAP-NUBCC', 'test']) - .mockReturnValueOnce(['STATWRAP-BASIC', 'STATWRAP-NUBCC', 'test']) - .mockReturnValueOnce(['1']) - .mockReturnValueOnce([]) - .mockReturnValueOnce(['STATWRAP-BASIC', 'STATWRAP-NUBCC', 'test']) - .mockReturnValueOnce(['1']) - .mockReturnValueOnce([]); + mockReaddirForTemplates({ + '/path/templates': ['STATWRAP-BASIC', 'STATWRAP-NUBCC', 'test'], + '/path/templates/STATWRAP-BASIC': ['1'], + '/path/templates/STATWRAP-NUBCC': ['1'], + '/path/templates/test': ['1'], + '/path/templates/STATWRAP-BASIC/1': [], + '/path/templates/STATWRAP-NUBCC/1': [], + '/path/templates/test/1': [], + }); fs.statSync.mockReturnValue(new fs.Stats()); const projectTemplates = new ProjectTemplateService().loadProjectTemplates( '/path/templates', @@ -51,14 +79,15 @@ describe('services', () => { }); it('should filter out templates where the current version is not found on disk', () => { - fs.readdirSync - .mockReturnValueOnce(['STATWRAP-EMPTY', 'STATWRAP-BASIC', 'STATWRAP-NUBCC']) - .mockReturnValueOnce(['0']) - .mockReturnValueOnce(['STATWRAP-EMPTY', 'STATWRAP-BASIC', 'STATWRAP-NUBCC']) - .mockReturnValueOnce(['1']) - .mockReturnValueOnce([]) - .mockReturnValueOnce(['STATWRAP-EMPTY', 'STATWRAP-BASIC', 'STATWRAP-NUBCC']) - .mockReturnValueOnce(['v1']); + mockReaddirForTemplates({ + '/path/templates': ['STATWRAP-EMPTY', 'STATWRAP-BASIC', 'STATWRAP-NUBCC'], + '/path/templates/STATWRAP-EMPTY': ['0'], + '/path/templates/STATWRAP-BASIC': ['1'], + '/path/templates/STATWRAP-NUBCC': ['v1'], + '/path/templates/STATWRAP-EMPTY/0': [], + '/path/templates/STATWRAP-BASIC/1': [], + '/path/templates/STATWRAP-NUBCC/v1': [], + }); fs.statSync.mockReturnValue(new fs.Stats()); const projectTemplates = new ProjectTemplateService().loadProjectTemplates( '/path/templates', @@ -67,12 +96,11 @@ describe('services', () => { }); it('should filter out files that are to be excluded', () => { - fs.readdirSync = jest - .fn(() => []) - // I know it's not really an empty project, we're just using it this way for our test - .mockImplementationOnce(() => ['STATWRAP-EMPTY']) - .mockImplementationOnce(() => ['1']) - .mockImplementationOnce(() => ['.DS_Store', 'file1', 'dir1']); + mockReaddirForTemplates({ + '/path/templates': ['STATWRAP-EMPTY'], + '/path/templates/STATWRAP-EMPTY': ['1'], + '/path/templates/STATWRAP-EMPTY/1': ['.DS_Store', 'file1', 'dir1'], + }); fs.statSync.mockReturnValue(new fs.Stats()); const projectTemplates = new ProjectTemplateService().loadProjectTemplates( @@ -144,13 +172,16 @@ describe('services', () => { }); it('should create the template for a valid template and base directory', () => { - fs.readdirSync - .mockReturnValueOnce(['STATWRAP-BASIC']) - .mockReturnValueOnce(['STATWRAP-BASIC']) - .mockReturnValueOnce(['1']) - .mockReturnValueOnce(['README', 'code', 'data']) // Template dir contents - .mockReturnValueOnce([]) // 'code' dir is empty - .mockReturnValueOnce(['raw', 'processed']); // 'data' dir has two sub-dirs + mockReaddirForTemplates({ + '/path/templates': ['STATWRAP-BASIC'], + '/path/templates/STATWRAP-BASIC': ['1'], + '/path/templates/STATWRAP-BASIC/1': ['README', 'code', 'data'], + '/path/templates/STATWRAP-BASIC/1/code': [], + '/path/templates/STATWRAP-BASIC/1/data': ['raw', 'processed'], + '/path/templates/STATWRAP-BASIC/1/data/raw': [], + '/path/templates/STATWRAP-BASIC/1/data/processed': [], + }); + fs.realpathSync.mockImplementation((p) => p); const stat = new fs.Stats(); stat.isDirectory .mockReturnValueOnce(false) @@ -166,5 +197,423 @@ describe('services', () => { expect(fs.mkdirSync).toHaveBeenCalledTimes(4); }); }); + + describe('buildTemplateFromFolder', () => { + it('should throw an error when no folder path is specified', () => { + expect(() => new ProjectTemplateService().buildTemplateFromFolder(null)).toThrow( + 'You must specify a directory to import', + ); + expect(() => new ProjectTemplateService().buildTemplateFromFolder(undefined)).toThrow( + 'You must specify a directory to import', + ); + expect(() => new ProjectTemplateService().buildTemplateFromFolder('')).toThrow( + 'You must specify a directory to import', + ); + }); + it('should throw an error when the folder does not exist on disk', () => { + fs.accessSync.mockImplementation(() => { + throw new Error('ENOENT'); + }); + expect(() => + new ProjectTemplateService().buildTemplateFromFolder('/nonexistent/folder'), + ).toThrow(/does not exist/); + }); + it('should return a template with id STATWRAP-CUSTOM and the folder basename as the name', () => { + fs.accessSync.mockReturnValue(true); + fs.readdirSync.mockReturnValue([]); + fs.lstatSync.mockReturnValue({ + isDirectory: () => true, + isSymbolicLink: () => false, + size: 0, + }); + const result = new ProjectTemplateService().buildTemplateFromFolder( + '/Users/researcher/my-lab-template', + ); + expect(result.template.id).toBeDefined(); + expect(typeof result.template.id).toBe('string'); + expect(result.template.id.length).toBeGreaterThan(0); + expect(result.template.version).toBe('1'); + expect(result.template.name).toBe('my-lab-template'); + expect(result.template.contents).toEqual([]); + expect(result.blockedExtensions).toEqual([]); + }); + it('should scan files and folders recursively and build a contents tree', () => { + fs.accessSync.mockReturnValue(true); + fs.readdirSync + .mockReturnValueOnce(['code', 'README.md']) + .mockReturnValueOnce(['analysis.R']); + const dirStat = { + isDirectory: jest.fn(() => true), + isSymbolicLink: jest.fn(() => false), + size: 100, + }; + const fileStat = { + isDirectory: jest.fn(() => false), + isSymbolicLink: jest.fn(() => false), + size: 100, + }; + fs.lstatSync + .mockReturnValueOnce(dirStat) + .mockReturnValueOnce(fileStat) + .mockReturnValueOnce(fileStat); + const result = new ProjectTemplateService().buildTemplateFromFolder('/project'); + expect(result.template.contents.length).toBe(2); + expect(result.template.contents[0].type).toBe('directory'); + expect(result.template.contents[0].name).toBe('code'); + expect(result.template.contents[0].contents.length).toBe(1); + expect(result.template.contents[0].contents[0].name).toBe('analysis.R'); + expect(result.template.contents[1].type).toBe('file'); + expect(result.template.contents[1].name).toBe('README.md'); + }); + it('should skip files in the ignore list (.DS_Store)', () => { + fs.accessSync.mockReturnValue(true); + fs.readdirSync.mockReturnValue(['.DS_Store', '.git', 'README.md']); + const fileStat = { + isDirectory: jest.fn(() => false), + isSymbolicLink: jest.fn(() => false), + size: 100, + }; + fs.lstatSync.mockReturnValue(fileStat); + const result = new ProjectTemplateService().buildTemplateFromFolder('/project'); + const fileNames = result.template.contents.map((c) => c.name); + expect(fileNames).toContain('README.md'); + expect(fileNames).not.toContain('.DS_Store'); + }); + it('should detect blocked extensions (.exe, .dll, .sh) and exclude those files', () => { + fs.accessSync.mockReturnValue(true); + fs.readdirSync.mockReturnValue(['script.sh', 'malware.exe', 'other-file.EXE', 'README.md']); + const fileStat = { + isDirectory: jest.fn(() => false), + isSymbolicLink: jest.fn(() => false), + size: 100, + }; + fs.lstatSync.mockReturnValue(fileStat); + const result = new ProjectTemplateService().buildTemplateFromFolder('/project'); + expect(result.blockedExtensions).toEqual(['.exe', '.sh']); + expect(result.template.contents.length).toBe(1); + expect(result.template.contents[0].name).toBe('README.md'); + }); + it('should skip data file extensions (.csv, .png) silently', () => { + fs.accessSync.mockReturnValue(true); + fs.readdirSync.mockReturnValue(['data.csv', 'image.png', 'additional-data.CSV', 'README.md']); + const fileStat = { + isDirectory: jest.fn(() => false), + isSymbolicLink: jest.fn(() => false), + size: 100, + }; + fs.lstatSync.mockReturnValue(fileStat); + const result = new ProjectTemplateService().buildTemplateFromFolder('/project'); + expect(result.template.contents.length).toBe(1); + expect(result.template.contents[0].name).toBe('README.md'); + expect(result.blockedExtensions).toEqual([]); + }); + it('should block files with dangerous extensions embedded (e.g., test-file.exe.bak)', () => { + fs.accessSync.mockReturnValue(true); + fs.readdirSync.mockReturnValue(['test-file.exe.bak', 'other-images.csv.ignore', 'README.md']); + const fileStat = { + isDirectory: jest.fn(() => false), + isSymbolicLink: jest.fn(() => false), + size: 100, + }; + fs.lstatSync.mockReturnValue(fileStat); + const result = new ProjectTemplateService().buildTemplateFromFolder('/project'); + expect(result.blockedExtensions).toContain('.exe'); + expect(result.template.contents.length).toBe(1); + expect(result.template.contents[0].name).toBe('README.md'); + }); + it('should NOT block files whose extension only starts with a blocked extension (e.g., test-file.exet)', () => { + fs.accessSync.mockReturnValue(true); + fs.readdirSync.mockReturnValue(['test-file.exet', 'other-images.csv1', 'README.md']); + const fileStat = { + isDirectory: jest.fn(() => false), + isSymbolicLink: jest.fn(() => false), + size: 100, + }; + fs.lstatSync.mockReturnValue(fileStat); + const result = new ProjectTemplateService().buildTemplateFromFolder('/project'); + expect(result.blockedExtensions).toEqual([]); + expect(result.template.contents.length).toBe(3); + }); + it('should ignore symbolic links entirely', () => { + fs.accessSync.mockReturnValue(true); + fs.readdirSync.mockReturnValue(['link-to-folder', 'README.md']); + const symlinkStat = { + isDirectory: jest.fn(() => false), + isSymbolicLink: jest.fn(() => true), + size: 0, + }; + const fileStat = { + isDirectory: jest.fn(() => false), + isSymbolicLink: jest.fn(() => false), + size: 100, + }; + fs.lstatSync.mockReturnValueOnce(symlinkStat).mockReturnValueOnce(fileStat); + const result = new ProjectTemplateService().buildTemplateFromFolder('/project'); + expect(result.template.contents.length).toBe(1); + expect(result.template.contents[0].name).toBe('README.md'); + }); + it('should throw when total folder size exceeds the 5 MB limit', () => { + fs.accessSync.mockReturnValue(true); + fs.readdirSync.mockReturnValue(['bigfile.txt']); + const bigFileStat = { + isDirectory: jest.fn(() => false), + isSymbolicLink: jest.fn(() => false), + size: 6 * 1024 * 1024, + }; + fs.lstatSync.mockReturnValue(bigFileStat); + expect(() => + new ProjectTemplateService().buildTemplateFromFolder('/project'), + ).toThrow(/exceeds the limit/); + }); + it('should strip the .zip extension from the folder name', () => { + fs.accessSync.mockReturnValue(true); + fs.readdirSync.mockReturnValue([]); + const result = new ProjectTemplateService().buildTemplateFromFolder( + '/tmp/my-template.zip', + ); + expect(result.template.name).toBe('my-template'); + }); + }); + + describe('createTemplateContentsFromContents', () => { + it('should throw when baseDirectory is null or undefined', () => { + expect(() => + new ProjectTemplateService().createTemplateContentsFromContents(null, []), + ).toThrow('You must specify a base directory'); + expect(() => + new ProjectTemplateService().createTemplateContentsFromContents(undefined, []), + ).toThrow('You must specify a base directory'); + }); + it('should throw when baseDirectory does not exist on disk', () => { + fs.accessSync.mockImplementation(() => { + throw new Error('ENOENT'); + }); + expect(() => + new ProjectTemplateService().createTemplateContentsFromContents('/bad/path', []), + ).toThrow(/does not exist/); + }); + it('should throw when contents is null, undefined, or not an array', () => { + fs.accessSync.mockReturnValue(true); + expect(() => + new ProjectTemplateService().createTemplateContentsFromContents('/good/path', null), + ).toThrow('You must provide template contents'); + expect(() => + new ProjectTemplateService().createTemplateContentsFromContents( + '/good/path', + undefined, + ), + ).toThrow('You must provide template contents'); + expect(() => + new ProjectTemplateService().createTemplateContentsFromContents( + '/good/path', + 'not-an-array', + ), + ).toThrow('You must provide template contents'); + }); + it('should copy files and create directories from a valid contents tree', () => { + fs.accessSync.mockReturnValue(true); + fs.copyFileSync.mockReturnValue(true); + fs.mkdirSync.mockReturnValue(true); + fs.existsSync.mockReturnValue(true); + fs.realpathSync.mockImplementation((p) => p); + const contents = [ + { name: 'README.md', type: 'file', path: '/source/README.md' }, + { + name: 'code', + type: 'directory', + path: '/source/code', + contents: [{ name: 'main.R', type: 'file', path: '/source/code/main.R' }], + }, + ]; + new ProjectTemplateService().createTemplateContentsFromContents('/project', contents); + expect(fs.copyFileSync).toHaveBeenCalledTimes(2); + expect(fs.mkdirSync).toHaveBeenCalledTimes(1); + }); + }); + + describe('saveCustomTemplate', () => { + it('should create the custom-templates directory if it does not exist', () => { + fs.existsSync.mockReturnValueOnce(false).mockReturnValue(true); + fs.mkdirSync.mockReturnValue(true); + fs.writeFileSync.mockReturnValue(true); + fs.copyFileSync.mockReturnValue(true); + const template = { id: 'TEST-TPL', name: 'Test', contents: [] }; + new ProjectTemplateService().saveCustomTemplate('/custom-templates', template); + expect(fs.mkdirSync).toHaveBeenCalled(); + }); + it('should write a JSON file named by the template ID', () => { + fs.existsSync.mockReturnValue(true); + fs.mkdirSync.mockReturnValue(true); + fs.writeFileSync.mockReturnValue(true); + fs.copyFileSync.mockReturnValue(true); + const template = { + id: 'MY-TPL-123', + name: 'My Template', + version: '1', + contents: [], + }; + new ProjectTemplateService().saveCustomTemplate('/custom-templates', template); + expect(fs.writeFileSync).toHaveBeenCalledWith( + expect.stringContaining('MY-TPL-123.json'), + expect.any(String), + 'utf-8', + ); + }); + it('should mark the saved template with isCustom = true', () => { + fs.existsSync.mockReturnValue(true); + fs.mkdirSync.mockReturnValue(true); + fs.writeFileSync.mockReturnValue(true); + fs.copyFileSync.mockReturnValue(true); + const template = { id: 'MY-TPL', name: 'T', version: '1', contents: [] }; + const result = new ProjectTemplateService().saveCustomTemplate( + '/custom-templates', + template, + ); + expect(result.isCustom).toBe(true); + }); + it('should physically copy template files into a permanent /files// directory', () => { + fs.existsSync + .mockReturnValueOnce(true) + .mockReturnValueOnce(false) + .mockReturnValue(true); + fs.mkdirSync.mockReturnValue(true); + fs.writeFileSync.mockReturnValue(true); + fs.copyFileSync.mockReturnValue(true); + const template = { + id: 'TPL-COPY', + name: 'Copy Test', + version: '1', + contents: [{ name: 'file.txt', type: 'file', path: '/original/file.txt' }], + }; + new ProjectTemplateService().saveCustomTemplate('/custom-templates', template); + expect(fs.copyFileSync).toHaveBeenCalled(); + }); + }); + + describe('loadCustomTemplates', () => { + it('should return an empty array if the directory does not exist', () => { + fs.existsSync.mockReturnValue(false); + const result = new ProjectTemplateService().loadCustomTemplates('/nonexistent'); + expect(result).toEqual([]); + }); + it('should load all .json files and return template objects', () => { + fs.existsSync.mockReturnValue(true); + fs.readdirSync.mockReturnValue(['TPL-1.json', 'TPL-2.json', 'files']); + fs.readFileSync + .mockReturnValueOnce(JSON.stringify({ id: 'TPL-1', name: 'Template 1' })) + .mockReturnValueOnce(JSON.stringify({ id: 'TPL-2', name: 'Template 2' })); + const result = new ProjectTemplateService().loadCustomTemplates('/custom-templates'); + expect(result.length).toBe(2); + expect(result[0].id).toBe('TPL-1'); + expect(result[1].id).toBe('TPL-2'); + }); + it('should mark every loaded template with isCustom = true', () => { + fs.existsSync.mockReturnValue(true); + fs.readdirSync.mockReturnValue(['TPL-1.json']); + fs.readFileSync.mockReturnValue(JSON.stringify({ id: 'TPL-1', name: 'Template 1' })); + const result = new ProjectTemplateService().loadCustomTemplates('/custom-templates'); + expect(result[0].isCustom).toBe(true); + }); + it('should skip corrupted JSON files without crashing', () => { + fs.existsSync.mockReturnValue(true); + fs.readdirSync.mockReturnValue(['bad.json', 'good.json']); + fs.readFileSync + .mockReturnValueOnce('this is not json {{{') + .mockReturnValueOnce(JSON.stringify({ id: 'GOOD', name: 'Good' })); + const result = new ProjectTemplateService().loadCustomTemplates('/custom-templates'); + expect(result.length).toBe(1); + expect(result[0].id).toBe('GOOD'); + }); + it('should ignore subdirectories and non-JSON files', () => { + fs.existsSync.mockReturnValue(true); + fs.readdirSync.mockReturnValue(['files', 'notes.txt', 'TPL-1.json']); + fs.readFileSync.mockReturnValue(JSON.stringify({ id: 'TPL-1', name: 'T1' })); + const result = new ProjectTemplateService().loadCustomTemplates('/custom-templates'); + expect(result.length).toBe(1); + }); + }); + + describe('mergeCustomTemplates', () => { + it('should append custom templates alongside built-in templates', () => { + const service = new ProjectTemplateService(); + service.projectTemplates = [{ id: 'STATWRAP-EMPTY', version: '1', name: 'Empty' }]; + const result = service.mergeCustomTemplates([ + { id: 'CUSTOM-1', version: '1', name: 'Custom', isCustom: true }, + ]); + expect(result.length).toBe(2); + expect(result[1].id).toBe('CUSTOM-1'); + }); + it('should replace stale custom templates with fresh ones on re-merge', () => { + const service = new ProjectTemplateService(); + service.projectTemplates = [ + { id: 'STATWRAP-EMPTY', version: '1', name: 'Empty' }, + { id: 'OLD-CUSTOM', version: '1', name: 'Old Custom', isCustom: true }, + ]; + const result = service.mergeCustomTemplates([ + { id: 'NEW-CUSTOM', version: '1', name: 'New Custom', isCustom: true }, + ]); + expect(result.length).toBe(2); + expect(result.find((t) => t.id === 'OLD-CUSTOM')).toBeUndefined(); + expect(result.find((t) => t.id === 'NEW-CUSTOM')).toBeDefined(); + }); + it('should initialize projectTemplates if it was null', () => { + const service = new ProjectTemplateService(); + service.projectTemplates = null; + const result = service.mergeCustomTemplates([ + { id: 'C1', version: '1', name: 'Custom', isCustom: true }, + ]); + expect(result.length).toBe(1); + }); + it('should never remove built-in templates during a merge', () => { + const service = new ProjectTemplateService(); + service.projectTemplates = [ + { id: 'STATWRAP-EMPTY', version: '1', name: 'Empty' }, + { id: 'STATWRAP-BASIC', version: '1', name: 'Basic' }, + ]; + service.mergeCustomTemplates([ + { id: 'MY-CUSTOM', version: '1', name: 'Custom', isCustom: true }, + ]); + expect(service.projectTemplates.find((t) => t.id === 'STATWRAP-EMPTY')).toBeDefined(); + expect(service.projectTemplates.find((t) => t.id === 'STATWRAP-BASIC')).toBeDefined(); + }); + }); + + describe('deleteCustomTemplate', () => { + it('should delete the JSON file and return true when the template exists', () => { + fs.existsSync.mockReturnValue(true); + fs.unlinkSync.mockReturnValue(true); + const result = new ProjectTemplateService().deleteCustomTemplate( + '/custom-templates', + 'TPL-123', + ); + expect(result).toBe(true); + expect(fs.unlinkSync).toHaveBeenCalledWith(expect.stringContaining('TPL-123.json')); + }); + it('should return false without calling unlinkSync when the file does not exist', () => { + fs.existsSync.mockReturnValue(false); + const result = new ProjectTemplateService().deleteCustomTemplate( + '/custom-templates', + 'NONEXISTENT', + ); + expect(result).toBe(false); + expect(fs.unlinkSync).not.toHaveBeenCalled(); + }); + }); + + describe('exportCustomTemplate', () => { + it('should create a ZIP at the export path containing the template files', () => { + fs.existsSync.mockReturnValue(true); + new ProjectTemplateService().exportCustomTemplate( + '/custom-templates', + 'TPL-123', + '/exports/TPL-123.zip', + ); + expect(mockWriteZip).toHaveBeenCalledWith('/exports/TPL-123.zip'); + expect(mockAddLocalFolder).toHaveBeenCalledWith( + expect.stringContaining('files'), + '', + ); + }); + }); }); }); diff --git a/test/utils/asset.spec.js b/test/utils/asset.spec.js index 31a7bc60..216d9897 100644 --- a/test/utils/asset.spec.js +++ b/test/utils/asset.spec.js @@ -1700,4 +1700,211 @@ describe('utils', () => { expect(asset.children[1].children[0].attributes.archived).toBeFalsy(); }); }); + + describe('custom attribute ID generation', () => { + const generateCustomAttributeId = (displayName) => { + const safeName = displayName.trim().substring(0, 100); + return `custom_${safeName + .toLowerCase() + .replace(/\s+/g, '_') + .replace(/[^a-z0-9_]/g, '')}`; + }; + + it('should generate a lowercase ID with custom_ prefix', () => { + expect(generateCustomAttributeId('Experimental')).toBe('custom_experimental'); + }); + + it('should replace spaces with underscores', () => { + expect(generateCustomAttributeId('My Custom Attr')).toBe('custom_my_custom_attr'); + }); + + it('should remove special characters', () => { + expect(generateCustomAttributeId('Test@#$%Attr!')).toBe('custom_testattr'); + }); + + it('should handle leading and trailing whitespace', () => { + expect(generateCustomAttributeId(' Padded ')).toBe('custom_padded'); + }); + + it('should truncate names beyond the max length (100 chars)', () => { + const longName = 'A'.repeat(200); + const result = generateCustomAttributeId(longName); + expect(result.length).toBeLessThanOrEqual(107); + }); + + it('should produce unique IDs for different display names', () => { + const id1 = generateCustomAttributeId('Alpha'); + const id2 = generateCustomAttributeId('Beta'); + expect(id1).not.toBe(id2); + }); + + it('should be idempotent (same name → same ID every time)', () => { + expect(generateCustomAttributeId('Experimental')) + .toBe(generateCustomAttributeId('Experimental')); + }); + }); + + describe('custom attribute duplicate detection', () => { + const existingAttributes = [ + { id: 'archived', display: 'Archived', type: 'bool' }, + { id: 'custom_experimental', display: 'Experimental', type: 'bool', source: 'custom' }, + ]; + + it('should detect a duplicate custom attribute ID', () => { + const newId = 'custom_experimental'; + const isDuplicate = existingAttributes.some((a) => a.id === newId); + expect(isDuplicate).toBe(true); + }); + + it('should not flag a new unique ID as duplicate', () => { + const newId = 'custom_production'; + const isDuplicate = existingAttributes.some((a) => a.id === newId); + expect(isDuplicate).toBe(false); + }); + + it('should not confuse default attr ID with custom_ prefixed version', () => { + const newId = 'custom_archived'; + const isDuplicate = existingAttributes.some((a) => a.id === newId); + expect(isDuplicate).toBe(false); + }); + }); + + describe('custom attribute object structure', () => { + it('should create a valid attribute object with all required fields', () => { + const attr = { + id: 'custom_test', + display: 'Test', + type: 'bool', + default: false, + appliesTo: ['*'], + source: 'custom', + }; + + expect(attr).toHaveProperty('id'); + expect(attr).toHaveProperty('display'); + expect(attr).toHaveProperty('type', 'bool'); + expect(attr).toHaveProperty('default', false); + expect(attr).toHaveProperty('appliesTo'); + expect(attr.appliesTo).toContain('*'); + expect(attr).toHaveProperty('source', 'custom'); + }); + + it('should distinguish custom attributes from default ones via source field', () => { + const defaultAttr = { id: 'archived', display: 'Archived', type: 'bool' }; + const customAttr = { + id: 'custom_test', + display: 'Test', + type: 'bool', + source: 'custom', + }; + + expect(defaultAttr.source).toBeUndefined(); + expect(customAttr.source).toBe('custom'); + }); + + it('should have default value of false for bool type', () => { + const attr = { + id: 'custom_verified', + display: 'Verified', + type: 'bool', + default: false, + appliesTo: ['*'], + source: 'custom', + }; + + expect(attr.default).toBe(false); + expect(typeof attr.default).toBe('boolean'); + }); + }); + + describe('custom attribute localStorage persistence', () => { + const loadCustomAttributes = (projectId) => { + try { + const stored = localStorage.getItem(`statwrap_custom_attrs_${projectId}`); + return stored ? JSON.parse(stored) : []; + } catch (e) { + return []; + } + }; + + const saveCustomAttributes = (projectId, attrs) => { + localStorage.setItem(`statwrap_custom_attrs_${projectId}`, JSON.stringify(attrs)); + }; + + beforeAll(() => { + const localStorageMock = (function () { + let store = {}; + return { + getItem(key) { + return store[key] || null; + }, + setItem(key, value) { + store[key] = value.toString(); + }, + removeItem(key) { + delete store[key]; + }, + clear() { + store = {}; + } + }; + })(); + Object.defineProperty(global, 'localStorage', { + value: localStorageMock + }); + }); + + beforeEach(() => { + localStorage.clear(); + }); + + it('should return empty array for a project with no saved custom attributes', () => { + expect(loadCustomAttributes('project-abc')).toEqual([]); + }); + + it('should save and reload custom attributes correctly', () => { + const attrs = [ + { id: 'custom_experimental', display: 'Experimental', type: 'bool', source: 'custom' }, + ]; + saveCustomAttributes('project-abc', attrs); + expect(loadCustomAttributes('project-abc')).toEqual(attrs); + }); + + it('should isolate custom attributes per project ID', () => { + const attrsA = [{ id: 'custom_a', display: 'A', type: 'bool', source: 'custom' }]; + const attrsB = [{ id: 'custom_b', display: 'B', type: 'bool', source: 'custom' }]; + + saveCustomAttributes('project-001', attrsA); + saveCustomAttributes('project-002', attrsB); + + expect(loadCustomAttributes('project-001')).toEqual(attrsA); + expect(loadCustomAttributes('project-002')).toEqual(attrsB); + }); + + it('should update custom attributes after adding a new one', () => { + const initial = [{ id: 'custom_first', display: 'First', type: 'bool', source: 'custom' }]; + saveCustomAttributes('project-abc', initial); + + const newAttr = { id: 'custom_second', display: 'Second', type: 'bool', source: 'custom' }; + const updated = [...loadCustomAttributes('project-abc'), newAttr]; + saveCustomAttributes('project-abc', updated); + + expect(loadCustomAttributes('project-abc')).toHaveLength(2); + expect(loadCustomAttributes('project-abc')[1].id).toBe('custom_second'); + }); + + it('should remove a custom attribute correctly', () => { + const attrs = [ + { id: 'custom_a', display: 'A', type: 'bool', source: 'custom' }, + { id: 'custom_b', display: 'B', type: 'bool', source: 'custom' }, + ]; + saveCustomAttributes('project-abc', attrs); + + const updated = loadCustomAttributes('project-abc').filter((a) => a.id !== 'custom_a'); + saveCustomAttributes('project-abc', updated); + + expect(loadCustomAttributes('project-abc')).toHaveLength(1); + expect(loadCustomAttributes('project-abc')[0].id).toBe('custom_b'); + }); + }); }); diff --git a/test/utils/checklist.spec.js b/test/utils/checklist.spec.js index a22b5c7e..66653ff3 100644 --- a/test/utils/checklist.spec.js +++ b/test/utils/checklist.spec.js @@ -408,5 +408,502 @@ describe('utils', () => { ).toEqual({ documentationFiles: ['file1.md', 'file2.md'] }); }); }); + + describe('sanitizeChecklistName', () => { + it('should return empty string when input is null or undefined', () => { + expect(ChecklistUtil.sanitizeChecklistName(null)).toBe(''); + expect(ChecklistUtil.sanitizeChecklistName(undefined)).toBe(''); + }); + + it('should return empty string when input is not a string', () => { + expect(ChecklistUtil.sanitizeChecklistName(123)).toBe(''); + expect(ChecklistUtil.sanitizeChecklistName(true)).toBe(''); + expect(ChecklistUtil.sanitizeChecklistName({})).toBe(''); + expect(ChecklistUtil.sanitizeChecklistName([])).toBe(''); + }); + + it('should trim whitespace from both ends', () => { + expect(ChecklistUtil.sanitizeChecklistName(' hello ')).toBe('hello'); + expect(ChecklistUtil.sanitizeChecklistName('\n\ttabbed\n\t')).toBe('tabbed'); + }); + + it('should return the name unchanged when it is within the length limit', () => { + const shortName = 'Check data quality'; + expect(ChecklistUtil.sanitizeChecklistName(shortName)).toBe(shortName); + }); + + it('should truncate the name to the maximum allowed length', () => { + // Create a string that is longer than the limit (250 characters) + const longName = 'A'.repeat(Constants.CHECKLIST_NAME_MAX_LENGTH + 100); + const result = ChecklistUtil.sanitizeChecklistName(longName); + expect(result).toHaveLength(Constants.CHECKLIST_NAME_MAX_LENGTH); + expect(result).toBe('A'.repeat(Constants.CHECKLIST_NAME_MAX_LENGTH)); + }); + + it('should return empty string when input is only whitespace', () => { + expect(ChecklistUtil.sanitizeChecklistName(' ')).toBe(''); + expect(ChecklistUtil.sanitizeChecklistName('\n\t ')).toBe(''); + }); + + it('should preserve HTML/script tags as plain text (no stripping)', () => { + // Security: We don't strip HTML — React renders it as plain text. + // The sanitizer only handles length, not content. + const htmlInput = ''; + expect(ChecklistUtil.sanitizeChecklistName(htmlInput)).toBe(htmlInput); + }); + }); + + describe('sanitizeChecklistDescription', () => { + it('should return empty string when input is null or undefined', () => { + expect(ChecklistUtil.sanitizeChecklistDescription(null)).toBe(''); + expect(ChecklistUtil.sanitizeChecklistDescription(undefined)).toBe(''); + }); + + it('should return empty string when input is not a string', () => { + expect(ChecklistUtil.sanitizeChecklistDescription(42)).toBe(''); + expect(ChecklistUtil.sanitizeChecklistDescription(false)).toBe(''); + }); + + it('should trim whitespace from both ends', () => { + expect(ChecklistUtil.sanitizeChecklistDescription(' description ')).toBe('description'); + }); + + it('should return the description unchanged when it is within the length limit', () => { + const shortDesc = 'This is a short description.'; + expect(ChecklistUtil.sanitizeChecklistDescription(shortDesc)).toBe(shortDesc); + }); + + it('should truncate the description to the maximum allowed length', () => { + const longDesc = 'B'.repeat(Constants.CHECKLIST_DESCRIPTION_MAX_LENGTH + 500); + const result = ChecklistUtil.sanitizeChecklistDescription(longDesc); + expect(result).toHaveLength(Constants.CHECKLIST_DESCRIPTION_MAX_LENGTH); + expect(result).toBe('B'.repeat(Constants.CHECKLIST_DESCRIPTION_MAX_LENGTH)); + }); + }); + + describe('renumberChecklist', () => { + it('should assign sequential order numbers starting from 1', () => { + const input = [ + { name: 'Item A', order: 5 }, + { name: 'Item B', order: 10 }, + { name: 'Item C', order: 15 }, + ]; + const result = ChecklistUtil.renumberChecklist(input); + expect(result[0].order).toBe(1); + expect(result[1].order).toBe(2); + expect(result[2].order).toBe(3); + }); + + it('should preserve all other properties of each item', () => { + const input = [ + { name: 'Item A', order: 99, statement: 'Test statement', answer: true }, + ]; + const result = ChecklistUtil.renumberChecklist(input); + expect(result[0].name).toBe('Item A'); + expect(result[0].statement).toBe('Test statement'); + expect(result[0].answer).toBe(true); + expect(result[0].order).toBe(1); + }); + + it('should return an empty array when given an empty array', () => { + expect(ChecklistUtil.renumberChecklist([])).toEqual([]); + }); + + it('should handle a single item', () => { + const input = [{ name: 'Only Item', order: 42 }]; + const result = ChecklistUtil.renumberChecklist(input); + expect(result).toHaveLength(1); + expect(result[0].order).toBe(1); + expect(result[0].name).toBe('Only Item'); + }); + + it('should not mutate the original array', () => { + const input = [ + { name: 'A', order: 5 }, + { name: 'B', order: 10 }, + ]; + const originalOrder0 = input[0].order; + const originalOrder1 = input[1].order; + ChecklistUtil.renumberChecklist(input); + // Original items should not be changed + expect(input[0].order).toBe(originalOrder0); + expect(input[1].order).toBe(originalOrder1); + }); + }); + + describe('generateChecklistExport', () => { + it('should return an object with the correct type and version', () => { + const checklist = [ + { id: 1, statement: 'Test item', description: 'Desc', answer: true, notes: [], assets: [] }, + ]; + const result = ChecklistUtil.generateChecklistExport(checklist); + expect(result.type).toBe(Constants.CHECKLIST_EXPORT_TYPE); + expect(result.version).toBe(Constants.CHECKLIST_EXPORT_VERSION); + }); + + it('should include an exportedAt timestamp in ISO format', () => { + const result = ChecklistUtil.generateChecklistExport([]); + expect(result.exportedAt).toBeDefined(); + expect(new Date(result.exportedAt).toISOString()).toBe(result.exportedAt); + }); + + it('should export only name and description, not notes, assets, or scanResult', () => { + const checklist = [ + { + id: 1, + statement: 'Check dependencies', + description: 'Verify all deps', + answer: true, + notes: [{ id: 'n1', content: 'Secret note' }], + assets: [{ uri: '/path/to/file.py', name: 'file.py' }], + scanResult: { Python: ['numpy'] }, + }, + ]; + const result = ChecklistUtil.generateChecklistExport(checklist); + const exported = result.checklists[0]; + + // Should have ONLY name and description + expect(exported.name).toBe('Check dependencies'); + expect(exported.description).toBe('Verify all deps'); + + // Should NOT have any internal data + expect(exported.notes).toBeUndefined(); + expect(exported.assets).toBeUndefined(); + expect(exported.scanResult).toBeUndefined(); + expect(exported.answer).toBeUndefined(); + expect(exported.id).toBeUndefined(); + }); + + it('should handle items with no description', () => { + const checklist = [ + { id: 1, statement: 'No description item', answer: false }, + ]; + const result = ChecklistUtil.generateChecklistExport(checklist); + expect(result.checklists[0].description).toBe(''); + }); + + it('should export all items in the checklist', () => { + const checklist = [ + { id: 1, statement: 'Item 1', description: '' }, + { id: 2, statement: 'Item 2', description: 'Desc 2' }, + { id: 3, statement: 'Item 3', description: 'Desc 3' }, + ]; + const result = ChecklistUtil.generateChecklistExport(checklist); + expect(result.checklists).toHaveLength(3); + }); + + it('should return an empty checklists array when given an empty checklist', () => { + const result = ChecklistUtil.generateChecklistExport([]); + expect(result.checklists).toEqual([]); + }); + }); + + describe('validateAndParseImport', () => { + const makeValidExportString = (checklists) => { + return JSON.stringify({ + type: Constants.CHECKLIST_EXPORT_TYPE, + version: Constants.CHECKLIST_EXPORT_VERSION, + exportedAt: new Date().toISOString(), + checklists, + }); + }; + + const existingChecklist = [ + { id: 1, statement: 'Software dependencies for the project are documented.', name: 'Dependency' }, + { id: 2, statement: 'Data file(s) used in the project are documented.', name: 'Data' }, + ]; + + describe('invalid JSON handling', () => { + it('should reject a string that is not valid JSON', () => { + const result = ChecklistUtil.validateAndParseImport('this is not json {{{', existingChecklist); + expect(result.valid).toBe(false); + expect(result.error).toContain('not valid JSON'); + expect(result.items).toEqual([]); + }); + + it('should reject a completely empty string', () => { + const result = ChecklistUtil.validateAndParseImport('', existingChecklist); + expect(result.valid).toBe(false); + expect(result.items).toEqual([]); + }); + + it('should reject a string with broken JSON syntax', () => { + const broken = '{"type": "statwrap-checklist", "checklists": [{"name": "missing quote}]}'; + const result = ChecklistUtil.validateAndParseImport(broken, existingChecklist); + expect(result.valid).toBe(false); + expect(result.error).toContain('not valid JSON'); + }); + }); + + describe('format validation', () => { + it('should reject JSON that is missing the type field', () => { + const noType = JSON.stringify({ checklists: [{ name: 'Test' }] }); + const result = ChecklistUtil.validateAndParseImport(noType, existingChecklist); + expect(result.valid).toBe(false); + expect(result.error).toContain('type'); + }); + + it('should reject JSON with a wrong type value', () => { + const wrongType = JSON.stringify({ + type: 'package-json', + checklists: [{ name: 'Test' }], + }); + const result = ChecklistUtil.validateAndParseImport(wrongType, existingChecklist); + expect(result.valid).toBe(false); + expect(result.error).toContain('does not appear to be a StatWrap checklist'); + }); + + it('should reject JSON where checklists is not an array', () => { + const notArray = JSON.stringify({ + type: Constants.CHECKLIST_EXPORT_TYPE, + checklists: 'not an array', + }); + const result = ChecklistUtil.validateAndParseImport(notArray, existingChecklist); + expect(result.valid).toBe(false); + expect(result.error).toContain('valid "checklists" array'); + }); + + it('should reject JSON where checklists array is empty', () => { + const empty = makeValidExportString([]); + const result = ChecklistUtil.validateAndParseImport(empty, existingChecklist); + expect(result.valid).toBe(false); + expect(result.error).toContain('empty checklist'); + }); + }); + + describe('successful import', () => { + it('should accept a valid export with new items', () => { + const valid = makeValidExportString([ + { name: 'New Custom Checklist', description: 'A new item' }, + ]); + const result = ChecklistUtil.validateAndParseImport(valid, existingChecklist); + expect(result.valid).toBe(true); + expect(result.error).toBeNull(); + expect(result.items).toHaveLength(1); + expect(result.items[0].name).toBe('New Custom Checklist'); + expect(result.items[0].description).toBe('A new item'); + }); + + it('should accept multiple valid items', () => { + const valid = makeValidExportString([ + { name: 'Item A', description: 'Desc A' }, + { name: 'Item B', description: 'Desc B' }, + { name: 'Item C', description: '' }, + ]); + const result = ChecklistUtil.validateAndParseImport(valid, existingChecklist); + expect(result.valid).toBe(true); + expect(result.items).toHaveLength(3); + }); + + it('should handle items with no description field', () => { + const valid = makeValidExportString([ + { name: 'No Desc Item' }, + ]); + const result = ChecklistUtil.validateAndParseImport(valid, existingChecklist); + expect(result.valid).toBe(true); + expect(result.items[0].description).toBe(''); + }); + }); + + describe('sanitization', () => { + it('should truncate names that exceed the maximum length', () => { + const longName = 'X'.repeat(500); + const valid = makeValidExportString([ + { name: longName, description: 'Short desc' }, + ]); + const result = ChecklistUtil.validateAndParseImport(valid, existingChecklist); + expect(result.valid).toBe(true); + expect(result.items[0].name).toHaveLength(Constants.CHECKLIST_NAME_MAX_LENGTH); + }); + + it('should truncate descriptions that exceed the maximum length', () => { + const longDesc = 'Y'.repeat(2000); + const valid = makeValidExportString([ + { name: 'Valid Name', description: longDesc }, + ]); + const result = ChecklistUtil.validateAndParseImport(valid, existingChecklist); + expect(result.valid).toBe(true); + expect(result.items[0].description).toHaveLength(Constants.CHECKLIST_DESCRIPTION_MAX_LENGTH); + }); + + it('should trim whitespace from names', () => { + const valid = makeValidExportString([ + { name: ' Padded Name ', description: 'desc' }, + ]); + const result = ChecklistUtil.validateAndParseImport(valid, existingChecklist); + expect(result.valid).toBe(true); + expect(result.items[0].name).toBe('Padded Name'); + }); + }); + + describe('duplicate handling', () => { + it('should skip items that already exist in the current checklist (case-insensitive)', () => { + const valid = makeValidExportString([ + { name: 'software dependencies for the project are documented.', description: '' }, + { name: 'Brand New Item', description: '' }, + ]); + const result = ChecklistUtil.validateAndParseImport(valid, existingChecklist); + expect(result.valid).toBe(true); + expect(result.items).toHaveLength(1); + expect(result.items[0].name).toBe('Brand New Item'); + expect(result.skippedCount).toBe(1); + }); + + it('should skip duplicate items within the import file itself', () => { + const valid = makeValidExportString([ + { name: 'Duplicate Item', description: 'First occurrence' }, + { name: 'Duplicate Item', description: 'Second occurrence' }, + ]); + const result = ChecklistUtil.validateAndParseImport(valid, existingChecklist); + expect(result.valid).toBe(true); + expect(result.items).toHaveLength(1); + expect(result.items[0].description).toBe('First occurrence'); + expect(result.skippedCount).toBe(1); + }); + + it('should return valid=false when ALL items are duplicates', () => { + const valid = makeValidExportString([ + { name: 'Software dependencies for the project are documented.' }, + { name: 'Data file(s) used in the project are documented.' }, + ]); + const result = ChecklistUtil.validateAndParseImport(valid, existingChecklist); + expect(result.valid).toBe(false); + expect(result.skippedCount).toBe(2); + }); + }); + + describe('invalid item handling', () => { + it('should skip items where name is not a string', () => { + const valid = makeValidExportString([ + { name: 123, description: 'Not a string name' }, + { name: 'Valid Name', description: 'Valid' }, + ]); + const result = ChecklistUtil.validateAndParseImport(valid, existingChecklist); + expect(result.valid).toBe(true); + expect(result.items).toHaveLength(1); + expect(result.items[0].name).toBe('Valid Name'); + expect(result.skippedCount).toBe(1); + }); + + it('should skip items where name is empty or whitespace-only', () => { + const valid = makeValidExportString([ + { name: '', description: 'Empty name' }, + { name: ' ', description: 'Whitespace name' }, + { name: 'Actual Item', description: 'Valid' }, + ]); + const result = ChecklistUtil.validateAndParseImport(valid, existingChecklist); + expect(result.valid).toBe(true); + expect(result.items).toHaveLength(1); + expect(result.skippedCount).toBe(2); + }); + + it('should skip null items in the checklists array', () => { + const valid = makeValidExportString([ + null, + { name: 'Valid After Null', description: '' }, + ]); + const result = ChecklistUtil.validateAndParseImport(valid, existingChecklist); + expect(result.valid).toBe(true); + expect(result.items).toHaveLength(1); + expect(result.skippedCount).toBe(1); + }); + }); + + describe('security - field allowlisting', () => { + it('should only extract name and description, ignoring all extra fields', () => { + const valid = makeValidExportString([ + { + name: 'Safe Item', + description: 'Safe description', + maliciousField: 'attack payload', + scanResult: { Python: ['hacked'] }, + notes: [{ id: 'fake', content: 'injected note' }], + assets: [{ uri: '/etc/passwd' }], + answer: true, + }, + ]); + const result = ChecklistUtil.validateAndParseImport(valid, existingChecklist); + expect(result.valid).toBe(true); + expect(result.items).toHaveLength(1); + + const item = result.items[0]; + expect(Object.keys(item)).toEqual(['name', 'description']); + expect(item.name).toBe('Safe Item'); + expect(item.description).toBe('Safe description'); + + expect(item.maliciousField).toBeUndefined(); + expect(item.scanResult).toBeUndefined(); + expect(item.notes).toBeUndefined(); + expect(item.assets).toBeUndefined(); + expect(item.answer).toBeUndefined(); + }); + + it('should preserve HTML/script tags as plain text in name (React will escape them)', () => { + const valid = makeValidExportString([ + { name: '', description: '' }, + ]); + const result = ChecklistUtil.validateAndParseImport(valid, existingChecklist); + expect(result.valid).toBe(true); + expect(result.items[0].name).toBe(''); + expect(result.items[0].description).toBe(''); + }); + }); + + describe('edge cases', () => { + it('should work with an empty existing checklist', () => { + const valid = makeValidExportString([ + { name: 'New Item', description: 'Desc' }, + ]); + const result = ChecklistUtil.validateAndParseImport(valid, []); + expect(result.valid).toBe(true); + expect(result.items).toHaveLength(1); + }); + + it('should handle valid JSON that is not an object (e.g., a JSON array)', () => { + const jsonArray = JSON.stringify([{ name: 'Test' }]); + const result = ChecklistUtil.validateAndParseImport(jsonArray, existingChecklist); + expect(result.valid).toBe(false); + }); + + it('should handle valid JSON that is a primitive (e.g., a number)', () => { + const result = ChecklistUtil.validateAndParseImport('42', existingChecklist); + expect(result.valid).toBe(false); + }); + }); + }); + + describe('isDuplicateChecklist', () => { + const mockChecklist = [ + { id: 1, uid: 'uid-1', statement: 'Dependency checks' }, + { id: 2, uid: 'uid-2', statement: 'Data validation' } + ]; + + it('should return false if the input name or checklist is invalid', () => { + expect(ChecklistUtil.isDuplicateChecklist(null, mockChecklist)).toBe(false); + expect(ChecklistUtil.isDuplicateChecklist('Test', null)).toBe(false); + expect(ChecklistUtil.isDuplicateChecklist('Test', {})).toBe(false); + }); + + it('should return true if an exact duplicate exists', () => { + expect(ChecklistUtil.isDuplicateChecklist('Dependency checks', mockChecklist)).toBe(true); + }); + + it('should return true if a case-insensitive duplicate exists', () => { + expect(ChecklistUtil.isDuplicateChecklist('DEPENDENCY checks', mockChecklist)).toBe(true); + expect(ChecklistUtil.isDuplicateChecklist('data validation', mockChecklist)).toBe(true); + }); + + it('should return false if the name is entirely unique', () => { + expect(ChecklistUtil.isDuplicateChecklist('New custom check', mockChecklist)).toBe(false); + }); + + it('should ignore the duplicate check if the matching item matches the excludeId (edit mode)', () => { + expect(ChecklistUtil.isDuplicateChecklist('Data validation', mockChecklist, 'uid-2')).toBe(false); + }); + + it('should still flag a duplicate if edit mode name matches a DIFFERENT existing item', () => { + expect(ChecklistUtil.isDuplicateChecklist('Dependency checks', mockChecklist, 'uid-2')).toBe(true); + }); + }); }); }); diff --git a/test/utils/templateContent.spec.js b/test/utils/templateContent.spec.js new file mode 100644 index 00000000..31aa6602 --- /dev/null +++ b/test/utils/templateContent.spec.js @@ -0,0 +1,91 @@ +import { filterContentsByPaths, collectAllPaths } from '../../app/utils/templateContent'; + +const sampleContents = [ + { + type: 'directory', + name: 'Chapter 1', + path: '/chapter-1', + contents: [ + { type: 'file', name: 'outline.md', path: '/chapter-1/outline.md' }, + { + type: 'directory', + name: 'data', + path: '/chapter-1/data', + contents: [ + { type: 'file', name: 'sample.csv', path: '/chapter-1/data/sample.csv' }, + ], + }, + ], + }, + { type: 'file', name: 'README.md', path: '/README.md' }, +]; + +describe('utils', () => { + describe('templateContent', () => { + describe('collectAllPaths', () => { + it('should return all paths recursively', () => { + const paths = collectAllPaths(sampleContents); + expect(paths).toEqual([ + '/chapter-1', + '/chapter-1/outline.md', + '/chapter-1/data', + '/chapter-1/data/sample.csv', + '/README.md', + ]); + }); + + it('should return an empty array for null input', () => { + expect(collectAllPaths(null)).toEqual([]); + }); + + it('should return an empty array for undefined input', () => { + expect(collectAllPaths(undefined)).toEqual([]); + }); + + it('should return an empty array for empty contents', () => { + expect(collectAllPaths([])).toEqual([]); + }); + }); + + describe('filterContentsByPaths', () => { + it('should return all items when all paths are checked', () => { + const allPaths = collectAllPaths(sampleContents); + const result = filterContentsByPaths(sampleContents, allPaths); + expect(result).toEqual(sampleContents); + }); + + it('should return an empty array when no paths are checked', () => { + const result = filterContentsByPaths(sampleContents, []); + expect(result).toEqual([]); + }); + + it('should return an empty array for null contents', () => { + expect(filterContentsByPaths(null, ['/foo'])).toEqual([]); + }); + + it('should keep only selected files', () => { + const result = filterContentsByPaths(sampleContents, ['/README.md']); + expect(result.length).toBe(1); + expect(result[0].name).toBe('README.md'); + }); + + it('should keep a folder and filter its children', () => { + const result = filterContentsByPaths(sampleContents, [ + '/chapter-1', + '/chapter-1/data', + '/chapter-1/data/sample.csv', + ]); + expect(result.length).toBe(1); + expect(result[0].name).toBe('Chapter 1'); + // outline.md was not in checkedPaths, so it should be excluded + expect(result[0].contents.length).toBe(1); + expect(result[0].contents[0].name).toBe('data'); + }); + + it('should exclude a folder if it is not in checkedPaths', () => { + const result = filterContentsByPaths(sampleContents, ['/README.md']); + expect(result.find((i) => i.name === 'Chapter 1')).toBeUndefined(); + }); + }); + }); +}); \ No newline at end of file