1 // Copyright (C) The Arvados Authors. All rights reserved.
3 // SPDX-License-Identifier: AGPL-3.0
5 import React, { useState, useRef, useEffect } from 'react';
6 import { CustomStyleRulesCallback } from 'common/custom-theme';
7 import { Card, CardHeader, Typography, CardContent, Tooltip, Collapse, Grid } from '@mui/material';
8 import { WithStyles } from '@mui/styles';
9 import withStyles from '@mui/styles/withStyles';
10 import { ArvadosTheme } from 'common/custom-theme';
11 import { RootState } from 'store/store';
12 import { connect } from 'react-redux';
13 import { getResource } from 'store/resources/resources';
14 import { getPropertyChip } from '../resource-properties-form/property-chip';
15 import { ProjectResource } from 'models/project';
16 import { FavoriteStar, PublicFavoriteStar } from 'views-components/favorite-star/favorite-star';
17 import { FreezeIcon } from 'components/icon/icon';
18 import { Resource } from 'models/resource';
19 import { Dispatch } from 'redux';
20 import { loadDetailsPanel } from 'store/details-panel/details-panel-action';
21 import { ExpandChevronRight } from 'components/expand-chevron-right/expand-chevron-right';
22 import { MultiselectToolbar } from 'components/multiselect-toolbar/MultiselectToolbar';
23 import { setSelectedResourceUuid } from 'store/selected-resource/selected-resource-actions';
24 import { deselectAllOthers } from 'store/multiselect/multiselect-actions';
28 | 'cardHeaderContainer'
43 | 'oneLineDescription'
46 const styles: CustomStyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
55 color: theme.palette.grey['600'],
64 cardHeaderContainer: {
69 justifyContent: 'space-between',
73 padding: '0.2rem 0.4rem 0.2rem 1rem',
79 marginTop: '-0.25rem',
80 paddingBottom: '0.5rem',
84 flexDirection: 'column',
86 paddingLeft: '0.1rem',
102 margin: 'auto 0 1rem 0.3rem',
103 color: theme.palette.text.primary,
107 marginLeft: '0.3rem',
109 color: theme.palette.text.primary,
117 alignItems: 'center',
121 marginBottom: '-1rem',
124 marginRight: '0.75rem',
125 marginBottom: '0.5rem',
131 oneLineDescription: {
134 marginBottom: '-0.85rem',
137 marginRight: '-0.5rem',
141 const mapStateToProps = ({ auth, selectedResourceUuid, resources, properties }: RootState) => {
142 const currentResource = getResource(properties.currentRouteUuid)(resources);
143 const frozenByUser = currentResource && getResource((currentResource as ProjectResource).frozenByUuid as string)(resources);
144 const frozenByFullName = frozenByUser && (frozenByUser as Resource & { fullName: string }).fullName;
145 const isSelected = selectedResourceUuid === properties.currentRouteUuid;
148 isAdmin: auth.user?.isAdmin,
155 const mapDispatchToProps = (dispatch: Dispatch) => ({
156 handleCardClick: (uuid: string) => {
157 dispatch<any>(loadDetailsPanel(uuid));
158 dispatch<any>(setSelectedResourceUuid(uuid));
159 dispatch<any>(deselectAllOthers(uuid));
163 type ProjectCardProps = WithStyles<CssRules> & {
164 currentResource: ProjectResource;
165 frozenByFullName: string | undefined;
168 handleCardClick: (resource: any) => void;
171 export const ProjectCard = connect(
175 withStyles(styles)((props: ProjectCardProps) => {
176 const { classes, currentResource, frozenByFullName, handleCardClick, isSelected } = props;
177 const { name, description, uuid } = currentResource as ProjectResource;
178 const [showDescription, setShowDescription] = useState(false);
179 const [showProperties, setShowProperties] = useState(false);
180 const [isMultiLine, setIsMultiLine] = useState(false);
181 const descriptionRef = useRef<HTMLDivElement>(null);
184 const checkIfMultiLine = () => {
185 const element = descriptionRef.current;
187 // Compare the scroll width and offset width to determine if wrapping occurs
188 setIsMultiLine(element.scrollWidth > element.offsetWidth);
195 const toggleDescription = () => {
196 setShowDescription(!showDescription);
199 const toggleProperties = () => {
200 setShowProperties(!showProperties);
205 className={classes.root}
206 onClick={() => handleCardClick(uuid)}
207 data-cy='project-details-card'
212 className={classes.cardHeaderContainer}
215 className={classes.cardHeader}
217 <section className={classes.nameSection}>
218 <section className={classes.namePlate}>
221 style={{ marginRight: '1rem' }}
226 className={classes.faveIcon}
227 resourceUuid={currentResource.uuid}
230 className={classes.faveIcon}
231 resourceUuid={currentResource.uuid}
233 {!!frozenByFullName && (
235 className={classes.frozenIcon}
237 title={<span>Project was frozen by {frozenByFullName}</span>}
239 <FreezeIcon style={{ fontSize: 'inherit' }} />
245 data-cy='no-description'
246 className={classes.noDescription}
248 no description available
254 {isSelected && <MultiselectToolbar injectedStyles={classes.toolbarStyles} />}
256 <section onClick={(ev) => ev.stopPropagation()}>
259 onClick={toggleDescription}
260 className={classes.descriptionToggle}
261 data-cy='toggle-description'
263 <ExpandChevronRight expanded={showDescription} />
264 <section className={classes.showMore}>
268 collapsedSize='1.25rem'
270 {/* Hidden paragraph for measuring the text to determine if it is longer than one line */}
271 <div ref={descriptionRef} style={{ position: 'absolute', visibility: 'hidden', whiteSpace: 'nowrap', width: '100%' }}>{description}</div>
274 className={isMultiLine ? classes.description : classes.oneLineDescription}
275 data-cy='project-description'
276 //dangerouslySetInnerHTML is ok here only if description is sanitized,
277 //which it is before it is loaded into the redux store
278 dangerouslySetInnerHTML={{ __html: description }}
286 {typeof currentResource.properties === 'object' && Object.keys(currentResource.properties).length > 0 ? (
288 onClick={toggleProperties}
289 className={classes.descriptionToggle}
292 className={classes.chipToggle}
293 data-cy='toggle-chips'
295 <ExpandChevronRight expanded={showProperties} />
297 <section className={classes.showMore}>
304 className={classes.description}
305 data-cy='project-description'
307 <CardContent className={classes.cardContent}>
310 className={classes.chipSection}
312 {Object.keys(currentResource.properties).map((k) =>
313 Array.isArray(currentResource.properties[k])
314 ? currentResource.properties[k].map((v: string) => getPropertyChip(k, v, undefined, classes.tag))
315 : getPropertyChip(k, currentResource.properties[k], undefined, classes.tag)