Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 17 additions & 31 deletions src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import JSZip from 'jszip';
import { MetadataForm } from './components/MetadataForm.jsx';
import { normalizeMetadata } from './metadata/normalizeMetadata.js';
import { importGithubMetadata } from './services/githubImporter.js';
import { importReviewHeadline } from './services/importReviewHeadline.js';
import { pickPreferredOrcidCandidate, searchOrcidCandidates } from './services/orcidSearch.js';
import { toCitationCff } from './services/citation.js';
import { validateCitationCffText } from './services/citationValidation.js';
Expand Down Expand Up @@ -244,10 +245,7 @@ function buildImportReviewSummary(importStatus) {

const uniqueWarnings = [...new Set(warnings)].filter(Boolean);
const uniqueRecommendations = [...new Set(recommendations)].filter(Boolean);
const healthy = uniqueWarnings.length === 0 && (importStatus.errors || []).length === 0;

return {
healthy,
warningCount: uniqueWarnings.length,
warnings: uniqueWarnings,
recommendations: uniqueRecommendations,
Expand Down Expand Up @@ -403,7 +401,7 @@ export default function App() {
const [orcidSuggestions, setOrcidSuggestions] = useState({});
const [exportNotice, setExportNotice] = useState({ kind: '', message: '', details: [] });
const [touchedFields, setTouchedFields] = useState({});
const [hasImportedMetadata, setHasImportedMetadata] = useState(false);
const [showValidationErrors, setShowValidationErrors] = useState(false);
const [theme, setTheme] = useState(() => {
if (typeof window === 'undefined') {
return 'dark';
Expand Down Expand Up @@ -500,6 +498,7 @@ export default function App() {
}, [normalizedForm.repositoryCode, hasMetadataCore, hasValidationErrors]);

function showValidationNotice(errors) {
setShowValidationErrors(true);
const details = formatValidationSummary(errors);
const missingFields = getValidationMissingFields(errors);
setExportNotice({
Expand Down Expand Up @@ -768,18 +767,6 @@ export default function App() {
return;
}

if (!importedMeaningfulMetadata) {
setTouchedFields((current) => ({
...current,
title: true,
authors: true,
license: true,
version: true,
typeOfWork: true,
publicationDate: true,
}));
}

setImportStatus({
loading: false,
warnings: result.warnings,
Expand All @@ -789,9 +776,10 @@ export default function App() {
comparisons: importedMeaningfulMetadata ? (Array.isArray(result.comparisons) ? result.comparisons : []) : [],
});

setHasImportedMetadata(importedMeaningfulMetadata);

if (result.errors.length === 0) {
setTouchedFields({});
setShowValidationErrors(false);
clearExportNotice();
setForm(nextForm);
setOrcidSuggestions(nextSuggestions);
}
Expand Down Expand Up @@ -1158,9 +1146,9 @@ export default function App() {
<div className="export-notice export-notice-info" role="status" aria-live="polite">
<strong>Reviewed metadata loaded in editor.</strong>
<ul>
<li>Warnings: {healthScanSummary.warning}</li>
<li>Passing checks: {healthScanSummary.pass}</li>
<li>Errors: {healthScanSummary.error}</li>
<li>Health checks needing review: {healthScanSummary.warning}</li>
<li>Passing health checks: {healthScanSummary.pass}</li>
<li>Health checks with errors: {healthScanSummary.error}</li>
</ul>
</div>
)}
Expand Down Expand Up @@ -1253,9 +1241,7 @@ export default function App() {
<div className="feedback-block feedback-summary">
<strong>Citation Review</strong>
<p className="review-summary">
{importReviewSummary.healthy
? '✓ Repository metadata looks healthy'
: `Warnings (${importReviewSummary.warningCount})`}
{importReviewHeadline(importStatus, importReviewSummary.warningCount)}
</p>
{importReviewSummary.warnings.length > 0 && (
<ul>
Expand Down Expand Up @@ -1297,9 +1283,9 @@ export default function App() {
)}
{importStatus.review && (
<div className="feedback-block feedback-review">
<strong>Metadata review</strong>
<strong>Metadata field review</strong>
<p className="review-summary">
Correct: {importStatus.review.summary?.byStatus?.correct ?? 0} | Missing: {importStatus.review.summary?.byStatus?.missing ?? 0} | Outdated: {importStatus.review.summary?.byStatus?.outdated ?? 0} | Invalid: {importStatus.review.summary?.byStatus?.invalid ?? 0}
Correct fields: {importStatus.review.summary?.byStatus?.correct ?? 0} | Missing fields: {importStatus.review.summary?.byStatus?.missing ?? 0} | Outdated fields: {importStatus.review.summary?.byStatus?.outdated ?? 0} | Invalid fields: {importStatus.review.summary?.byStatus?.invalid ?? 0}
</p>
{Array.isArray(importStatus.review.recommendations?.actions) && importStatus.review.recommendations.actions.length > 0 && (
<ul>
Expand All @@ -1324,7 +1310,7 @@ export default function App() {
<div className="feedback-block feedback-health">
<strong>Citation health scan</strong>
<p className="review-summary">
Passing checks: {healthScanSummary.pass} | Warnings to review: {healthScanSummary.warning} | Errors to fix: {healthScanSummary.error}
Passing checks: {healthScanSummary.pass} | Checks needing review: {healthScanSummary.warning} | Checks with errors: {healthScanSummary.error}
</p>
<div className="actions">
<button type="button" onClick={openReviewedMetadataInGenerator}>
Expand All @@ -1334,7 +1320,7 @@ export default function App() {

{healthErrorChecks.length > 0 && (
<div className="health-group">
<h3>Errors (Fix first)</h3>
<h3>Health check errors</h3>
<ul className="health-list">
{healthErrorChecks.map((check, index) => renderHealthCheckCard(check, `health-error-${index}`))}
</ul>
Expand All @@ -1343,7 +1329,7 @@ export default function App() {

{healthWarningChecks.length > 0 && (
<div className="health-group">
<h3>Warnings (Recommended updates)</h3>
<h3>Health checks needing review</h3>
<ul className="health-list">
{healthWarningChecks.map((check, index) => renderHealthCheckCard(check, `health-warning-${index}`))}
</ul>
Expand All @@ -1352,7 +1338,7 @@ export default function App() {

{healthPassChecks.length > 0 && (
<div className="health-group">
<h3>Done Well</h3>
<h3>Passing health checks</h3>
<ul className="health-list">
{healthPassChecks.map((check, index) => renderHealthCheckCard(check, `health-pass-${index}`))}
</ul>
Expand Down Expand Up @@ -1392,7 +1378,7 @@ export default function App() {
grantSuggestions={grantSuggestions}
errors={validationErrors}
touchedFields={touchedFields}
importedMetadataAvailable={hasImportedMetadata}
showValidationErrors={showValidationErrors}
orcidSuggestions={orcidSuggestions}
updateField={updateField}
appendGrantSuggestion={appendGrantSuggestion}
Expand Down
28 changes: 14 additions & 14 deletions src/components/MetadataForm.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export function MetadataForm({
grantSuggestions = [],
errors = {},
touchedFields = {},
importedMetadataAvailable = false,
showValidationErrors = false,
orcidSuggestions = {},
updateField,
appendGrantSuggestion,
Expand All @@ -28,8 +28,8 @@ export function MetadataForm({
const hiddenAuthorCount = Math.max(0, totalAuthors - visibleAuthorCount);
const hasTouchedField = (field) => Boolean(touchedFields[field]);
const hasTouchedAuthorField = (index, field) => Boolean(touchedFields[`authors.${index}.${field}`]);
const shouldShowFieldError = (field) => Boolean(errors[field]) && (hasTouchedField(field) || importedMetadataAvailable);
const shouldShowAuthorFieldError = (index, field) => Boolean(errors[`authorOrcid`]?.[index]) && (hasTouchedAuthorField(index, field) || importedMetadataAvailable);
const shouldShowFieldError = (field) => Boolean(errors[field]) && (hasTouchedField(field) || showValidationErrors);
const shouldShowAuthorFieldError = (index, field) => Boolean(errors[`authorOrcid`]?.[index]) && (hasTouchedAuthorField(index, field) || showValidationErrors);

function toggleAuthorExpanded(index) {
setExpandedAuthors((current) => ({
Expand Down Expand Up @@ -59,7 +59,7 @@ export function MetadataForm({
placeholder="Project title"
aria-invalid={Boolean(shouldShowFieldError('title'))}
/>
{(hasTouchedField('title') || importedMetadataAvailable) && errors.title ? <small className="error-text">{errors.title}</small> : null}
{shouldShowFieldError('title') ? <small className="error-text">{errors.title}</small> : null}
</label>

<label className="full-width">
Expand Down Expand Up @@ -138,7 +138,7 @@ export function MetadataForm({
className={shouldShowAuthorFieldError(index, 'orcid') ? 'input-error' : ''}
aria-invalid={Boolean(shouldShowAuthorFieldError(index, 'orcid'))}
/>
{(hasTouchedAuthorField(index, 'orcid') || importedMetadataAvailable) && errors.authorOrcid?.[index] ? <small className="error-text">{errors.authorOrcid[index]}</small> : null}
{shouldShowAuthorFieldError(index, 'orcid') ? <small className="error-text">{errors.authorOrcid[index]}</small> : null}
<div className="orcid-tools">
<button
type="button"
Expand Down Expand Up @@ -208,7 +208,7 @@ export function MetadataForm({
<button type="button" className="secondary" onClick={addAuthor}>
Add author
</button>
{(hasTouchedField('authors') || importedMetadataAvailable) && errors.authors ? <small className="error-text">{errors.authors}</small> : null}
{shouldShowFieldError('authors') ? <small className="error-text">{errors.authors}</small> : null}
</label>
</section>

Expand All @@ -234,7 +234,7 @@ export function MetadataForm({
</option>
))}
</select>
{(hasTouchedField('typeOfWork') || importedMetadataAvailable) && errors.typeOfWork ? <small className="error-text">{errors.typeOfWork}</small> : null}
{shouldShowFieldError('typeOfWork') ? <small className="error-text">{errors.typeOfWork}</small> : null}
</label>

{form.typeOfWork === 'other' && (
Expand Down Expand Up @@ -266,7 +266,7 @@ export function MetadataForm({
<a href="https://semver.org/" target="_blank" rel="noreferrer">semver.org</a>
.
</small>
{(hasTouchedField('version') || importedMetadataAvailable) && errors.version ? <small className="error-text">{errors.version}</small> : null}
{shouldShowFieldError('version') ? <small className="error-text">{errors.version}</small> : null}
</label>

<label className={shouldShowFieldError('publicationDate') ? 'field-error' : ''}>
Expand All @@ -279,7 +279,7 @@ export function MetadataForm({
placeholder="YYYY-MM-DD"
aria-invalid={Boolean(shouldShowFieldError('publicationDate'))}
/>
{(hasTouchedField('publicationDate') || importedMetadataAvailable) && errors.publicationDate ? <small className="error-text">{errors.publicationDate}</small> : null}
{shouldShowFieldError('publicationDate') ? <small className="error-text">{errors.publicationDate}</small> : null}
</label>

<label className={shouldShowFieldError('license') ? 'field-error' : ''}>
Expand All @@ -298,7 +298,7 @@ export function MetadataForm({
</option>
))}
</select>
{(hasTouchedField('license') || importedMetadataAvailable) && errors.license ? <small className="error-text">{errors.license}</small> : null}
{shouldShowFieldError('license') ? <small className="error-text">{errors.license}</small> : null}
</label>

<label>
Expand Down Expand Up @@ -351,16 +351,16 @@ export function MetadataForm({
<p className="section-step">5. Funding</p>
<h3 id="section-funding-title">Grant IDs and acknowledgements</h3>
</header>
<label className={`full-width ${hasTouchedField('grants') && errors.grants ? 'field-error' : ''}`}>
<label className={`full-width ${shouldShowFieldError('grants') ? 'field-error' : ''}`}>
<span>Grants</span>
<textarea
className={hasTouchedField('grants') && errors.grants ? 'input-error' : ''}
className={shouldShowFieldError('grants') ? 'input-error' : ''}
name="grants"
value={form.grants}
onChange={updateField}
rows="3"
placeholder="One grant ID per line"
aria-invalid={Boolean(hasTouchedField('grants') && errors.grants)}
aria-invalid={Boolean(shouldShowFieldError('grants'))}
/>
<div className="grant-suggestions">
{grantSuggestions.map((grant) => (
Expand Down Expand Up @@ -390,7 +390,7 @@ export function MetadataForm({
))}
</ul>
<small>Format: &lt;funder-code&gt;::&lt;grant-number&gt; (e.g., 021nxhr62::2118240)</small>
{hasTouchedField('grants') && errors.grants ? <small className="error-text">{errors.grants}</small> : null}
{shouldShowFieldError('grants') ? <small className="error-text">{errors.grants}</small> : null}
</label>
</section>
</form>
Expand Down
6 changes: 6 additions & 0 deletions src/services/importReviewHeadline.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export function importReviewHeadline(importStatus, findingCount) {
if (findingCount > 0) return `Review findings (${findingCount})`;
if (importStatus.errors.length > 0) return `Import errors (${importStatus.errors.length})`;
if (importStatus.warnings.length > 0) return `Import warnings (${importStatus.warnings.length})`;
return 'No health or comparison findings';
}
12 changes: 12 additions & 0 deletions tests/services/importReviewHeadline.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import test from 'node:test';
import assert from 'node:assert/strict';

import { importReviewHeadline } from '../../src/services/importReviewHeadline.js';

test('review headlines count their own category rather than import and validation together', () => {
const status = { errors: [{ message: 'Import failed' }], warnings: [{ message: 'Missing file' }] };
assert.equal(importReviewHeadline(status, 2), 'Review findings (2)');
assert.equal(importReviewHeadline(status, 0), 'Import errors (1)');
assert.equal(importReviewHeadline({ errors: [], warnings: status.warnings }, 0), 'Import warnings (1)');
assert.equal(importReviewHeadline({ errors: [], warnings: [] }, 0), 'No health or comparison findings');
});
39 changes: 39 additions & 0 deletions tests/validation/validationDisplay.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { createServer } from 'vite';

test('required fields stay neutral until touched or export validation is requested', async () => {
const vite = await createServer({ server: { middlewareMode: true }, appType: 'custom' });
try {
const { MetadataForm } = await vite.ssrLoadModule('/src/components/MetadataForm.jsx');
const form = {
title: '', authors: [], typeOfWork: 'software', version: '0.1.0',
publicationDate: '', license: '', grants: '', abstract: '', keywords: '',
repositoryCode: '', doi: '', references: '',
};
const errors = { title: 'Title is required', authors: 'Author is required' };
const props = {
form, errors, typeOptions: [], licenseOptions: [], orcidSuggestions: {}, grantSuggestions: [],
updateField: () => {}, updateAuthorField: () => {},
};

const initial = renderToStaticMarkup(createElement(MetadataForm, props));
assert.match(initial, /Title\*/);
assert.match(initial, /Authors\*/);
assert.doesNotMatch(initial, /Title is required|Author is required|input-error/);
assert.match(initial, /name="title"[^>]*aria-invalid="false"/);

const touched = renderToStaticMarkup(createElement(MetadataForm, { ...props, touchedFields: { title: true } }));
assert.match(touched, /Title is required/);
assert.doesNotMatch(touched, /Author is required/);

const attemptedExport = renderToStaticMarkup(createElement(MetadataForm, { ...props, showValidationErrors: true }));
assert.match(attemptedExport, /Title is required/);
assert.match(attemptedExport, /Author is required/);
assert.match(attemptedExport, /name="title"[^>]*aria-invalid="true"/);
} finally {
await vite.close();
}
});
Loading