21224: fixed description width
[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 from 'react';
6 import { StyleRulesCallback, Card, CardHeader, WithStyles, withStyles, Typography, CardContent, Tooltip, Collapse, Grid } from '@material-ui/core';
7 import { ArvadosTheme } from 'common/custom-theme';
8 import { RootState } from 'store/store';
9 import { connect } from 'react-redux';
10 import { getResource } from 'store/resources/resources';
11 import { getPropertyChip } from '../resource-properties-form/property-chip';
12 import { ProjectResource } from 'models/project';
13 import { FavoriteStar, PublicFavoriteStar } from 'views-components/favorite-star/favorite-star';
14 import { FreezeIcon } from 'components/icon/icon';
15 import { Resource } from 'models/resource';
16 import { Dispatch } from 'redux';
17 import { loadDetailsPanel } from 'store/details-panel/details-panel-action';
18 import { ExpandChevronRight } from 'components/expand-chevron-right/expand-chevron-right';
19 import { MultiselectToolbar } from 'components/multiselect-toolbar/MultiselectToolbar';
20 import { setSelectedResourceUuid } from 'store/selected-resource/selected-resource-actions';
21 import { deselectAllOthers } from 'store/multiselect/multiselect-actions';
22
23 type CssRules =
24     | 'root'
25     | 'cardHeaderContainer'
26     | 'cardHeader'
27     | 'descriptionToggle'
28     | 'showMore'
29     | 'noDescription'
30     | 'userNameContainer'
31     | 'cardContent'
32     | 'nameSection'
33     | 'namePlate'
34     | 'faveIcon'
35     | 'frozenIcon'
36     | 'chipToggle'
37     | 'chipSection'
38     | 'tag'
39     | 'description'
40     | 'toolbarStyles';
41
42 const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
43     root: {
44         width: '100%',
45         marginBottom: '1rem',
46         flex: '0 0 auto',
47         padding: 0,
48         minHeight: '3rem',
49     },
50     showMore: {
51         cursor: 'pointer',
52     },
53     noDescription: {
54         color: theme.palette.grey['600'],
55         fontStyle: 'italic',
56         marginLeft: '2rem',
57     },
58     userNameContainer: {
59         display: 'flex',
60         alignItems: 'center',
61         minHeight: '2.7rem',
62     },
63     cardHeaderContainer: {
64         width: '100%',
65         display: 'flex',
66         flexDirection: 'row',
67         alignItems: 'center',
68         justifyContent: 'space-between',
69     },
70     cardHeader: {
71         minWidth: '30rem',
72         padding: '0.2rem 0.4rem 0.2rem 1rem',
73     },
74     descriptionToggle: {
75         display: 'flex',
76         flexDirection: 'row',
77         cursor: 'pointer',
78         paddingBottom: '0.5rem',
79     },
80     cardContent: {
81         display: 'flex',
82         flexDirection: 'column',
83         paddingTop: 0,
84         paddingLeft: '0.1rem',
85     },
86     nameSection: {
87         display: 'flex',
88         flexDirection: 'row',
89         alignItems: 'center',
90     },
91     namePlate: {
92         display: 'flex',
93         flexDirection: 'row',
94         alignItems: 'center',
95         margin: 0,
96         minHeight: '2.7rem',
97     },
98     faveIcon: {
99         fontSize: '0.8rem',
100         margin: 'auto 0 1rem 0.3rem',
101         color: theme.palette.text.primary,
102     },
103     frozenIcon: {
104         fontSize: '0.5rem',
105         marginLeft: '0.3rem',
106         height: '1rem',
107         color: theme.palette.text.primary,
108     },
109     chipToggle: {
110         display: 'flex',
111         alignItems: 'center',
112         height: '2rem',
113     },
114     chipSection: {
115         marginBottom: '-2rem',
116     },
117     tag: {
118         marginRight: '0.75rem',
119         marginBottom: '0.5rem',
120     },
121     description: {
122         width: '95%',
123         marginTop: 0,
124     },
125     toolbarStyles: {
126         marginRight: '-1rem',
127     },
128 });
129
130 const mapStateToProps = ({ auth, selectedResourceUuid, resources, properties }: RootState) => {
131     const currentResource = getResource(properties.currentRouteUuid)(resources);
132     const frozenByUser = currentResource && getResource((currentResource as ProjectResource).frozenByUuid as string)(resources);
133     const frozenByFullName = frozenByUser && (frozenByUser as Resource & { fullName: string }).fullName;
134     const isSelected = selectedResourceUuid === properties.currentRouteUuid;
135
136     return {
137         isAdmin: auth.user?.isAdmin,
138         currentResource,
139         frozenByFullName,
140         isSelected,
141     };
142 };
143
144 const mapDispatchToProps = (dispatch: Dispatch) => ({
145     handleCardClick: (uuid: string) => {
146         dispatch<any>(loadDetailsPanel(uuid));
147         dispatch<any>(setSelectedResourceUuid(uuid));
148         dispatch<any>(deselectAllOthers(uuid));
149     },
150 });
151
152 type ProjectCardProps = WithStyles<CssRules> & {
153     currentResource: ProjectResource;
154     frozenByFullName: string | undefined;
155     isAdmin: boolean;
156     isSelected: boolean;
157     handleCardClick: (resource: any) => void;
158 };
159
160 export const ProjectCard = connect(
161     mapStateToProps,
162     mapDispatchToProps
163 )(
164     withStyles(styles)((props: ProjectCardProps) => {
165         const { classes, currentResource, frozenByFullName, handleCardClick, isSelected } = props;
166         const { name, description, uuid } = currentResource as ProjectResource;
167         const [showDescription, setShowDescription] = React.useState(false);
168         const [showProperties, setShowProperties] = React.useState(false);
169
170         const toggleDescription = () => {
171             setShowDescription(!showDescription);
172         };
173
174         const toggleProperties = () => {
175             setShowProperties(!showProperties);
176         };
177
178         const parser = new DOMParser();
179
180         return (
181             <Card
182                 className={classes.root}
183                 onClick={() => handleCardClick(uuid)}
184                 data-cy='project-details-card'
185             >
186                 <Grid
187                     container
188                     wrap='nowrap'
189                     className={classes.cardHeaderContainer}
190                 >
191                     <CardHeader
192                         className={classes.cardHeader}
193                         title={
194                             <section className={classes.nameSection}>
195                                 <section className={classes.namePlate}>
196                                     <Typography
197                                         variant='h6'
198                                         style={{ marginRight: '1rem' }}
199                                     >
200                                         {name}
201                                     </Typography>
202                                     <FavoriteStar
203                                         className={classes.faveIcon}
204                                         resourceUuid={currentResource.uuid}
205                                     />
206                                     <PublicFavoriteStar
207                                         className={classes.faveIcon}
208                                         resourceUuid={currentResource.uuid}
209                                     />
210                                     {!!frozenByFullName && (
211                                         <Tooltip
212                                             className={classes.frozenIcon}
213                                             disableFocusListener
214                                             title={<span>Project was frozen by {frozenByFullName}</span>}
215                                         >
216                                             <FreezeIcon style={{ fontSize: 'inherit' }} />
217                                         </Tooltip>
218                                     )}
219                                 </section>
220                                 {!description && (
221                                     <Typography
222                                         data-cy='no-description'
223                                         className={classes.noDescription}
224                                     >
225                                         no description available
226                                     </Typography>
227                                 )}
228                             </section>
229                         }
230                     />
231                     {isSelected && <MultiselectToolbar injectedStyles={classes.toolbarStyles} />}
232                 </Grid>
233                 <section onClick={(ev) => ev.stopPropagation()}>
234                     {description ? (
235                         <section
236                             onClick={toggleDescription}
237                             className={classes.descriptionToggle}
238                             data-cy='toggle-description'
239                         >
240                             <ExpandChevronRight expanded={showDescription} />
241                             <section className={classes.showMore}>
242                                 <Collapse
243                                     in={showDescription}
244                                     timeout='auto'
245                                     collapsedHeight='1.25rem'
246                                 >
247                                     <Typography
248                                         className={classes.description}
249                                         data-cy='project-description'
250                                         //dangerouslySetInnerHTML is ok here only if description is sanitized,
251                                         //which it is before it is loaded into the redux store
252                                         dangerouslySetInnerHTML={{ __html: description }}
253                                     />
254                                 </Collapse>
255                             </section>
256                         </section>
257                     ) : (
258                         <></>
259                     )}
260                     {typeof currentResource.properties === 'object' && Object.keys(currentResource.properties).length > 0 ? (
261                         <section
262                             onClick={toggleProperties}
263                             className={classes.descriptionToggle}
264                         >
265                             <div
266                                 className={classes.chipToggle}
267                                 data-cy='toggle-chips'
268                             >
269                                 <ExpandChevronRight expanded={showProperties} />
270                             </div>
271                             <section className={classes.showMore}>
272                                 <Collapse
273                                     in={showProperties}
274                                     timeout='auto'
275                                     collapsedHeight='35px'
276                                 >
277                                     <div
278                                         className={classes.description}
279                                         data-cy='project-description'
280                                     >
281                                         <CardContent className={classes.cardContent}>
282                                             <Typography
283                                                 component='div'
284                                                 className={classes.chipSection}
285                                             >
286                                                 {Object.keys(currentResource.properties).map((k) =>
287                                                     Array.isArray(currentResource.properties[k])
288                                                         ? currentResource.properties[k].map((v: string) => getPropertyChip(k, v, undefined, classes.tag))
289                                                         : getPropertyChip(k, currentResource.properties[k], undefined, classes.tag)
290                                                 )}
291                                             </Typography>
292                                         </CardContent>
293                                     </div>
294                                 </Collapse>
295                             </section>
296                         </section>
297                     ) : null}
298                 </section>
299             </Card>
300         );
301     })
302 );