Merge branch 'main' into 22207-icon-resort
[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 { 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     | 'noDescription'
32     | 'userNameContainer'
33     | 'cardContent'
34     | 'nameSection'
35     | 'namePlate'
36     | 'faveIcon'
37     | 'frozenIcon'
38     | 'chipSection'
39     | 'tag'
40     | 'description'
41     | 'toolbarStyles';
42
43 const styles: CustomStyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
44     root: {
45         width: '100%',
46         marginBottom: '1rem',
47         flex: '0 0 auto',
48         padding: 0,
49         minHeight: '3rem',
50     },
51     noDescription: {
52         color: theme.palette.grey['600'],
53         fontStyle: 'italic',
54         marginLeft: '2rem',
55     },
56     userNameContainer: {
57         display: 'flex',
58         alignItems: 'center',
59         minHeight: '2.7rem',
60     },
61     cardHeaderContainer: {
62         width: '100%',
63         display: 'flex',
64         flexDirection: 'row',
65         alignItems: 'center',
66         justifyContent: 'space-between',
67     },
68     cardHeader: {
69         minWidth: '30rem',
70         padding: '0.2rem',
71     },
72     descriptionToggle: {
73         marginLeft: '-16px',
74     },
75     cardContent: {
76         display: 'flex',
77         flexDirection: 'column',
78         marginTop: '.5rem',
79         paddingTop: '0px',
80         paddingBottom: '0px',
81         paddingLeft: '.5rem',
82         paddingRight: '.5rem',
83     },
84     nameSection: {
85         display: 'flex',
86         flexDirection: 'row',
87         alignItems: 'center',
88     },
89     namePlate: {
90         display: 'flex',
91         flexDirection: 'row',
92         alignItems: 'center',
93         margin: 0,
94         minHeight: '2.7rem',
95         marginLeft: '.5rem',
96     },
97     faveIcon: {
98         fontSize: '0.8rem',
99         margin: 'auto 0 1rem 0.3rem',
100         color: theme.palette.text.primary,
101     },
102     frozenIcon: {
103         fontSize: '0.5rem',
104         marginLeft: '0.3rem',
105         height: '1rem',
106         color: theme.palette.text.primary,
107     },
108     chipSection: {
109         marginBottom: '.5rem',
110     },
111     tag: {
112         marginRight: '0.75rem',
113         marginBottom: '0.5rem',
114     },
115     description: {
116         marginTop: 0,
117         marginRight: '2rem',
118         marginLeft: '8px',
119         maxWidth: "50em",
120     },
121     toolbarStyles: {
122         marginRight: '-0.5rem',
123     },
124 });
125
126 const mapStateToProps = ({ auth, selectedResourceUuid, resources, properties }: RootState) => {
127     const currentResource = getResource(properties.currentRouteUuid)(resources);
128     const frozenByUser = currentResource && getResource((currentResource as ProjectResource).frozenByUuid as string)(resources);
129     const frozenByFullName = frozenByUser && (frozenByUser as Resource & { fullName: string }).fullName;
130     const isSelected = selectedResourceUuid === properties.currentRouteUuid;
131
132     return {
133         isAdmin: auth.user?.isAdmin,
134         currentResource,
135         frozenByFullName,
136         isSelected,
137     };
138 };
139
140 const mapDispatchToProps = (dispatch: Dispatch) => ({
141     handleCardClick: (uuid: string) => {
142         dispatch<any>(loadDetailsPanel(uuid));
143         dispatch<any>(setSelectedResourceUuid(uuid));
144         dispatch<any>(deselectAllOthers(uuid));
145     },
146 });
147
148 type ProjectCardProps = WithStyles<CssRules> & {
149     currentResource: ProjectResource;
150     frozenByFullName: string | undefined;
151     isAdmin: boolean;
152     isSelected: boolean;
153     handleCardClick: (resource: any) => void;
154 };
155
156 export const ProjectCard = connect(
157     mapStateToProps,
158     mapDispatchToProps
159 )(
160     withStyles(styles)((props: ProjectCardProps) => {
161         const { classes, currentResource, frozenByFullName, handleCardClick, isSelected } = props;
162         const { name, description, uuid } = currentResource as ProjectResource;
163         const [showDescription, setShowDescription] = React.useState(false);
164
165         const toggleDescription = () => {
166             setShowDescription(!showDescription);
167         };
168
169         const hasDescription = !!(description && description.length > 0);
170         const hasProperties = (typeof currentResource.properties === 'object' && Object.keys(currentResource.properties).length > 0);
171         const expandable = hasDescription || hasProperties;
172
173         return (
174             <Card
175                 className={classes.root}
176                 onClick={() => handleCardClick(uuid)}
177                 data-cy='project-details-card'
178             >
179                 <Grid
180                     container
181                     wrap='nowrap'
182                     className={classes.cardHeaderContainer}
183                 >
184                     <CardHeader
185                         className={classes.cardHeader}
186                         title={
187                             <section className={classes.nameSection}>
188                                 <section className={classes.namePlate}>
189                                     <Typography
190                                         variant='h6'
191                                         style={{ marginRight: '1rem' }}
192                                     >
193                                                  {name}
194                                                  {expandable && <span className={classes.descriptionToggle}
195                                                                       onClick={toggleDescription}
196                                                                       data-cy="toggle-description">
197                                                      <ExpandChevronRight expanded={showDescription} />
198                                                  </span>}
199                                     </Typography>
200
201                                     <FavoriteStar
202                                         className={classes.faveIcon}
203                                         resourceUuid={currentResource.uuid}
204                                     />
205                                     <PublicFavoriteStar
206                                         className={classes.faveIcon}
207                                         resourceUuid={currentResource.uuid}
208                                     />
209                                                   {!!frozenByFullName && (
210                                                       <Tooltip
211                                                           className={classes.frozenIcon}
212                                                           disableFocusListener
213                                                           title={<span>Project was frozen by {frozenByFullName}</span>}
214                                                       >
215                                                           <FreezeIcon style={{ fontSize: 'inherit' }} />
216                                                       </Tooltip>
217                                                   )}
218                                                                             {!hasDescription && (
219                                                                                 <Typography
220                                                                                     data-cy='no-description'
221                                                                                     className={classes.noDescription}
222                                                                                 >
223                                                                                              no description available
224                                                                                 </Typography>
225                                                                             )}
226
227                                 </section>
228                             </section>
229                         }
230                     />
231                     {isSelected && <MultiselectToolbar injectedStyles={classes.toolbarStyles} />}
232                 </Grid>
233
234                 {expandable && <Collapse
235                                    in={showDescription}
236                                    timeout='auto'
237                                    collapsedSize='0rem'
238                                >
239                     <CardContent className={classes.cardContent}>
240                         {hasProperties &&
241                          <section data-cy='project-properties'>
242                              <Typography
243                                  component='div'
244                                  className={classes.chipSection}
245                              >
246                                  {Object.keys(currentResource.properties).map((k) =>
247                                      Array.isArray(currentResource.properties[k])
248                                      ? currentResource.properties[k].map((v: string) => getPropertyChip(k, v, undefined, classes.tag))
249                                      : getPropertyChip(k, currentResource.properties[k], undefined, classes.tag)
250                                  )}
251                              </Typography>
252                          </section>}
253
254                         {hasDescription && (
255                             <section data-cy='project-description'>
256                                 <Typography
257                                     className={classes.description}
258                                     component='div'
259                                     //dangerouslySetInnerHTML is ok here only if description is sanitized,
260                                     //which it is before it is loaded into the redux store
261                                     dangerouslySetInnerHTML={{ __html: description }}
262                                 />
263                             </section>
264                         )}
265                     </CardContent>
266                 </Collapse>}
267             </Card>
268         );
269     })
270 );