diff --git a/package-lock.json b/package-lock.json index 94b01cf53..f08d2a5ca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,10 @@ "name": "dataverse-frontend", "version": "0.1.0", "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", "@faker-js/faker": "7.6.0", "@iqss/dataverse-client-javascript": "2.0.0-pr192.6406015", "@iqss/dataverse-design-system": "*", diff --git a/package.json b/package.json index 0a0b77593..fab2dd3e9 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,10 @@ ] }, "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", "@faker-js/faker": "7.6.0", "@iqss/dataverse-client-javascript": "2.0.0-pr192.6406015", "@iqss/dataverse-design-system": "*", diff --git a/packages/design-system/src/lib/assets/styles/bootstrap-customized.scss b/packages/design-system/src/lib/assets/styles/bootstrap-customized.scss index 935ceda5f..3b4167528 100644 --- a/packages/design-system/src/lib/assets/styles/bootstrap-customized.scss +++ b/packages/design-system/src/lib/assets/styles/bootstrap-customized.scss @@ -100,6 +100,8 @@ $tooltip-max-width: 500px; // Navbar +@import 'bootstrap/scss/carousel'; + $navbar-light-brand-color: $dv-brand-color; $navbar-brand-font-size: $dv-brand-font-size; diff --git a/packages/design-system/src/lib/components/form/form-group/form-element/FormTextArea.tsx b/packages/design-system/src/lib/components/form/form-group/form-element/FormTextArea.tsx index b18448dc5..6292a32c5 100644 --- a/packages/design-system/src/lib/components/form/form-group/form-element/FormTextArea.tsx +++ b/packages/design-system/src/lib/components/form/form-group/form-element/FormTextArea.tsx @@ -2,23 +2,24 @@ import { Form as FormBS } from 'react-bootstrap' import * as React from 'react' export type FormInputElement = HTMLInputElement | HTMLTextAreaElement -export interface FormTextAreaProps extends Omit, 'rows'> { +export interface FormTextAreaProps extends React.HTMLAttributes { name?: string disabled?: boolean isValid?: boolean isInvalid?: boolean value?: string autoFocus?: boolean + rows?: number } export const FormTextArea = React.forwardRef(function FormTextArea( - { name, disabled, isValid, isInvalid, value, autoFocus, ...props }: FormTextAreaProps, + { name, disabled, isValid, isInvalid, value, autoFocus, rows = 5, ...props }: FormTextAreaProps, ref ) { return ( + getFeaturedItems(collectionIdOrAlias: number | string): Promise } diff --git a/src/collection/domain/useCases/getCollectionFeaturedItems.ts b/src/collection/domain/useCases/getCollectionFeaturedItems.ts new file mode 100644 index 000000000..c8a7eff01 --- /dev/null +++ b/src/collection/domain/useCases/getCollectionFeaturedItems.ts @@ -0,0 +1,11 @@ +import { CollectionFeaturedItem } from '../models/CollectionFeaturedItem' +import { CollectionRepository } from '../repositories/CollectionRepository' + +export async function getCollectionFeaturedItems( + collectionRepository: CollectionRepository, + collectionIdOrAlias: number | string +): Promise { + return collectionRepository.getFeaturedItems(collectionIdOrAlias).catch((error: Error) => { + throw new Error(error.message) + }) +} diff --git a/src/collection/infrastructure/repositories/CollectionJSDataverseRepository.ts b/src/collection/infrastructure/repositories/CollectionJSDataverseRepository.ts index 85f526ad2..20bf36cb6 100644 --- a/src/collection/infrastructure/repositories/CollectionJSDataverseRepository.ts +++ b/src/collection/infrastructure/repositories/CollectionJSDataverseRepository.ts @@ -15,6 +15,7 @@ import { CollectionItemsPaginationInfo } from '../../domain/models/CollectionIte import { CollectionItemSubset } from '../../domain/models/CollectionItemSubset' import { CollectionSearchCriteria } from '../../domain/models/CollectionSearchCriteria' import { JSCollectionItemsMapper } from '../mappers/JSCollectionItemsMapper' +import { CollectionFeaturedItem } from '@/collection/domain/models/CollectionFeaturedItem' export class CollectionJSDataverseRepository implements CollectionRepository { getById(id: string): Promise { @@ -57,4 +58,54 @@ export class CollectionJSDataverseRepository implements CollectionRepository { } }) } + + getFeaturedItems(_collectionIdOrAlias: number | string): Promise { + // TODO:ME Mocked data for now + + return new Promise((resolve) => { + setTimeout(() => { + resolve([ + { + title: 'Featured item 1', + content: + 'Lorem ipsum, dolor sit amet consectetur adipisicing elit. Aut alias eos expedita quae quisquam ea nemo neque incidunt amet. Odit quos libero aliquam labore dicta eaque dolorum, consequuntur itaque corrupti, reiciendis quas ab. Voluptatem alias, quam, aliquid excepturi repudiandae ab ex pariatur, est id perspiciatis porro impedit adipisci beatae ipsam.' + }, + { + title: 'Featured item 2', + content: + 'Lorem ipsum dolor sit amet consectetur adipisicing elit. Autem assumenda nam repellendus adipisci doloremque fugit maiores. Repudiandae sequi illo eum quod id quisquam vero enim ipsa distinctio, quia, consequatur harum dolor non voluptatibus ipsum, rem vel quo voluptates magni eveniet velit hic! Repellendus, provident? Dolore maxime ullam ut est, delectus itaque beatae alias corporis doloremque architecto magni officiis tenetur reprehenderit.', + image: { + url: 'https://via.placeholder.com/150x80', + altText: 'Placeholder image item 2' + } + }, + { + title: 'Featured item 3', + content: + 'Lorem ipsum dolor sit amet consectetur adipisicing elit. Autem assumenda nam repellendus adipisci doloremque fugit maiores. Repudiandae sequi illo eum quod id quisquam vero enim ipsa distinctio, quia, consequatur harum dolor non voluptatibus ipsum, rem vel quo voluptates magni eveniet velit hic! Repellendus, provident? Dolore maxime ullam ut est, delectus itaque beatae alias corporis doloremque architecto magni officiis tenetur reprehenderit.', + image: { + url: 'https://via.placeholder.com/400x400', + altText: 'Placeholder image item 2' + } + }, + { + title: 'Featured item 4', + content: + 'Lorem ipsum dolor sit amet consectetur adipisicing elit. Autem assumenda nam repellendus adipisci doloremque fugit maiores. Repudiandae sequi illo eum quod id quisquam vero enim ipsa distinctio, quia, consequatur harum dolor non voluptatibus ipsum, rem vel quo voluptates magni eveniet velit hic! Repellendus, provident? Dolore maxime ullam ut est, delectus itaque beatae alias corporis doloremque architecto magni officiis tenetur reprehenderit.', + image: { + url: 'https://via.placeholder.com/800x400', + altText: 'Placeholder image item 2' + } + }, + { + title: 'Featured item 5', + content: + 'Lorem ipsum dolor sit amet consectetur adipisicing elit. Facere enim alias quibusdam debitis quaerat, consectetur velit aliquid ipsum! Iusto obcaecati quibusdam hic nam voluptate. Consequuntur laborum officia aliquid recusandae ut, numquam sequi explicabo voluptatum reprehenderit, minima nulla repudiandae at magni fugit quis. Numquam architecto voluptas repudiandae vel totam officia ut? Nobis ex natus optio. Laborum veniam inventore suscipit architecto consectetur minima commodi dolore ducimus ipsam sint vitae doloremque, dolorem ullam! Iusto corporis commodi, pariatur vel magni, dolorum vero non, exercitationem cumque in deserunt? Iste quia dolor aut ullam dolorem eum quidem id accusamus officiis dolore. Quia placeat sapiente aperiam vero distinctio quidem, fugiat recusandae quisquam saepe ut, nesciunt laborum. Aspernatur ea, quasi, facilis impedit optio repellendus quo dolorum velit officia nihil mollitia provident commodi, delectus vero sint porro modi dolores? Ipsam doloribus impedit iure nemo architecto minima explicabo, eligendi dolorem voluptatem sequi aperiam, quisquam placeat ullam facere ut, minus at sit inventore enim eveniet cum. Repellendus eius quam architecto vel cum quod, tenetur neque deserunt, hic a perferendis adipisci fuga quibusdam dignissimos accusantium autem. Quisquam, ipsum harum fugit voluptatum aperiam minus corrupti hic qui impedit eveniet facere ut iure omnis error dicta nobis eum. Repellendus quia, laboriosam laudantium voluptates expedita fuga nulla? Quia nobis labore possimus debitis temporibus minus neque molestiae quam! Repellat reiciendis culpa similique odit doloribus quas praesentium quasi. Voluptatem amet iusto dolore, id, ut eligendi soluta assumenda excepturi perferendis, reiciendis odit explicabo ipsam maiores. Aut corporis eveniet quia repudiandae dolorem nam, excepturi ipsam veniam amet perferendis ullam eaque suscipit unde consequuntur asperiores maiores vel. Adipisci, asperiores. Iure quasi natus repudiandae quod, placeat blanditiis earum tenetur at dolores? Laudantium nam aperiam architecto consequatur, quos molestiae, amet sit itaque debitis neque quo? Iste repellendus vero illo deleniti eum impedit perferendis odit earum, iusto in porro id itaque quasi voluptate.' + } + ]) + + // resolve([]) + }, 1_000) + }) + } } diff --git a/src/router/routes.tsx b/src/router/routes.tsx index b71090f75..f2f5a6044 100644 --- a/src/router/routes.tsx +++ b/src/router/routes.tsx @@ -12,6 +12,10 @@ import { CreateCollectionFactory } from '../sections/create-collection/CreateCol import { AccountFactory } from '../sections/account/AccountFactory' import { ProtectedRoute } from './ProtectedRoute' import { HomepageFactory } from '../sections/homepage/HomepageFactory' +import { CollectionFeaturedItemsFactory } from '@/sections/collection-featured-items/CollectionFeaturedItemsFactory' + +// TODO:ME We are going to need nested layouts routes to achieve the desired layout structure of a collection page with the edition pages +// TODO:ME Or maybe reuse the collection header as a component easier perhaps export const routes: RouteObject[] = [ { @@ -62,6 +66,10 @@ export const routes: RouteObject[] = [ { path: Route.ACCOUNT, element: AccountFactory.create() + }, + { + path: Route.COLLECTION_FEATURED_ITEMS, + element: CollectionFeaturedItemsFactory.create() } ] } diff --git a/src/sections/Route.enum.ts b/src/sections/Route.enum.ts index 179458fd3..84ceb35f3 100644 --- a/src/sections/Route.enum.ts +++ b/src/sections/Route.enum.ts @@ -13,7 +13,8 @@ export enum Route { COLLECTIONS_BASE = '/collections', COLLECTIONS = '/collections/:collectionId', CREATE_COLLECTION = '/collections/:ownerCollectionId/create', - ACCOUNT = '/account' + ACCOUNT = '/account', + COLLECTION_FEATURED_ITEMS = '/collections/:collectionId/featured-items' } export const RouteWithParams = { @@ -21,7 +22,9 @@ export const RouteWithParams = { CREATE_COLLECTION: (ownerCollectionId?: string) => `/collections/${ownerCollectionId ?? ROOT_COLLECTION_ALIAS}/create`, CREATE_DATASET: (collectionId?: string) => - `/datasets/${collectionId ?? ROOT_COLLECTION_ALIAS}/create` + `/datasets/${collectionId ?? ROOT_COLLECTION_ALIAS}/create`, + COLLECTION_FEATURED_ITEMS: (collectionId?: string) => + `/collections/${collectionId ?? ROOT_COLLECTION_ALIAS}/featured-items` } export enum QueryParamKey { diff --git a/src/sections/collection-featured-items/CollectionFeaturedItems.tsx b/src/sections/collection-featured-items/CollectionFeaturedItems.tsx new file mode 100644 index 000000000..4edf236f4 --- /dev/null +++ b/src/sections/collection-featured-items/CollectionFeaturedItems.tsx @@ -0,0 +1,54 @@ +import { Alert, Col, Row } from '@iqss/dataverse-design-system' +import { CollectionRepository } from '@/collection/domain/repositories/CollectionRepository' +import { useCollection } from '../collection/useCollection' +import { BreadcrumbsGenerator } from '../shared/hierarchy/BreadcrumbsGenerator' +import { CollectionInfo } from '../collection/CollectionInfo' +import { CollectionSkeleton } from '../collection/CollectionSkeleton' +import { PageNotFound } from '../page-not-found/PageNotFound' +import { SeparationLine } from '../shared/layout/SeparationLine/SeparationLine' +import { FeaturedItemsForm } from './FeaturedItemsForm/FeaturedItemsForm' + +interface CollectionFeaturedItemsProps { + collectionRepository: CollectionRepository + collectionId: string +} + +export const CollectionFeaturedItems = ({ + collectionId, + collectionRepository +}: CollectionFeaturedItemsProps) => { + const { collection, isLoading } = useCollection(collectionRepository, collectionId) + + if (!isLoading && !collection) { + return + } + + return ( + + + {!collection ? ( + + ) : ( + <> + + + + + + + Add Featured Items to showcase key content in your collection. These items will appear + as cards in a carousel, each including a title and either text or text with an image. + If your collection has a description, it will be the first carousel item by default. + + + + + )} + + + ) +} diff --git a/src/sections/collection-featured-items/CollectionFeaturedItemsFactory.tsx b/src/sections/collection-featured-items/CollectionFeaturedItemsFactory.tsx new file mode 100644 index 000000000..a4e332987 --- /dev/null +++ b/src/sections/collection-featured-items/CollectionFeaturedItemsFactory.tsx @@ -0,0 +1,23 @@ +import { ReactElement } from 'react' +import { CollectionFeaturedItems } from './CollectionFeaturedItems' +import { CollectionJSDataverseRepository } from '@/collection/infrastructure/repositories/CollectionJSDataverseRepository' +import { useParams } from 'react-router-dom' + +const collectionRepository = new CollectionJSDataverseRepository() + +export class CollectionFeaturedItemsFactory { + static create(): ReactElement { + return + } +} + +function CollectionFeaturedItemsWithSearchParams() { + const { collectionId = 'root' } = useParams<{ collectionId: string }>() + + return ( + + ) +} diff --git a/src/sections/collection-featured-items/FeaturedItemsForm/FeaturedItem/FeaturedItem.module.scss b/src/sections/collection-featured-items/FeaturedItemsForm/FeaturedItem/FeaturedItem.module.scss new file mode 100644 index 000000000..25fb9ddaf --- /dev/null +++ b/src/sections/collection-featured-items/FeaturedItemsForm/FeaturedItem/FeaturedItem.module.scss @@ -0,0 +1,64 @@ +@use 'sass:color'; +@import 'node_modules/@iqss/dataverse-design-system/src/lib/assets/styles/design-tokens/colors.module'; + +.featured-item-fields-wrapper { + position: relative; + padding: 1rem; + border: solid 1px $dv-secondary-color; + border-radius: 6px; + + &.sorting { + background-color: color.adjust($dv-secondary-color, $alpha: -0.4); + + &.active { + z-index: 999; + background-color: color.adjust($dv-primary-color, $alpha: -0.4); + } + } + + .drag-handle { + width: 38px; + height: 38px; + padding: 0; + background-color: transparent; + border: 0; + border: solid 1px $dv-secondary-color; + border-radius: 4px; + cursor: grab; + + &:hover:not(.disabled) { + background-color: $dv-secondary-color; + } + + &:active:not(.disabled) { + cursor: grabbing; + } + + &.disabled { + opacity: 0.5; + pointer-events: none; + } + } + + .image-dropzone { + display: grid; + place-items: center; + height: 38px; + border: dashed 1px #ced4da; + border-radius: 6px; + cursor: pointer; + transition: border-color 0.3s; + + &:hover { + border-color: #333; + } + + &:focus { + border-color: #333; + } + + small { + margin: 0; + } + } +} diff --git a/src/sections/collection-featured-items/FeaturedItemsForm/FeaturedItem/FeaturedItem.tsx b/src/sections/collection-featured-items/FeaturedItemsForm/FeaturedItem/FeaturedItem.tsx new file mode 100644 index 000000000..227dc1912 --- /dev/null +++ b/src/sections/collection-featured-items/FeaturedItemsForm/FeaturedItem/FeaturedItem.tsx @@ -0,0 +1,160 @@ +import { Controller, useFormContext } from 'react-hook-form' +import { useSortable } from '@dnd-kit/sortable' +import { CSS } from '@dnd-kit/utilities' +import { Col, Form, Row } from '@iqss/dataverse-design-system' +import cn from 'classnames' +import { DynamicFieldsButtons } from '@/sections/shared/form/DynamicFieldsButtons/DynamicFieldsButtons' +import styles from './FeaturedItem.module.scss' + +interface FeaturedItemProps { + itemId: string + itemIndex: number + onAddField: (index: number) => void + onRemoveField: (index: number) => void + disableDragWhenOnlyOneItem: boolean +} + +export const FeaturedItem = ({ + itemId, + itemIndex, + onAddField, + onRemoveField, + disableDragWhenOnlyOneItem +}: FeaturedItemProps) => { + const { control } = useFormContext() + const { + attributes, + listeners, + transform, + transition, + setNodeRef, + setActivatorNodeRef, + isSorting, + active + } = useSortable({ id: itemId }) + + const attributesCheckingDisabled = disableDragWhenOnlyOneItem + ? { ...attributes, ['aria-disabled']: true, tabIndex: -1 } + : attributes + + const style = { + transform: CSS.Translate.toString(transform), + transition + } + + return ( +
+ + + + + + + + + Title + + + ( + + + {error?.message} + + )} + /> + + + + Item Image + + + ( + +
+ Drop an image here or click to upload +
+ + )} + /> +
+
+ + + + Content + + + ( + + + {error?.message} + + )} + /> + + + + + onAddField(itemIndex)} + onRemoveButtonClick={() => onRemoveField(itemIndex)} + originalField={itemIndex === 0} + /> + +
+
+ ) +} diff --git a/src/sections/collection-featured-items/FeaturedItemsForm/FeaturedItemsForm.module.scss b/src/sections/collection-featured-items/FeaturedItemsForm/FeaturedItemsForm.module.scss new file mode 100644 index 000000000..c3ad8560f --- /dev/null +++ b/src/sections/collection-featured-items/FeaturedItemsForm/FeaturedItemsForm.module.scss @@ -0,0 +1,16 @@ +.form { + display: flex; + flex-direction: column; + gap: 1rem; + padding-top: 2rem; +} + +.show-data-checkbox-wrapper { + display: flex; + gap: 0.5rem; + align-items: center; + + :global .form-check { + margin-bottom: 0; + } +} diff --git a/src/sections/collection-featured-items/FeaturedItemsForm/FeaturedItemsForm.tsx b/src/sections/collection-featured-items/FeaturedItemsForm/FeaturedItemsForm.tsx new file mode 100644 index 000000000..a813df776 --- /dev/null +++ b/src/sections/collection-featured-items/FeaturedItemsForm/FeaturedItemsForm.tsx @@ -0,0 +1,153 @@ +import { Controller, FormProvider, useFieldArray, useForm } from 'react-hook-form' +import { DndContext, DragEndEvent } from '@dnd-kit/core' +import { restrictToVerticalAxis } from '@dnd-kit/modifiers' +import { Button, Form, QuestionMarkTooltip } from '@iqss/dataverse-design-system' +import { FeaturedItem } from './FeaturedItem/FeaturedItem' +import { SortableContext } from '@dnd-kit/sortable' +import { PreviewCarousel } from './PreviewCarousel/PreviewCarousel' +import { CollectionFeaturedItem } from '@/collection/domain/models/CollectionFeaturedItem' +import styles from './FeaturedItemsForm.module.scss' + +export type FeaturedItemsFormData = { + featuredItems: FeaturedItemField[] + withShowDataButton: boolean +} + +type FeaturedItemField = { + title: string + content: string + image?: { + file: File + altText: string + } +} + +type FeaturedItemFieldWithId = FeaturedItemField & { + id: string +} + +export const FeaturedItemsForm = () => { + const defaultValues: FeaturedItemsFormData = { + featuredItems: [ + { + title: '', + content: '', + image: undefined + } + ], + withShowDataButton: true + } + + const form = useForm({ + mode: 'onChange', + defaultValues + }) + + const { + fields: fieldsArray, + insert, + remove, + move + } = useFieldArray({ + name: 'featuredItems', + control: form.control + }) + + const handleOnAddField = (index: number) => { + insert( + index + 1, + { title: '', content: '', image: undefined }, + { + shouldFocus: true, + focusName: `featuredItems.${index + 1}.title` + } + ) + } + + const handleOnRemoveField = (index: number) => remove(index) + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event + + if (over && active.id !== over?.id) { + const activeIndex = (active.data.current?.sortable as { index: number })?.index + const overIndex = (over.data.current?.sortable as { index: number })?.index + + if (activeIndex !== undefined && overIndex !== undefined) { + move(activeIndex, overIndex) + } + } + } + + const submitForm = (data: FeaturedItemsFormData) => { + console.log(data) + } + + const formFieldsToFeaturedItems: CollectionFeaturedItem[] = form + .watch('featuredItems') + .map((field) => { + const { title, content, image } = field + + if (image?.file) { + const url = URL.createObjectURL(image.file) + return { title, content, image: { url, altText: image.altText } } + } + + return { title, content } + }) + + return ( + + +
+ {fieldsArray.length > 3 && ( +
+ +
+ )} + + ( +
+ + +
+ )} + /> + + + + {(fieldsArray as FeaturedItemFieldWithId[]).map((field, index) => ( + + ))} + + +
+ +
+ +
+ ) +} diff --git a/src/sections/collection-featured-items/FeaturedItemsForm/PreviewCarousel/PreviewCarousel.tsx b/src/sections/collection-featured-items/FeaturedItemsForm/PreviewCarousel/PreviewCarousel.tsx new file mode 100644 index 000000000..e40941300 --- /dev/null +++ b/src/sections/collection-featured-items/FeaturedItemsForm/PreviewCarousel/PreviewCarousel.tsx @@ -0,0 +1,21 @@ +import { Accordion } from '@iqss/dataverse-design-system' +import FeaturedItems from '@/sections/collection/featured-items/FeaturedItems' +import { CollectionFeaturedItem } from '@/collection/domain/models/CollectionFeaturedItem' + +interface PreviewCarouselProps { + currentFormFeaturedItems: CollectionFeaturedItem[] +} + +export const PreviewCarousel = ({ currentFormFeaturedItems }: PreviewCarouselProps) => { + console.log({ currentFormFeaturedItems }) + return ( + + + Preview of the carousel of featured items + + + + + + ) +} diff --git a/src/sections/collection/Collection.module.scss b/src/sections/collection/Collection.module.scss index 25af1b00f..b44e3efc4 100644 --- a/src/sections/collection/Collection.module.scss +++ b/src/sections/collection/Collection.module.scss @@ -1,6 +1,16 @@ @import 'node_modules/bootstrap/scss/functions'; @import 'node_modules/bootstrap/scss/variables'; @import 'node_modules/@iqss/dataverse-design-system/src/lib/assets/styles/design-tokens/colors.module'; +@import '../../assets/variables'; + +.show-me-data-wrapper { + display: flex; + justify-content: center; +} + +.coll-items-wrapper { + scroll-margin-top: $header-aproximate-height; +} .header { margin-bottom: $spacer; @@ -11,6 +21,12 @@ gap: 10px; } +.action-buttons { + display: flex; + justify-content: flex-end; + margin-bottom: 1rem; +} + .subtext { color: $dv-subtext-color; } diff --git a/src/sections/collection/Collection.tsx b/src/sections/collection/Collection.tsx index 6edb35b25..e44a1b3dc 100644 --- a/src/sections/collection/Collection.tsx +++ b/src/sections/collection/Collection.tsx @@ -1,5 +1,16 @@ +import { useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { Col, Row } from '@iqss/dataverse-design-system' +import { useNavigate } from 'react-router-dom' +import { + Button, + ButtonGroup, + Col, + DropdownButton, + DropdownButtonItem, + DropdownSeparator, + Row +} from '@iqss/dataverse-design-system' +import { PencilFill } from 'react-bootstrap-icons' import { CollectionRepository } from '../../collection/domain/repositories/CollectionRepository' import { useCollection } from './useCollection' import { useScrollTop } from '../../shared/hooks/useScrollTop' @@ -13,6 +24,10 @@ import { CollectionInfo } from './CollectionInfo' import { CollectionSkeleton } from './CollectionSkeleton' import { PageNotFound } from '../page-not-found/PageNotFound' import { CreatedAlert } from './CreatedAlert' +import FeaturedItems from './featured-items/FeaturedItems' +import { RouteWithParams } from '../Route.enum' +import { useGetCollectionFeaturedItems } from './useGetCollectionFeaturedItems' +import styles from './Collection.module.scss' interface CollectionProps { collectionRepository: CollectionRepository @@ -28,21 +43,40 @@ export function Collection({ created, collectionQueryParams }: CollectionProps) { + const navigate = useNavigate() useTranslation('collection') useScrollTop() const { user } = useSession() - const { collection, isLoading } = useCollection(collectionRepository, collectionId) + const [dataShown, setDataShown] = useState(false) + const divRef = useRef(null) + + const { collection, isLoading: isLoadingCollection } = useCollection( + collectionRepository, + collectionId + ) const { collectionUserPermissions } = useGetCollectionUserPermissions({ collectionIdOrAlias: collectionId, collectionRepository }) + const { collectionFeaturedItems, isLoading: isLoadingCollectionFeaturedItems } = + useGetCollectionFeaturedItems({ + collectionIdOrAlias: collectionId, + collectionRepository + }) + + const hasFeaturedItems = collectionFeaturedItems.length > 0 + const canUserAddCollection = Boolean(collectionUserPermissions?.canAddCollection) const canUserAddDataset = Boolean(collectionUserPermissions?.canAddDataset) const showAddDataActions = Boolean(user && (canUserAddCollection || canUserAddDataset)) - if (!isLoading && !collection) { + if (isLoadingCollection || isLoadingCollectionFeaturedItems) { + return + } + + if (!isLoadingCollection && !collection) { return } @@ -54,25 +88,85 @@ export function Collection({ ) : ( <> - + + + {created && } + + {hasFeaturedItems && ( +
+ +
+ )} + + {/* TODO:ME When showing the data we could focus the scroll to the collection items panel */} + {hasFeaturedItems && !dataShown && ( +
+ +
+ )} + + {(!hasFeaturedItems || dataShown) && ( + <> +
+ + }> + General Information + Theme + Widgets + { + navigate(RouteWithParams.COLLECTION_FEATURED_ITEMS(collectionId)) + }}> + Featured Items + + Permissions + Groups + Dataset Templates + Dataset Guestbooks + Featured Dataverses + + Delete Collection + + +
+
+ + ) : null + } + /> +
+ + )} )} - - ) : null - } - /> ) diff --git a/src/sections/collection/CollectionInfo.tsx b/src/sections/collection/CollectionInfo.tsx index 82c6ef0ae..9bbc64497 100644 --- a/src/sections/collection/CollectionInfo.tsx +++ b/src/sections/collection/CollectionInfo.tsx @@ -6,9 +6,10 @@ import { DatasetLabelSemanticMeaning } from '../../dataset/domain/models/Dataset interface CollectionInfoProps { collection: Collection + showDescription: boolean } -export function CollectionInfo({ collection }: CollectionInfoProps) { +export function CollectionInfo({ collection, showDescription = true }: CollectionInfoProps) { return ( <>
@@ -24,7 +25,7 @@ export function CollectionInfo({ collection }: CollectionInfoProps) { )}
- {collection.description && ( + {collection.description && showDescription && (
diff --git a/src/sections/collection/featured-items/FeaturedItems.module.scss b/src/sections/collection/featured-items/FeaturedItems.module.scss new file mode 100644 index 000000000..5e04164e9 --- /dev/null +++ b/src/sections/collection/featured-items/FeaturedItems.module.scss @@ -0,0 +1,59 @@ +@use 'sass:color'; +@import 'node_modules/@iqss/dataverse-design-system/src/lib/assets/styles/design-tokens/colors.module'; + +.featured-items-carousel { + position: relative; + padding: 2rem 0 2.5rem; + background-color: color.adjust($dv-secondary-color, $lightness: 10%); + + // border-bottom: solid 1px #000; + // border-left: solid 1px #000; + // border-right: solid 1px #000; + + // border-bottom-right-radius: 6px; + // border-bottom-left-radius: 6px; + + // border: solid 2px black; + + // Carousel + + :global .carousel-indicators { + margin-bottom: 0; + } + + :global .carousel-indicators button { + width: 14px; + height: 14px; + background-color: #000; + border-radius: 50%; + } + + .featured-item-card { + width: 70%; + height: 360px; + max-height: 360px; + overflow-y: auto; + margin-inline: auto; + + h2 { + margin-bottom: 1rem; + } + + &::-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%); + } + } + } +} diff --git a/src/sections/collection/featured-items/FeaturedItems.tsx b/src/sections/collection/featured-items/FeaturedItems.tsx new file mode 100644 index 000000000..a6a5e09d6 --- /dev/null +++ b/src/sections/collection/featured-items/FeaturedItems.tsx @@ -0,0 +1,62 @@ +import { Card, Carousel } from 'react-bootstrap' +import { ChevronLeft, ChevronRight } from 'react-bootstrap-icons' +import { CollectionFeaturedItem } from '@/collection/domain/models/CollectionFeaturedItem' +import { MarkdownComponent } from '@/sections/dataset/markdown/MarkdownComponent' +import styles from './FeaturedItems.module.scss' + +interface FeaturedItemsProps { + featuredItems: CollectionFeaturedItem[] + collectionDescription?: string +} + +const FeaturedItems = ({ featuredItems, collectionDescription }: FeaturedItemsProps) => { + return ( + } + nextIcon={}> + {/* First item should be the description */} + {/* Description can't be inside the cover, this one has a limited height */} + {collectionDescription && ( + + + + About + + + + + + )} + + {featuredItems.map((featuredItem, index) => ( + + + + + {featuredItem.title} + +
+ {featuredItem.image && ( + {featuredItem.image.altText} + )} +

{featuredItem.content}

+
+
+
+
+ ))} +
+ ) +} + +export default FeaturedItems diff --git a/src/sections/collection/useGetCollectionFeaturedItems.tsx b/src/sections/collection/useGetCollectionFeaturedItems.tsx new file mode 100644 index 000000000..7a5f224de --- /dev/null +++ b/src/sections/collection/useGetCollectionFeaturedItems.tsx @@ -0,0 +1,54 @@ +import { useEffect, useState } from 'react' +import { CollectionRepository } from '@/collection/domain/repositories/CollectionRepository' +import { CollectionFeaturedItem } from '@/collection/domain/models/CollectionFeaturedItem' +import { getCollectionFeaturedItems } from '@/collection/domain/useCases/getCollectionFeaturedItems' + +interface Props { + collectionIdOrAlias: string + collectionRepository: CollectionRepository +} + +interface UseGetCollectionFeaturedItemsReturn { + collectionFeaturedItems: CollectionFeaturedItem[] + error: string | null + isLoading: boolean +} + +export const useGetCollectionFeaturedItems = ({ + collectionIdOrAlias, + collectionRepository +}: Props): UseGetCollectionFeaturedItemsReturn => { + const [collectionFeaturedItems, setCollectionFeaturedItems] = useState( + [] + ) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + const handleGetCollectionFeaturedItems = async () => { + setIsLoading(true) + try { + const collectionFeaturedItemsResponse: CollectionFeaturedItem[] = + await getCollectionFeaturedItems(collectionRepository, collectionIdOrAlias) + + setCollectionFeaturedItems(collectionFeaturedItemsResponse) + } catch (err) { + const errorMessage = + err instanceof Error && err.message + ? err.message + : 'Something went wrong getting the featured items for the collection. Try again later.' + setError(errorMessage) + } finally { + setIsLoading(false) + } + } + + void handleGetCollectionFeaturedItems() + }, [collectionIdOrAlias, collectionRepository]) + + return { + collectionFeaturedItems, + error, + isLoading + } +} diff --git a/src/sections/shared/hierarchy/BreadcrumbsGenerator.module.scss b/src/sections/shared/hierarchy/BreadcrumbsGenerator.module.scss index d944811d6..a1d189877 100644 --- a/src/sections/shared/hierarchy/BreadcrumbsGenerator.module.scss +++ b/src/sections/shared/hierarchy/BreadcrumbsGenerator.module.scss @@ -1,3 +1,7 @@ .breadcrumb-generator { - padding-top: 0.5rem; + padding-block: 0.5rem; + + .breadcrumb { + margin-bottom: 0; + } } diff --git a/src/stories/collection-features-items/CollectionFeaturedItems.stories.tsx b/src/stories/collection-features-items/CollectionFeaturedItems.stories.tsx new file mode 100644 index 000000000..753d282bf --- /dev/null +++ b/src/stories/collection-features-items/CollectionFeaturedItems.stories.tsx @@ -0,0 +1,28 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { CollectionFeaturedItems } from '@/sections/collection-featured-items/CollectionFeaturedItems' +import { WithI18next } from '../WithI18next' +import { WithLayout } from '../WithLayout' +import { WithLoggedInUser } from '../WithLoggedInUser' +import { CollectionMockRepository } from '../collection/CollectionMockRepository' + +const meta: Meta = { + title: 'Pages/Collection Featured Items', + component: CollectionFeaturedItems, + decorators: [WithI18next, WithLayout, WithLoggedInUser], + parameters: { + // Sets the delay for all stories. + chromatic: { delay: 15000, pauseAnimationAtEnd: true } + } +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + render: () => ( + + ) +} diff --git a/src/stories/collection/Collection.stories.tsx b/src/stories/collection/Collection.stories.tsx index e194d7ab5..ba96ff97e 100644 --- a/src/stories/collection/Collection.stories.tsx +++ b/src/stories/collection/Collection.stories.tsx @@ -5,6 +5,9 @@ import { WithLayout } from '../WithLayout' import { WithLoggedInUser } from '../WithLoggedInUser' import { CollectionMockRepository } from './CollectionMockRepository' import { CollectionLoadingMockRepository } from './CollectionLoadingMockRepository' +import { CollectionFeaturedItemsMother } from '@tests/component/collection/domain/models/CollectionFeaturedItemsMother' +import { CollectionMother } from '@tests/component/collection/domain/models/CollectionMother' +import { faker } from '@faker-js/faker' const meta: Meta = { title: 'Pages/Collection', @@ -68,3 +71,49 @@ export const Created: Story = { /> ) } + +export const WithFeaturedItems: Story = { + render: () => { + const collectionRepositoryWitFeaturedItems = new CollectionMockRepository() + + collectionRepositoryWitFeaturedItems.getById = () => { + return new Promise((resolve) => { + setTimeout(() => { + resolve( + CollectionMother.createRealistic({ + description: + 'This is the description of the collection. When the user configures any featured item for the collection and the collection already has a description, it will be displayed here in the featured item entitled About.' + }) + ) + }, 1_000) + }) + } + + collectionRepositoryWitFeaturedItems.getFeaturedItems = () => { + return new Promise((resolve) => { + setTimeout(() => { + resolve([ + CollectionFeaturedItemsMother.createWithImage(undefined, 'city'), + CollectionFeaturedItemsMother.create(), + CollectionFeaturedItemsMother.createWithImage(undefined, 'dog'), + CollectionFeaturedItemsMother.create({ + content: faker.lorem.paragraphs(100) + }) + ]) + }, 1_000) + }) + } + return ( + + ) + } +} diff --git a/src/stories/collection/CollectionInfo.stories.tsx b/src/stories/collection/CollectionInfo.stories.tsx index 67e7b2d74..25df9067d 100644 --- a/src/stories/collection/CollectionInfo.stories.tsx +++ b/src/stories/collection/CollectionInfo.stories.tsx @@ -13,20 +13,26 @@ export default meta type Story = StoryObj export const Default: Story = { - render: () => + render: () => ( + + ) } export const Complete: Story = { - render: () => + render: () => } export const WithAffiliation: Story = { - render: () => + render: () => ( + + ) } export const Unpublished: Story = { - render: () => + render: () => } export const WithDescription: Story = { - render: () => + render: () => ( + + ) } diff --git a/src/stories/collection/CollectionMockRepository.ts b/src/stories/collection/CollectionMockRepository.ts index a9aea847e..fa77659a4 100644 --- a/src/stories/collection/CollectionMockRepository.ts +++ b/src/stories/collection/CollectionMockRepository.ts @@ -11,6 +11,7 @@ import { CollectionItemSubset } from '@/collection/domain/models/CollectionItemS import { CollectionSearchCriteria } from '@/collection/domain/models/CollectionSearchCriteria' import { CollectionItemsMother } from '../../../tests/component/collection/domain/models/CollectionItemsMother' import { CollectionItemType } from '@/collection/domain/models/CollectionItemType' +import { CollectionFeaturedItem } from '@/collection/domain/models/CollectionFeaturedItem' export class CollectionMockRepository implements CollectionRepository { getById(_id: string): Promise { @@ -77,4 +78,12 @@ export class CollectionMockRepository implements CollectionRepository { }, FakerHelper.loadingTimout()) }) } + + getFeaturedItems(_collectionIdOrAlias: number | string): Promise { + return new Promise((resolve) => { + setTimeout(() => { + resolve([]) + }, 1_000) + }) + } } diff --git a/tests/component/collection/domain/models/CollectionFeaturedItemsMother.ts b/tests/component/collection/domain/models/CollectionFeaturedItemsMother.ts new file mode 100644 index 000000000..3f2004e19 --- /dev/null +++ b/tests/component/collection/domain/models/CollectionFeaturedItemsMother.ts @@ -0,0 +1,29 @@ +import { faker } from '@faker-js/faker' +import { CollectionFeaturedItem } from '@/collection/domain/models/CollectionFeaturedItem' + +export class CollectionFeaturedItemsMother { + static create(props?: Partial): CollectionFeaturedItem { + return { + title: this.capitalizeWord(faker.lorem.words(2)), + content: faker.lorem.paragraph(), + ...props + } + } + + static createWithImage( + props?: Partial, + imageCategory?: string + ): CollectionFeaturedItem { + return CollectionFeaturedItemsMother.create({ + image: { + url: faker.image.imageUrl(undefined, undefined, imageCategory), + altText: faker.lorem.words(2), + ...props + } + }) + } + + private static capitalizeWord(word: string): string { + return word.charAt(0).toUpperCase() + word.slice(1) + } +} diff --git a/tests/component/collection/domain/models/CollectionMother.ts b/tests/component/collection/domain/models/CollectionMother.ts index d539bd432..f1e1d5d5a 100644 --- a/tests/component/collection/domain/models/CollectionMother.ts +++ b/tests/component/collection/domain/models/CollectionMother.ts @@ -20,13 +20,14 @@ export class CollectionMother { } } - static createRealistic(): Collection { + static createRealistic(props?: Partial): Collection { return CollectionMother.create({ id: 'science', isReleased: true, name: 'Collection Name', description: 'We do all the science.', - affiliation: 'Scientific Research University' + affiliation: 'Scientific Research University', + ...props }) }