diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index b57aa23fc..f28b0de8c 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,15 +1,15 @@ -**What this PR does / why we need it**: +## What this PR does / why we need it: -**Which issue(s) this PR closes**: +## Which issue(s) this PR closes: -Closes # +- Closes # -**Special notes for your reviewer**: +## Special notes for your reviewer: -**Suggestions on how to test this**: +## Suggestions on how to test this: -**Does this PR introduce a user interface change? If mockups are available, please link/include them here**: +## Does this PR introduce a user interface change? If mockups are available, please link/include them here: -**Is there a release notes update needed for this change?**: +## Is there a release notes update needed for this change?: -**Additional documentation**: +## Additional documentation: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index db4356d24..848e3fa76 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -41,6 +41,18 @@ jobs: echo VITE_DATAVERSE_BACKEND_URL="$DATAVERSE_BACKEND_URL" >> .env shell: bash + - name: Create containerized development environment .env file + working-directory: dev-env + run: cp .env.example .env + shell: bash + + - name: Set S3 secrets for the containerized development environment + working-directory: dev-env + run: | + sed -i -e 's//${{ secrets.S3_ACCESS_KEY }}/g' .env + sed -i -e 's//${{ secrets.S3_SECRET_KEY }}/g' .env + shell: bash + - name: Update registry for the containerized development environment working-directory: dev-env run: | diff --git a/dev-env/.env b/dev-env/.env.example similarity index 58% rename from dev-env/.env rename to dev-env/.env.example index 05d4bebe8..972aa9026 100644 --- a/dev-env/.env +++ b/dev-env/.env.example @@ -2,3 +2,5 @@ POSTGRES_VERSION=13 DATAVERSE_DB_USER=dataverse SOLR_VERSION=9.3.0 REGISTRY=docker.io +S3_ACCESS_KEY= +S3_SECRET_KEY= diff --git a/dev-env/.gitignore b/dev-env/.gitignore new file mode 100644 index 000000000..4c49bd78f --- /dev/null +++ b/dev-env/.gitignore @@ -0,0 +1 @@ +.env diff --git a/dev-env/docker-compose-dev.yml b/dev-env/docker-compose-dev.yml index 68b1b6c40..94b83bae0 100644 --- a/dev-env/docker-compose-dev.yml +++ b/dev-env/docker-compose-dev.yml @@ -44,6 +44,7 @@ services: restart: on-failure user: payara environment: + dataverse_files_storage__driver__id: s3 DATAVERSE_DB_HOST: postgres DATAVERSE_DB_PASSWORD: secret DATAVERSE_DB_USER: ${DATAVERSE_DB_USER} @@ -54,6 +55,19 @@ services: -Ddataverse.pid.fake.label=FakeDOIProvider -Ddataverse.pid.fake.authority=10.5072 -Ddataverse.pid.fake.shoulder=FK2/ + -Ddataverse.files.s3.access-key=${S3_ACCESS_KEY} + -Ddataverse.files.s3.label=s3 + -Ddataverse.files.s3.secret-key=${S3_SECRET_KEY} + -Ddataverse.files.storage-driver-id=s3 + -Ddataverse.files.s3.type=s3 + -Ddataverse.files.s3.bucket-name=beta-dataverse-direct + -Ddataverse.files.s3.upload-redirect=true + -Ddataverse.files.s3.download-redirect=true + -Ddataverse.files.s3.ingestsizelimit=50000000 + -Ddataverse.files.s3.url-expiration-minutes=60 + -Ddataverse.files.s3.connection-pool-size=2048 + -Ddataverse.files.s3.custom-endpoint-region=us-east-1 + -Ddataverse.files.s3.custom-endpoint-url=https://s3.us-east-1.amazonaws.com expose: - '8080' networks: diff --git a/package-lock.json b/package-lock.json index 96b2e0ca1..7513fce0b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.1.0", "dependencies": { "@faker-js/faker": "7.6.0", - "@iqss/dataverse-client-javascript": "2.0.0-pr175.3f8ee0a", + "@iqss/dataverse-client-javascript": "2.0.0-pr169.aa49f06", "@iqss/dataverse-design-system": "*", "@istanbuljs/nyc-config-typescript": "1.0.2", "@tanstack/react-table": "8.9.2", @@ -3039,6 +3039,68 @@ "node": ">=10.0.0" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.0.tgz", + "integrity": "sha512-ea7IkhKvlJUv9iSHJOnxinBcoOI3ppGnnL+VDJ75O45Nss6HtZd8IdN8touXPDtASfeI2T2LImb8VOZcL47wjQ==", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.1.0.tgz", + "integrity": "sha512-J3cQBClB4TVxwGo3KEjssGEXNJqGVWx17aRTZ1ob0FliR5IjYgTxl5YJbKTzA6IzrtelotH19v6y7uoIRUZPSg==", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.0", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/modifiers": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/modifiers/-/modifiers-7.0.0.tgz", + "integrity": "sha512-BG/ETy3eBjFap7+zIti53f0PCLGDzNXyTmn6fSdrudORf+OH04MxrW4p5+mPu4mgMk9kM41iYONjc3DOUWTcfg==", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.1.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-8.0.0.tgz", + "integrity": "sha512-U3jk5ebVXe1Lr7c2wU7SBZjcWdQP+j7peHJfCspnA81enlu88Mgd7CC8Q+pub9ubP7eKVETzJW+IBAhsqbSu/g==", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.1.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@emotion/is-prop-valid": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.2.1.tgz", @@ -3610,9 +3672,9 @@ }, "node_modules/@iqss/dataverse-client-javascript": { "name": "@IQSS/dataverse-client-javascript", - "version": "2.0.0-pr175.3f8ee0a", - "resolved": "https://npm.pkg.github.com/download/@IQSS/dataverse-client-javascript/2.0.0-pr175.3f8ee0a/8896348d8b93c7f362dd41f6ed5e701c5b04e49a", - "integrity": "sha512-LS85ZcOTdmuiGrOJFjsGwsdoiVhd4WKDO1736Qy6q1Mmrws9K1HjV7DJFtiXuSNWzrN4mxToaBJfoHNDao7lbg==", + "version": "2.0.0-pr169.aa49f06", + "resolved": "https://npm.pkg.github.com/download/@IQSS/dataverse-client-javascript/2.0.0-pr169.aa49f06/d8061b0af0068e530c6ef78b89e0a4ce668df4b3", + "integrity": "sha512-2D3wxWA87kU8EXltK7pBMGX9OoK7aecX869zbalbdtSNcPz9ATbZOOhDYbfaU+J526AyNTVQ6xlPTY5hWIRFvQ==", "license": "MIT", "dependencies": { "@types/node": "^18.15.11", @@ -41090,8 +41152,7 @@ "node_modules/tslib": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.1.0.tgz", - "integrity": "sha512-hcVC3wYEziELGGmEEXue7D75zbwIIVUMWAVbHItGPx0ziyXxrOMQx4rQEVEV45Ut/1IotuEvwqPopzIOkDMf0A==", - "dev": true + "integrity": "sha512-hcVC3wYEziELGGmEEXue7D75zbwIIVUMWAVbHItGPx0ziyXxrOMQx4rQEVEV45Ut/1IotuEvwqPopzIOkDMf0A==" }, "node_modules/tsutils": { "version": "3.21.0", @@ -43605,6 +43666,10 @@ "version": "1.1.0", "license": "MIT", "dependencies": { + "@dnd-kit/core": "6.1.0", + "@dnd-kit/modifiers": "^7.0.0", + "@dnd-kit/sortable": "8.0.0", + "@dnd-kit/utilities": "3.2.2", "@types/react": "18.0.27", "bootstrap": "5.2.3", "react-bootstrap": "2.7.2", diff --git a/package.json b/package.json index e14704d3e..894d361f3 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ }, "dependencies": { "@faker-js/faker": "7.6.0", - "@iqss/dataverse-client-javascript": "2.0.0-pr175.3f8ee0a", + "@iqss/dataverse-client-javascript": "2.0.0-pr169.aa49f06", "@iqss/dataverse-design-system": "*", "@istanbuljs/nyc-config-typescript": "1.0.2", "@tanstack/react-table": "8.9.2", diff --git a/packages/design-system/CHANGELOG.md b/packages/design-system/CHANGELOG.md index 6135938c5..992bef245 100644 --- a/packages/design-system/CHANGELOG.md +++ b/packages/design-system/CHANGELOG.md @@ -46,6 +46,8 @@ See [Conventional Commits](https://conventionalcommits.org) for commit guideline - **FormTextArea:** extend Props Interface to accept `autoFocus` prop. - **FormSelect:** extend Props Interface to accept `autoFocus` prop. - **Stack:** NEW Stack element to manage layouts. +- **TransferList:** NEW TransferList component to transfer items between two list, also sortable. +- **Table:** extend Props Interface to accept `bordered` prop to add or remove borders on all sides of the table and cells. Defaults to true. # [1.1.0](https://github.com/IQSS/dataverse-frontend/compare/@iqss/dataverse-design-system@1.0.1...@iqss/dataverse-design-system@1.1.0) (2024-03-12) diff --git a/packages/design-system/package.json b/packages/design-system/package.json index 00b5361bc..4d0fe2e08 100644 --- a/packages/design-system/package.json +++ b/packages/design-system/package.json @@ -31,6 +31,10 @@ "test:coverage": "nyc check-coverage" }, "dependencies": { + "@dnd-kit/core": "6.1.0", + "@dnd-kit/modifiers": "^7.0.0", + "@dnd-kit/sortable": "8.0.0", + "@dnd-kit/utilities": "3.2.2", "@types/react": "18.0.27", "bootstrap": "5.2.3", "react-bootstrap": "2.7.2", diff --git a/packages/design-system/src/lib/assets/styles/design-tokens/colors.module.scss b/packages/design-system/src/lib/assets/styles/design-tokens/colors.module.scss index 429903c63..4d57df5ac 100644 --- a/packages/design-system/src/lib/assets/styles/design-tokens/colors.module.scss +++ b/packages/design-system/src/lib/assets/styles/design-tokens/colors.module.scss @@ -1,6 +1,6 @@ // Base colors -$dv-brand-color: #C55B28; -$dv-primary-color: #337AB7; +$dv-brand-color: #c55b28; +$dv-primary-color: #337ab7; $dv-secondary-color: #e0e0e0; $dv-success-color: #3c763d; $dv-danger-color: #a94442; @@ -10,7 +10,11 @@ $dv-success-box-color: #e0e0e0; $dv-danger-box-color: #f2dede; $dv-warning-box-color: #fcf8e3; $dv-info-box-color: #d9edf7; -$dv-info-border-color: #428BCA; +$dv-info-border-color: #428bca; + +$dv-collection-border-color: #c55b28; +$dv-dataset-border-color: #428BCA; +$dv-file-border-color: #808080; // Text colors $dv-text-color: #333; @@ -22,13 +26,16 @@ $dv-secondary-text-color: $dv-text-color; $dv-headings-color: #333; // Link colors -$dv-link-color: #3174AF; +$dv-link-color: #3174af; $dv-link-hover-color: #23527c; $dv-tooltip-color: #99bcdb; $dv-tooltip-hover-color: #337ab7; // Button colors -$dv-button-border-color: #CCC; +$dv-button-border-color: #ccc; + +// Border colors +$dv-border-color: #dee2e6; :export { brand: $dv-brand-color; @@ -52,4 +59,5 @@ $dv-button-border-color: #CCC; buttonBorderColor: $dv-button-border-color; tooltipFillColor: $dv-tooltip-color; tooltipBorderColor: $dv-tooltip-hover-color; -} \ No newline at end of file + borderColor: $dv-border-color; +} diff --git a/packages/design-system/src/lib/components/table/Table.tsx b/packages/design-system/src/lib/components/table/Table.tsx index ab83b19c6..1548a467a 100644 --- a/packages/design-system/src/lib/components/table/Table.tsx +++ b/packages/design-system/src/lib/components/table/Table.tsx @@ -1,10 +1,14 @@ -import { PropsWithChildren } from 'react' import { Table as TableBS } from 'react-bootstrap' import styles from './Table.module.scss' -export function Table({ children }: PropsWithChildren) { +interface TableProps { + bordered?: boolean + children: React.ReactNode +} + +export function Table({ bordered = true, children }: TableProps) { return ( - + {children} ) diff --git a/packages/design-system/src/lib/components/transfer-list/ItemsList.tsx b/packages/design-system/src/lib/components/transfer-list/ItemsList.tsx new file mode 100644 index 000000000..562cfc4d9 --- /dev/null +++ b/packages/design-system/src/lib/components/transfer-list/ItemsList.tsx @@ -0,0 +1,86 @@ +import { ListGroup } from 'react-bootstrap' +import { DndContext, DragEndEvent } from '@dnd-kit/core' +import { arrayMove, SortableContext } from '@dnd-kit/sortable' +import { restrictToVerticalAxis } from '@dnd-kit/modifiers' +import { type TransferListItem } from './TransferList' +import { ListItem } from './ListItem' +import styles from './TransferList.module.scss' + +type ListProps = + | { + items: TransferListItem[] + side: 'left' + checked: TransferListItem[] + onToggle: (item: TransferListItem) => () => void + rightItems?: never + setRight?: never + onChange?: never + } + | { + items: TransferListItem[] + side: 'right' + checked: TransferListItem[] + onToggle: (item: TransferListItem) => () => void + rightItems: TransferListItem[] + setRight: React.Dispatch> + onChange?: (selected: TransferListItem[]) => void + } + +export const ItemsList = ({ + items, + side, + checked, + onToggle, + rightItems, + setRight, + onChange +}: ListProps) => { + if (side === 'left') { + return ( + + {items.map((item: TransferListItem) => ( + + ))} + + ) + } + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event + + if (over && active.id !== over.id) { + const oldIndex = rightItems.findIndex((item) => item.id === active.id) + const newIndex = rightItems.findIndex((item) => item.id === over.id) + + const newItems = arrayMove(rightItems, oldIndex, newIndex) + + setRight(newItems) + + onChange && onChange(newItems) + } + } + + return ( + + + + {items.map((item: TransferListItem) => ( + + ))} + + + + ) +} diff --git a/packages/design-system/src/lib/components/transfer-list/ListItem.tsx b/packages/design-system/src/lib/components/transfer-list/ListItem.tsx new file mode 100644 index 000000000..b7b25d021 --- /dev/null +++ b/packages/design-system/src/lib/components/transfer-list/ListItem.tsx @@ -0,0 +1,74 @@ +import { useId } from 'react' +import { useSortable } from '@dnd-kit/sortable' +import { CSS } from '@dnd-kit/utilities' +import { ListGroup } from 'react-bootstrap' +import { Form } from '../form/Form' +import { TransferListItem } from './TransferList' +import { Stack } from '../stack/Stack' +import styles from './TransferList.module.scss' + +interface ListItemProps { + item: TransferListItem + side: 'left' | 'right' + checked: readonly TransferListItem[] + onToggle: (item: TransferListItem) => () => void +} + +export const ListItem = ({ item, side, checked, onToggle }: ListItemProps) => { + const { attributes, listeners, transform, transition, setNodeRef, setActivatorNodeRef } = + useSortable({ id: item.id }) + + const uniqueID = useId() + const labelId = `transfer-list-item-${item.value}-label-${uniqueID}` + + if (side === 'left') { + return ( + + + + ) + } + + const style = { + transform: CSS.Transform.toString(transform), + transition + } + + return ( + + + + + + + ) +} diff --git a/packages/design-system/src/lib/components/transfer-list/TransferList.module.scss b/packages/design-system/src/lib/components/transfer-list/TransferList.module.scss new file mode 100644 index 000000000..83fcc7593 --- /dev/null +++ b/packages/design-system/src/lib/components/transfer-list/TransferList.module.scss @@ -0,0 +1,130 @@ +@use 'sass:color'; +@import 'src/lib/assets/styles/design-tokens/colors.module'; + +.transfer-list { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + + @media screen and (min-width: 768px) { + flex-flow: row wrap; + } +} + +.items-column { + position: relative; + z-index: 1; + width: 230px; + height: 240px; + overflow-x: hidden; + overflow-y: auto; + border: solid 1px $dv-border-color; + border-radius: 6px; + + &::-webkit-scrollbar { + width: 8px; + } + + &::-webkit-scrollbar-track { + background-color: transparent; + } + + &::-webkit-scrollbar-thumb { + background-color: $dv-secondary-color; + border-radius: 6px; + + &:hover { + background-color: color.adjust($dv-secondary-color, $blackness: 40%); + } + } + + .items-list { + margin-bottom: 0; + padding-left: 0; + list-style-type: none; + + .list-item { + position: relative; + padding-inline: 1rem; + touch-action: none; + + :global .form-check { + display: flex; + align-items: center; + margin-bottom: 0; + padding-left: 0; + } + + input[type='checkbox'] { + flex-shrink: 0; + float: unset; + margin-top: 0; + margin-left: 0; + cursor: pointer; + } + + label { + width: 100%; + padding-left: 0.5rem; + padding-block: 0.5rem; + cursor: pointer; + } + + .drag-handle { + padding: 0; + background-color: transparent; + border: 0; + border-radius: 4px; + cursor: grab; + + &:hover { + background-color: $dv-secondary-color; + } + + &:active { + cursor: grabbing; + } + } + + &:hover, + &:focus-visible { + color: $dv-text-color; + background-color: color.adjust($dv-secondary-color, $alpha: -0.7); + } + + &:has(input[type='checkbox']:checked) { + background-color: color.adjust($dv-secondary-color, $alpha: -0.4); + } + + &[aria-pressed='true'] { + z-index: 999; + background-color: color.adjust($dv-secondary-color, $alpha: -0.3); + } + } + } + + .column-label { + position: sticky; + top: 0; + left: 0; + margin: 0; + padding: 0.35rem 0.5rem; + font-weight: bold; + text-align: center; + background-color: #f5f5f5; + } +} + +.middle-column { + display: flex; + flex-direction: column; + gap: 0.5rem; + align-items: center; + justify-content: center; + padding: 1rem 2rem; + + .transfer-button { + min-width: 64px; + } +} diff --git a/packages/design-system/src/lib/components/transfer-list/TransferList.tsx b/packages/design-system/src/lib/components/transfer-list/TransferList.tsx new file mode 100644 index 000000000..7b052d942 --- /dev/null +++ b/packages/design-system/src/lib/components/transfer-list/TransferList.tsx @@ -0,0 +1,151 @@ +import { useEffect, useState } from 'react' +import { Button } from '../button/Button' +import { + ChevronDoubleLeft, + ChevronDoubleRight, + ChevronLeft, + ChevronRight +} from 'react-bootstrap-icons' +import { ItemsList } from './ItemsList' +import styles from './TransferList.module.scss' + +function not(a: TransferListItem[], b: TransferListItem[]) { + return a.filter((item) => !b.some((bItem) => bItem.value === item.value)) +} + +function intersection(a: TransferListItem[], b: TransferListItem[]) { + return a.filter((item) => b.some((bItem) => bItem.value === item.value)) +} + +export interface TransferListItem { + value: string | number + label: string + id: string | number +} + +export interface TransferListProps { + availableItems: TransferListItem[] + defaultSelected?: TransferListItem[] + onChange?: (selected: TransferListItem[]) => void + leftLabel?: string + rightLabel?: string +} + +export const TransferList = ({ + availableItems, + defaultSelected = [], + onChange, + leftLabel, + rightLabel +}: TransferListProps) => { + const [checked, setChecked] = useState([]) + const [left, setLeft] = useState(not(availableItems, defaultSelected)) + const [right, setRight] = useState( + intersection(availableItems, defaultSelected) + ) + + const leftChecked = intersection(checked, left) + const rightChecked = intersection(checked, right) + + const handleToggle = (item: TransferListItem) => () => { + const currentIndex = checked.findIndex((checkedItem) => checkedItem.value === item.value) + const newChecked = [...checked] + + if (currentIndex === -1) { + newChecked.push(item) + } else { + newChecked.splice(currentIndex, 1) + } + + setChecked(newChecked) + } + + const handleAllRight = () => { + setRight(right.concat(left)) + onChange && onChange(right.concat(left)) + setLeft([]) + } + + const handleCheckedRight = () => { + setRight(right.concat(leftChecked)) + onChange && onChange(right.concat(leftChecked)) + setLeft(not(left, leftChecked)) + setChecked(not(checked, leftChecked)) + } + + const handleCheckedLeft = () => { + setLeft(left.concat(rightChecked.filter((item) => availableItems.includes(item)))) + setRight(not(right, rightChecked)) + onChange && onChange(not(right, rightChecked)) + setChecked(not(checked, rightChecked)) + } + + const handleAllLeft = () => { + setLeft(left.concat(right.filter((item) => availableItems.includes(item)))) + setRight([]) + onChange && onChange([]) + } + + useEffect(() => { + // Update the left items when the available items change + setLeft(not(availableItems, right)) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [availableItems]) + + return ( +
+
+ {leftLabel &&

{leftLabel}

} + +
+
+
+
+ {rightLabel &&

{rightLabel}

} + +
+
+ ) +} diff --git a/packages/design-system/src/lib/index.ts b/packages/design-system/src/lib/index.ts index 6c08768a9..533931299 100644 --- a/packages/design-system/src/lib/index.ts +++ b/packages/design-system/src/lib/index.ts @@ -28,3 +28,4 @@ export { SelectAdvanced } from './components/select-advanced/SelectAdvanced' export { Card } from './components/card/Card' export { ProgressBar } from './components/progress-bar/ProgressBar' export { Stack } from './components/stack/Stack' +export { TransferList } from './components/transfer-list/TransferList' diff --git a/packages/design-system/src/lib/stories/transfer-list/TransferList.stories.tsx b/packages/design-system/src/lib/stories/transfer-list/TransferList.stories.tsx new file mode 100644 index 000000000..a28d5737e --- /dev/null +++ b/packages/design-system/src/lib/stories/transfer-list/TransferList.stories.tsx @@ -0,0 +1,307 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { TransferList, TransferListItem } from '../../components/transfer-list/TransferList' +import { useState } from 'react' +import { SelectAdvanced } from '../../components/select-advanced/SelectAdvanced' + +/** + * ## Description + * The transfer list component is an element that allows users to transfer on or more items between two lists. + * + * The list on the left is the source list and the list on the right is the target list. + * + * The right list can be empty or have some items initially. + * + * You can also sort the items on the right list by dragging them. + * + * You can pass an onChange function to know when the selected items change. + * + * *Note: You can open the developer console to see the selected items when they change.* + */ + +const meta: Meta = { + title: 'Transfer List', + component: TransferList, + tags: ['autodocs'] +} + +const aToEitems: TransferListItem[] = [ + { + label: 'Item A', + value: 'A', + id: 'A' + }, + { + label: 'Item B', + value: 'B', + id: 'B' + }, + { + label: 'Item C', + value: 'C', + id: 'C' + }, + { + label: 'Item D', + value: 'D', + id: 'D' + }, + { + label: 'Item E', + value: 'E', + id: 'E' + } +] + +const fToJitems: TransferListItem[] = [ + { + label: 'Item F', + value: 'F', + id: 'F' + }, + { + label: 'Item G', + value: 'G', + id: 'G' + }, + { + label: 'Item H', + value: 'H', + id: 'H' + }, + { + label: 'Item I', + value: 'I', + id: 'I' + }, + { + label: 'Item J', + value: 'J', + id: 'J' + } +] + +const kToÑitems: TransferListItem[] = [ + { + label: 'Item K', + value: 'K', + id: 'K' + }, + { + label: 'Item L', + value: 'L', + id: 'L' + }, + { + label: 'Item M', + value: 'M', + id: 'M' + }, + { + label: 'Item N', + value: 'N', + id: 'N' + }, + { + label: 'Item Ñ', + value: 'Ñ', + id: 'Ñ' + } +] + +const oToUitems: TransferListItem[] = [ + { + label: 'Item O', + value: 'O', + id: 'O' + }, + { + label: 'Item P', + value: 'P', + id: 'P' + }, + { + label: 'Item Q', + value: 'Q', + id: 'Q' + }, + { + label: 'Item R', + value: 'R', + id: 'R' + }, + { + label: 'Item S', + value: 'S', + id: 'S' + }, + { + label: 'Item T', + value: 'T', + id: 'T' + }, + { + label: 'Item U', + value: 'U', + id: 'U' + } +] + +const vToZitems: TransferListItem[] = [ + { + label: 'Item V', + value: 'V', + id: 'V' + }, + { + label: 'Item W', + value: 'W', + id: 'W' + }, + { + label: 'Item X', + value: 'X', + id: 'X' + }, + { + label: 'Item Y', + value: 'Y', + id: 'Y' + }, + { + label: 'Item Z', + value: 'Z', + id: 'Z' + } +] + +const allItems: TransferListItem[] = [ + ...aToEitems, + ...fToJitems, + ...kToÑitems, + ...oToUitems, + ...vToZitems +] + +const defaultSelected: TransferListItem[] = [ + { + label: 'Item B', + value: 'B', + id: 'B' + }, + { + label: 'Item C', + value: 'C', + id: 'C' + }, + { + label: 'Item D', + value: 'D', + id: 'D' + } +] + +export default meta +type Story = StoryObj + +const onChangeFn = (items: TransferListItem[]) => { + console.log('%cSelected items:', 'color: #00d400; font-weight: bold;', items) +} + +export const Default: Story = { + render: () => +} + +/** + * Use the `defaultSelected` prop to set the items that are selected by default. + * The component will render with the selected items on the right list and remove them from the left list of available items. + */ + +export const WithDefaultSelected: Story = { + render: () => ( + + ) +} + +/** + * Use the `leftLabel` and `rightLabel` props to set the labels of the lists. + * Both left and right labels are optional. + */ + +export const WithLabels: Story = { + render: () => ( + + ) +} + +/** + * You can change the items of the left list dynamically by changing the `availableItems` prop. This is just an example using the SelectAdvanced component. + * - In this example, the `availableItems` prop changes when the user selects an option from the select and make the items of the left list change. + * - The right list will keep the selected items even if they are not available in the left list. + * - If there is an item in the right list that belongs to new available items, it will remain on the right list and will be filtered out from the new available items for the left list. + * - If you move an item from the right list to the left list but this item does not exist anymore in the current available items, it wont appear on the left list. + */ + +export const WithChangingAvailableItems: Story = { + render: () => +} + +type SelectOption = 'All' | 'A to E' | 'F to J' | 'K to Ñ' | 'O to U' | 'V to Z' +const WithChangingAvailableItemsComponent = () => { + const [items, setItems] = useState(allItems) + const options: SelectOption[] = ['All', 'A to E', 'F to J', 'K to Ñ', 'O to U', 'V to Z'] + + const handleChangeSelect = (selected: string) => { + const castedSelected = selected as SelectOption + switch (castedSelected) { + case 'All': + setItems(allItems) + break + case 'A to E': + setItems(aToEitems) + break + case 'F to J': + setItems(fToJitems) + break + case 'K to Ñ': + setItems(kToÑitems) + break + case 'O to U': + setItems(oToUitems) + break + case 'V to Z': + setItems(vToZitems) + break + } + } + + return ( +
+
+
+ +
+ + +
+
+ ) +} diff --git a/packages/design-system/tests/component/transfer-list/TransferList.spec.tsx b/packages/design-system/tests/component/transfer-list/TransferList.spec.tsx new file mode 100644 index 000000000..9e1e19a83 --- /dev/null +++ b/packages/design-system/tests/component/transfer-list/TransferList.spec.tsx @@ -0,0 +1,689 @@ +import { + TransferList, + TransferListItem +} from '../../../src/lib/components/transfer-list/TransferList' + +const availableItems: TransferListItem[] = [ + { + label: 'Item A', + value: 'A', + id: 'A' + }, + { + label: 'Item B', + value: 'B', + id: 'B' + }, + { + label: 'Item C', + value: 'C', + id: 'C' + }, + { + label: 'Item D', + value: 'D', + id: 'D' + }, + { + label: 'Item E', + value: 'E', + id: 'E' + } +] + +describe('TransferList', () => { + beforeEach(() => { + cy.viewport(1280, 720) + }) + it('should render correctly', () => { + cy.mount() + + cy.findByTestId('left-list-group').as('leftList') + cy.findByTestId('actions-column').as('actionsColumn') + cy.findByTestId('right-list-group').as('rightList') + + cy.get('@leftList').should('exist') + cy.get('@leftList').children().should('have.length', 5) + cy.get('@leftList').within(() => { + cy.findByText('Item A').should('exist') + cy.findByText('Item B').should('exist') + cy.findByText('Item C').should('exist') + cy.findByText('Item D').should('exist') + cy.findByText('Item E').should('exist') + }) + + cy.get('@rightList').should('exist') + cy.get('@rightList').children().should('have.length', 0) + }) + + it('should render correctly with default selected items', () => { + cy.mount( + + ) + + cy.findByTestId('left-list-group').as('leftList') + cy.findByTestId('actions-column').as('actionsColumn') + cy.findByTestId('right-list-group').as('rightList') + + cy.get('@leftList').should('exist') + cy.get('@leftList').children().should('have.length', 3) + cy.get('@leftList').within(() => { + cy.findByText('Item A').should('not.exist') + cy.findByText('Item B').should('exist') + cy.findByText('Item C').should('not.exist') + cy.findByText('Item D').should('exist') + cy.findByText('Item E').should('exist') + }) + + cy.get('@rightList').should('exist') + cy.get('@rightList').children().should('have.length', 2) + cy.get('@rightList').within(() => { + cy.findByText('Item A').should('exist') + cy.findByText('Item C').should('exist') + }) + }) + + it('should move all items to the right', () => { + cy.mount() + + cy.findByTestId('left-list-group').as('leftList') + cy.findByTestId('actions-column').as('actionsColumn') + cy.findByTestId('right-list-group').as('rightList') + + cy.get('@leftList').should('exist') + cy.get('@leftList').children().should('have.length', 5) + cy.get('@rightList').should('exist') + cy.get('@rightList').children().should('have.length', 0) + + cy.get('@actionsColumn').within(() => { + cy.findByLabelText('move all right').click() + }) + + cy.get('@leftList').children().should('have.length', 0) + cy.get('@rightList').children().should('have.length', 5) + }) + + it('should move all items to the left', () => { + cy.mount( + + ) + + cy.findByTestId('left-list-group').as('leftList') + cy.findByTestId('actions-column').as('actionsColumn') + cy.findByTestId('right-list-group').as('rightList') + + cy.get('@leftList').should('exist') + cy.get('@leftList').children().should('have.length', 3) + cy.get('@rightList').should('exist') + cy.get('@rightList').children().should('have.length', 2) + + cy.get('@actionsColumn').within(() => { + cy.findByLabelText('move all left').click() + }) + + cy.get('@leftList').children().should('have.length', 5) + cy.get('@rightList').children().should('have.length', 0) + }) + + it('should move selected items to the right', () => { + cy.mount() + + cy.findByTestId('left-list-group').as('leftList') + cy.findByTestId('actions-column').as('actionsColumn') + cy.findByTestId('right-list-group').as('rightList') + + cy.get('@leftList').should('exist') + cy.get('@leftList').children().should('have.length', 5) + cy.get('@rightList').should('exist') + cy.get('@rightList').children().should('have.length', 0) + + cy.get('@actionsColumn').within(() => { + cy.findByLabelText('move selected to right').should('be.disabled') + }) + + cy.get('@leftList').within(() => { + cy.findByLabelText('Item A').click() + cy.findByLabelText('Item C').click() + }) + + cy.get('@actionsColumn').within(() => { + cy.findByLabelText('move selected to right').should('not.be.disabled') + cy.findByLabelText('move selected to right').click() + }) + + cy.get('@leftList').children().should('have.length', 3) + cy.get('@rightList').children().should('have.length', 2) + + cy.get('@leftList').within(() => { + cy.findByText('Item A').should('not.exist') + cy.findByText('Item B').should('exist') + cy.findByText('Item C').should('not.exist') + cy.findByText('Item D').should('exist') + cy.findByText('Item E').should('exist') + }) + + cy.get('@rightList').within(() => { + cy.findByText('Item A').should('exist') + cy.findByText('Item B').should('not.exist') + cy.findByText('Item C').should('exist') + cy.findByText('Item D').should('not.exist') + cy.findByText('Item E').should('not.exist') + }) + }) + + it('should move selected items to the left', () => { + cy.mount( + + ) + + cy.findByTestId('left-list-group').as('leftList') + cy.findByTestId('actions-column').as('actionsColumn') + cy.findByTestId('right-list-group').as('rightList') + + cy.get('@leftList').should('exist') + cy.get('@leftList').children().should('have.length', 3) + cy.get('@rightList').should('exist') + cy.get('@rightList').children().should('have.length', 2) + + cy.get('@actionsColumn').within(() => { + cy.findByLabelText('move selected to left').should('be.disabled') + }) + + cy.get('@rightList').within(() => { + cy.findByLabelText('Item A').click() + cy.findByLabelText('Item C').click() + }) + + cy.get('@actionsColumn').within(() => { + cy.findByLabelText('move selected to left').should('not.be.disabled') + cy.findByLabelText('move selected to left').click() + }) + + cy.get('@leftList').children().should('have.length', 5) + cy.get('@rightList').children().should('have.length', 0) + + cy.get('@leftList').within(() => { + cy.findByText('Item A').should('exist') + cy.findByText('Item B').should('exist') + cy.findByText('Item C').should('exist') + cy.findByText('Item D').should('exist') + cy.findByText('Item E').should('exist') + }) + }) + + it('should show left and right labels', () => { + cy.mount( + + ) + + cy.findByTestId('left-list-group').as('leftList') + cy.findByTestId('actions-column').as('actionsColumn') + cy.findByTestId('right-list-group').as('rightList') + + cy.findByText('Left label').should('exist') + cy.findByText('Right label').should('exist') + }) + + it('should check and uncheck items', () => { + cy.mount() + + cy.findByTestId('left-list-group').as('leftList') + cy.findByTestId('actions-column').as('actionsColumn') + cy.findByTestId('right-list-group').as('rightList') + + cy.get('@leftList').should('exist') + cy.get('@leftList').children().should('have.length', 5) + cy.get('@rightList').should('exist') + cy.get('@rightList').children().should('have.length', 0) + + cy.get('@leftList').within(() => { + cy.findByLabelText('Item A').should('not.be.checked') + cy.findByLabelText('Item C').should('not.be.checked') + cy.findByLabelText('Item E').should('not.be.checked') + + cy.findByLabelText('Item A').click() + cy.findByLabelText('Item C').click() + cy.findByLabelText('Item E').click() + }) + + cy.get('@leftList').within(() => { + cy.findByLabelText('Item A').should('be.checked') + cy.findByLabelText('Item C').should('be.checked') + cy.findByLabelText('Item E').should('be.checked') + }) + + cy.get('@rightList').within(() => { + cy.findByLabelText('Item A').should('not.exist') + cy.findByLabelText('Item C').should('not.exist') + cy.findByLabelText('Item E').should('not.exist') + }) + + cy.get('@leftList').within(() => { + cy.findByLabelText('Item A').click() + cy.findByLabelText('Item C').click() + cy.findByLabelText('Item E').click() + }) + + cy.get('@leftList').within(() => { + cy.findByLabelText('Item A').should('not.be.checked') + cy.findByLabelText('Item C').should('not.be.checked') + cy.findByLabelText('Item E').should('not.be.checked') + }) + }) + + describe('drag and drop', () => { + it('should sort item A for Item B', () => { + cy.mount( + + ) + + cy.findByTestId('right-list-group').as('rightList') + + // Check initial order of items + cy.get('@rightList').within(() => { + cy.get('[aria-roledescription="sortable"]').as('sortableItems') + cy.get('@sortableItems').should('have.length', 3) + + cy.get('@sortableItems').spread((firstItem, secondItem, thirdItem) => { + cy.wrap(firstItem).should('contain.text', 'Item A') + cy.wrap(secondItem).should('contain.text', 'Item B') + cy.wrap(thirdItem).should('contain.text', 'Item C') + }) + }) + + cy.wait(1000) + + cy.findAllByLabelText('press space to select and keys to drag').as('dragHandles') + + cy.get('@dragHandles').should('have.length', 3) + + cy.get('@dragHandles') + .first() + .focus() + .type('{enter}') + .type('{downArrow}') + .type('{downArrow}') // with two presses of down arrow, item A should be moved to the Item B position + .type('{enter}') + + // Check the new order of items + cy.get('@rightList').within(() => { + cy.get('[aria-roledescription="sortable"]').as('sortableItems') + cy.get('@sortableItems').should('have.length', 3) + + cy.get('@sortableItems').spread((firstItem, secondItem, thirdItem) => { + cy.wrap(firstItem).should('contain.text', 'Item B') + cy.wrap(secondItem).should('contain.text', 'Item A') + cy.wrap(thirdItem).should('contain.text', 'Item C') + }) + }) + }) + + it('should sort item C to the top of the list', () => { + cy.mount( + + ) + + cy.findByTestId('right-list-group').as('rightList') + + // Check initial order of items + cy.get('@rightList').within(() => { + cy.get('[aria-roledescription="sortable"]').as('sortableItems') + cy.get('@sortableItems').should('have.length', 3) + + cy.get('@sortableItems').spread((firstItem, secondItem, thirdItem) => { + cy.wrap(firstItem).should('contain.text', 'Item A') + cy.wrap(secondItem).should('contain.text', 'Item B') + cy.wrap(thirdItem).should('contain.text', 'Item C') + }) + }) + + cy.wait(1000) + + cy.findAllByLabelText('press space to select and keys to drag').as('dragHandles') + + cy.get('@dragHandles').should('have.length', 3) + + cy.get('@dragHandles') + .last() + .focus() + .type('{enter}') + .type('{upArrow}') + .type('{upArrow}') + .type('{upArrow}') + .type('{upArrow}') // with 4 press of up arrow, item C should be moved to the top of the list + .type('{enter}') + + // Check the new order of items + cy.get('@rightList').within(() => { + cy.get('[aria-roledescription="sortable"]').as('sortableItems') + cy.get('@sortableItems').should('have.length', 3) + + cy.get('@sortableItems').spread((firstItem, secondItem, thirdItem) => { + cy.wrap(firstItem).should('contain.text', 'Item C') + cy.wrap(secondItem).should('contain.text', 'Item A') + cy.wrap(thirdItem).should('contain.text', 'Item B') + }) + }) + }) + }) + + describe('onChange calls', () => { + it('should call onChange correctly when moving checked items to right', () => { + const onChange = cy.stub().as('onChange') + + cy.mount() + + cy.findByTestId('left-list-group').as('leftList') + cy.findByTestId('actions-column').as('actionsColumn') + cy.findByTestId('right-list-group').as('rightList') + + cy.get('@leftList').should('exist') + cy.get('@leftList').children().should('have.length', 5) + cy.get('@rightList').should('exist') + cy.get('@rightList').children().should('have.length', 0) + + cy.get('@leftList').within(() => { + cy.findByLabelText('Item A').click() + cy.findByLabelText('Item C').click() + cy.findByLabelText('Item E').click() + }) + + cy.get('@actionsColumn').within(() => { + cy.findByLabelText('move selected to right').click() + }) + cy.get('@onChange').should('have.been.calledOnce') + cy.get('@onChange').should('have.been.calledWith', [ + { + label: 'Item A', + value: 'A', + id: 'A' + }, + { + label: 'Item C', + value: 'C', + id: 'C' + }, + { + label: 'Item E', + value: 'E', + id: 'E' + } + ]) + }) + + it('should call onChange correctly when moving checked items to left', () => { + const onChange = cy.stub().as('onChange') + + cy.mount( + + ) + + cy.findByTestId('left-list-group').as('leftList') + cy.findByTestId('actions-column').as('actionsColumn') + cy.findByTestId('right-list-group').as('rightList') + + cy.get('@leftList').should('exist') + cy.get('@leftList').children().should('have.length', 3) + cy.get('@rightList').should('exist') + cy.get('@rightList').children().should('have.length', 2) + + cy.get('@rightList').within(() => { + cy.findByLabelText('Item A').click() + cy.findByLabelText('Item C').click() + }) + + cy.get('@actionsColumn').within(() => { + cy.findByLabelText('move selected to left').click() + }) + + cy.get('@onChange').should('have.been.calledOnce') + cy.get('@onChange').should('have.been.calledWith', []) + }) + + it('should call onChange correctly when moving all items to right', () => { + const onChange = cy.stub().as('onChange') + + cy.mount() + + cy.findByTestId('left-list-group').as('leftList') + cy.findByTestId('actions-column').as('actionsColumn') + cy.findByTestId('right-list-group').as('rightList') + + cy.get('@leftList').should('exist') + cy.get('@leftList').children().should('have.length', 5) + cy.get('@rightList').should('exist') + cy.get('@rightList').children().should('have.length', 0) + + cy.get('@actionsColumn').within(() => { + cy.findByLabelText('move all right').click() + }) + + cy.get('@onChange').should('have.been.calledOnce') + cy.get('@onChange').should('have.been.calledWith', [ + { + label: 'Item A', + value: 'A', + id: 'A' + }, + { + label: 'Item B', + value: 'B', + id: 'B' + }, + { + label: 'Item C', + value: 'C', + id: 'C' + }, + { + label: 'Item D', + value: 'D', + id: 'D' + }, + { + label: 'Item E', + value: 'E', + id: 'E' + } + ]) + }) + + it('should call onChange correctly when moving all items to left', () => { + const onChange = cy.stub().as('onChange') + + cy.mount( + + ) + + cy.findByTestId('left-list-group').as('leftList') + cy.findByTestId('actions-column').as('actionsColumn') + cy.findByTestId('right-list-group').as('rightList') + + cy.get('@leftList').should('exist') + cy.get('@leftList').children().should('have.length', 3) + cy.get('@rightList').should('exist') + cy.get('@rightList').children().should('have.length', 2) + + cy.get('@actionsColumn').within(() => { + cy.findByLabelText('move all left').click() + }) + + cy.get('@onChange').should('have.been.calledOnce') + cy.get('@onChange').should('have.been.calledWith', []) + }) + + it('should call onChange correctly when sorting items', () => { + const onChange = cy.stub().as('onChange') + + cy.mount( + + ) + + cy.wait(1000) + + cy.findAllByLabelText('press space to select and keys to drag').as('dragHandles') + + cy.get('@dragHandles').should('have.length', 3) + + cy.get('@dragHandles') + .first() + .focus() + .type('{enter}') + .type('{downArrow}') + .type('{downArrow}') // with two presses of down arrow, item A should be moved to the Item B position + .type('{enter}') + + cy.get('@onChange').should('have.been.calledWith', [ + { + label: 'Item B', + value: 'B', + id: 'B' + }, + { + label: 'Item A', + value: 'A', + id: 'A' + }, + { + label: 'Item C', + value: 'C', + id: 'C' + } + ]) + }) + }) +}) diff --git a/public/locales/en/createCollection.json b/public/locales/en/createCollection.json index 03fe2a959..5ae082251 100644 --- a/public/locales/en/createCollection.json +++ b/public/locales/en/createCollection.json @@ -45,14 +45,35 @@ "invalid": "Email is not a valid email" }, "metadataFields": { - "label": "Metadata Fields", - "helperText": "Choose the metadata fields to use in dataset templates and when adding a dataset to this dataverse." + "sectionLabel": "Metadata Fields", + "helperText": "Choose the metadata fields to use in dataset templates and when adding a dataset to this dataverse.", + "useMetadataFieldsFrom": "Use metadata fields from", + "citationMetadata": "Citation Metadata (Required)", + "geospatialMetadata": "Geospatial Metadata", + "socialScienceMetadata": "Social Science and Humanities Metadata", + "astrophysicsMetadata": "Astronomy and Astrophysics Metadata", + "biomedicalMetadata": "Life Sciences Metadata", + "journalMetadata": "Journal Metadata", + "inputLevelsTable": { + "hideTableAriaLabel": "Hide input levels table", + "requiredByDataverse": "Required by Dataverse", + "hidden": "Hidden", + "optional": "Optional", + "required": "Required", + "conditionallyRequired": "Conditionally Required" + } }, "browseSearchFacets": { "label": "Browse/Search Facets", "helperText": "Choose the metadata fields to use as facets for browsing datasets and dataverses in this dataverse." } }, + "confirmResetModal": { + "title": "Reset Modifications", + "warning": "Are you sure you want to reset the selected metadata fields? If you do this, any customizations (hidden, required, optional) you have done will no longer appear.", + "continue": "Continue", + "cancel": "Cancel" + }, "submitStatus": { "success": "Collection created successfully." }, diff --git a/public/locales/en/datasetMetadataForm.json b/public/locales/en/datasetMetadataForm.json index 29bb4a830..e547a0894 100644 --- a/public/locales/en/datasetMetadataForm.json +++ b/public/locales/en/datasetMetadataForm.json @@ -28,8 +28,6 @@ "submitting": "Form Submitting", "submissionSuccess": "Form submission successful" }, - "addRowButton": "Add", - "deleteRowButton": "Delete", "saveButton": { "createMode": "Save Dataset", "editMode": "Save Changes" diff --git a/public/locales/en/shared.json b/public/locales/en/shared.json index 2d2caa7dc..55aedf5cd 100644 --- a/public/locales/en/shared.json +++ b/public/locales/en/shared.json @@ -1,3 +1,5 @@ { - "asterisksIndicateRequiredFields": "Asterisks indicate required fields" + "asterisksIndicateRequiredFields": "Asterisks indicate required fields", + "remove": "Remove", + "add": "Add" } diff --git a/src/collection/domain/models/Collection.ts b/src/collection/domain/models/Collection.ts index 2ea8770ff..dca9ed2c5 100644 --- a/src/collection/domain/models/Collection.ts +++ b/src/collection/domain/models/Collection.ts @@ -7,6 +7,13 @@ export interface Collection { isReleased: boolean description?: string affiliation?: string + inputLevels?: CollectionInputLevel[] +} + +export interface CollectionInputLevel { + datasetFieldName: string + include: boolean + required: boolean } export const ROOT_COLLECTION_ALIAS = 'root' diff --git a/src/collection/domain/useCases/DTOs/CollectionDTO.ts b/src/collection/domain/useCases/DTOs/CollectionDTO.ts index 1e32dc172..a6728733a 100644 --- a/src/collection/domain/useCases/DTOs/CollectionDTO.ts +++ b/src/collection/domain/useCases/DTOs/CollectionDTO.ts @@ -3,6 +3,11 @@ export interface CollectionDTO { name: string contacts: string[] type: CollectionType + affiliation?: string + description?: string + metadataBlockNames?: string[] + facetIds?: string[] + inputLevels?: CollectionInputLevelDTO[] } export enum CollectionType { @@ -17,6 +22,12 @@ export enum CollectionType { DEPARTMENT = 'DEPARTMENT' } +export interface CollectionInputLevelDTO { + datasetFieldName: string + include: boolean + required: boolean +} + export const collectionTypeOptions = { RESEARCHERS: { label: 'Researchers', diff --git a/src/collection/infrastructure/mappers/JSCollectionMapper.ts b/src/collection/infrastructure/mappers/JSCollectionMapper.ts index e4d036fa3..988946da7 100644 --- a/src/collection/infrastructure/mappers/JSCollectionMapper.ts +++ b/src/collection/infrastructure/mappers/JSCollectionMapper.ts @@ -21,7 +21,8 @@ export class JSCollectionMapper { jsCollection.name, jsCollection.alias, jsCollection.isPartOf - ) + ), + inputLevels: jsCollection.inputLevels } } diff --git a/src/files/domain/models/FilePreview.ts b/src/files/domain/models/FilePreview.ts index b4c6b20c8..74426be1e 100644 --- a/src/files/domain/models/FilePreview.ts +++ b/src/files/domain/models/FilePreview.ts @@ -12,4 +12,8 @@ export interface FilePreview { ingest: FileIngest metadata: FileMetadata permissions: FilePermissions + releaseOrCreateDate?: Date + someDatasetVersionHasBeenReleased?: boolean + datasetPersistentId?: string + datasetName?: string } diff --git a/src/metadata-block-info/domain/models/MetadataBlockInfo.ts b/src/metadata-block-info/domain/models/MetadataBlockInfo.ts index 4e2cd657d..9ace52756 100644 --- a/src/metadata-block-info/domain/models/MetadataBlockInfo.ts +++ b/src/metadata-block-info/domain/models/MetadataBlockInfo.ts @@ -8,6 +8,17 @@ export interface MetadataBlockInfo { displayOnCreate: boolean } +export enum MetadataBlockName { + CITATION = 'citation', + GEOSPATIAL = 'geospatial', + ASTROPHYSICS = 'astrophysics', + BIOMEDICAL = 'biomedical', + CODE_META = 'codeMeta20', + COMPUTATIONAL_WORKFLOW = 'computationalworkflow', + JOURNAL = 'journal', + SOCIAL_SCIENCE = 'socialscience' +} + export interface MetadataBlockInfoWithMaybeValues extends MetadataBlockInfo { metadataFields: Record } diff --git a/src/metadata-block-info/domain/repositories/MetadataBlockInfoRepository.ts b/src/metadata-block-info/domain/repositories/MetadataBlockInfoRepository.ts index b7488d995..15c21e8aa 100644 --- a/src/metadata-block-info/domain/repositories/MetadataBlockInfoRepository.ts +++ b/src/metadata-block-info/domain/repositories/MetadataBlockInfoRepository.ts @@ -2,8 +2,9 @@ import { MetadataBlockInfoDisplayFormat, MetadataBlockInfo } from '../models/Met export interface MetadataBlockInfoRepository { getByName: (name: string) => Promise + getAllTemporal: (names: string[]) => Promise getDisplayedOnCreateByCollectionId: ( collectionId: number | string ) => Promise - getByColecctionId: (collectionId: number | string) => Promise + getByCollectionId: (collectionId: number | string) => Promise } diff --git a/src/metadata-block-info/domain/useCases/getAllMetadataBlocksInfoTemporal.ts b/src/metadata-block-info/domain/useCases/getAllMetadataBlocksInfoTemporal.ts new file mode 100644 index 000000000..03ebd4611 --- /dev/null +++ b/src/metadata-block-info/domain/useCases/getAllMetadataBlocksInfoTemporal.ts @@ -0,0 +1,11 @@ +import { MetadataBlockInfoRepository } from '../repositories/MetadataBlockInfoRepository' +import { MetadataBlockInfo } from '../models/MetadataBlockInfo' + +export async function getAllMetadataBlocksInfoTemporal( + metadataBlockInfoRepository: MetadataBlockInfoRepository, + names: string[] +): Promise { + return metadataBlockInfoRepository.getAllTemporal(names).catch((error: Error) => { + throw new Error(error.message) + }) +} diff --git a/src/metadata-block-info/domain/useCases/getMetadataBlockInfoByCollectionId.ts b/src/metadata-block-info/domain/useCases/getMetadataBlockInfoByCollectionId.ts index 76f32c51f..ab3fba345 100644 --- a/src/metadata-block-info/domain/useCases/getMetadataBlockInfoByCollectionId.ts +++ b/src/metadata-block-info/domain/useCases/getMetadataBlockInfoByCollectionId.ts @@ -5,7 +5,7 @@ export async function getMetadataBlockInfoByCollectionId( metadataBlockInfoRepository: MetadataBlockInfoRepository, collectionId: number | string ): Promise { - return metadataBlockInfoRepository.getByColecctionId(collectionId).catch((error: Error) => { + return metadataBlockInfoRepository.getByCollectionId(collectionId).catch((error: Error) => { throw new Error(error.message) }) } diff --git a/src/metadata-block-info/infrastructure/repositories/MetadataBlockInfoJSDataverseRepository.ts b/src/metadata-block-info/infrastructure/repositories/MetadataBlockInfoJSDataverseRepository.ts index 7eef0c166..a435bc552 100644 --- a/src/metadata-block-info/infrastructure/repositories/MetadataBlockInfoJSDataverseRepository.ts +++ b/src/metadata-block-info/infrastructure/repositories/MetadataBlockInfoJSDataverseRepository.ts @@ -23,7 +23,20 @@ export class MetadataBlockInfoJSDataverseRepository implements MetadataBlockInfo }) } - getByColecctionId(collectionIdOrAlias: number | string): Promise { + // TODO: This will be replaced to a new use case that will return all metadata blocks info + getAllTemporal(names: string[]): Promise { + const blockPromises = names.map((name) => getMetadataBlockByName.execute(name)) + + return Promise.all(blockPromises) + .then((jsMetadataBlockInfo: JSMetadataBlockInfo[]) => { + return jsMetadataBlockInfo + }) + .catch((error: ReadError) => { + throw new Error(error.message) + }) + } + + getByCollectionId(collectionIdOrAlias: number | string): Promise { return getCollectionMetadataBlocks .execute(collectionIdOrAlias) .then((metadataBlocks: MetadataBlockInfo[]) => { diff --git a/src/sections/Route.enum.ts b/src/sections/Route.enum.ts index a5a203dce..47abe7297 100644 --- a/src/sections/Route.enum.ts +++ b/src/sections/Route.enum.ts @@ -10,11 +10,12 @@ export enum Route { UPLOAD_DATASET_FILES = '/datasets/upload-files', EDIT_DATASET_METADATA = '/datasets/edit-metadata', FILES = '/files', - COLLECTIONS = '/collections', + COLLECTIONS = '/collections/:collectionId', CREATE_COLLECTION = '/collections/:ownerCollectionId/create' } export const RouteWithParams = { + COLLECTIONS: (collectionId?: string) => `/collections/${collectionId ?? 'root'}`, CREATE_COLLECTION: (ownerCollectionId?: string) => `/collections/${ownerCollectionId ?? ROOT_COLLECTION_ALIAS}/create` } diff --git a/src/sections/collection/CollectionFactory.tsx b/src/sections/collection/CollectionFactory.tsx index c3e7f6108..3d6af655c 100644 --- a/src/sections/collection/CollectionFactory.tsx +++ b/src/sections/collection/CollectionFactory.tsx @@ -1,10 +1,9 @@ import { ReactElement } from 'react' import { Collection } from './Collection' import { DatasetJSDataverseRepository } from '../../dataset/infrastructure/repositories/DatasetJSDataverseRepository' -import { useLocation, useSearchParams } from 'react-router-dom' +import { useLocation, useParams, useSearchParams } from 'react-router-dom' import { CollectionJSDataverseRepository } from '../../collection/infrastructure/repositories/CollectionJSDataverseRepository' import { INFINITE_SCROLL_ENABLED } from './config' -import { ROOT_COLLECTION_ALIAS } from '../../collection/domain/models/Collection' const datasetRepository = new DatasetJSDataverseRepository() const repository = new CollectionJSDataverseRepository() @@ -16,9 +15,9 @@ export class CollectionFactory { function CollectionWithSearchParams() { const [searchParams] = useSearchParams() + const { collectionId = 'root' } = useParams<{ collectionId: string }>() const location = useLocation() const page = searchParams.get('page') ? parseInt(searchParams.get('page') as string) : undefined - const id = searchParams.get('id') ? (searchParams.get('id') as string) : ROOT_COLLECTION_ALIAS const state = location.state as { created: boolean } | undefined const created = state?.created ?? false @@ -27,7 +26,7 @@ function CollectionWithSearchParams() { repository={repository} datasetRepository={datasetRepository} page={page} - id={id} + id={collectionId} created={created} infiniteScrollEnabled={INFINITE_SCROLL_ENABLED} /> diff --git a/src/sections/collection/datasets-list/dataset-card/DatasetCardHeader.tsx b/src/sections/collection/datasets-list/dataset-card/DatasetCardHeader.tsx index 995177fc4..942f338f9 100644 --- a/src/sections/collection/datasets-list/dataset-card/DatasetCardHeader.tsx +++ b/src/sections/collection/datasets-list/dataset-card/DatasetCardHeader.tsx @@ -8,6 +8,7 @@ import { DatasetVersion, DatasetNonNumericVersionSearchParam } from '../../../../dataset/domain/models/Dataset' +import { DvObjectType } from '../../../../shared/hierarchy/domain/models/UpwardHierarchyNode' interface DatasetCardHeaderProps { persistentId: string @@ -29,6 +30,7 @@ export function DatasetCardHeader({ persistentId, version }: DatasetCardHeaderPr
{version.title} diff --git a/src/sections/collection/datasets-list/dataset-card/DatasetCardThumbnail.tsx b/src/sections/collection/datasets-list/dataset-card/DatasetCardThumbnail.tsx index d8065b0fa..78af22d41 100644 --- a/src/sections/collection/datasets-list/dataset-card/DatasetCardThumbnail.tsx +++ b/src/sections/collection/datasets-list/dataset-card/DatasetCardThumbnail.tsx @@ -3,6 +3,7 @@ import { LinkToPage } from '../../../shared/link-to-page/LinkToPage' import { Route } from '../../../Route.enum' import { DatasetThumbnail } from '../../../dataset/dataset-thumbnail/DatasetThumbnail' import { DatasetPublishingStatus, DatasetVersion } from '../../../../dataset/domain/models/Dataset' +import { DvObjectType } from '../../../../shared/hierarchy/domain/models/UpwardHierarchyNode' interface DatasetCardThumbnailProps { persistentId: string @@ -17,7 +18,10 @@ export function DatasetCardThumbnail({ }: DatasetCardThumbnailProps) { return (
- + div >span { + margin-right: 0; + } +} + +.thumbnail { + width: 48px; + margin: 8px 12px 6px 0; + font-size: 2.8em; + + img { + vertical-align: top; + } +} + +.info { + display: flex; + color: $dv-subtext-color; +} + +.card-info-container { + display: flex; + font-size: $dv-font-size-sm; +} + +.description { + display: -webkit-box; -webkit-line-clamp: 3; line-clamp: 3; -webkit-box-orient: vertical; + flex-direction: column; + width: 100%; + overflow: hidden; + color: black; +} + +.date { + color: $dv-subtext-color; + +} + +.citation-box { + margin-top: 4px; + margin-bottom: .5em; + padding: 4px; + background-color: color.adjust($dv-primary-color, $lightness: 51%) ; +} + +.citation-box-deaccessioned { + margin-top: 4px; + margin-bottom: .5em; + padding: 4px; + background-color: color.adjust($dv-danger-box-color, $lightness: 6%); +} \ No newline at end of file diff --git a/src/sections/collection/datasets-list/file-card/FileCard.tsx b/src/sections/collection/datasets-list/file-card/FileCard.tsx new file mode 100644 index 000000000..a1900e4d6 --- /dev/null +++ b/src/sections/collection/datasets-list/file-card/FileCard.tsx @@ -0,0 +1,25 @@ +import styles from './FileCard.module.scss' +import { FileCardHeader } from './FileCardHeader' +import { FileCardThumbnail } from './FileCardThumbnail' +import { FileCardInfo } from './FileCardInfo' +import { FilePreview } from '../../../../files/domain/models/FilePreview' +import { Stack } from '@iqss/dataverse-design-system' + +interface FileCardProps { + filePreview: FilePreview + persistentId: string +} + +export function FileCard({ filePreview, persistentId }: FileCardProps) { + return ( +
+ +
+ + + + +
+
+ ) +} diff --git a/src/sections/collection/datasets-list/file-card/FileCardHeader.tsx b/src/sections/collection/datasets-list/file-card/FileCardHeader.tsx new file mode 100644 index 000000000..a8a887920 --- /dev/null +++ b/src/sections/collection/datasets-list/file-card/FileCardHeader.tsx @@ -0,0 +1,41 @@ +import styles from './FileCard.module.scss' +import { LinkToPage } from '../../../shared/link-to-page/LinkToPage' +import { Route } from '../../../Route.enum' +import { FilePreview } from '../../../../files/domain/models/FilePreview' +import { DatasetLabels } from '../../../dataset/dataset-labels/DatasetLabels' +import { FileCardIcon } from './FileCardIcon' +import { FileType } from '../../../../files/domain/models/FileMetadata' +import { FileCardHelper } from './FileCardHelper' +import { DvObjectType } from '../../../../shared/hierarchy/domain/models/UpwardHierarchyNode' + +interface FileCardHeaderProps { + filePreview: FilePreview +} + +export function FileCardHeader({ filePreview }: FileCardHeaderProps) { + const iconFileType = new FileType('text/tab-separated-values', 'Comma Separated Values') + return ( +
+
+ + {filePreview.name} + + +
+
+ +
+
+ ) +} diff --git a/src/sections/collection/datasets-list/file-card/FileCardHelper.ts b/src/sections/collection/datasets-list/file-card/FileCardHelper.ts new file mode 100644 index 000000000..ce506c1ce --- /dev/null +++ b/src/sections/collection/datasets-list/file-card/FileCardHelper.ts @@ -0,0 +1,48 @@ +import { + DatasetLabel, + DatasetLabelSemanticMeaning, + DatasetLabelValue, + DatasetNonNumericVersionSearchParam, + DatasetPublishingStatus +} from '../../../../dataset/domain/models/Dataset' +export class FileCardHelper { + static getDatasetSearchParams( + persistentId: string, + publishingStatus: DatasetPublishingStatus + ): Record { + const params: Record = { persistentId: persistentId } + if (publishingStatus === DatasetPublishingStatus.DRAFT) { + params.version = DatasetNonNumericVersionSearchParam.DRAFT + } + return params + } + static getFileSearchParams( + id: number, + publishingStatus: DatasetPublishingStatus + ): Record { + const params: Record = { id: id.toString() } + if (publishingStatus === DatasetPublishingStatus.DRAFT) { + params.datasetVersion = DatasetNonNumericVersionSearchParam.DRAFT + } + return params + } + + static getDatasetLabels( + datasetPublishingStatus: DatasetPublishingStatus, + someDatasetVersionHasBeenReleased: boolean | undefined + ) { + const labels: DatasetLabel[] = [] + if (datasetPublishingStatus === DatasetPublishingStatus.DRAFT) { + labels.push(new DatasetLabel(DatasetLabelSemanticMeaning.DATASET, DatasetLabelValue.DRAFT)) + } + if ( + someDatasetVersionHasBeenReleased == undefined || + someDatasetVersionHasBeenReleased == false + ) { + labels.push( + new DatasetLabel(DatasetLabelSemanticMeaning.WARNING, DatasetLabelValue.UNPUBLISHED) + ) + } + return labels + } +} diff --git a/src/sections/collection/datasets-list/file-card/FileCardIcon.tsx b/src/sections/collection/datasets-list/file-card/FileCardIcon.tsx new file mode 100644 index 000000000..c8f695c69 --- /dev/null +++ b/src/sections/collection/datasets-list/file-card/FileCardIcon.tsx @@ -0,0 +1,14 @@ +import styles from './FileCard.module.scss' +import { IconName } from '@iqss/dataverse-design-system' +import { FileType } from '../../../../files/domain/models/FileMetadata' +import { FileTypeToFileIconMap } from '../../../file/file-preview/FileTypeToFileIconMap' + +export function FileCardIcon({ type }: { type: FileType }) { + const icon = FileTypeToFileIconMap[type.value] || IconName.OTHER + + return ( + + {icon} + + ) +} diff --git a/src/sections/collection/datasets-list/file-card/FileCardInfo.tsx b/src/sections/collection/datasets-list/file-card/FileCardInfo.tsx new file mode 100644 index 000000000..c0cbafdd6 --- /dev/null +++ b/src/sections/collection/datasets-list/file-card/FileCardInfo.tsx @@ -0,0 +1,44 @@ +import styles from './FileCard.module.scss' +import { DateHelper } from '../../../../shared/helpers/DateHelper' +import { FilePreview } from '../../../../files/domain/models/FilePreview' +import { Stack } from '@iqss/dataverse-design-system' +import { LinkToPage } from '../../../shared/link-to-page/LinkToPage' +import { Route } from '../../../Route.enum' +import { FileChecksum } from '../../../dataset/dataset-files/files-table/file-info/file-info-cell/file-info-data/FileChecksum' +import { FileTabularData } from '../../../dataset/dataset-files/files-table/file-info/file-info-cell/file-info-data/FileTabularData' +import { FileCardHelper } from './FileCardHelper' +import { DvObjectType } from '../../../../shared/hierarchy/domain/models/UpwardHierarchyNode' + +interface FileCardInfoProps { + filePreview: FilePreview + persistentId: string +} + +export function FileCardInfo({ filePreview, persistentId }: FileCardInfoProps) { + return ( +
+ + + {DateHelper.toDisplayFormat(filePreview.metadata.depositDate)} -{' '} + + {filePreview.datasetName} + + + + + {filePreview.metadata.type.toDisplayFormat()} - {filePreview.metadata.size.toString()} + + + + +

{filePreview.metadata.description}

+
+
+ ) +} diff --git a/src/sections/collection/datasets-list/file-card/FileCardThumbnail.tsx b/src/sections/collection/datasets-list/file-card/FileCardThumbnail.tsx new file mode 100644 index 000000000..95bbe515d --- /dev/null +++ b/src/sections/collection/datasets-list/file-card/FileCardThumbnail.tsx @@ -0,0 +1,28 @@ +import styles from './FileCard.module.scss' +import { LinkToPage } from '../../../shared/link-to-page/LinkToPage' +import { Route } from '../../../Route.enum' +import { FileThumbnail } from '../../../dataset/dataset-files/files-table/file-info/file-info-cell/file-info-data/file-thumbnail/FileThumbnail' +import { FilePreview } from '../../../../files/domain/models/FilePreview' +import { FileCardHelper } from './FileCardHelper' +import { DvObjectType } from '../../../../shared/hierarchy/domain/models/UpwardHierarchyNode' + +interface FileCardThumbnailProps { + filePreview: FilePreview + thumbnail?: string +} + +export function FileCardThumbnail({ filePreview }: FileCardThumbnailProps) { + return ( +
+ + + +
+ ) +} diff --git a/src/sections/create-collection/CreateCollection.tsx b/src/sections/create-collection/CreateCollection.tsx index b7d1b493c..fd7884352 100644 --- a/src/sections/create-collection/CreateCollection.tsx +++ b/src/sections/create-collection/CreateCollection.tsx @@ -1,14 +1,29 @@ import { useEffect } from 'react' import { useTranslation } from 'react-i18next' import { Alert } from '@iqss/dataverse-design-system' +import { useDeepCompareMemo } from 'use-deep-compare' import { useCollection } from '../collection/useCollection' +import { useGetCollectionMetadataBlocksInfo } from './useGetCollectionMetadataBlocksInfo' +import { useGetAllMetadataBlocksInfo } from './useGetAllMetadataBlocksInfo' import { CollectionRepository } from '../../collection/domain/repositories/CollectionRepository' +import { MetadataBlockInfoRepository } from '../../metadata-block-info/domain/repositories/MetadataBlockInfoRepository' +import { MetadataBlockName } from '../../dataset/domain/models/Dataset' import { useLoading } from '../loading/LoadingContext' import { useSession } from '../session/SessionContext' -import { RequiredFieldText } from '../shared/form/RequiredFieldText/RequiredFieldText' +import { CollectionFormHelper } from './collection-form/CollectionFormHelper' +import { + CollectionForm, + CollectionFormData, + CollectionFormMetadataBlocks, + FormattedCollectionInputLevels, + FormattedCollectionInputLevelsWithoutParentBlockName, + INPUT_LEVELS_GROUPER, + METADATA_BLOCKS_NAMES_GROUPER, + USE_FIELDS_FROM_PARENT +} from './collection-form/CollectionForm' import { BreadcrumbsGenerator } from '../shared/hierarchy/BreadcrumbsGenerator' -import { CollectionForm, CollectionFormData } from './collection-form/CollectionForm' import { SeparationLine } from '../shared/layout/SeparationLine/SeparationLine' +import { RequiredFieldText } from '../shared/form/RequiredFieldText/RequiredFieldText' import { PageNotFound } from '../page-not-found/PageNotFound' import { CreateCollectionSkeleton } from './CreateCollectionSkeleton' import { useGetCollectionUserPermissions } from '../../shared/hooks/useGetCollectionUserPermissions' @@ -16,11 +31,13 @@ import { useGetCollectionUserPermissions } from '../../shared/hooks/useGetCollec interface CreateCollectionProps { ownerCollectionId: string collectionRepository: CollectionRepository + metadataBlockInfoRepository: MetadataBlockInfoRepository } export function CreateCollection({ ownerCollectionId, - collectionRepository + collectionRepository, + metadataBlockInfoRepository }: CreateCollectionProps) { const { t } = useTranslation('createCollection') const { isLoading, setIsLoading } = useLoading() @@ -39,17 +56,82 @@ export function CreateCollection({ const canUserAddCollection = Boolean(collectionUserPermissions?.canAddCollection) + // TODO:ME In edit mode, collection id should not be from the collection owner but from the collection being edited, but this can perhaps be differentiated by page. + const { metadataBlocksInfo, isLoading: isLoadingMetadataBlocksInfo } = + useGetCollectionMetadataBlocksInfo({ + collectionId: ownerCollectionId, + metadataBlockInfoRepository + }) + + const { allMetadataBlocksInfo, isLoading: isLoadingAllMetadataBlocksInfo } = + useGetAllMetadataBlocksInfo({ metadataBlockInfoRepository }) + + const baseInputLevels: FormattedCollectionInputLevels = useDeepCompareMemo(() => { + return CollectionFormHelper.defineBaseInputLevels(allMetadataBlocksInfo) + }, [allMetadataBlocksInfo]) + + const formattedCollectionInputLevels: FormattedCollectionInputLevelsWithoutParentBlockName = + useDeepCompareMemo(() => { + return CollectionFormHelper.formatCollectiontInputLevels(collection?.inputLevels) + }, [collection?.inputLevels]) + + const mergedInputLevels = useDeepCompareMemo(() => { + return CollectionFormHelper.mergeBaseAndDefaultInputLevels( + baseInputLevels, + formattedCollectionInputLevels + ) + }, [baseInputLevels, formattedCollectionInputLevels]) + + const defaultBlocksNames = useDeepCompareMemo( + () => + metadataBlocksInfo + .map((block) => block.name) + .reduce( + (acc, blockName) => { + acc[blockName as keyof CollectionFormMetadataBlocks] = true + return acc + }, + { + [MetadataBlockName.CITATION]: false, + [MetadataBlockName.GEOSPATIAL]: false, + [MetadataBlockName.SOCIAL_SCIENCE]: false, + [MetadataBlockName.ASTROPHYSICS]: false, + [MetadataBlockName.BIOMEDICAL]: false, + [MetadataBlockName.JOURNAL]: false + } + ), + [metadataBlocksInfo] + ) + useEffect(() => { - if (!isLoadingCollection && !isLoadingCollectionUserPermissions) { + if ( + !isLoadingCollection && + !isLoadingMetadataBlocksInfo && + !isLoadingAllMetadataBlocksInfo && + !isLoadingCollectionUserPermissions + ) { setIsLoading(false) } - }, [isLoading, isLoadingCollection, isLoadingCollectionUserPermissions, setIsLoading]) + }, [ + isLoading, + isLoadingCollection, + isLoadingMetadataBlocksInfo, + isLoadingAllMetadataBlocksInfo, + isLoadingCollectionUserPermissions, + setIsLoading + ]) if (!isLoadingCollection && !collection) { return } - if (isLoadingCollection || isLoadingCollectionUserPermissions || !collection) { + if ( + isLoadingCollection || + isLoadingMetadataBlocksInfo || + isLoadingAllMetadataBlocksInfo || + isLoadingCollectionUserPermissions || + !collection + ) { return } @@ -71,7 +153,10 @@ export function CreateCollection({ contacts: [{ value: user?.email ?? '' }], affiliation: user?.affiliation ?? '', storage: 'Local (Default)', - description: '' + description: '', + [USE_FIELDS_FROM_PARENT]: true, + [METADATA_BLOCKS_NAMES_GROUPER]: defaultBlocksNames, + [INPUT_LEVELS_GROUPER]: mergedInputLevels } return ( @@ -92,6 +177,7 @@ export function CreateCollection({ collectionRepository={collectionRepository} ownerCollectionId={ownerCollectionId} defaultValues={formDefaultValues} + allMetadataBlocksInfo={allMetadataBlocksInfo} /> ) diff --git a/src/sections/create-collection/CreateCollectionFactory.tsx b/src/sections/create-collection/CreateCollectionFactory.tsx index 339032878..1e65d3b9b 100644 --- a/src/sections/create-collection/CreateCollectionFactory.tsx +++ b/src/sections/create-collection/CreateCollectionFactory.tsx @@ -3,8 +3,10 @@ import { useParams } from 'react-router-dom' import { CollectionJSDataverseRepository } from '../../collection/infrastructure/repositories/CollectionJSDataverseRepository' import { CreateCollection } from './CreateCollection' import { ROOT_COLLECTION_ALIAS } from '../../collection/domain/models/Collection' +import { MetadataBlockInfoJSDataverseRepository } from '../../metadata-block-info/infrastructure/repositories/MetadataBlockInfoJSDataverseRepository' const collectionRepository = new CollectionJSDataverseRepository() +const metadataBlockInfoRepository = new MetadataBlockInfoJSDataverseRepository() export class CreateCollectionFactory { static create(): ReactElement { @@ -19,6 +21,7 @@ function CreateCollectionWithParams() { ) diff --git a/src/sections/create-collection/CreateCollectionSkeleton.tsx b/src/sections/create-collection/CreateCollectionSkeleton.tsx index 6dfe6a60e..b3fd3ef4a 100644 --- a/src/sections/create-collection/CreateCollectionSkeleton.tsx +++ b/src/sections/create-collection/CreateCollectionSkeleton.tsx @@ -69,8 +69,8 @@ export const CreateCollectionSkeleton = () => ( - - + + diff --git a/src/sections/create-collection/collection-form/CollectionForm.tsx b/src/sections/create-collection/collection-form/CollectionForm.tsx index 151372002..58a1b4fa3 100644 --- a/src/sections/create-collection/collection-form/CollectionForm.tsx +++ b/src/sections/create-collection/collection-form/CollectionForm.tsx @@ -8,17 +8,24 @@ import { CollectionType, CollectionStorage } from '../../../collection/domain/useCases/DTOs/CollectionDTO' -import { SeparationLine } from '../../shared/layout/SeparationLine/SeparationLine' import { SubmissionStatus, useSubmitCollection } from './useSubmitCollection' +import { ReducedMetadataBlockInfo } from '../useGetAllMetadataBlocksInfo' +import { MetadataBlockName } from '../../../metadata-block-info/domain/models/MetadataBlockInfo' +import { SeparationLine } from '../../shared/layout/SeparationLine/SeparationLine' import { TopFieldsSection } from './top-fields-section/TopFieldsSection' import { MetadataFieldsSection } from './metadata-fields-section/MetadataFieldsSection' import { BrowseSearchFacetsSection } from './browse-search-facets-section/BrowseSearchFacetsSection' import styles from './CollectionForm.module.scss' +export const METADATA_BLOCKS_NAMES_GROUPER = 'metadataBlockNames' +export const USE_FIELDS_FROM_PARENT = 'useFieldsFromParent' +export const INPUT_LEVELS_GROUPER = 'inputLevels' + export interface CollectionFormProps { collectionRepository: CollectionRepository ownerCollectionId: string defaultValues: CollectionFormData + allMetadataBlocksInfo: ReducedMetadataBlockInfo[] } export type CollectionFormData = { @@ -30,7 +37,42 @@ export type CollectionFormData = { type: CollectionType | '' description: string contacts: { value: string }[] + [USE_FIELDS_FROM_PARENT]: boolean + [METADATA_BLOCKS_NAMES_GROUPER]: CollectionFormMetadataBlocks + [INPUT_LEVELS_GROUPER]: FormattedCollectionInputLevels } +export type CollectionFormMetadataBlock = Exclude< + MetadataBlockName, + MetadataBlockName.CODE_META | MetadataBlockName.COMPUTATIONAL_WORKFLOW +> + +export type CollectionFormMetadataBlocks = Record + +export type FormattedCollectionInputLevels = { + [key: string]: { + include: boolean + optionalOrRequired: CollectionFormInputLevelValue + parentBlockName: CollectionFormMetadataBlock + } +} + +export type FormattedCollectionInputLevelsWithoutParentBlockName = { + [K in keyof FormattedCollectionInputLevels]: Omit< + FormattedCollectionInputLevels[K], + 'parentBlockName' + > +} + +export const CollectionFormInputLevelOptions = { + OPTIONAL: 'optional', + REQUIRED: 'required' +} as const + +export type CollectionFormInputLevelValue = + (typeof CollectionFormInputLevelOptions)[keyof typeof CollectionFormInputLevelOptions] + +export const CONDITIONALLY_REQUIRED_FIELDS = ['producerName'] + // On the submit function callback, type is CollectionType as type field is required and wont never be "" export type CollectionFormValuesOnSubmit = Omit & { type: CollectionType @@ -39,7 +81,8 @@ export type CollectionFormValuesOnSubmit = Omit & { export const CollectionForm = ({ collectionRepository, ownerCollectionId, - defaultValues + defaultValues, + allMetadataBlocksInfo }: CollectionFormProps) => { const formContainerRef = useRef(null) const { t } = useTranslation('createCollection') @@ -83,7 +126,6 @@ export const CollectionForm = ({ return submissionStatus === SubmissionStatus.IsSubmitting || !formState.isDirty }, [submissionStatus, formState.isDirty]) - // TODO:ME Apply max width to container return (
- + diff --git a/src/sections/create-collection/collection-form/CollectionFormHelper.ts b/src/sections/create-collection/collection-form/CollectionFormHelper.ts new file mode 100644 index 000000000..d3296efcb --- /dev/null +++ b/src/sections/create-collection/collection-form/CollectionFormHelper.ts @@ -0,0 +1,214 @@ +import { CollectionInputLevel } from '../../../collection/domain/models/Collection' +import { + CollectionDTO, + CollectionInputLevelDTO +} from '../../../collection/domain/useCases/DTOs/CollectionDTO' +import { MetadataBlockName } from '../../../metadata-block-info/domain/models/MetadataBlockInfo' +import { ReducedMetadataBlockInfo, ReducedMetadataFieldInfo } from '../useGetAllMetadataBlocksInfo' +import { + CollectionFormMetadataBlock, + CollectionFormMetadataBlocks, + CONDITIONALLY_REQUIRED_FIELDS, + FormattedCollectionInputLevels, + FormattedCollectionInputLevelsWithoutParentBlockName +} from './CollectionForm' + +export class CollectionFormHelper { + public static replaceDotWithSlash = (str: string) => str.replace(/\./g, '/') + + public static replaceSlashWithDot = (str: string) => str.replace(/\//g, '.') + + public static defineBaseInputLevels( + allMetadataBlocksInfoReduced: ReducedMetadataBlockInfo[] + ): FormattedCollectionInputLevels { + const fields: FormattedCollectionInputLevels = {} + const childFields: FormattedCollectionInputLevels = {} + + allMetadataBlocksInfoReduced.forEach((block) => { + Object.entries(block.metadataFields).forEach(([_key, field]) => { + const normalizedFieldName = this.replaceDotWithSlash(field.name) + const isFieldRequiredByDataverse = field.isRequired + const isAConditionallyRequiredField = CONDITIONALLY_REQUIRED_FIELDS.includes(field.name) + + fields[normalizedFieldName] = { + include: true, + optionalOrRequired: + isFieldRequiredByDataverse && !isAConditionallyRequiredField ? 'required' : 'optional', + parentBlockName: block.name as CollectionFormMetadataBlock + } + + if (field.childMetadataFields) { + Object.entries(field.childMetadataFields).forEach(([_key, childField]) => { + const normalizedFieldName = this.replaceDotWithSlash(childField.name) + const isChildFieldRequiredByDataverse = childField.isRequired + const isAConditionallyRequiredChildField = CONDITIONALLY_REQUIRED_FIELDS.includes( + childField.name + ) + + childFields[normalizedFieldName] = { + include: true, + optionalOrRequired: + isChildFieldRequiredByDataverse && !isAConditionallyRequiredChildField + ? 'required' + : 'optional', + parentBlockName: block.name as CollectionFormMetadataBlock + } + }) + } + }) + }) + + return { + ...fields, + ...childFields + } + } + + public static formatCollectiontInputLevels( + collectionInputLevels: CollectionInputLevel[] | undefined + ): FormattedCollectionInputLevelsWithoutParentBlockName { + const result: FormattedCollectionInputLevelsWithoutParentBlockName = {} + + if (!collectionInputLevels) { + return result + } + + collectionInputLevels.forEach((level) => { + const { datasetFieldName, include, required } = level + const replaceDotWithSlash = (str: string) => str.replace(/\./g, '/') + const normalizedFieldName = replaceDotWithSlash(datasetFieldName) + + result[normalizedFieldName] = { + include, + optionalOrRequired: required ? 'required' : 'optional' + } + }) + return result + } + + public static mergeBaseAndDefaultInputLevels( + baseInputLevels: FormattedCollectionInputLevels, + formattedCollectionInputLevels: FormattedCollectionInputLevelsWithoutParentBlockName + ): FormattedCollectionInputLevels { + const result: FormattedCollectionInputLevels = { ...baseInputLevels } + + for (const key in formattedCollectionInputLevels) { + if (baseInputLevels[key]) { + result[key] = { + ...baseInputLevels[key], + ...formattedCollectionInputLevels[key], + parentBlockName: baseInputLevels[key].parentBlockName + } + } + } + + return result + } + + public static separateMetadataBlocksInfoByNames( + allMetadataBlocksInfo: ReducedMetadataBlockInfo[] + ): { + citationBlock: ReducedMetadataBlockInfo + geospatialBlock: ReducedMetadataBlockInfo + socialScienceBlock: ReducedMetadataBlockInfo + astrophysicsBlock: ReducedMetadataBlockInfo + biomedicalBlock: ReducedMetadataBlockInfo + journalBlock: ReducedMetadataBlockInfo + } { + const citationBlock: ReducedMetadataBlockInfo = allMetadataBlocksInfo.find( + (block) => block.name === MetadataBlockName.CITATION + ) as ReducedMetadataBlockInfo + + const geospatialBlock: ReducedMetadataBlockInfo = allMetadataBlocksInfo.find( + (block) => block.name === MetadataBlockName.GEOSPATIAL + ) as ReducedMetadataBlockInfo + + const socialScienceBlock: ReducedMetadataBlockInfo = allMetadataBlocksInfo.find( + (block) => block.name === MetadataBlockName.SOCIAL_SCIENCE + ) as ReducedMetadataBlockInfo + + const astrophysicsBlock: ReducedMetadataBlockInfo = allMetadataBlocksInfo.find( + (block) => block.name === MetadataBlockName.ASTROPHYSICS + ) as ReducedMetadataBlockInfo + + const biomedicalBlock: ReducedMetadataBlockInfo = allMetadataBlocksInfo.find( + (block) => block.name === MetadataBlockName.BIOMEDICAL + ) as ReducedMetadataBlockInfo + + const journalBlock: ReducedMetadataBlockInfo = allMetadataBlocksInfo.find( + (block) => block.name === MetadataBlockName.JOURNAL + ) as ReducedMetadataBlockInfo + + return { + citationBlock, + geospatialBlock, + socialScienceBlock, + astrophysicsBlock, + biomedicalBlock, + journalBlock + } + } + + public static formatFormMetadataBlockNamesToMetadataBlockNamesDTO( + formMetadataBlockNames: CollectionFormMetadataBlocks + ): string[] { + const result: CollectionDTO['metadataBlockNames'] = [] + + Object.entries(formMetadataBlockNames).forEach(([key, value]) => { + if (value) { + result.push(key) + } + }) + + return result + } + + public static formatFormInputLevelsToInputLevelsDTO( + metadataBlockNamesSelected: string[], + formCollectionInputLevels: FormattedCollectionInputLevels + ): CollectionInputLevelDTO[] { + const normalizedInputLevels = + this.replaceSlashBackToDotsFromInputLevels(formCollectionInputLevels) + + const result: CollectionInputLevelDTO[] = [] + + Object.entries(normalizedInputLevels).forEach(([key, value]) => { + if (metadataBlockNamesSelected.includes(value.parentBlockName)) { + result.push({ + datasetFieldName: key, + include: value.include, + required: value.optionalOrRequired === 'required' + }) + } + }) + + return result + } + + private static replaceSlashBackToDotsFromInputLevels( + inputLevels: FormattedCollectionInputLevels + ): FormattedCollectionInputLevels { + const result: FormattedCollectionInputLevels = {} + + Object.entries(inputLevels).forEach(([key, value]) => { + const replaceSlashWithDot = (str: string) => str.replace(/\//g, '.') + const normalizedFieldName = replaceSlashWithDot(key) + + result[normalizedFieldName] = value + }) + + return result + } + + public static getChildFieldSiblings = ( + childMetadataFields: Record, + targetChildFieldName: string + ): Record => { + return Object.entries(childMetadataFields) + .filter(([_key, { name }]) => name !== targetChildFieldName) + .reduce((acc, [key, field]) => { + acc[key] = field + return acc + }, {} as Record) + } +} diff --git a/src/sections/create-collection/collection-form/metadata-fields-section/MetadataFieldsSection.tsx b/src/sections/create-collection/collection-form/metadata-fields-section/MetadataFieldsSection.tsx index 8395ea535..1593d7206 100644 --- a/src/sections/create-collection/collection-form/metadata-fields-section/MetadataFieldsSection.tsx +++ b/src/sections/create-collection/collection-form/metadata-fields-section/MetadataFieldsSection.tsx @@ -1,20 +1,74 @@ import { useTranslation } from 'react-i18next' -import { Alert, Col, Form, Row } from '@iqss/dataverse-design-system' +import { Col, Form, Row, Stack } from '@iqss/dataverse-design-system' +import { ReducedMetadataBlockInfo } from '../../useGetAllMetadataBlocksInfo' +import { MetadataInputLevelFieldsBlock } from './metadata-input-level-fields-block/MetadataInputLevelFieldsBlock' +import { FieldsFromParentCheckbox } from './fields-from-parent-checkbox/FieldsFromParentCheckbox' +import { MetadataBlockName } from '../../../../metadata-block-info/domain/models/MetadataBlockInfo' +import { CollectionFormHelper } from '../CollectionFormHelper' +import { CollectionFormData } from '../CollectionForm' -export const MetadataFieldsSection = () => { - const { t } = useTranslation('createCollection') +interface MetadataFieldsSectionProps { + allMetadataBlocksInfo: ReducedMetadataBlockInfo[] + defaultValues: CollectionFormData +} + +export const MetadataFieldsSection = ({ + allMetadataBlocksInfo, + defaultValues +}: MetadataFieldsSectionProps) => { + const { t } = useTranslation('createCollection', { keyPrefix: 'fields.metadataFields' }) + + const { + citationBlock, + geospatialBlock, + socialScienceBlock, + astrophysicsBlock, + biomedicalBlock, + journalBlock + } = CollectionFormHelper.separateMetadataBlocksInfoByNames(allMetadataBlocksInfo) return ( - {t('fields.metadataFields.label')} + {t('sectionLabel')} - {t('fields.metadataFields.helperText')} + {t('helperText')} - - Work in progress - + + + + + + + + + + diff --git a/src/sections/create-collection/collection-form/metadata-fields-section/fields-from-parent-checkbox/ConfirmResetModificationsModal.tsx b/src/sections/create-collection/collection-form/metadata-fields-section/fields-from-parent-checkbox/ConfirmResetModificationsModal.tsx new file mode 100644 index 000000000..9412b8fb9 --- /dev/null +++ b/src/sections/create-collection/collection-form/metadata-fields-section/fields-from-parent-checkbox/ConfirmResetModificationsModal.tsx @@ -0,0 +1,37 @@ +import { Alert, Button, Modal } from '@iqss/dataverse-design-system' +import { useTranslation } from 'react-i18next' + +interface ConfirmResetModificationsModalProps { + showModal: boolean + onContinue: () => void + onCancel: () => void +} + +export const ConfirmResetModificationsModal = ({ + showModal, + onContinue, + onCancel +}: ConfirmResetModificationsModalProps) => { + const { t } = useTranslation('createCollection', { keyPrefix: 'confirmResetModal' }) + + return ( + + + {t('title')} + + + + {t('warning')} + + + + + + + + ) +} diff --git a/src/sections/create-collection/collection-form/metadata-fields-section/fields-from-parent-checkbox/FieldsFromParentCheckbox.tsx b/src/sections/create-collection/collection-form/metadata-fields-section/fields-from-parent-checkbox/FieldsFromParentCheckbox.tsx new file mode 100644 index 000000000..91f38cee5 --- /dev/null +++ b/src/sections/create-collection/collection-form/metadata-fields-section/fields-from-parent-checkbox/FieldsFromParentCheckbox.tsx @@ -0,0 +1,99 @@ +import { ChangeEvent, useId, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Controller, UseControllerProps, useFormContext, useWatch } from 'react-hook-form' +import { Form } from '@iqss/dataverse-design-system' +import { MetadataBlockName } from '../../../../../metadata-block-info/domain/models/MetadataBlockInfo' +import { ConfirmResetModificationsModal } from './ConfirmResetModificationsModal' +import { + CollectionFormData, + CollectionFormMetadataBlocks, + INPUT_LEVELS_GROUPER, + METADATA_BLOCKS_NAMES_GROUPER, + USE_FIELDS_FROM_PARENT +} from '../../CollectionForm' + +const ALL_INPUT_LEVEL_FIELDS = [ + MetadataBlockName.CITATION, + MetadataBlockName.GEOSPATIAL, + MetadataBlockName.SOCIAL_SCIENCE, + MetadataBlockName.ASTROPHYSICS, + MetadataBlockName.BIOMEDICAL, + MetadataBlockName.JOURNAL +] + +interface FieldsFromParentCheckboxProps { + defaultValues: CollectionFormData +} + +export const FieldsFromParentCheckbox = ({ defaultValues }: FieldsFromParentCheckboxProps) => { + const { t } = useTranslation('createCollection') + const checkboxID = useId() + const { control, setValue } = useFormContext() + const [showResetConfirmationModal, setShowResetConfirmationModal] = useState(false) + const hostCollectionFieldValue = useWatch({ name: 'hostCollection' }) as string + + const handleContinueWithReset = () => { + setValue(USE_FIELDS_FROM_PARENT, true) + + // Reset metadata block names checboxes to the inital value + ALL_INPUT_LEVEL_FIELDS.forEach((blockName) => { + const castedBlockName = blockName as keyof CollectionFormMetadataBlocks + + setValue( + `${METADATA_BLOCKS_NAMES_GROUPER}.${blockName}`, + defaultValues[METADATA_BLOCKS_NAMES_GROUPER][castedBlockName] + ) + }) + + // Reset input levels to the initial value + setValue(INPUT_LEVELS_GROUPER, defaultValues[INPUT_LEVELS_GROUPER]) + + closeModal() + } + + const openModal = () => setShowResetConfirmationModal(true) + const closeModal = () => setShowResetConfirmationModal(false) + + const rules: UseControllerProps['rules'] = {} + + return ( + <> + { + const handleChange = (e: ChangeEvent) => { + // Only if trying to check the checkbox, open the modal to confirm the reset + if (e.target.checked) { + openModal() + } else { + onChange(e) + } + } + return ( + + ) + }} + /> + + + + ) +} diff --git a/src/sections/create-collection/collection-form/metadata-fields-section/metadata-input-level-fields-block/MetadataInputLevelFieldsBlock.tsx b/src/sections/create-collection/collection-form/metadata-fields-section/metadata-input-level-fields-block/MetadataInputLevelFieldsBlock.tsx new file mode 100644 index 000000000..e3c0d67d9 --- /dev/null +++ b/src/sections/create-collection/collection-form/metadata-fields-section/metadata-input-level-fields-block/MetadataInputLevelFieldsBlock.tsx @@ -0,0 +1,153 @@ +import { ChangeEvent, useEffect, useId, useState } from 'react' +import { Controller, UseControllerProps, useFormContext, useWatch } from 'react-hook-form' +import { Button, Form, Stack } from '@iqss/dataverse-design-system' +import { CloseButton } from 'react-bootstrap' +import { MetadataBlockName } from '../../../../../metadata-block-info/domain/models/MetadataBlockInfo' +import { ReducedMetadataBlockInfo } from '../../../useGetAllMetadataBlocksInfo' +import { METADATA_BLOCKS_NAMES_GROUPER, USE_FIELDS_FROM_PARENT } from '../../CollectionForm' +import { InputLevelsTable } from './input-levels-table/InputLevelsTable' +import { useTranslation } from 'react-i18next' + +interface MetadataInputLevelFieldsBlockProps { + blockName: MetadataBlockName + blockDisplayName: string + reducedMetadataBlockInfo: ReducedMetadataBlockInfo +} + +export const MetadataInputLevelFieldsBlock = ({ + blockName, + blockDisplayName, + reducedMetadataBlockInfo +}: MetadataInputLevelFieldsBlockProps) => { + const checkboxID = useId() + const { control } = useFormContext() + const { t } = useTranslation('createCollection', { + keyPrefix: 'fields.metadataFields.inputLevelsTable' + }) + + const [inputLevelsTableStatus, setInputLevelsTableStatus] = useState({ + show: false, + asDisabled: false + }) + + const metadataBlockFieldName = `${METADATA_BLOCKS_NAMES_GROUPER}.${blockName}` + + const useFieldsFromParentCheckedValue = useWatch({ + name: USE_FIELDS_FROM_PARENT + }) as boolean + const metadataBlockCheckedValue = useWatch({ + name: metadataBlockFieldName + }) as boolean + + const isCitation = blockName === MetadataBlockName.CITATION + + const rules: UseControllerProps['rules'] = {} + + const handleEditInputLevels = () => { + setInputLevelsTableStatus({ + show: true, + asDisabled: false + }) + } + + const handleShowInputLevels = () => { + setInputLevelsTableStatus({ + show: true, + asDisabled: true + }) + } + + const handleHideInputLevelsTable = () => { + setInputLevelsTableStatus({ + show: false, + asDisabled: false + }) + } + + const handleIncludeBlockChange = ( + e: ChangeEvent, + formOnChange: (...event: unknown[]) => void + ) => { + // If input levels table is open, change the disabled status according to the block checked status + if (inputLevelsTableStatus.show) { + setInputLevelsTableStatus((currentStatus) => ({ + ...currentStatus, + asDisabled: !e.target.checked + })) + } + + formOnChange(e) + } + + // In order to close the table when use fields from parent change from unchecked to checked + useEffect(() => { + if (useFieldsFromParentCheckedValue) { + setInputLevelsTableStatus({ + show: false, + asDisabled: false + }) + } + }, [useFieldsFromParentCheckedValue]) + + return ( + + + ( + handleIncludeBlockChange(e, onChange)} + name={metadataBlockFieldName} + label={blockDisplayName} + checked={value as boolean} + isInvalid={invalid} + invalidFeedback={error?.message} + disabled={isCitation ? true : useFieldsFromParentCheckedValue} + ref={ref} + /> + )} + /> + + {/* If checked and use fields from parent not checked */} + {!inputLevelsTableStatus.show && + metadataBlockCheckedValue && + !useFieldsFromParentCheckedValue && ( + + )} + + {/* If checked and use fields from parent checked */} + {!inputLevelsTableStatus.show && + metadataBlockCheckedValue && + useFieldsFromParentCheckedValue && ( + + )} + {/* If just not checked */} + {!inputLevelsTableStatus.show && !metadataBlockCheckedValue && ( + + )} + + + + } + /> + + ) +} diff --git a/src/sections/create-collection/collection-form/metadata-fields-section/metadata-input-level-fields-block/input-levels-table/InputLevelFieldRow.tsx b/src/sections/create-collection/collection-form/metadata-fields-section/metadata-input-level-fields-block/input-levels-table/InputLevelFieldRow.tsx new file mode 100644 index 000000000..9c1d2c314 --- /dev/null +++ b/src/sections/create-collection/collection-form/metadata-fields-section/metadata-input-level-fields-block/input-levels-table/InputLevelFieldRow.tsx @@ -0,0 +1,136 @@ +import { ChangeEvent, useId } from 'react' +import { Controller, UseControllerProps, useFormContext, useWatch } from 'react-hook-form' +import cn from 'classnames' +import { Form } from '@iqss/dataverse-design-system' +import { ReducedMetadataFieldInfo } from '../../../../useGetAllMetadataBlocksInfo' +import { CONDITIONALLY_REQUIRED_FIELDS, INPUT_LEVELS_GROUPER } from '../../../CollectionForm' +import styles from './InputLevelsTable.module.scss' +import { CollectionFormHelper } from '../../../CollectionFormHelper' +import { RequiredOptionalRadios } from './RequiredOptionalRadios' +import { useTranslation } from 'react-i18next' + +interface InputLevelFieldRowProps { + metadataField: ReducedMetadataFieldInfo + disabled: boolean +} + +export const InputLevelFieldRow = ({ metadataField, disabled }: InputLevelFieldRowProps) => { + const { t } = useTranslation('createCollection', { + keyPrefix: 'fields.metadataFields.inputLevelsTable' + }) + const uniqueInputLevelRowID = useId() + const { control, setValue } = useFormContext() + + const includeCheckboxValue = useWatch({ + name: `${INPUT_LEVELS_GROUPER}.${metadataField.name}.include` + }) as boolean + + const { name, displayName, isRequired, childMetadataFields } = metadataField + + const isAConditionallyRequiredField = CONDITIONALLY_REQUIRED_FIELDS.includes(name) + + const rules: UseControllerProps['rules'] = {} + + const handleIncludeChange = ( + e: ChangeEvent, + formOnChange: (...event: unknown[]) => void + ) => { + if (e.target.checked === false) { + // If include is set to false, then field and child fields should be set to optional and include false + if (!childMetadataFields) { + setValue(`${INPUT_LEVELS_GROUPER}.${name}.optionalOrRequired`, 'optional') + } else { + setValue(`${INPUT_LEVELS_GROUPER}.${name}.optionalOrRequired`, 'optional') + + Object.values(childMetadataFields).forEach(({ name }) => { + setValue(`${INPUT_LEVELS_GROUPER}.${name}.include`, false) + setValue(`${INPUT_LEVELS_GROUPER}.${name}.optionalOrRequired`, 'optional') + }) + } + formOnChange(e) + } else { + formOnChange(e) + } + } + + return ( + <> + + + ( + handleIncludeChange(e, onChange)} + label={displayName} + checked={Boolean(value as boolean)} + disabled={disabled || isRequired} + ref={ref} + /> + )} + /> + + + {isRequired && !isAConditionallyRequiredField && ( + + {t('requiredByDataverse')} + + )} + {!childMetadataFields && (!isRequired || isAConditionallyRequiredField) && ( + + )} + + + {childMetadataFields && + Object.entries(childMetadataFields).map(([key, childField]) => { + const isAConditionallyRequiredChildField = CONDITIONALLY_REQUIRED_FIELDS.includes( + childField.name + ) + + return ( + + + + + + {childField.isRequired && !isAConditionallyRequiredChildField ? ( + + {t('requiredByDataverse')} + + ) : ( + + )} + + + ) + })} + + ) +} diff --git a/src/sections/create-collection/collection-form/metadata-fields-section/metadata-input-level-fields-block/input-levels-table/InputLevelsTable.module.scss b/src/sections/create-collection/collection-form/metadata-fields-section/metadata-input-level-fields-block/input-levels-table/InputLevelsTable.module.scss new file mode 100644 index 000000000..3c33011f4 --- /dev/null +++ b/src/sections/create-collection/collection-form/metadata-fields-section/metadata-input-level-fields-block/input-levels-table/InputLevelsTable.module.scss @@ -0,0 +1,63 @@ +@import 'node_modules/@iqss/dataverse-design-system/src/lib/assets/styles/design-tokens/colors.module'; + +.input-levels-table-container { + position: relative; + display: none; + max-height: 400px; + margin-bottom: 1rem; + overflow-y: auto; + border-radius: 6px; + box-shadow: 4px 4px 16px 0 rgb(0 0 0 / 10%); + + &--show { + display: block; + border: solid 1px $dv-secondary-color; + } + + @media (min-width: 768px) { + width: 70%; + margin-left: 3rem; + } +} + +.close-button-container { + position: sticky; + top: 0; + z-index: 1; + display: flex; + justify-content: flex-end; + padding: 0.35rem 0.5rem; + background-color: #fff; + border-bottom: solid 2px $dv-secondary-color; +} + +.input-level-row { + td { + width: 50%; + } + + .required-by-dataverse-label { + font-style: italic; + opacity: 0.5; + } + + &--child-field { + .displayName-disabled { + opacity: 0.5; + } + + .required-by-dataverse-label { + font-style: italic; + opacity: 0.5; + } + + td { + padding-block: 1rem; + + &:first-child { + padding-left: 3rem; + text-wrap: balance; + } + } + } +} diff --git a/src/sections/create-collection/collection-form/metadata-fields-section/metadata-input-level-fields-block/input-levels-table/InputLevelsTable.tsx b/src/sections/create-collection/collection-form/metadata-fields-section/metadata-input-level-fields-block/input-levels-table/InputLevelsTable.tsx new file mode 100644 index 000000000..3f18c5cf1 --- /dev/null +++ b/src/sections/create-collection/collection-form/metadata-fields-section/metadata-input-level-fields-block/input-levels-table/InputLevelsTable.tsx @@ -0,0 +1,38 @@ +import { ReactNode } from 'react' +import cn from 'classnames' +import { Table } from '@iqss/dataverse-design-system' +import { ReducedMetadataBlockInfo } from '../../../../useGetAllMetadataBlocksInfo' +import { InputLevelFieldRow } from './InputLevelFieldRow' +import styles from './InputLevelsTable.module.scss' + +interface InputLevelsTableProps { + show: boolean + disabled: boolean + blockMetadataInputLevelFields: ReducedMetadataBlockInfo + closeButton: ReactNode +} + +export const InputLevelsTable = ({ + show, + disabled, + blockMetadataInputLevelFields, + closeButton +}: InputLevelsTableProps) => { + return ( +
+
{closeButton}
+ + + + {Object.entries(blockMetadataInputLevelFields.metadataFields).map(([key, field]) => ( + + ))} + +
+
+ ) +} diff --git a/src/sections/create-collection/collection-form/metadata-fields-section/metadata-input-level-fields-block/input-levels-table/RequiredOptionalRadios.tsx b/src/sections/create-collection/collection-form/metadata-fields-section/metadata-input-level-fields-block/input-levels-table/RequiredOptionalRadios.tsx new file mode 100644 index 000000000..a150e3589 --- /dev/null +++ b/src/sections/create-collection/collection-form/metadata-fields-section/metadata-input-level-fields-block/input-levels-table/RequiredOptionalRadios.tsx @@ -0,0 +1,146 @@ +import { ChangeEvent } from 'react' +import { useTranslation } from 'react-i18next' +import { Form, Stack } from '@iqss/dataverse-design-system' +import { Controller, useFormContext, useWatch } from 'react-hook-form' +import { ReducedMetadataFieldInfo } from '../../../../useGetAllMetadataBlocksInfo' +import { + CollectionFormInputLevelValue, + CONDITIONALLY_REQUIRED_FIELDS, + INPUT_LEVELS_GROUPER +} from '../../../CollectionForm' + +type RequiredOptionalRadiosProps = + | { + disabled: boolean + formBuiltedFieldName: string + isForChildField: true + siblingChildFields: Record + parentIncludeName: string + parentIsRequiredByDataverse: boolean + parentFieldChecked: boolean + uniqueInputLevelRowID: string + } + | { + disabled: boolean + formBuiltedFieldName: string + isForChildField?: false + siblingChildFields?: never + parentIncludeName?: never + parentIsRequiredByDataverse?: never + parentFieldChecked: boolean + uniqueInputLevelRowID: string + } + +/** + * Component that renders a pair of radio buttons for selecting if a field is required or optional + * + * If used from a child field, makes parent field required if one of the child fields is required + */ + +export const RequiredOptionalRadios = ({ + disabled, + formBuiltedFieldName, + isForChildField, + siblingChildFields, + parentIncludeName, + parentIsRequiredByDataverse, + parentFieldChecked, + uniqueInputLevelRowID +}: RequiredOptionalRadiosProps) => { + const { t } = useTranslation('createCollection', { + keyPrefix: 'fields.metadataFields.inputLevelsTable' + }) + const { control, setValue } = useFormContext() + + const siblingChildFieldsNames = Object.values(siblingChildFields || {}) + .map((field) => field.name) + .map((field) => `${INPUT_LEVELS_GROUPER}.${field}.optionalOrRequired`) + + const siblingChildFieldsValues = useWatch({ + name: siblingChildFieldsNames + }) as CollectionFormInputLevelValue[] | undefined[] + + const handleOptionalOrRequiredChange = ( + e: ChangeEvent, + formOnChange: (...event: unknown[]) => void + ) => { + // Check if any siblingChildFields is required then set parent to required also unless parent is required by dataverse + if (isForChildField) { + // If parent is required by dataverse, then is already required + if (e.target.value === 'required' && !parentIsRequiredByDataverse) { + setValue(`${INPUT_LEVELS_GROUPER}.${parentIncludeName}.optionalOrRequired`, 'required') + } + + // If parent is required by dataverse, then is already required and should not be set to optional + if (e.target.value === 'optional' && !parentIsRequiredByDataverse) { + const isSomeSiblingRequired = ( + siblingChildFieldsValues as CollectionFormInputLevelValue[] + ).some((value) => value === 'required') + + if (!isSomeSiblingRequired) { + setValue(`${INPUT_LEVELS_GROUPER}.${parentIncludeName}.optionalOrRequired`, 'optional') + } else { + setValue(`${INPUT_LEVELS_GROUPER}.${parentIncludeName}.optionalOrRequired`, 'required') + } + } + } + formOnChange(e) + } + + const isAConditionallyRequiredField = CONDITIONALLY_REQUIRED_FIELDS.some((field) => + formBuiltedFieldName.includes(field) + ) + + return ( + { + const castedValue = value as CollectionFormInputLevelValue + + if (!parentFieldChecked) { + return ( + + ) + } + { + /* For now we are just disabling the radios if this is a conditionally required field */ + } + return ( + + handleOptionalOrRequiredChange(e, onChange)} + checked={castedValue === 'required'} + value="required" + name={`${uniqueInputLevelRowID}-radio-group`} + id={`${uniqueInputLevelRowID}-required-radio`} + disabled={disabled || isAConditionallyRequiredField} + ref={ref} + /> + + handleOptionalOrRequiredChange(e, onChange)} + checked={castedValue === 'optional'} + value="optional" + name={`${uniqueInputLevelRowID}-radio-group`} + id={`${uniqueInputLevelRowID}-optional-radio`} + disabled={disabled || isAConditionallyRequiredField} + ref={ref} + /> + + ) + }} + /> + ) +} diff --git a/src/sections/create-collection/collection-form/top-fields-section/ContactsField.tsx b/src/sections/create-collection/collection-form/top-fields-section/ContactsField.tsx index 3c7c93744..6d762e751 100644 --- a/src/sections/create-collection/collection-form/top-fields-section/ContactsField.tsx +++ b/src/sections/create-collection/collection-form/top-fields-section/ContactsField.tsx @@ -3,11 +3,9 @@ import { useCallback, useMemo } from 'react' import { Controller, UseControllerProps, useFieldArray, useFormContext } from 'react-hook-form' import { useTranslation } from 'react-i18next' import cn from 'classnames' + import styles from '../CollectionForm.module.scss' -// TODO:ME This imports are only used in the DynamicFieldsButtons component (temporal) -import { MouseEvent } from 'react' -import { Button, Tooltip } from '@iqss/dataverse-design-system' -import { Dash, Plus } from 'react-bootstrap-icons' +import { DynamicFieldsButtons } from '../../../shared/form/DynamicFieldsButtons/DynamicFieldsButtons' interface ContactsFieldProps { rules: UseControllerProps['rules'] @@ -97,46 +95,3 @@ export const ContactsField = ({ rules }: ContactsFieldProps) => { ) } - -// TODO:ME Create reusable DynamicFieldsButtons component inside shared Form when merged with issue 422 -// TODO:ME This component here is temporal, it will be moved to the shared form folder -interface DynamicFieldsButtonsProps { - fieldName: string - originalField?: boolean - onAddButtonClick: (event: MouseEvent) => void - onRemoveButtonClick: (event: MouseEvent) => void -} - -const DynamicFieldsButtons = ({ - fieldName, - originalField, - onAddButtonClick, - onRemoveButtonClick -}: DynamicFieldsButtonsProps) => { - return ( -
- - - - {!originalField && ( - - - - )} -
- ) -} diff --git a/src/sections/create-collection/collection-form/top-fields-section/IdentifierField.tsx b/src/sections/create-collection/collection-form/top-fields-section/IdentifierField.tsx index 3db7f0049..e1ef1d0a3 100644 --- a/src/sections/create-collection/collection-form/top-fields-section/IdentifierField.tsx +++ b/src/sections/create-collection/collection-form/top-fields-section/IdentifierField.tsx @@ -44,9 +44,7 @@ export const IdentifierField = ({ rules }: IdentifierFieldProps) => { render={({ field: { onChange, ref, value }, fieldState: { invalid, error } }) => ( - - {window.location.origin}/spa/collections/?id= - + {window.location.origin}/spa/collections/ { onChange={onChange} isInvalid={invalid} ref={ref} - disabled /> {error?.message} @@ -147,7 +146,6 @@ export const TopFieldsSection = () => { isInvalid={invalid} ref={ref} disabled> - {/* TODO:ME What are this options? do they come from a configuration? */} {Object.values(collectionStorageOptions).map((type) => (