diff --git a/.dockerignore b/.dockerignore
index bf881194fb..52d1cb5546 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -1 +1,3 @@
-ui/node_modules/
\ No newline at end of file
+ui/node_modules/
+.idea/
+.vscode/
\ No newline at end of file
diff --git a/.envrc.template b/.envrc.template
new file mode 100644
index 0000000000..1e8d0585a8
--- /dev/null
+++ b/.envrc.template
@@ -0,0 +1,26 @@
+# Database settings for Django
+export POSTGRES_SERVICE_HOSTNAME=localhost
+export POSTGRES_SERVICE_PORT=5433
+export POSTGRES_USERNAME=postgres
+export POSTGRES_PASSWORD=a-very-secure-password
+export POSTGRES_DATABASE=seqrdb
+
+# Leave blank for local env
+export DEPLOYMENT_TYPE=
+
+# Convenience commands for creating database
+export CREATE_REF_DB_CMD="psql -W -U ${POSTGRES_USERNAME} -h ${POSTGRES_SERVICE_HOSTNAME} -p ${POSTGRES_SERVICE_PORT} -c 'create database reference_data_db;'"
+export LIST_DBS_CMD="psql -W -U ${POSTGRES_USERNAME} -h ${POSTGRES_SERVICE_HOSTNAME} -p ${POSTGRES_SERVICE_PORT} -c '\list'"
+
+# ----------------------------------------------------------------------------
+# Apple Developers
+# ----------------------------------------------------------------------------
+# Install libpg for the Postgres client library without the server (brew install libpg)
+# Export these variables so that pip packages know where to find the appropriate binaries, libs and headers.
+# export PATH="/opt/homebrew/opt/libpq/bin:$PATH"
+# export LDFLAGS="-L/opt/homebrew/opt/libpq/lib $LDFLAGS"
+# export CPPFLAGS="-I/opt/homebrew/opt/libpq/include $CPPFLAGS"
+
+# Apple silicon (M1 chip etc) fix for grpcio
+# export GRPC_PYTHON_BUILD_SYSTEM_OPENSSL=1
+# export GRPC_PYTHON_BUILD_SYSTEM_ZLIB=1
diff --git a/.gitignore b/.gitignore
index ffe9555219..e7dd445a2c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,5 @@
.idea*
+.vscode*
*.iml
*.sqlite3
*.log
@@ -22,3 +23,7 @@ seqr_settings
django_key
static/
.coverage
+
+.envrc
+
+data/
\ No newline at end of file
diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml
new file mode 100644
index 0000000000..8bd604a5ed
--- /dev/null
+++ b/docker-compose-dev.yml
@@ -0,0 +1,19 @@
+version: '2.4'
+
+services:
+ postgres:
+ image: postgres:12.6
+ command: postgres -c listen_addresses='*'
+ environment:
+ - PGPORT=5433
+ - POSTGRES_DB=seqrdb
+ - POSTGRES_PASSWORD=a-very-secure-password
+ volumes:
+ - ./data/postgres:/var/lib/postgresql/data
+ ports:
+ - "5433:5433"
+ healthcheck:
+ test: pg_isready -h postgres -U postgres
+ interval: 5s
+ timeout: 10s
+ retries: 100
diff --git a/environment-dev.yaml b/environment-dev.yaml
new file mode 100644
index 0000000000..88875070ad
--- /dev/null
+++ b/environment-dev.yaml
@@ -0,0 +1,13 @@
+name: seqr
+
+channels:
+ - cpg
+ - bioconda
+ - conda-forge
+dependencies:
+ - python=3.8
+ - pip
+ - nodejs==16.10.0
+ - pip:
+ - '-r requirements-dev.txt'
+ - '-r requirements.txt'
diff --git a/ui/config/webpack.config.dev.js b/ui/config/webpack.config.dev.js
index 96f7d9c7f2..04f7d744aa 100644
--- a/ui/config/webpack.config.dev.js
+++ b/ui/config/webpack.config.dev.js
@@ -10,6 +10,10 @@ const eslintFormatter = require('react-dev-utils/eslintFormatter');
const glob = require('glob');
const paths = require('./paths');
+// HACK: OpenSSL 3 does not support md4 any more, but webpack hardcodes it all over the place: https://github.com/webpack/webpack/issues/13572
+const crypto = require('crypto');
+const cryptoOriginalCreateHash = crypto.createHash;
+crypto.createHash = algorithm => cryptoOriginalCreateHash(algorithm === 'md4' ? 'sha256' : algorithm);
// This is the development configuration.
diff --git a/ui/config/webpack.config.prod.js b/ui/config/webpack.config.prod.js
index bd72fc805a..44c6c6f455 100644
--- a/ui/config/webpack.config.prod.js
+++ b/ui/config/webpack.config.prod.js
@@ -16,6 +16,11 @@ const eslintFormatter = require('react-dev-utils/eslintFormatter');
const paths = require('./paths');
const getClientEnvironment = require('./env');
+// HACK: OpenSSL 3 does not support md4 any more, but webpack hardcodes it all over the place: https://github.com/webpack/webpack/issues/13572
+const crypto = require('crypto');
+const cryptoOriginalCreateHash = crypto.createHash;
+crypto.createHash = algorithm => cryptoOriginalCreateHash(algorithm === 'md4' ? 'sha256' : algorithm);
+
// Webpack uses `publicPath` to determine where the app is being served from.
// It requires a trailing slash, or the file assets will get an incorrect path.
const publicPath = paths.servedPath;
diff --git a/ui/package-lock.json b/ui/package-lock.json
index 36bd9317ca..31785d6935 100644
--- a/ui/package-lock.json
+++ b/ui/package-lock.json
@@ -24,6 +24,7 @@
"lodash": "^4.17.21",
"markdown-draft-js": "^2.4.0",
"object-hash": "^1.3.0",
+ "papaparse": "^5.3.1",
"pedigreejs": "github:CCGE-BOADICEA/pedigreejs#2e17296",
"prop-types": "^15.7.2",
"query-string": "^6.1.0",
@@ -15205,6 +15206,11 @@
"node": ">=4"
}
},
+ "node_modules/papaparse": {
+ "version": "5.3.1",
+ "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.3.1.tgz",
+ "integrity": "sha512-Dbt2yjLJrCwH2sRqKFFJaN5XgIASO9YOFeFP8rIBRG2Ain8mqk5r1M6DkfvqEVozVcz3r3HaUGw253hA1nLIcA=="
+ },
"node_modules/param-case": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz",
@@ -31938,6 +31944,11 @@
"version": "1.0.0",
"dev": true
},
+ "papaparse": {
+ "version": "5.3.1",
+ "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.3.1.tgz",
+ "integrity": "sha512-Dbt2yjLJrCwH2sRqKFFJaN5XgIASO9YOFeFP8rIBRG2Ain8mqk5r1M6DkfvqEVozVcz3r3HaUGw253hA1nLIcA=="
+ },
"param-case": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz",
diff --git a/ui/package.json b/ui/package.json
index 14b4380393..a53b471bdd 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -83,6 +83,7 @@
"lodash": "^4.17.21",
"markdown-draft-js": "^2.4.0",
"object-hash": "^1.3.0",
+ "papaparse": "^5.3.1",
"pedigreejs": "github:CCGE-BOADICEA/pedigreejs#2e17296",
"prop-types": "^15.7.2",
"query-string": "^6.1.0",
diff --git a/ui/pages/Project/Project.jsx b/ui/pages/Project/Project.jsx
index f9e29ebac2..843037f6e2 100644
--- a/ui/pages/Project/Project.jsx
+++ b/ui/pages/Project/Project.jsx
@@ -12,6 +12,7 @@ import CaseReview from './components/CaseReview'
import FamilyPage from './components/FamilyPage'
import Matchmaker from './components/Matchmaker'
import SavedVariants from './components/SavedVariants'
+import DataLoadingWizard from './components/DataLoadingWizard/DataLoadingWizard'
class Project extends React.PureComponent {
@@ -45,6 +46,7 @@ class Project extends React.PureComponent {
+
)
diff --git a/ui/pages/Project/components/DataLoadingWizard/DataLoadingWizard.jsx b/ui/pages/Project/components/DataLoadingWizard/DataLoadingWizard.jsx
new file mode 100644
index 0000000000..1f4bc46990
--- /dev/null
+++ b/ui/pages/Project/components/DataLoadingWizard/DataLoadingWizard.jsx
@@ -0,0 +1,196 @@
+/* eslint-disable react-perf/jsx-no-new-function-as-prop */
+/* eslint-disable no-console */
+/* eslint-disable no-alert */
+
+import React, { useState, useEffect, useCallback } from 'react'
+import PropTypes from 'prop-types'
+import { connect } from 'react-redux'
+import { Breadcrumb } from 'semantic-ui-react'
+
+import { Error404 } from '../../../../shared/components/page/Errors'
+import { getCurrentProject } from '../../selectors'
+
+import { Centered, FormSection, FormStepButtons } from './ui'
+import { Welcome, TemplateUpload } from './steps'
+import PedigreeTemplateColumns from './templates/Pedigree'
+import IndividualTemplateColumns from './templates/Individual'
+import FamilyTemplateColumns from './templates/Family'
+import TemplateFile from './templates/File/TemplateFile'
+import TemplateHelp from './TemplateHelp'
+
+const BREADCRUMBS = [
+ { key: 0, content: 'Welcome', link: true, active: true },
+ { key: 1, content: 'Pedigree', link: true, active: false },
+ { key: 2, content: 'Individual metadata', link: true, active: false },
+ { key: 3, content: 'Family metadata', link: true, active: false },
+ { key: 4, content: 'Sample mapping', link: true, active: false },
+ { key: 5, content: 'Review', link: true, active: false },
+]
+
+const BaseMultistepForm = ({ project }) => {
+ // ---- Debug ---- //
+ console.group('Project Object')
+ console.log(project)
+ console.groupEnd()
+
+ // ---- State ----- //
+ const [breadCrumbs, setBreadcrumbs] = useState(BREADCRUMBS.map(b => ({ ...b })))
+ const [activeFormStepIndex, setActiveFormStepIndex] = useState(0)
+ const [formSteps, setFormSteps] = useState(BREADCRUMBS.map(
+ (_, index) => ({ formData: {}, isComplete: (index === 0) }),
+ ))
+
+ // ---- Callbacks ----- //
+ const updateFormStep = useCallback((stepNumber, { formData, isComplete }) => {
+ setFormSteps(formSteps.map(
+ (step, index) => {
+ if (stepNumber === index) {
+ return { formData, isComplete }
+ }
+ return { ...step }
+ },
+ ))
+ }, [formSteps])
+
+ const enableReview = useCallback(() => formSteps
+ .slice(0, formSteps.length - 1)
+ .reduce((acc, step) => acc && step.isComplete, true), [formSteps])
+
+ const enableNext = useCallback(() => {
+ if (activeFormStepIndex === (formSteps.length - 2)) {
+ return enableReview()
+ }
+
+ return activeFormStepIndex < formSteps.length
+ }, [formSteps, activeFormStepIndex, enableReview])
+
+ const getFormStepComponent = useCallback(() => {
+ const onFormChange = ({ formData, isComplete }) => updateFormStep(activeFormStepIndex, { formData, isComplete })
+
+ switch (activeFormStepIndex) {
+ case 0:
+ return
+ case 1:
+ return (
+ }
+ project={project}
+ />
+ )
+ case 2:
+ return (
+ }
+ project={project}
+ />
+ )
+ case 3:
+ return (
+ }
+ project={project}
+ />
+ )
+ case 4:
+ return
{ breadCrumbs[activeFormStepIndex].content }
+ case 5:
+ return { breadCrumbs[activeFormStepIndex].content }
+ default:
+ return
+ }
+ }, [project, activeFormStepIndex, updateFormStep])
+
+ // ---- Effects ---- //
+ useEffect(() => {
+ setBreadcrumbs(breadCrumbs.map((b, index) => ({ ...b, active: (index === activeFormStepIndex) })))
+ }, [activeFormStepIndex])
+
+ useEffect(() => {
+ setBreadcrumbs(
+ breadCrumbs.map((b, index) => {
+ if (index === (breadCrumbs.length - 1)) {
+ return {
+ ...b,
+ link: enableReview(),
+ onClick: enableReview() ? () => setActiveFormStepIndex(index) : null,
+ }
+ }
+
+ return {
+ ...b,
+ link: true,
+ onClick: () => setActiveFormStepIndex(index),
+ }
+ }),
+ )
+ }, [enableReview])
+
+ // ---- Render ----- //
+ return (
+
+
+
+
+
+
+
+
+ {getFormStepComponent()}
+
+
+
+ setActiveFormStepIndex(Math.min(activeFormStepIndex + 1, formSteps.length - 1))}
+ onBack={() => setActiveFormStepIndex(Math.max(0, activeFormStepIndex - 1))}
+ enableNext={enableNext()}
+ enableSubmit={enableReview()}
+ onSubmit={() => alert('Yay')}
+ />
+
+
+
+
+
+
+
+
+ )
+}
+
+BaseMultistepForm.propTypes = {
+ project: PropTypes.object.isRequired,
+}
+
+const mapStateToProps = state => ({
+ project: getCurrentProject(state),
+})
+
+export const MultistepForm = connect(mapStateToProps)(BaseMultistepForm)
+
+const DataLoadingWizard = () =>
+
+export default DataLoadingWizard
diff --git a/ui/pages/Project/components/DataLoadingWizard/TemplateDirectoryLink.jsx b/ui/pages/Project/components/DataLoadingWizard/TemplateDirectoryLink.jsx
new file mode 100644
index 0000000000..533b102b83
--- /dev/null
+++ b/ui/pages/Project/components/DataLoadingWizard/TemplateDirectoryLink.jsx
@@ -0,0 +1,13 @@
+import React from 'react'
+
+const TemplateDirectoryLink = () => (
+
+ here
+
+)
+
+export default TemplateDirectoryLink
diff --git a/ui/pages/Project/components/DataLoadingWizard/TemplateHelp.jsx b/ui/pages/Project/components/DataLoadingWizard/TemplateHelp.jsx
new file mode 100644
index 0000000000..4233af88f5
--- /dev/null
+++ b/ui/pages/Project/components/DataLoadingWizard/TemplateHelp.jsx
@@ -0,0 +1,17 @@
+import React from 'react'
+
+import { ReadableText } from './ui'
+import TemplateDirectoryLink from './TemplateDirectoryLink'
+
+const TemplateHelp = () => (
+
+ In this section, you will provide all information relating to the family pedigree and associated metadata in your
+ project. Please download the families template from
+ {' '}
+
+ { '. ' }
+ Once you have filled in this template, upload it via this step and correct any validation errors before proceeding.
+
+)
+
+export default TemplateHelp
diff --git a/ui/pages/Project/components/DataLoadingWizard/steps/IndividualMetadataUpload.jsx b/ui/pages/Project/components/DataLoadingWizard/steps/IndividualMetadataUpload.jsx
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/ui/pages/Project/components/DataLoadingWizard/steps/Review.jsx b/ui/pages/Project/components/DataLoadingWizard/steps/Review.jsx
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/ui/pages/Project/components/DataLoadingWizard/steps/SampleMapping.jsx b/ui/pages/Project/components/DataLoadingWizard/steps/SampleMapping.jsx
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/ui/pages/Project/components/DataLoadingWizard/steps/TemplateUpload.jsx b/ui/pages/Project/components/DataLoadingWizard/steps/TemplateUpload.jsx
new file mode 100644
index 0000000000..258165c97c
--- /dev/null
+++ b/ui/pages/Project/components/DataLoadingWizard/steps/TemplateUpload.jsx
@@ -0,0 +1,210 @@
+import React, { useCallback, useState } from 'react'
+import PropTypes from 'prop-types'
+import { capitalize, isBoolean, isEqual } from 'lodash'
+
+import { Form, Table, Icon, List, Message } from 'semantic-ui-react'
+
+import TemplateFile from '../templates/File/TemplateFile'
+import { Scrollable, WithSpace } from '../ui'
+
+const renderCellContent = (value) => {
+ if (Array.isArray(value)) {
+ return value.length ? {value.map(v => {v} )}
: '-'
+ }
+
+ if (isBoolean(value)) {
+ return capitalize(value.toString())
+ }
+
+ return value == null ? '-' : value.toString()
+}
+
+const ERROR_LIST_STYLE = { width: 250 }
+
+/**
+ * @param {TemplateRow} row
+ * @returns {JSX.Element}
+ */
+const renderRowErrorList = (row) => {
+ if (row.valid) return null
+
+ return (
+
+ {
+ row.columns.map(({ data, definition }) => {
+ if (data.valid) return null
+
+ const errorList = data.errors.map(e => {e} )
+ return (
+
+ {definition.key}
+ {errorList}
+
+ )
+ })
+ }
+
+ )
+}
+
+const TemplateUpload = ({ id, label, information, template, project, onFormChange }) => {
+ const [isLoading, setIsLoading] = useState(false)
+ const [errors, setErrors] = useState(/** @type {string[]|JSXElement[]} */ [])
+ const [valid, setValid] = useState(true)
+ const [rows, setRows] = useState(/** @type {TemplateRow[]} */ [])
+
+ const parseFile = useCallback((event) => {
+ if (event.target.files[0]) {
+ if (!event.target.files[0].name.match(/\.(tsv|csv)$/)) {
+ setErrors(['Please upload a TSV or CSV file.'])
+ setValid(false)
+ setRows([])
+ return
+ }
+
+ setIsLoading(true)
+ template.parse(
+ {
+ file: event.target.files[0],
+ onComplete: ((result) => {
+ console.debug(result)
+
+ setIsLoading(false)
+
+ // Copy array before sorting since sort function has side effects and modifies the original array
+ const columnMismatch = !isEqual([...result.header].sort(), template.columns.map(c => c.key).sort())
+
+ if (columnMismatch) {
+ setErrors([
+ (
+
+
Template file should have the following header columns:
+
+ {template.columns.map(c => {c.key} )}
+
+
However, your file contains the header columns:
+
+ {result.header.map(f => {f} )}
+
+
+ ),
+ ])
+ setValid(false)
+ setRows([])
+
+ return
+ }
+
+ setErrors(result.errors)
+ setValid(result.valid)
+ setRows(result.rows)
+ }),
+ onError: ((e) => {
+ setErrors([e.message])
+ setIsLoading(false)
+ setValid(false)
+ setRows([])
+ }),
+ },
+ )
+ }
+ }, [template, setIsLoading, setErrors, setRows, setValid])
+
+ const renderTableBody = useCallback(() => {
+ if (!valid && !rows.length) {
+ return (
+
+ Data could not be loaded. See errors for help.
+
+ )
+ }
+
+ // Render rows if we have them even if the parsing result is not valid, so we can display the errors
+ // to the user.
+ if (rows.length) {
+ return rows.map(row => (
+
+
+ {row.valid ? : }
+
+
+ {renderRowErrorList(row)}
+
+ {
+ row.columns.map(({ data, definition }) => (
+
+ {renderCellContent(data.value)}
+
+ ))
+ }
+
+ ))
+ }
+
+ return (
+
+ No data to display
+
+ )
+ }, [rows, valid, errors])
+
+ return (
+
+ {information}
+
+
+
+
+ {
+ !valid ? (
+
{e} )}
+ />
+ ) : null
+ }
+
+
+
+
+
+
+ Status
+
+ {
+ template
+ .columns
+ .map(c => (
+
+ {`${c.key}${c.required ? ' *' : ''}`}
+
+ ))
+ }
+
+
+
+ {renderTableBody()}
+
+
+
+
+
+ )
+}
+
+TemplateUpload.propTypes = {
+ id: PropTypes.string.isRequired,
+ label: PropTypes.string.isRequired,
+ information: PropTypes.element,
+ template: PropTypes.instanceOf(TemplateFile).isRequired,
+ project: PropTypes.object.isRequired,
+ onFormChange: PropTypes.func.isRequired,
+}
+
+TemplateUpload.defaultProps = {
+ information: null,
+}
+
+export default TemplateUpload
diff --git a/ui/pages/Project/components/DataLoadingWizard/steps/Welcome.jsx b/ui/pages/Project/components/DataLoadingWizard/steps/Welcome.jsx
new file mode 100644
index 0000000000..fd916c7a4f
--- /dev/null
+++ b/ui/pages/Project/components/DataLoadingWizard/steps/Welcome.jsx
@@ -0,0 +1,36 @@
+import React from 'react'
+import styled from 'styled-components'
+import { ReadableText } from '../ui'
+
+const WelcomeSection = styled.section`
+ margin: 1em 0;
+`
+
+const Welcome = () => (
+
+
+
+ Welcome to the seqr data loading wizard! This wizard will guide you through the process of uploading
+ the relevant individual template, family template and associated metadata files. Each step will validate
+ these files to ensure that the information relating individuals, families and samples is correctly formatted.
+
+
+
+
+ Introduction
+ Overview of what the collaborator needs to do goes here
+
+
+
+ Resources
+ Overview of required template and mapping formats
+
+
+
+ FAQ
+ Frequently asked questions and answers
+
+
+)
+
+export default Welcome
diff --git a/ui/pages/Project/components/DataLoadingWizard/steps/index.js b/ui/pages/Project/components/DataLoadingWizard/steps/index.js
new file mode 100644
index 0000000000..c2fb5f7051
--- /dev/null
+++ b/ui/pages/Project/components/DataLoadingWizard/steps/index.js
@@ -0,0 +1,7 @@
+import Welcome from './Welcome'
+import TemplateUpload from './TemplateUpload'
+
+export {
+ Welcome,
+ TemplateUpload,
+}
diff --git a/ui/pages/Project/components/DataLoadingWizard/templates/Family.js b/ui/pages/Project/components/DataLoadingWizard/templates/Family.js
new file mode 100644
index 0000000000..e76be572a7
--- /dev/null
+++ b/ui/pages/Project/components/DataLoadingWizard/templates/Family.js
@@ -0,0 +1,41 @@
+import TemplateColumn from './File/TemplateColumn'
+import { parseString, parseStringArray } from './utils/parsers'
+
+const FamilyTemplateColumns = () => ([
+ new TemplateColumn({
+ id: 'familyId',
+ key: 'Family ID',
+ index: 0,
+ required: true,
+ parser: parseString,
+ validators: [
+ v => ((!v) ? 'A family ID must be present.' : null),
+ ],
+ }),
+ new TemplateColumn({
+ id: 'displayName',
+ key: 'Display Name',
+ index: 1,
+ required: false,
+ parser: parseString,
+ validators: [],
+ }),
+ new TemplateColumn({
+ id: 'description',
+ key: 'Description',
+ index: 2,
+ required: false,
+ parser: parseString,
+ validators: [],
+ }),
+ new TemplateColumn({
+ id: 'codedPhenotype',
+ key: 'Coded Phenotype',
+ index: 3,
+ required: false,
+ parser: parseStringArray,
+ validators: [],
+ }),
+])
+
+export default FamilyTemplateColumns
diff --git a/ui/pages/Project/components/DataLoadingWizard/templates/File/TemplateColumn.js b/ui/pages/Project/components/DataLoadingWizard/templates/File/TemplateColumn.js
new file mode 100644
index 0000000000..b10701d723
--- /dev/null
+++ b/ui/pages/Project/components/DataLoadingWizard/templates/File/TemplateColumn.js
@@ -0,0 +1,41 @@
+class TemplateColumn {
+
+ constructor({ id, key, index, required = false, parser = x => x, validators = [] }) {
+ this.id = id
+ this.key = key
+ this.index = index
+ this.required = required || false
+ this.parser = parser || (x => x)
+ this.validators = validators || []
+ }
+
+ /**
+ * @param {*[]|{}} row
+ * @returns {{value: *, valid: boolean, errors: string[]}}
+ */
+ parse(row) {
+ let result = null
+ if (Array.isArray(row)) {
+ result = this.parser(row[this.index])
+ } else if (row.constructor.name === 'Object') {
+ result = this.parser(row[this.key])
+ } else {
+ throw new Error(
+ `TemplateColumn 'parse' function expects an Array or an Object, but received ${row.constructor.name}`,
+ )
+ }
+
+ // Some validators may return a list of errors if they validate a field which is parsed into an array, for example
+ // a comma-delimited list of string values. Use flatMap to flatten all validation errors into a single array.
+ const validationErrors = this.validators.flatMap(validator => validator(result)).filter(e => e != null)
+
+ return {
+ value: result,
+ errors: validationErrors,
+ valid: validationErrors.length === 0,
+ }
+ }
+
+}
+
+export default TemplateColumn
diff --git a/ui/pages/Project/components/DataLoadingWizard/templates/File/TemplateFile.js b/ui/pages/Project/components/DataLoadingWizard/templates/File/TemplateFile.js
new file mode 100644
index 0000000000..6fb18e52bf
--- /dev/null
+++ b/ui/pages/Project/components/DataLoadingWizard/templates/File/TemplateFile.js
@@ -0,0 +1,103 @@
+import * as Papa from 'papaparse'
+import { isEqual } from 'lodash'
+import TemplateRow from './TemplateRow'
+
+/**
+ * Papa-parse error array
+ * @typedef {{type: string, code: string, message: string, row: number}[]} ParseErrorList
+ */
+
+/**
+ * Papa-parse metadata
+ * @typedef {
+ * {delimiter: string, linebreak: string, aborted: boolean, fields: ?string[], truncated: boolean}
+* } ParseMetadata
+ */
+
+/**
+ * TemplateFile `parse` onComplete callback signature
+ * @callback onComplete
+ * @param {{ rows: TemplateRow[], valid: boolean, errors: string[], header: string[] }} result
+ */
+
+/**
+ * TemplateFile `onError` onError callback signature
+ * @callback onError
+ * @param {Error} error
+ */
+
+class TemplateFile {
+
+ /**
+ * @param {TemplateColumn[]} columns
+ */
+ constructor(columns) {
+ if (!Array.isArray(columns)) {
+ throw new Error("Parameter 'columns' must be an array of TemplateColumn objects")
+ }
+
+ /**
+ * @type {TemplateColumn[]}
+ */
+ this.columns = Array.from(columns)
+ }
+
+ /**
+ * @param {File} file
+ * @param {onComplete} onComplete
+ * @param {onError} onError
+ *
+ * @returns {TemplateFile}
+ */
+ parse({ file, onComplete, onError }) {
+ Papa.parse(
+ file,
+ {
+ header: true,
+ skipEmptyLines: true,
+ /**
+ * @param {{data: *[], errors: ParseErrorList, meta: ParseMetadata}} results
+ */
+ complete: (results) => {
+ const errors = results.errors.map(e => `(Row ${e.row}) ${e.message}`)
+
+ if (!isEqual(this.columns.map(c => c.key).sort(), [...(results.meta.fields || [])].sort())) {
+ onComplete({
+ rows: [],
+ valid: false,
+ errors: ['File contains invalid columns in header'],
+ header: results.meta.fields || [],
+ })
+ return
+ }
+
+ if (errors.length > 0) {
+ onComplete({ rows: [], valid: false, errors, header: results.meta.fields || [] })
+ } else {
+ const rows = results.data.map((row, index) => {
+ const columns = this.columns.map(c => ({ data: c.parse(row), definition: c }))
+ return new TemplateRow({ index, columns })
+ })
+
+ const valid = rows.reduce((isValid, row) => isValid && row.valid, true)
+ const validationErrors = valid ? [] : ['Some rows contain invalid information']
+
+ onComplete({
+ rows,
+ valid,
+ errors: validationErrors,
+ header: results.meta.fields || [],
+ })
+ }
+ },
+ /**
+ * @param {Error} error
+ */
+ error: error => onError(error),
+ },
+ )
+ }
+
+}
+
+export default TemplateFile
diff --git a/ui/pages/Project/components/DataLoadingWizard/templates/File/TemplateRow.js b/ui/pages/Project/components/DataLoadingWizard/templates/File/TemplateRow.js
new file mode 100644
index 0000000000..449834ddcf
--- /dev/null
+++ b/ui/pages/Project/components/DataLoadingWizard/templates/File/TemplateRow.js
@@ -0,0 +1,34 @@
+class TemplateRow {
+
+ /** @type {number} #index */
+ #index
+
+ /** @type {{data: {value: *, valid: boolean, errors: string[]}, definition: TemplateColumn }[]} #columns */
+ #columns
+
+ /** @type {boolean} #valid */
+ #valid
+
+ /**
+ * @param {number} index Row number
+ * @param {{data: {value: *, valid: boolean, errors: string[]}, definition: TemplateColumn }[]} columns
+ */
+ constructor({ index, columns }) {
+ if (!Array.isArray(columns)) {
+ throw new Error("Parameter 'columns' must be an array")
+ }
+
+ this.#index = index
+ this.#columns = Array.from(columns)
+ this.#valid = this.columns.map(column => column.data.valid).reduce((a, b) => a && b, true)
+ }
+
+ get index() { return this.#index }
+
+ get columns() { return this.#columns }
+
+ get valid() { return this.#valid }
+
+}
+
+export default TemplateRow
diff --git a/ui/pages/Project/components/DataLoadingWizard/templates/Individual.js b/ui/pages/Project/components/DataLoadingWizard/templates/Individual.js
new file mode 100644
index 0000000000..dd7a6b18da
--- /dev/null
+++ b/ui/pages/Project/components/DataLoadingWizard/templates/Individual.js
@@ -0,0 +1,196 @@
+import TemplateColumn from './File/TemplateColumn'
+import { parseString, parseStringArray, parseBoolean, parseYear } from './utils/parsers'
+import { validateHpoTerms, validateModeOfInheritanceTerms, validateOmimTerms, validateOnsetCategory } from './utils/validators'
+
+const IndividualTemplateColumns = () => ([
+ new TemplateColumn({
+ id: 'familyId',
+ key: 'Family ID',
+ index: 0,
+ required: true,
+ parser: parseString,
+ validators: [
+ v => ((!v) ? 'A family ID must be present.' : null),
+ ],
+ }),
+ new TemplateColumn({
+ id: 'individualId',
+ key: 'Individual ID',
+ index: 1,
+ required: true,
+ parser: parseString,
+ validators: [
+ v => ((!v) ? 'An individual ID must be present.' : null),
+ ],
+ }),
+ new TemplateColumn({
+ id: 'hpoTermsPresent',
+ key: 'HPO Terms (present)',
+ index: 2,
+ required: false,
+ parser: parseStringArray,
+ validators: [validateHpoTerms],
+ }),
+ new TemplateColumn({
+ id: 'hpoTermsAbsent',
+ key: 'HPO Terms (absent)',
+ index: 3,
+ required: false,
+ parser: parseStringArray,
+ validators: [validateHpoTerms],
+ }),
+ new TemplateColumn({
+ id: 'birthYear',
+ key: 'Birth Year',
+ index: 4,
+ required: false,
+ parser: parseYear,
+ validators: [],
+ }),
+ new TemplateColumn({
+ id: 'deathYear',
+ key: 'Death Year',
+ index: 5,
+ required: false,
+ parser: parseYear,
+ validators: [],
+ }),
+ new TemplateColumn({
+ id: 'ageOfOnset',
+ key: 'Age of Onset',
+ index: 6,
+ required: false,
+ parser: parseString,
+ validators: [validateOnsetCategory],
+ }),
+ new TemplateColumn({
+ id: 'individualNotes',
+ key: 'Individual Notes',
+ index: 7,
+ required: false,
+ parser: parseString,
+ validators: [],
+ }),
+ new TemplateColumn({
+ id: 'consanguinity',
+ key: 'Consanguinity',
+ index: 8,
+ required: false,
+ parser: parseBoolean,
+ validators: [],
+ }),
+ new TemplateColumn({
+ id: 'otherAffectedRelatives',
+ key: 'Other Affected Relatives',
+ index: 9,
+ required: false,
+ parser: parseBoolean,
+ validators: [],
+ }),
+ new TemplateColumn({
+ id: 'expectedInheritanceMode',
+ key: 'Expected Mode of Inheritance',
+ index: 10,
+ required: false,
+ parser: parseStringArray,
+ validators: [validateModeOfInheritanceTerms],
+ }),
+ new TemplateColumn({
+ id: 'fertilityMedications',
+ key: 'Fertility medications',
+ index: 11,
+ required: false,
+ parser: parseBoolean,
+ validators: [],
+ }),
+ new TemplateColumn({
+ id: 'intrauterineInsemination',
+ key: 'Intrauterine insemination',
+ index: 12,
+ required: false,
+ parser: parseBoolean,
+ validators: [],
+ }),
+ new TemplateColumn({
+ id: 'inVitroFertilization',
+ key: 'In vitro fertilization',
+ index: 13,
+ required: false,
+ parser: parseBoolean,
+ validators: [],
+ }),
+ new TemplateColumn({
+ id: 'intraCytoplasmicSpermInjection',
+ key: 'Intra-cytoplasmic sperm injection',
+ index: 14,
+ required: false,
+ parser: parseBoolean,
+ validators: [],
+ }),
+ new TemplateColumn({
+ id: 'gestationalSurrogacy',
+ key: 'Gestational surrogacy',
+ index: 15,
+ required: false,
+ parser: parseBoolean,
+ validators: [],
+ }),
+ new TemplateColumn({
+ id: 'donorEgg',
+ key: 'Donor egg',
+ index: 16,
+ required: false,
+ parser: parseBoolean,
+ validators: [],
+ }),
+ new TemplateColumn({
+ id: 'donorSperm',
+ key: 'Donor sperm',
+ index: 17,
+ required: false,
+ parser: parseBoolean,
+ validators: [],
+ }),
+ new TemplateColumn({
+ id: 'maternalAncestry',
+ key: 'Maternal Ancestry',
+ index: 18,
+ required: false,
+ parser: parseStringArray,
+ validators: [],
+ }),
+ new TemplateColumn({
+ id: 'paternalAncestry',
+ key: 'Paternal Ancestry',
+ index: 19,
+ required: false,
+ parser: parseStringArray,
+ validators: [],
+ }),
+ new TemplateColumn({
+ id: 'preDiscoveryOmimDisorders',
+ key: 'Pre-discovery OMIM disorders',
+ index: 20,
+ required: false,
+ parser: parseStringArray,
+ validators: [validateOmimTerms],
+ }),
+ new TemplateColumn({
+ id: 'previouslyTestedGenes',
+ key: 'Previously Tested Genes',
+ index: 21,
+ required: false,
+ parser: parseStringArray,
+ validators: [],
+ }),
+ new TemplateColumn({
+ id: 'candidateGenes',
+ key: 'Candidate Genes',
+ index: 22,
+ required: false,
+ parser: parseStringArray,
+ validators: [],
+ }),
+])
+
+export default IndividualTemplateColumns
diff --git a/ui/pages/Project/components/DataLoadingWizard/templates/Pedigree.js b/ui/pages/Project/components/DataLoadingWizard/templates/Pedigree.js
new file mode 100644
index 0000000000..e3ccf7aa5d
--- /dev/null
+++ b/ui/pages/Project/components/DataLoadingWizard/templates/Pedigree.js
@@ -0,0 +1,89 @@
+import TemplateColumn from './File/TemplateColumn'
+import { parseString } from './utils/parsers'
+
+const PedigreeTemplateColumns = () => ([
+ new TemplateColumn({
+ id: 'familyId',
+ key: 'Family ID',
+ index: 0,
+ required: true,
+ parser: parseString,
+ validators: [
+ v => ((!v) ? 'A family ID must be present.' : null),
+ ],
+ }),
+ new TemplateColumn({
+ id: 'individualId',
+ key: 'Individual ID',
+ index: 1,
+ required: true,
+ parser: parseString,
+ validators: [
+ v => ((!v) ? 'An individual ID must be present.' : null),
+ ],
+ }),
+ new TemplateColumn({
+ id: 'paternalId',
+ key: 'Paternal ID',
+ index: 2,
+ required: false,
+ parser: parseString,
+ validators: [],
+ }),
+ new TemplateColumn({
+ id: 'maternalId',
+ key: 'Maternal ID',
+ index: 3,
+ required: false,
+ parser: parseString,
+ validators: [],
+ }),
+ new TemplateColumn({
+ id: 'sex',
+ key: 'Sex',
+ index: 4,
+ required: true,
+ parser: parseString,
+ validators: [
+ (v) => {
+ if (!v) {
+ return (
+ 'A character representing sex must be present. ' +
+ "The character '1' represents male, '2' represents female, " +
+ "and any other character besides '1' and '2' represents unknown."
+ )
+ }
+ return null
+ },
+ ],
+ }),
+ new TemplateColumn({
+ id: 'affectedStatus',
+ key: 'Affected Status',
+ index: 5,
+ required: true,
+ parser: parseString,
+ validators: [
+ (v) => {
+ if (!v) {
+ return (
+ 'A character representing phenotype affect status must be present. ' +
+ "The characters '0' and '-9' represent missing, '1' represents unaffected and '2' represents affected. " +
+ 'Any other characters are interpreted as string phenotype values.'
+ )
+ }
+ return null
+ },
+ ],
+ }),
+ new TemplateColumn({
+ id: 'notes',
+ key: 'Notes',
+ index: 6,
+ required: false,
+ parser: parseString,
+ validators: [],
+ }),
+])
+
+export default PedigreeTemplateColumns
diff --git a/ui/pages/Project/components/DataLoadingWizard/templates/utils/parsers.js b/ui/pages/Project/components/DataLoadingWizard/templates/utils/parsers.js
new file mode 100644
index 0000000000..935b50b7d8
--- /dev/null
+++ b/ui/pages/Project/components/DataLoadingWizard/templates/utils/parsers.js
@@ -0,0 +1,83 @@
+/**
+ * @param {?string} value
+ * @returns {string|null}
+ */
+export const parseString = (value) => {
+ if (value?.toString()?.trim()) {
+ return value.toString().trim()
+ }
+ return null
+}
+
+/**
+ * @param {?string} value
+ * @param {string} separator
+ * @returns {string[]}
+ */
+export const parseStringArray = (value, separator = ',') => {
+ if (!value) return []
+
+ if (value.toString().trim()) {
+ return Array.from(
+ new Set(
+ value
+ .toString()
+ .trim()
+ .split(separator)
+ .map(p => p?.trim() || null)
+ .filter(p => p?.trim() && p.trim().length > 0),
+ ),
+ )
+ }
+ return []
+}
+
+/**
+ * @param {?string} value
+ * @returns {number|null}
+ */
+export const parseNumber = (value) => {
+ const n = Number(value?.toString()?.trim())
+ if (Number.isFinite(n)) {
+ return n
+ }
+ return null
+}
+
+/**
+ * @param {?string} value
+ * @returns {boolean|null}
+ */
+export const parseBoolean = (value) => {
+ const b = value?.toString()?.trim()?.toLowerCase()
+ if (value) {
+ switch (b) {
+ case 'true': return true
+ case 'false': return false
+ default: return null
+ }
+ }
+ return null
+}
+
+/**
+ * @param {?string} value
+ * @returns {string|null}
+ */
+export const parseYear = (value) => {
+ const d = value?.toString()?.trim()
+ const match = d.match(/(?\d{4})/)
+
+ if (match?.groups?.year) {
+ return match.groups.year
+ }
+ return null
+}
+
+export default {
+ parseString,
+ parseStringArray,
+ parseNumber,
+ parseBoolean,
+ parseYear,
+}
diff --git a/ui/pages/Project/components/DataLoadingWizard/templates/utils/validators.js b/ui/pages/Project/components/DataLoadingWizard/templates/utils/validators.js
new file mode 100644
index 0000000000..a9c4aaebe9
--- /dev/null
+++ b/ui/pages/Project/components/DataLoadingWizard/templates/utils/validators.js
@@ -0,0 +1,126 @@
+import * as _ from 'lodash'
+import { INHERITANCE_MODE_OPTIONS, ONSET_AGE_OPTIONS } from '../../../../constants'
+
+/**
+ * @param {*[]} collection
+ * @param {?string} message
+ * @returns {string[]}
+ */
+export const validateUnique = (collection, message = null) => {
+ const counts = _.countBy(collection)
+
+ const errors = _.zip(collection, Object.values(counts)).map(([key, count]) => {
+ if (count > 1) {
+ return message || `'${key}' is not unique, and was counted ${count} times`
+ }
+ return null
+ })
+
+ return errors.filter(e => e != null)
+}
+
+/**
+ * @param {?string} value
+ * @param {?string} message
+ * @returns {string|null}
+ */
+export const validateHpoTerm = (value, message = null) => {
+ if (!value) return null
+
+ if (!value.toString().toUpperCase().match(/HP:\d{7}/)) {
+ return message || `'${value}' is not a valid HPO term`
+ }
+ return null
+}
+
+/**
+ * @param {string[]} collection
+ * @param {?string} message
+ * @returns {string[]}
+ */
+export const validateHpoTerms = (collection, message = null) => (
+ collection
+ .map(v => validateHpoTerm(v, message))
+ .filter(e => e != null)
+)
+
+/**
+ * @param {?string} value
+ * @param {?string} message
+ * @returns {string|null}
+ */
+export const validateOnsetCategory = (value, message = null) => {
+ if (!value) return null
+
+ const categories = ONSET_AGE_OPTIONS.map(o => o.text)
+
+ if (!categories.map(c => c.toLowerCase()).includes(value.toLowerCase())) {
+ return message || `'${value}' is not one of ${categories.join(', ')}`
+ }
+
+ return null
+}
+
+/**
+ * @param {?string} value
+ * @param {?string} message
+ * @returns {string|null}
+ */
+export const validateModeOfInheritanceTerm = (value, message = null) => {
+ if (!value) return null
+
+ const categories = INHERITANCE_MODE_OPTIONS.map(o => o.text)
+
+ if (!categories.map(c => c.toLowerCase()).includes(value.toLowerCase())) {
+ return message || `'${value}' is not one of ${categories.join(', ')}`
+ }
+
+ return null
+}
+
+/**
+ * @param {string[]} collection
+ * @param {?string} message
+ * @returns {string[]}
+ */
+export const validateModeOfInheritanceTerms = (collection, message) => (
+ collection
+ .map(v => validateModeOfInheritanceTerm(v, message))
+ .filter(e => e != null)
+)
+
+/**
+ * @param {?string} value
+ * @param {?string} message
+ * @returns {string|null}
+ */
+export const validateOmimTerm = (value, message = null) => {
+ if (!value) return null
+
+ if (!value.toString().toUpperCase().match(/^OMIM:\d+$/)) {
+ return message || `'${value}' is not a valid OMIM term`
+ }
+ return null
+}
+
+/**
+ * @param {string[]} collection
+ * @param {?string} message
+ * @returns {string[]}
+ */
+export const validateOmimTerms = (collection, message) => (
+ collection
+ .map(v => validateOmimTerm(v, message))
+ .filter(e => e != null)
+)
+
+export default {
+ validateUnique,
+ validateHpoTerm,
+ validateHpoTerms,
+ validateOnsetCategory,
+ validateModeOfInheritanceTerm,
+ validateModeOfInheritanceTerms,
+ validateOmimTerm,
+ validateOmimTerms,
+}
diff --git a/ui/pages/Project/components/DataLoadingWizard/ui/Centered.jsx b/ui/pages/Project/components/DataLoadingWizard/ui/Centered.jsx
new file mode 100644
index 0000000000..0e82f3fd03
--- /dev/null
+++ b/ui/pages/Project/components/DataLoadingWizard/ui/Centered.jsx
@@ -0,0 +1,8 @@
+import styled from 'styled-components'
+
+const Centered = styled.div`
+ display: flex;
+ justify-content: center;
+`
+
+export default Centered
diff --git a/ui/pages/Project/components/DataLoadingWizard/ui/FormSection.jsx b/ui/pages/Project/components/DataLoadingWizard/ui/FormSection.jsx
new file mode 100644
index 0000000000..66af0e103e
--- /dev/null
+++ b/ui/pages/Project/components/DataLoadingWizard/ui/FormSection.jsx
@@ -0,0 +1,8 @@
+import styled from 'styled-components'
+
+const FormSection = styled.div`
+ margin-top: 2em;
+ margin-bottom: 2em;
+`
+
+export default FormSection
diff --git a/ui/pages/Project/components/DataLoadingWizard/ui/FormStepButtons.jsx b/ui/pages/Project/components/DataLoadingWizard/ui/FormStepButtons.jsx
new file mode 100644
index 0000000000..fec464d5ee
--- /dev/null
+++ b/ui/pages/Project/components/DataLoadingWizard/ui/FormStepButtons.jsx
@@ -0,0 +1,70 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import { Button } from 'semantic-ui-react'
+import styled from 'styled-components'
+
+const FormButtonGroup = styled.div`
+ display: flex;
+ justify-content: right;
+`
+
+const FormStepButtons = ({ isLastStep, onNext, onBack, onSubmit, enableNext, enableSubmit, loading, size }) => {
+ const submitButton = (
+
+ Submit
+
+ )
+
+ return (
+
+
+ Back
+
+
+ Next
+
+ {
+ isLastStep ? submitButton : null
+ }
+
+ )
+}
+
+FormStepButtons.propTypes = {
+ isLastStep: PropTypes.bool,
+ onNext: PropTypes.func,
+ onBack: PropTypes.func,
+ onSubmit: PropTypes.func,
+ enableNext: PropTypes.bool,
+ enableSubmit: PropTypes.bool,
+ loading: PropTypes.bool,
+ size: PropTypes.string,
+}
+
+FormStepButtons.defaultProps = {
+ isLastStep: false,
+ onNext: () => {},
+ onBack: () => {},
+ onSubmit: () => {},
+ enableNext: false,
+ enableSubmit: false,
+ loading: false,
+ size: 'small',
+}
+
+export default FormStepButtons
diff --git a/ui/pages/Project/components/DataLoadingWizard/ui/ReadableText.jsx b/ui/pages/Project/components/DataLoadingWizard/ui/ReadableText.jsx
new file mode 100644
index 0000000000..175c5552f4
--- /dev/null
+++ b/ui/pages/Project/components/DataLoadingWizard/ui/ReadableText.jsx
@@ -0,0 +1,19 @@
+import styled from 'styled-components'
+import PropTypes from 'prop-types'
+
+const ReadableText = styled.p`
+ font-size: ${props => props.fontSize};
+ line-height: ${props => props.lineHeight};
+`
+
+ReadableText.propTypes = {
+ fontSize: PropTypes.string,
+ lineHeight: PropTypes.number,
+}
+
+ReadableText.defaultProps = {
+ fontSize: '16px',
+ lineHeight: 1.6,
+}
+
+export default ReadableText
diff --git a/ui/pages/Project/components/DataLoadingWizard/ui/Scrollable.jsx b/ui/pages/Project/components/DataLoadingWizard/ui/Scrollable.jsx
new file mode 100644
index 0000000000..248e0bed38
--- /dev/null
+++ b/ui/pages/Project/components/DataLoadingWizard/ui/Scrollable.jsx
@@ -0,0 +1,19 @@
+import styled from 'styled-components'
+import PropTypes from 'prop-types'
+
+const Scrollable = styled.div`
+ overflow-x: ${props => props.x};
+ overflow-y: ${props => props.y};
+`
+
+Scrollable.propTypes = {
+ x: PropTypes.oneOf(['visible', 'hidden', 'scroll', 'auto', 'initial', 'inherit']),
+ y: PropTypes.oneOf(['visible', 'hidden', 'scroll', 'auto', 'initial', 'inherit']),
+}
+
+Scrollable.defaultProps = {
+ x: 'initial',
+ y: 'initial',
+}
+
+export default Scrollable
diff --git a/ui/pages/Project/components/DataLoadingWizard/ui/WithSpace.jsx b/ui/pages/Project/components/DataLoadingWizard/ui/WithSpace.jsx
new file mode 100644
index 0000000000..208b6c8772
--- /dev/null
+++ b/ui/pages/Project/components/DataLoadingWizard/ui/WithSpace.jsx
@@ -0,0 +1,27 @@
+import styled from 'styled-components'
+import PropTypes from 'prop-types'
+
+const WithSpace = styled.div`
+ ${props => `${props.type}-top: ${props.top}`};
+ ${props => `${props.type}-bottom: ${props.bottom}`};
+ ${props => `${props.type}-left: ${props.left}`};
+ ${props => `${props.type}-right: ${props.right}`};
+`
+
+WithSpace.propTypes = {
+ type: PropTypes.oneOf(['padding', 'margin']),
+ top: PropTypes.string,
+ bottom: PropTypes.string,
+ left: PropTypes.string,
+ right: PropTypes.string,
+}
+
+WithSpace.defaultProps = {
+ type: 'margin',
+ top: 'initial',
+ bottom: 'initial',
+ left: 'initial',
+ right: 'initial',
+}
+
+export default WithSpace
diff --git a/ui/pages/Project/components/DataLoadingWizard/ui/index.js b/ui/pages/Project/components/DataLoadingWizard/ui/index.js
new file mode 100644
index 0000000000..5621e6685b
--- /dev/null
+++ b/ui/pages/Project/components/DataLoadingWizard/ui/index.js
@@ -0,0 +1,15 @@
+import Centered from './Centered'
+import FormSection from './FormSection'
+import FormStepButtons from './FormStepButtons'
+import ReadableText from './ReadableText'
+import WithSpace from './WithSpace'
+import Scrollable from './Scrollable'
+
+export {
+ Centered,
+ FormSection,
+ FormStepButtons,
+ ReadableText,
+ WithSpace,
+ Scrollable,
+}
diff --git a/ui/pages/Project/components/DataLoadingWizardPage.jsx b/ui/pages/Project/components/DataLoadingWizardPage.jsx
new file mode 100644
index 0000000000..79bbb72cee
--- /dev/null
+++ b/ui/pages/Project/components/DataLoadingWizardPage.jsx
@@ -0,0 +1,26 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import { connect } from 'react-redux'
+
+import { Error401 } from '../../../shared/components/page/Errors'
+import { getCurrentProject } from '../selectors'
+
+import DataLoadingWizardForm from './DataLoadingWizardForm/DataLoadingWizardForm'
+
+const DataLoadingWizardPage = ({ project }) => {
+ if (!project.canEdit) {
+ return
+ }
+
+ return
+}
+
+DataLoadingWizardPage.propTypes = {
+ project: PropTypes.object,
+}
+
+const mapStateToProps = state => ({
+ project: getCurrentProject(state),
+})
+
+export default connect(mapStateToProps)(DataLoadingWizardPage)