diff --git a/app/components/ProjectEntryPoint/ProjectEntryPoint.js b/app/components/ProjectEntryPoint/ProjectEntryPoint.js index 548136e..29c736b 100644 --- a/app/components/ProjectEntryPoint/ProjectEntryPoint.js +++ b/app/components/ProjectEntryPoint/ProjectEntryPoint.js @@ -7,17 +7,6 @@ import { faFileImport, faShareFromSquare } from '@fortawesome/free-solid-svg-ico import styles from './ProjectEntryPoint.css'; import AssetUtil from '../../utils/asset'; -function findEntryPoints(asset, entryPointsList) { - if (asset.attributes && asset.attributes.entrypoint === true) { - entryPointsList.push(asset); - } - if (asset.children) { - asset.children.forEach((child) => { - findEntryPoints(child, entryPointsList); - }); - } -} - const projectEntryPoint = (props) => { const { assets, rootUri, onSelect } = props; @@ -31,10 +20,7 @@ const projectEntryPoint = (props) => { } }; - const entryPointsList = []; - if (assets) { - findEntryPoints(assets, entryPointsList); - } + const entryPointsList = AssetUtil.findEntryPointAssets(assets); if (entryPointsList && entryPointsList.length > 0) { return ( diff --git a/app/components/ReproChecklist/ChecklistItem/ChecklistItem.js b/app/components/ReproChecklist/ChecklistItem/ChecklistItem.js index d84af97..3d617f7 100644 --- a/app/components/ReproChecklist/ChecklistItem/ChecklistItem.js +++ b/app/components/ReproChecklist/ChecklistItem/ChecklistItem.js @@ -2,8 +2,15 @@ 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 { IconButton, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle } from '@mui/material'; +import { ContentCopy, Done, Delete } from '@mui/icons-material'; +import { + IconButton, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, +} from '@mui/material'; import { FaFolderOpen, FaFolderMinus, FaChevronUp, FaChevronDown } from 'react-icons/fa'; import AssetTree from '../../AssetTree/AssetTree'; import AssetUtil from '../../../utils/asset'; @@ -12,9 +19,25 @@ import Constants from '../../../constants/constants'; const { v4: uuidv4 } = require('uuid'); function ChecklistItem(props) { - const { item, project, onUpdatedNote, onDeletedNote, onAddedNote, onItemUpdate, onSelectedAsset } = props; + const { + item, + project, + onUpdatedNote, + onDeletedNote, + onAddedNote, + onItemUpdate, + onSelectedAsset, + } = props; const treeRef = React.useRef(null); + const externalTreeRef = React.useRef(null); + + const [assets, setAssets] = useState(project && project.assets); + const [externalAssets, setExternalAssets] = useState( + project && project.externalAssets + ? project.externalAssets + : AssetUtil.createEmptyExternalAssets(), + ); const [expanded, setExpanded] = useState(false); @@ -39,7 +62,7 @@ function ChecklistItem(props) { const handleSelectAsset = (selAsset) => { let asset = selAsset; if (asset && (asset.contentTypes === null || asset.contentTypes === undefined)) { - if (project && project.assets) { + if (project && project.assets && asset.type === Constants.AssetType.FILE) { asset = AssetUtil.findDescendantAssetByUri(project.assets, asset.uri); } } @@ -48,10 +71,10 @@ function ChecklistItem(props) { if (asset.type === Constants.AssetType.FILE) { const fileName = AssetUtil.getAssetNameFromUri(asset.uri); setAssetTitle(fileName); - // Handles case for URL assets - // TODO: Title is currently the same as URI, some way to get a better title? + // Handles case for URL assets + // TODO: Title is currently the same as URI, some way to get a better title? } else if (asset.type === Constants.AssetType.URL) { - setAssetTitle(asset.uri); + setAssetTitle(asset.name); } else { setAssetTitle(''); } @@ -65,34 +88,67 @@ function ChecklistItem(props) { const handleAddAsset = () => { if (!selectedAsset) { - console.log("No asset selected - cannot add asset reference"); + console.log('No asset selected - cannot add asset reference'); return; } if (!assetTitle) { - console.log("No title provided - cannot add asset reference"); + console.log('No title provided - cannot add asset reference'); return; } if (selectedAsset.type === Constants.AssetType.FILE) { // Adds image files to checklist images - if(selectedAsset.contentTypes.includes(Constants.AssetContentType.IMAGE)) { - const updatedItem = { ...item, images: [...item.images, {id: uuidv4(), uri: selectedAsset.uri, title: assetTitle, description: assetDescription}] }; + if (selectedAsset.contentTypes.includes(Constants.AssetContentType.IMAGE)) { + const updatedItem = { + ...item, + images: [ + ...item.images, + { + id: uuidv4(), + uri: selectedAsset.uri, + title: assetTitle, + description: assetDescription, + }, + ], + }; onItemUpdate(updatedItem); setShowImages(true); - // Adds file as URL + // Adds file as URL } else { - const updatedItem = { ...item, urls: [...item.urls, {id: uuidv4(), hyperlink: AssetUtil.absoluteToRelativePath(project.path, selectedAsset), title: assetTitle, description: assetDescription}] }; + const updatedItem = { + ...item, + urls: [ + ...item.urls, + { + id: uuidv4(), + hyperlink: AssetUtil.absoluteToRelativePath(project.path, selectedAsset), + title: assetTitle, + description: assetDescription, + }, + ], + }; onItemUpdate(updatedItem); setShowURLs(true); } } else if (selectedAsset.type === Constants.AssetType.URL) { - const updatedItem = { ...item, urls: [...item.urls, {id: uuidv4(), hyperlink: selectedAsset.uri, title: assetTitle, description: assetDescription}] }; + const updatedItem = { + ...item, + urls: [ + ...item.urls, + { + id: uuidv4(), + hyperlink: selectedAsset.uri, + title: assetTitle, + description: assetDescription, + }, + ], + }; onItemUpdate(updatedItem); setShowURLs(true); } resetAssetDialog(); - } + }; const handleCopy = (urlId, hyperlink) => { // Copy the URL to the clipboard @@ -138,9 +194,15 @@ function ChecklistItem(props) {
- {item.id}. {item.statement} + + {item.id}. {item.statement} +
- {expanded &&
-
- {item.scanResult && - Object.keys(item.scanResult).map((key) => { - return ( -
- {key} -
    - {item.scanResult[key].length ? ( - item.scanResult[key].map((answer) => ( -
  • {answer}
  • - )) - ) : ( -
  • No answers available
  • - )} -
-
- )})} -
- -
- -
- setAddAsset(false)}> - Add Asset Reference - -
-
- treeRef.current.setExpandAll(true)} - className={styles.toolbarButton} - aria-label="expand all tree items" - fontSize="small" - > -  Expand - - treeRef.current.setExpandAll(false)} - > -  Collapse - -
- -
-
-
- - setAssetTitle(e.target.value)} - required + {expanded && ( +
+
+ {item.scanResult && + Object.keys(item.scanResult).map((key) => { + return ( +
+ {key} +
    + {item.scanResult[key].length ? ( + item.scanResult[key].map((answer, index) => ( +
  • {answer}
  • + )) + ) : ( +
  • No answers available
  • + )} +
+
+ ); + })} +
+ +
+ +
+ setAddAsset(false)}> + Add Asset Reference + +
+
+ treeRef.current.setExpandAll(true)} + className={styles.toolbarButton} + aria-label="expand all tree items" + fontSize="small" + > +  Expand + + treeRef.current.setExpandAll(false)} + > +  Collapse + +
+ -
-
- - setAssetDescription(e.target.value)} +
-
- - - - - -
- {item.urls.length > 0 && ( -
-
-

Attached URLs:

- -
-
-
    - {item.urls.map((url) => ( -
    -
    -
  • -
    - {url.title} -
    - {copiedUrlId === url.id ? ( - - ) : ( - handleCopy(url.id, url.hyperlink)} + + + + {item.urls.length > 0 && ( +
    +
    +

    Attached URLs:

    + +
    +
    +
      + {item.urls.map((url) => ( +
      +
      +
    • +
      + {url.title} +
      + {copiedUrlId === url.id ? ( + + ) : ( + handleCopy(url.id, url.hyperlink)} + /> + )} + handleDeleteUrl(url.id)} /> - )} - handleDeleteUrl(url.id)} - /> +
      -
    • - - {url.hyperlink} - -

      {url.description}

      - -
      -
      -
    - ))} -
-
-
- )} - {item.images.length > 0 && ( -
-
-

Attached Images:

- -
-
-
    - {item.images.map((image) => ( -
  • -
    - {image.title} - handleDeleteImage(image.id)} - /> + + {url.hyperlink} + +

    {url.description}

    +
  • +
+
- attached -

{image.description}

- - ))} - + ))} + +
- - )} - {item.subChecklist.length > 0 && ( -
-
-

Sub-Checklist:

- + )} + {item.images.length > 0 && ( +
+
+

Attached Images:

+ +
+
+
    + {item.images.map((image) => ( +
  • +
    + {image.title} + handleDeleteImage(image.id)} + /> +
    + attached +

    {image.description}

    +
  • + ))} +
+
-
-
    - {item.subChecklist.map((subCheck) => ( -
  • -
    - {subCheck.statement} -
    - - -
    -
    -
  • - ))} -
+ )} + {item.subChecklist.length > 0 && ( +
+
+

Sub-Checklist:

+ +
+
+
    + {item.subChecklist.map((subCheck) => ( +
  • +
    + {subCheck.statement} +
    + + +
    +
    +
  • + ))} +
+
-
- )} -
} + )} +
+ )} )} @@ -401,16 +491,14 @@ ChecklistItem.propTypes = { name: PropTypes.string.isRequired, statement: PropTypes.string.isRequired, answer: PropTypes.bool.isRequired, - scanResult: PropTypes.objectOf( - PropTypes.arrayOf(PropTypes.string) - ), + scanResult: PropTypes.objectOf(PropTypes.arrayOf(PropTypes.string)), notes: PropTypes.arrayOf( PropTypes.shape({ id: PropTypes.string.isRequired, author: PropTypes.string.isRequired, updated: PropTypes.string.isRequired, content: PropTypes.string.isRequired, - }) + }), ), images: PropTypes.arrayOf( PropTypes.shape({ @@ -418,7 +506,7 @@ ChecklistItem.propTypes = { uri: PropTypes.string.isRequired, title: PropTypes.string.isRequired, description: PropTypes.string.isRequired, - }) + }), ), urls: PropTypes.arrayOf( PropTypes.shape({ @@ -426,14 +514,14 @@ ChecklistItem.propTypes = { hyperlink: PropTypes.string.isRequired, title: PropTypes.string.isRequired, description: PropTypes.string.isRequired, - }) + }), ), subChecklist: PropTypes.arrayOf( PropTypes.shape({ id: PropTypes.string.isRequired, statement: PropTypes.string.isRequired, answer: PropTypes.bool.isRequired, - }) + }), ), }).isRequired, project: PropTypes.object.isRequired, diff --git a/app/components/ReproChecklist/ReproChecklist.js b/app/components/ReproChecklist/ReproChecklist.js index eaab298..3cb40ea 100644 --- a/app/components/ReproChecklist/ReproChecklist.js +++ b/app/components/ReproChecklist/ReproChecklist.js @@ -3,36 +3,68 @@ import PropTypes from 'prop-types'; import styles from './ReproChecklist.css'; import ChecklistItem from './ChecklistItem/ChecklistItem'; import Error from '../Error/Error'; -import { Typography, Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle } from '@mui/material'; +import { + Typography, + Button, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, +} from '@mui/material'; import { SaveAlt } from '@mui/icons-material'; import pdfMake from 'pdfmake/build/pdfmake'; import pdfFonts from 'pdfmake/build/vfs_fonts'; import GeneralUtil from '../../utils/general'; import ChecklistUtil from '../../utils/checklist'; +import AssetUtil from '../../utils/asset'; +import Constants from '../../constants/constants'; pdfMake.vfs = pdfFonts.pdfMake.vfs; const path = require('path'); +// These functions are mapped using the statement type to the corresponding scan function +const scanFunctions = { + Dependency: ChecklistUtil.findAssetLanguagesAndDependencies, + Data: ChecklistUtil.findDataFiles, + Entrypoint: ChecklistUtil.findEntryPointFiles, + Documentation: ChecklistUtil.findDocumentationFiles, +}; + function ReproChecklist(props) { - const { project, checklist, error, onUpdated, onAddedNote, onUpdatedNote, onDeletedNote, onSelectedAsset} = props; + const { + project, + checklist, + error, + onUpdated, + onAddedNote, + onUpdatedNote, + onDeletedNote, + onSelectedAsset, + } = props; const [openExportDialog, setOpenExportDialog] = useState(false); // this useEffect hook is here to load the scan results for all the checklist statements useEffect(() => { if (project && checklist && !error) { - if(project.assets) { - // scan result for the first checklist statement - const scanResult1 = ChecklistUtil.findAssetLanguagesAndDependencies(project.assets); - checklist[0].scanResult = scanResult1; + if (project.assets) { + // scan the project assets for each checklist statement + Constants.CHECKLIST.forEach((statement, index) => { + // if used here as there might be a statement that doesn't have a corresponding scan function + if (scanFunctions[statement[0]]) { + const scanResult = scanFunctions[statement[0]](project.assets); + checklist[index].scanResult = scanResult; + } + }); } } }, [project]); // Handles the update of checklist for changes in the checklist items const handleItemUpdate = (updatedItem) => { - const updatedChecklist = checklist.map(item => - item.id === updatedItem.id ? updatedItem : item + const updatedChecklist = checklist.map((item) => + item.id === updatedItem.id ? updatedItem : item, ); onUpdated(project, updatedChecklist); }; @@ -41,7 +73,9 @@ function ReproChecklist(props) { const handleReportGeneration = (exportNotes) => { // pdfMake requires base64 encoded images const checkedIcon = GeneralUtil.convertImageToBase64(path.join(__dirname, 'images/yes.png')); - const statWrapLogo = GeneralUtil.convertImageToBase64(path.join(__dirname, 'images/banner.png')); + const statWrapLogo = GeneralUtil.convertImageToBase64( + path.join(__dirname, 'images/banner.png'), + ); const documentDefinition = { content: [ @@ -96,151 +130,157 @@ function ReproChecklist(props) { }, ], }, - ...checklist.map((item, index) => { - const maxWidth = 450; - let subChecklist = []; - if (item.subChecklist && item.subChecklist.length > 0) { - subChecklist = item.subChecklist.map((subItem, subIndex) => ({ - columns: [ - { - text: `${index + 1}.${subIndex + 1} ${subItem.statement}`, - margin: [15, 5], - width: '*', - alignment: 'left', - }, - subItem.answer ? { - image: checkedIcon, - width: 15, - alignment: 'right', - margin: [0, 5, 25, 0], - } : { - image: checkedIcon, - width: 15, - alignment: 'right', - margin: [0, 5, 1, 0], - }, - ], - columnGap: 0, - })); - } + ...checklist + .map((item, index) => { + const maxWidth = 450; + let subChecklist = []; + if (item.subChecklist && item.subChecklist.length > 0) { + subChecklist = item.subChecklist.map((subItem, subIndex) => ({ + columns: [ + { + text: `${index + 1}.${subIndex + 1} ${subItem.statement}`, + margin: [15, 5], + width: '*', + alignment: 'left', + }, + subItem.answer + ? { + image: checkedIcon, + width: 15, + alignment: 'right', + margin: [0, 5, 25, 0], + } + : { + image: checkedIcon, + width: 15, + alignment: 'right', + margin: [0, 5, 1, 0], + }, + ], + columnGap: 0, + })); + } - let notes = []; - if (exportNotes && item.notes && item.notes.length > 0) { - notes = item.notes.map((note, noteIndex) => ({ - text: `${noteIndex + 1}. ${note.content}`, - margin: [20, 2], - width: maxWidth, - })); - } + let notes = []; + if (exportNotes && item.notes && item.notes.length > 0) { + notes = item.notes.map((note, noteIndex) => ({ + text: `${noteIndex + 1}. ${note.content}`, + margin: [20, 2], + width: maxWidth, + })); + } - let images = []; - const imageWidth = 135; - if(item.images && item.images.length > 0){ - images = item.images.map((image) => { - const base64Image = GeneralUtil.convertImageToBase64(image.uri); - if (base64Image) { - return { - image: base64Image, - width: imageWidth, - margin: [0, 5], - alignment: 'center', - }; - } - return { text: `Failed to load image: ${image.uri}`, color: 'red' }; - }); - } + let images = []; + const imageWidth = 135; + if (item.images && item.images.length > 0) { + images = item.images.map((image) => { + const base64Image = GeneralUtil.convertImageToBase64(image.uri); + if (base64Image) { + return { + image: base64Image, + width: imageWidth, + margin: [0, 5], + alignment: 'center', + }; + } + return { text: `Failed to load image: ${image.uri}`, color: 'red' }; + }); + } - // Rendering images by rows, as rendering in columns overflows the page and we can't wrap columns under each other, - // math for 3 images per row is as follows: - // imageWidth*n + calumnGap*(n-1) <= maxWidth - leftMargin - rightMargin - // 135*n + 10*(n-1) <= 450 - 20 - 0; - // n <= 440/145 --> n = 3 - const imagesPerRow = Math.floor((maxWidth - 20 + 10) / (imageWidth + 10)); + // Rendering images by rows, as rendering in columns overflows the page and we can't wrap columns under each other, + // math for 3 images per row is as follows: + // imageWidth*n + calumnGap*(n-1) <= maxWidth - leftMargin - rightMargin + // 135*n + 10*(n-1) <= 450 - 20 - 0; + // n <= 440/145 --> n = 3 + const imagesPerRow = Math.floor((maxWidth - 20 + 10) / (imageWidth + 10)); - let imageRows = []; - for (let i = 0; i < images.length; i += imagesPerRow) { - imageRows.push({ - columns: images.slice(i, i + imagesPerRow), - columnGap: 10, - margin: [20, 5], - }); - } + let imageRows = []; + for (let i = 0; i < images.length; i += imagesPerRow) { + imageRows.push({ + columns: images.slice(i, i + imagesPerRow), + columnGap: 10, + margin: [20, 5], + }); + } + + let urls = []; + if (item.urls && item.urls.length > 0) { + urls = item.urls.map((url, urlIndex) => { + return { + unbreakable: true, + columns: [ + { + text: `${urlIndex + 1}. `, + width: 25, + margin: [20, 1, 0, 0], + alignment: 'left', + noWrap: true, + }, + { + stack: [ + { + text: url.title, + margin: [7, 1], + alignment: 'left', + style: 'hyperlink', + link: url.hyperlink, + }, + { + text: url.description, + margin: [7, 3], + alignment: 'left', + }, + ], + width: maxWidth, + }, + ], + }; + }); + } - let urls = []; - if(item.urls && item.urls.length > 0){ - urls = item.urls.map((url, urlIndex) => { - return { - unbreakable: true, + return [ + { columns: [ { - text: `${urlIndex + 1}. `, - width: 25, - margin: [20, 1 , 0, 0], + text: `${index + 1}. `, + width: 10, + margin: [0, 10], alignment: 'left', - noWrap: true, }, { - stack: [ - { - text: url.title, - margin: [7, 1], - alignment: 'left', - style: 'hyperlink', - link: url.hyperlink, - }, - { - text: url.description, - margin: [7, 3], - alignment: 'left', - }, - ], + text: `${item.statement}`, + margin: [0, 10, 25, 0], width: maxWidth, + alignment: 'left', + bold: true, }, + item.answer + ? { + image: checkedIcon, + width: 15, + alignment: 'right', + marginRight: 10, + marginTop: 12, + } + : { + image: checkedIcon, + width: 15, + marginLeft: 28, + marginTop: 12, + }, ], - }; - }); - } - - return [ - { - columns: [ - { - text: `${index + 1}. `, - width: 10, - margin: [0, 10], - alignment: 'left', - }, - { - text: `${item.statement}`, - margin: [0, 10, 25, 0], - width: maxWidth, - alignment: 'left', - bold: true, - }, - item.answer ? { - image: checkedIcon, - width: 15, - alignment: 'right', - marginRight: 10, - marginTop: 12, - } : { - image: checkedIcon, - width: 15, - marginLeft: 28, - marginTop: 12, - }, - ], - columnGap: 5, - }, - ...subChecklist, - notes.length > 0 ? { text: 'Notes:', margin: [15, 5] } : '', - ...notes, - images.length > 0 ? { text: 'Related Images:', margin: [15, 10] } : '', - ...imageRows, - urls.length > 0 ? { text: 'Related URLs:', margin: [15, 10] } : '', - ...urls, - ]; - }).flat(), + columnGap: 5, + }, + ...subChecklist, + notes.length > 0 ? { text: 'Notes:', margin: [15, 5] } : '', + ...notes, + images.length > 0 ? { text: 'Related Images:', margin: [15, 10] } : '', + ...imageRows, + urls.length > 0 ? { text: 'Related URLs:', margin: [15, 10] } : '', + ...urls, + ]; + }) + .flat(), ], styles: { mainHeader: { fontSize: 22, bold: true, color: '#663399' }, @@ -267,53 +307,53 @@ function ReproChecklist(props) { let content =
Checklist not configured.
; if (checklist && checklist.length > 0) { - content = -
- Reproducibility Checklist -
- {checklist.map(item => ( - - ))} -
-
- -
+ content = ( +
+ + Reproducibility Checklist + +
+ {checklist.map((item) => ( + + ))} +
+
+ +
- setOpenExportDialog(false)}> - Export Report - - - Do you want to include the checklist notes in the exported reproducibility checklist? - - - - {/* selective export of notes */} - - - - -
+ setOpenExportDialog(false)}> + Export Report + + + Do you want to include the checklist notes in the exported reproducibility checklist? + + + + {/* selective export of notes */} + + + + +
+ ); } else if (error) { content = There was an error loading the project checklist: {error}; } diff --git a/app/constants/constants.js b/app/constants/constants.js index fb4e9f0..76737af 100644 --- a/app/constants/constants.js +++ b/app/constants/constants.js @@ -72,7 +72,7 @@ module.exports = { PROJECT: 'project', PERSON: 'person', ASSET: 'asset', - EXTERNAL_ASSET: 'external asset', // Slightly different from 'asset' in that it lives outside the project folder + EXTERNAL_ASSET: 'external asset', // Slightly different from 'asset' in that it lives outside the project folder CHECKLIST: 'checklist', }, diff --git a/app/utils/asset.js b/app/utils/asset.js index 8b486b1..4f2a741 100644 --- a/app/utils/asset.js +++ b/app/utils/asset.js @@ -140,6 +140,30 @@ export default class AssetUtil { return descendantsList; } + /** + * Finds all the assets marked as entry points in the asset tree + * @param {object} asset The asset to search for entry points + * @param {array} entryPointList The list of entry points found so far + * @returns {array} The list of entry points found + */ + static findEntryPointAssets(asset, entryPointList = []) { + if (!asset) { + return entryPointList; + } + + if (asset.attributes && asset.attributes.entrypoint) { + entryPointList.push(asset); + } + + if (asset.children) { + asset.children.forEach((child) => { + AssetUtil.findEntryPointAssets(child, entryPointList); + }); + } + + return entryPointList; + } + /** * Recursively collect and flatten all asset notes into an array * @param {object} asset The asset to find all notes for diff --git a/app/utils/checklist.js b/app/utils/checklist.js index 9cf38dc..759cba1 100644 --- a/app/utils/checklist.js +++ b/app/utils/checklist.js @@ -1,5 +1,7 @@ import Constants from '../constants/constants'; import AssetsConfig from '../constants/assets-config'; +import AssetUtil from './asset'; +import WorkflowUtil from './workflow'; const path = require('path'); export default class ChecklistUtil { @@ -26,43 +28,147 @@ export default class ChecklistUtil { } /** - * This function returns the languages and dependencies of an asset and its children + * This function returns the languages and dependencies of the asset * @param {object} asset The asset to find the languages and dependencies of - * @param {object} languages Empty object that acts like a map to store the languages found as keys - * @param {object} dependencies Empty object that acts like a map to store the dependencies found as keys * @returns {object} An object containing the languages and dependencies found as arrays */ - static findAssetLanguagesAndDependencies(asset, languages = {}, dependencies = {}) { + static findAssetLanguagesAndDependencies(asset) { if (!asset) { return { languages: [], - dependencies: [] + dependencies: [], }; } - if (asset.type === Constants.AssetType.FILE && asset.contentTypes.includes(Constants.AssetContentType.CODE) ) { + + return { + languages: ChecklistUtil.findAssetLanguages(asset), + dependencies: ChecklistUtil.findAssetDependencies(asset), + }; + } + + /** + * This function returns the languages of an asset and its children recursively + * @param {object} asset The asset to find the languages of + * @param {object} languages Empty object that acts like a map to store the languages found as keys + * @returns {array} An array containing the languages found + */ + static findAssetLanguages(asset, languages = {}) { + if ( + asset.type === Constants.AssetType.FILE && + asset.contentTypes.includes(Constants.AssetContentType.CODE) + ) { const lastSep = asset.uri.lastIndexOf(path.sep); const fileName = asset.uri.substring(lastSep + 1); const ext = fileName.split('.').pop(); - if(ext){ + if (ext) { AssetsConfig.contentTypes.forEach((contentType) => { // Ensures both the extension and content type are for code files - if(contentType.categories.includes(Constants.AssetContentType.CODE) && contentType.extensions.includes(ext)) { + if ( + contentType.categories.includes(Constants.AssetContentType.CODE) && + contentType.extensions.includes(ext) + ) { languages[contentType.name] = true; } }); } } - if(asset.children){ - asset.children.forEach(child => { - this.findAssetLanguagesAndDependencies(child, languages, dependencies); + if (asset.children) { + asset.children.forEach((child) => { + ChecklistUtil.findAssetLanguages(child, languages); }); } - return { - languages: Object.keys(languages), - dependencies: Object.keys(dependencies) - }; + return Object.keys(languages); + } + + /** + * This function returns the dependencies of an asset and its children recursively + * @param {object} asset The asset to find the dependencies of + * @param {object} dependencies Empty object that acts like a map to store the dependencies found as keys + * @returns {array} An array containing the dependencies found + */ + static findAssetDependencies(asset) { + const dependencies = []; + const assetDepedencies = WorkflowUtil.getAllLibraryDependencies(asset); + assetDepedencies.forEach((x) => { + if (x.assetType && x.assetType !== Constants.AssetType.GENERIC) { + x.dependencies.forEach((dep) => { + if (dependencies.findIndex((i) => i === dep.id) === -1) { + dependencies.push(WorkflowUtil.getShortDependencyName(dep.id)); + } + }); + } + }); + return dependencies; + } + + /** This function finds the data files in the asset and its children recursively + * @param {object} asset The asset to find the data files within + * @param {array} dataFiles An array to store the data files found + * @returns {object} An object containing the data files found + */ + static findDataFiles(asset, dataFiles = []) { + if (!asset) { + return { dataFiles: dataFiles }; + } + if ( + asset.type === Constants.AssetType.FILE && + asset.contentTypes.includes(Constants.AssetContentType.DATA) + ) { + const fileName = AssetUtil.getAssetNameFromUri(asset.uri); + dataFiles.push(fileName); + } + + if (asset.children) { + asset.children.forEach((child) => { + ChecklistUtil.findDataFiles(child, dataFiles); + }); + } + + return { dataFiles: dataFiles }; + } + + /** + * This function gets the entry point file names from the entryPoints assets array + * @param {object} asset The asset to find the entry point files within + * @returns {object} An object containing the entry point file names found + */ + static findEntryPointFiles(asset) { + const entryPoints = AssetUtil.findEntryPointAssets(asset); + const entryPointFiles = []; + entryPoints?.forEach((entryPoint) => { + const fileName = AssetUtil.getAssetNameFromUri(entryPoint.uri); + entryPointFiles.push(fileName); + }); + return { entryPoints: entryPointFiles }; + } + + /** + * This function finds the documentation files in the asset + * @param {object} asset The asset to find the documentation files within + * @param {array} documentationFiles An array to store the documentation files found + * @returns {object} An object containing the documentation files found + */ + static findDocumentationFiles(asset, documentationFiles = []) { + if (!asset) { + return { documentationFiles: documentationFiles }; + } + if ( + asset.type === Constants.AssetType.FILE && + asset.contentTypes.includes(Constants.AssetContentType.DOCUMENTATION) + ) { + const fileName = AssetUtil.getAssetNameFromUri(asset.uri); + documentationFiles.push(fileName); + } + + if (asset.children) { + asset.children.forEach((child) => { + ChecklistUtil.findDocumentationFiles(child, documentationFiles); + }); + } + + return { documentationFiles: documentationFiles }; } } diff --git a/app/utils/workflow.js b/app/utils/workflow.js index aff630b..f24bff0 100644 --- a/app/utils/workflow.js +++ b/app/utils/workflow.js @@ -45,10 +45,8 @@ export default class WorkflowUtil { // approximating it. So we roughly cut the max length in half, but it may not be exact, and // we don't count the 3 periods towards the max length (meaning, our resulting label will // technically go over the max length we detect). - // eslint-disable-next-line prettier/prettier - const beginningPart = name.substring(0, (Constants.MAX_GRAPH_LABEL_LENGTH / 2)); - // eslint-disable-next-line prettier/prettier - const endPart = name.substring(name.length - (Constants.MAX_GRAPH_LABEL_LENGTH / 2) + 1); + const beginningPart = name.substring(0, Constants.MAX_GRAPH_LABEL_LENGTH / 2); + const endPart = name.substring(name.length - Constants.MAX_GRAPH_LABEL_LENGTH / 2 + 1); return `${beginningPart}...${endPart}`; } @@ -310,4 +308,47 @@ export default class WorkflowUtil { } return dependencies.flat(); } + + /** + * Given an asset, get the list of library dependencies + * @param {object} asset The asset to find library dependencies for + * @returns Array of library dependencies + */ + static getLibraryDependencies(asset) { + if (!asset) { + return []; + } + + const libraries = []; + WorkflowUtil._getMetadataDependencies(asset, PythonHandler.id, libraries, [], []); + WorkflowUtil._getMetadataDependencies(asset, RHandler.id, libraries, [], []); + WorkflowUtil._getMetadataDependencies(asset, SASHandler.id, libraries, [], []); + WorkflowUtil._getMetadataDependencies(asset, StataHandler.id, libraries, [], []); + + return libraries; + } + + /** + * This function finds all library dependencies of an asset and its children recursively, that can be used to find overall project dependencies with `project.assets` as input + * @param {object} asset The asset to find the library dependencies of + * @returns {array} An array containing the library dependencies found + */ + static getAllLibraryDependencies(asset) { + const libraries = asset + ? [ + { + asset: asset.uri, + assetType: WorkflowUtil.getAssetType(asset), + dependencies: WorkflowUtil.getLibraryDependencies(asset), + }, + ] + : []; + if (!asset || !asset.children) { + return libraries.flat(); + } + for (let index = 0; index < asset.children.length; index++) { + libraries.push(WorkflowUtil.getAllLibraryDependencies(asset.children[index])); + } + return libraries.flat(); + } } diff --git a/test/utils/checklist.spec.js b/test/utils/checklist.spec.js index c173033..93439f6 100644 --- a/test/utils/checklist.spec.js +++ b/test/utils/checklist.spec.js @@ -1,5 +1,5 @@ -import ChecklistUtil from "../../app/utils/checklist"; -import Constants from "../../app/constants/constants"; +import ChecklistUtil from '../../app/utils/checklist'; +import Constants from '../../app/constants/constants'; describe('utils', () => { describe('ChecklistUtil', () => { @@ -16,14 +16,22 @@ describe('utils', () => { }); it('should return correct languages and dependencies for valid code assets', () => { - const languages = {'R': ['r', 'rmd', 'rnw', 'snw'], 'Python': ['py', 'py3', 'pyi'], 'SAS': ['sas'], 'Stata': ['do', 'ado', 'mata'], 'HTML': ['htm', 'html']}; + const languages = { + R: ['r', 'rmd', 'rnw', 'snw'], + Python: ['py', 'py3', 'pyi'], + SAS: ['sas'], + Stata: ['do', 'ado', 'mata'], + HTML: ['htm', 'html'], + }; Object.keys(languages).forEach((lang) => { languages[lang].forEach((ext) => { - expect(ChecklistUtil.findAssetLanguagesAndDependencies({ - type: Constants.AssetType.FILE, - contentTypes: [Constants.AssetContentType.CODE], - uri: `path/to/file.${ext}`, - })).toEqual({ + expect( + ChecklistUtil.findAssetLanguagesAndDependencies({ + type: Constants.AssetType.FILE, + contentTypes: [Constants.AssetContentType.CODE], + uri: `path/to/file.${ext}`, + }), + ).toEqual({ languages: [lang], dependencies: [], }); @@ -32,102 +40,255 @@ describe('utils', () => { }); it('should return empty result for non-code assets (data, documentation)', () => { - expect(ChecklistUtil.findAssetLanguagesAndDependencies({ - type: Constants.AssetType.FILE, - contentTypes: [Constants.AssetContentType.DATA], - uri: 'path/to/file.csv', - })).toEqual({ + expect( + ChecklistUtil.findAssetLanguagesAndDependencies({ + type: Constants.AssetType.FILE, + contentTypes: [Constants.AssetContentType.DATA], + uri: 'path/to/file.csv', + }), + ).toEqual({ languages: [], dependencies: [], }); }); - it('should return empty result for unmatching content type and asset type', () => { - expect(ChecklistUtil.findAssetLanguagesAndDependencies({ - type: Constants.AssetType.FILE, - contentTypes: [Constants.AssetContentType.DATA], - uri: 'path/to/file.py', - })).toEqual({ + it('should return empty result for unmatching content type and extension', () => { + expect( + ChecklistUtil.findAssetLanguagesAndDependencies({ + type: Constants.AssetType.FILE, + contentTypes: [Constants.AssetContentType.DATA], + uri: 'path/to/file.py', + }), + ).toEqual({ languages: [], dependencies: [], }); - expect(ChecklistUtil.findAssetLanguagesAndDependencies({ - type: Constants.AssetType.FILE, - contentTypes: [Constants.AssetContentType.CODE], - uri: 'path/to/file.csv', - })).toEqual({ + expect( + ChecklistUtil.findAssetLanguagesAndDependencies({ + type: Constants.AssetType.FILE, + contentTypes: [Constants.AssetContentType.CODE], + uri: 'path/to/file.csv', + }), + ).toEqual({ languages: [], dependencies: [], }); }); it('should return empty result for directory/folder type assets', () => { - expect(ChecklistUtil.findAssetLanguagesAndDependencies({ - type: Constants.AssetType.DIRECTORY, - contentTypes: [Constants.AssetContentType.CODE], - uri: 'path/to/directory/', - })).toEqual({ + expect( + ChecklistUtil.findAssetLanguagesAndDependencies({ + type: Constants.AssetType.DIRECTORY, + contentTypes: [Constants.AssetContentType.CODE], + uri: 'path/to/directory/', + }), + ).toEqual({ languages: [], dependencies: [], }); }); it('should not identify malformed URIs', () => { - expect(ChecklistUtil.findAssetLanguagesAndDependencies({ - type: Constants.AssetType.FILE, - contentTypes: [Constants.AssetContentType.CODE], - uri: 'path/to/malformed-uri.', - })).toEqual({ + expect( + ChecklistUtil.findAssetLanguagesAndDependencies({ + type: Constants.AssetType.FILE, + contentTypes: [Constants.AssetContentType.CODE], + uri: 'path/to/malformed-uri.', + }), + ).toEqual({ languages: [], dependencies: [], }); }); it('should ignore random/unknown extensions that are not in the content types', () => { - expect(ChecklistUtil.findAssetLanguagesAndDependencies({ - type: Constants.AssetType.FILE, - contentTypes: [Constants.AssetContentType.CODE], - uri: 'path/to/file.cmd', - })).toEqual({ + expect( + ChecklistUtil.findAssetLanguagesAndDependencies({ + type: Constants.AssetType.FILE, + contentTypes: [Constants.AssetContentType.CODE], + uri: 'path/to/file.cmd', + }), + ).toEqual({ languages: [], dependencies: [], }); }); it('should handle nested assets and recurse properly', () => { - expect(ChecklistUtil.findAssetLanguagesAndDependencies({ - type: Constants.AssetType.FOLDER, - contentTypes: [Constants.AssetContentType.CODE], - uri: 'path/to/folder', - children: [ - { - type: Constants.AssetType.FILE, - contentTypes: [Constants.AssetContentType.CODE], - uri: 'path/to/folder/file1.py', - }, - { - type: Constants.AssetType.FILE, - contentTypes: [Constants.AssetContentType.CODE], - uri: 'path/to/folder/file2.r', - } - ], - })).toEqual({ + expect( + ChecklistUtil.findAssetLanguagesAndDependencies({ + type: Constants.AssetType.FOLDER, + contentTypes: [Constants.AssetContentType.CODE], + uri: 'path/to/folder', + children: [ + { + type: Constants.AssetType.FILE, + contentTypes: [Constants.AssetContentType.CODE], + uri: 'path/to/folder/file1.py', + }, + { + type: Constants.AssetType.FILE, + contentTypes: [Constants.AssetContentType.CODE], + uri: 'path/to/folder/file2.r', + }, + ], + }), + ).toEqual({ languages: ['Python', 'R'], // don't change the languages ordering in this array dependencies: [], }); }); it('should not crash when asset has no extension in its URI', () => { - expect(ChecklistUtil.findAssetLanguagesAndDependencies({ - type: Constants.AssetType.FILE, - contentTypes: [Constants.AssetContentType.CODE], - uri: 'path/to/file', - })).toEqual({ + expect( + ChecklistUtil.findAssetLanguagesAndDependencies({ + type: Constants.AssetType.FILE, + contentTypes: [Constants.AssetContentType.CODE], + uri: 'path/to/file', + }), + ).toEqual({ languages: [], dependencies: [], }); }); }); + + describe('findDataFiles', () => { + it('should return empty result when asset is null or undefined', () => { + expect(ChecklistUtil.findDataFiles(null)).toEqual({ dataFiles: [] }); + expect(ChecklistUtil.findDataFiles(undefined)).toEqual({ dataFiles: [] }); + }); + + it('should return empty result when asset is not a data file', () => { + expect( + ChecklistUtil.findDataFiles({ + type: Constants.AssetType.FILE, + contentTypes: [Constants.AssetContentType.CODE], + uri: 'path/to/file.py', + }), + ).toEqual({ dataFiles: [] }); + }); + + it('should return data file name when asset is a data file', () => { + expect( + ChecklistUtil.findDataFiles({ + type: Constants.AssetType.FILE, + contentTypes: [Constants.AssetContentType.DATA], + uri: 'path/to/file.csv', + }), + ).toEqual({ dataFiles: ['file.csv'] }); + }); + + it('should return data file names for nested data files', () => { + expect( + ChecklistUtil.findDataFiles({ + type: Constants.AssetType.FOLDER, + contentTypes: [Constants.AssetContentType.DATA], + uri: 'path/to/folder', + children: [ + { + type: Constants.AssetType.FILE, + contentTypes: [Constants.AssetContentType.DATA], + uri: 'path/to/folder/file1.csv', + }, + { + type: Constants.AssetType.FILE, + contentTypes: [Constants.AssetContentType.DATA], + uri: 'path/to/folder/file2.csv', + }, + ], + }), + ).toEqual({ dataFiles: ['file1.csv', 'file2.csv'] }); + }); + }); + + describe('findEntryPointFiles', () => { + it('should return empty result when entryPoints is null or undefined', () => { + expect(ChecklistUtil.findEntryPointFiles(null)).toEqual({ entryPoints: [] }); + expect(ChecklistUtil.findEntryPointFiles(undefined)).toEqual({ entryPoints: [] }); + }); + + it('should return entry point file names for given entrypoint assets', () => { + expect( + ChecklistUtil.findEntryPointFiles({ + type: Constants.AssetType.FOLDER, + uri: 'path/to/folder', + children: [ + { + type: Constants.AssetType.FILE, + contentTypes: [Constants.AssetContentType.CODE], + uri: 'path/to/folder/file1.py', + attributes: { + entrypoint: true, + }, + }, + { + type: Constants.AssetType.FILE, + contentTypes: [Constants.AssetContentType.CODE], + uri: 'path/to/folder/file2.py', + attributes: { + entrypoint: false, + }, + }, + ], + }), + ).toEqual({ entryPoints: ['file1.py'] }); + }); + }); + + describe('findDocumentationFiles', () => { + it('should return empty result when asset is null or undefined', () => { + expect(ChecklistUtil.findDocumentationFiles(null)).toEqual({ documentationFiles: [] }); + expect(ChecklistUtil.findDocumentationFiles(undefined)).toEqual({ documentationFiles: [] }); + }); + + it('should return empty result when asset is not a documentation file', () => { + expect( + ChecklistUtil.findDocumentationFiles({ + type: Constants.AssetType.FILE, + contentTypes: [Constants.AssetContentType.CODE], + uri: 'path/to/file.py', + }), + ).toEqual({ documentationFiles: [] }); + }); + + it('should return documentation file name when asset is a documentation file', () => { + expect( + ChecklistUtil.findDocumentationFiles({ + type: Constants.AssetType.FILE, + contentTypes: [Constants.AssetContentType.DOCUMENTATION], + uri: 'path/to/file.md', + }), + ).toEqual({ documentationFiles: ['file.md'] }); + }); + + it('should return documentation file names for nested documentation files', () => { + expect( + ChecklistUtil.findDocumentationFiles({ + type: Constants.AssetType.FOLDER, + contentTypes: [Constants.AssetContentType.DOCUMENTATION], + uri: 'path/to/folder', + children: [ + { + type: Constants.AssetType.FILE, + contentTypes: [Constants.AssetContentType.DOCUMENTATION], + uri: 'path/to/folder/file1.md', + }, + { + type: Constants.AssetType.FILE, + contentTypes: [Constants.AssetContentType.DOCUMENTATION], + uri: 'path/to/folder/file2.md', + }, + { + type: Constants.AssetType.FILE, + contentTypes: [Constants.AssetContentType.CODE], + uri: 'path/to/folder/file3.py', + }, + ], + }), + ).toEqual({ documentationFiles: ['file1.md', 'file2.md'] }); + }); + }); }); });