22155 fixed project description cutoff
[arvados.git] / services / workbench2 / src / views-components / details-card / project-details-card.tsx
1 // Copyright (C) The Arvados Authors. All rights reserved.
2 //
3 // SPDX-License-Identifier: AGPL-3.0
4
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';
25
26 type CssRules =
27     | 'root'
28     | 'cardHeaderContainer'
29     | 'cardHeader'
30     | 'descriptionToggle'
31     | 'showMore'
32     | 'noDescription'
33     | 'userNameContainer'
34     | 'cardContent'
35     | 'nameSection'
36     | 'namePlate'
37     | 'faveIcon'
38     | 'frozenIcon'
39     | 'chipToggle'
40     | 'chipSection'
41     | 'tag'
42     | 'description'
43     | 'oneLineDescription'
44     | 'toolbarStyles';
45
46 const styles: CustomStyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
47     root: {
48         width: '100%',
49         marginBottom: '1rem',
50         flex: '0 0 auto',
51         padding: 0,
52         minHeight: '3rem',
53     },
54     noDescription: {
55         color: theme.palette.grey['600'],
56         fontStyle: 'italic',
57         marginLeft: '2rem',
58     },
59     userNameContainer: {
60         display: 'flex',
61         alignItems: 'center',
62         minHeight: '2.7rem',
63     },
64     cardHeaderContainer: {
65         width: '100%',
66         display: 'flex',
67         flexDirection: 'row',
68         alignItems: 'center',
69         justifyContent: 'space-between',
70     },
71     cardHeader: {
72         minWidth: '30rem',
73         padding: '0.2rem 0.4rem 0.2rem 1rem',
74     },
75     descriptionToggle: {
76         display: 'flex',
77         flexDirection: 'row',
78         cursor: 'pointer',
79         marginTop: '-0.25rem',
80         paddingBottom: '0.5rem',
81     },
82     cardContent: {
83         display: 'flex',
84         flexDirection: 'column',
85         paddingTop: 0,
86         paddingLeft: '0.1rem',
87     },
88     nameSection: {
89         display: 'flex',
90         flexDirection: 'row',
91         alignItems: 'center',
92     },
93     namePlate: {
94         display: 'flex',
95         flexDirection: 'row',
96         alignItems: 'center',
97         margin: 0,
98         minHeight: '2.7rem',
99     },
100     faveIcon: {
101         fontSize: '0.8rem',
102         margin: 'auto 0 1rem 0.3rem',
103         color: theme.palette.text.primary,
104     },
105     frozenIcon: {
106         fontSize: '0.5rem',
107         marginLeft: '0.3rem',
108         height: '1rem',
109         color: theme.palette.text.primary,
110     },
111     showMore: {
112         marginTop: 0,
113         cursor: 'pointer',
114     },
115     chipToggle: {
116         display: 'flex',
117         alignItems: 'center',
118         height: '2rem',
119     },
120     chipSection: {
121         marginBottom: '-1rem',
122     },
123     tag: {
124         marginRight: '0.75rem',
125         marginBottom: '0.5rem',
126     },
127     description: {
128         marginTop: 0,
129         marginRight: '2rem',
130     },
131     oneLineDescription: {
132         marginTop: 0,
133         marginRight: '2rem',
134         marginBottom: '-0.85rem',
135     },
136     toolbarStyles: {
137         marginRight: '-0.5rem',
138     },
139 });
140
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;
146
147     return {
148         isAdmin: auth.user?.isAdmin,
149         currentResource,
150         frozenByFullName,
151         isSelected,
152     };
153 };
154
155 const mapDispatchToProps = (dispatch: Dispatch) => ({
156     handleCardClick: (uuid: string) => {
157         dispatch<any>(loadDetailsPanel(uuid));
158         dispatch<any>(setSelectedResourceUuid(uuid));
159         dispatch<any>(deselectAllOthers(uuid));
160     },
161 });
162
163 type ProjectCardProps = WithStyles<CssRules> & {
164     currentResource: ProjectResource;
165     frozenByFullName: string | undefined;
166     isAdmin: boolean;
167     isSelected: boolean;
168     handleCardClick: (resource: any) => void;
169 };
170
171 export const ProjectCard = connect(
172     mapStateToProps,
173     mapDispatchToProps
174 )(
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);
182
183         useEffect(() => {
184             const checkIfMultiLine = () => {
185               const element = descriptionRef.current;
186               if (element) {
187                 // Compare the scroll width and offset width to determine if wrapping occurs
188                 setIsMultiLine(element.scrollWidth > element.offsetWidth);
189               }
190             };
191         
192             checkIfMultiLine();
193           }, [description]);
194
195         const toggleDescription = () => {
196             setShowDescription(!showDescription);
197         };
198
199         const toggleProperties = () => {
200             setShowProperties(!showProperties);
201         };
202
203         return (
204             <Card
205                 className={classes.root}
206                 onClick={() => handleCardClick(uuid)}
207                 data-cy='project-details-card'
208             >
209                 <Grid
210                     container
211                     wrap='nowrap'
212                     className={classes.cardHeaderContainer}
213                 >
214                     <CardHeader
215                         className={classes.cardHeader}
216                         title={
217                             <section className={classes.nameSection}>
218                                 <section className={classes.namePlate}>
219                                     <Typography
220                                         variant='h6'
221                                         style={{ marginRight: '1rem' }}
222                                     >
223                                         {name}
224                                     </Typography>
225                                     <FavoriteStar
226                                         className={classes.faveIcon}
227                                         resourceUuid={currentResource.uuid}
228                                     />
229                                     <PublicFavoriteStar
230                                         className={classes.faveIcon}
231                                         resourceUuid={currentResource.uuid}
232                                     />
233                                     {!!frozenByFullName && (
234                                         <Tooltip
235                                             className={classes.frozenIcon}
236                                             disableFocusListener
237                                             title={<span>Project was frozen by {frozenByFullName}</span>}
238                                         >
239                                             <FreezeIcon style={{ fontSize: 'inherit' }} />
240                                         </Tooltip>
241                                     )}
242                                 </section>
243                                 {!description && (
244                                     <Typography
245                                         data-cy='no-description'
246                                         className={classes.noDescription}
247                                     >
248                                         no description available
249                                     </Typography>
250                                 )}
251                             </section>
252                         }
253                     />
254                     {isSelected && <MultiselectToolbar injectedStyles={classes.toolbarStyles} />}
255                 </Grid>
256                 <section onClick={(ev) => ev.stopPropagation()}>
257                     {description ? (
258                         <section
259                             onClick={toggleDescription}
260                             className={classes.descriptionToggle}
261                             data-cy='toggle-description'
262                         >
263                             <ExpandChevronRight expanded={showDescription} />
264                             <section className={classes.showMore}>
265                                 <Collapse
266                                     in={showDescription}
267                                     timeout='auto'
268                                     collapsedSize='1.25rem'
269                                 >
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>
272                                     
273                                     <Typography
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 }}
279                                     />
280                                 </Collapse>
281                             </section>
282                         </section>
283                     ) : (
284                         <></>
285                     )}
286                     {typeof currentResource.properties === 'object' && Object.keys(currentResource.properties).length > 0 ? (
287                         <section
288                             onClick={toggleProperties}
289                             className={classes.descriptionToggle}
290                         >
291                             <div
292                                 className={classes.chipToggle}
293                                 data-cy='toggle-chips'
294                             >
295                                 <ExpandChevronRight expanded={showProperties} />
296                             </div>
297                             <section className={classes.showMore}>
298                                 <Collapse
299                                     in={showProperties}
300                                     timeout='auto'
301                                     collapsedSize='35px'
302                                 >
303                                     <div
304                                         className={classes.description}
305                                         data-cy='project-description'
306                                     >
307                                         <CardContent className={classes.cardContent}>
308                                             <Typography
309                                                 component='div'
310                                                 className={classes.chipSection}
311                                             >
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)
316                                                 )}
317                                             </Typography>
318                                         </CardContent>
319                                     </div>
320                                 </Collapse>
321                             </section>
322                         </section>
323                     ) : null}
324                 </section>
325             </Card>
326         );
327     })
328 );