Skip to content

Commit 2f1d45e

Browse files
refactor(projects): deduplicate code via UniversalProjectsRoute (#5251)
Use single universal component for My Projects (`#/projects/home`) and Custom Project Views (`#/projects/<uid>`) routes. Move transfer project ownership code from `myProjectsRoute` to separate component - logic gathered in `ProjectOwnershipTransferModalWithBanner` component and banner in `ProjectTransferInviteBanner` component. Create `UniversalProjectsRoute` component that gathers similar logic from both `MyProjectsRoute` and `CustomViewRoute`. To not increase complexity of the code deduplication is done under assumption that all routes wants (almost) all the same functionalities.
1 parent 7b744e3 commit 2f1d45e

File tree

9 files changed

+423
-429
lines changed

9 files changed

+423
-429
lines changed
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
// Libraries
2+
import React, {useState, useEffect} from 'react';
3+
import {useSearchParams} from 'react-router-dom';
4+
5+
// Partial components
6+
import TransferProjectsInvite from './transferProjectsInvite.component';
7+
import ProjectTransferInviteBanner from './projectTransferInviteBanner';
8+
9+
// Stores, hooks and utilities
10+
import {
11+
isInviteForLoggedInUser,
12+
type TransferStatuses,
13+
} from './transferProjects.api';
14+
15+
// Constants and types
16+
import type {TransferInviteState} from './projectTransferInviteBanner';
17+
18+
/**
19+
* This is a glue component that displays a modal from `TransferProjectsInvite`
20+
* and a banner from `ProjectTransferInviteBanner` as an outcome of the modal
21+
* action.
22+
*/
23+
export default function ProjectOwnershipTransferModalWithBanner() {
24+
const [invite, setInvite] = useState<TransferInviteState>({
25+
valid: false,
26+
uid: '',
27+
status: null,
28+
name: '',
29+
currentOwner: '',
30+
});
31+
const [isBannerVisible, setIsBannerVisible] = useState(true);
32+
const [searchParams] = useSearchParams();
33+
34+
useEffect(() => {
35+
const inviteParams = searchParams.get('invite');
36+
if (inviteParams) {
37+
isInviteForLoggedInUser(inviteParams).then((data) => {
38+
setInvite({...invite, valid: data, uid: inviteParams});
39+
});
40+
} else {
41+
setInvite({...invite, valid: false, uid: ''});
42+
}
43+
}, [searchParams]);
44+
45+
const setInviteDetail = (
46+
newStatus: TransferStatuses.Accepted | TransferStatuses.Declined,
47+
name: string,
48+
currentOwner: string
49+
) => {
50+
setInvite({
51+
...invite,
52+
status: newStatus,
53+
name: name,
54+
currentOwner: currentOwner,
55+
});
56+
};
57+
58+
return (
59+
<>
60+
{isBannerVisible &&
61+
<ProjectTransferInviteBanner
62+
invite={invite}
63+
onRequestClose={() => {setIsBannerVisible(false);}}
64+
/>
65+
}
66+
67+
{invite.valid && invite.uid !== '' && (
68+
<TransferProjectsInvite
69+
setInvite={setInviteDetail}
70+
inviteUid={invite.uid}
71+
/>
72+
)}
73+
</>
74+
);
75+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
@use 'scss/colors';
2+
3+
.banner {
4+
display: flex;
5+
justify-content: space-between;
6+
margin: 24px 24px 0;
7+
padding: 12px;
8+
background-color: colors.$kobo-light-blue;
9+
border-radius: 5px;
10+
align-items: center;
11+
}
12+
13+
.bannerIcon {
14+
padding-right: 18px;
15+
}
16+
17+
.bannerButton {
18+
margin-left: auto;
19+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import React from 'react';
2+
import Icon from 'js/components/common/icon';
3+
import Button from 'js/components/common/button';
4+
import {TransferStatuses} from 'js/components/permissions/transferProjects/transferProjects.api';
5+
import styles from './projectTransferInviteBanner.module.scss';
6+
7+
export interface TransferInviteState {
8+
valid: boolean;
9+
uid: string;
10+
status: TransferStatuses.Accepted | TransferStatuses.Declined | null;
11+
name: string;
12+
currentOwner: string;
13+
}
14+
15+
interface ProjectTransferInviteBannerProps {
16+
invite: TransferInviteState;
17+
onRequestClose: () => void;
18+
}
19+
20+
/**
21+
* Displays a banner about accepting or declining project transfer invitation.
22+
*/
23+
export default function ProjectTransferInviteBanner(props: ProjectTransferInviteBannerProps) {
24+
if (props.invite.status) {
25+
return (
26+
<div className={styles.banner}>
27+
<Icon
28+
name='information'
29+
color='blue'
30+
className={styles.bannerIcon}
31+
/>
32+
33+
{props.invite.status === TransferStatuses.Declined && (
34+
<>
35+
{t('You have declined the request of transfer ownership for ##PROJECT_NAME##. ##CURRENT_OWNER_NAME## will receive a notification that the transfer was incomplete.')
36+
.replace('##PROJECT_NAME##', props.invite.name)
37+
.replace('##CURRENT_OWNER_NAME##', props.invite.currentOwner)}
38+
&nbsp;
39+
{t('##CURRENT_OWNER_NAME## will remain the project owner.')
40+
.replace('##CURRENT_OWNER_NAME##', props.invite.currentOwner)}
41+
</>
42+
)}
43+
44+
{props.invite.status === TransferStatuses.Accepted && (
45+
<>
46+
{t('You have accepted project ownership from ##CURRENT_OWNER_NAME## for ##PROJECT_NAME##. This process can take up to a few minutes to complete.')
47+
.replace('##PROJECT_NAME##', props.invite.name)
48+
.replace('##CURRENT_OWNER_NAME##', props.invite.currentOwner)}
49+
</>
50+
)}
51+
52+
<Button
53+
type='text'
54+
size='s'
55+
startIcon='close'
56+
onClick={() => {props.onRequestClose();}}
57+
className={styles.bannerButton}
58+
/>
59+
</div>
60+
);
61+
}
62+
63+
return null;
64+
}

jsapp/js/projects/customViewRoute.tsx

Lines changed: 15 additions & 145 deletions
Original file line numberDiff line numberDiff line change
@@ -1,169 +1,39 @@
11
// Libraries
2-
import React, {useState, useEffect} from 'react';
2+
import React from 'react';
33
import {useParams} from 'react-router-dom';
4-
import {observer} from 'mobx-react-lite';
5-
import {toJS} from 'mobx';
64

75
// Partial components
8-
import ProjectsFilter from './projectViews/projectsFilter';
9-
import ProjectsFieldsSelector from './projectViews/projectsFieldsSelector';
10-
import ViewSwitcher from './projectViews/viewSwitcher';
11-
import ProjectsTable from 'js/projects/projectsTable/projectsTable';
12-
import Button from 'js/components/common/button';
13-
import ProjectQuickActionsEmpty from './projectsTable/projectQuickActionsEmpty';
14-
import ProjectQuickActions from './projectsTable/projectQuickActions';
15-
import LimitNotifications from 'js/components/usageLimits/limitNotifications.component';
16-
import ProjectBulkActions from './projectsTable/projectBulkActions';
17-
18-
// Stores, hooks and utilities
19-
import {notify} from 'js/utils';
20-
import {handleApiFail, fetchPostUrl} from 'js/api';
21-
import customViewStore from './customViewStore';
22-
import projectViewsStore from './projectViews/projectViewsStore';
6+
import UniversalProjectsRoute from './universalProjectsRoute';
237

248
// Constants and types
25-
import type {
26-
ProjectsFilterDefinition,
27-
ProjectFieldName,
28-
} from './projectViews/constants';
299
import {
3010
DEFAULT_VISIBLE_FIELDS,
3111
DEFAULT_ORDERABLE_FIELDS,
12+
DEFAULT_EXCLUDED_FIELDS,
3213
} from './projectViews/constants';
3314
import {ROOT_URL} from 'js/constants';
3415

35-
// Styles
36-
import styles from './projectViews.module.scss';
37-
3816
/**
3917
* Component responsible for rendering a custom project view route (`#/projects/<vid>`).
4018
*/
41-
function CustomViewRoute() {
19+
export default function CustomViewRoute() {
4220
const {viewUid} = useParams();
4321

22+
// This condition is here to satisfy TS, as without it the code below would
23+
// need to be unnecessarily more lengthy.
4424
if (viewUid === undefined) {
4525
return null;
4626
}
4727

48-
const [projectViews] = useState(projectViewsStore);
49-
const [customView] = useState(customViewStore);
50-
const [selectedRows, setSelectedRows] = useState<string[]>([]);
51-
52-
useEffect(() => {
53-
customView.setUp(
54-
viewUid,
55-
`${ROOT_URL}/api/v2/project-views/${viewUid}/assets/`,
56-
DEFAULT_VISIBLE_FIELDS,
57-
false
58-
);
59-
}, [viewUid]);
60-
61-
// Whenever we do a full page (of results) reload, we need to clear up
62-
// `selectedRows` to not end up with a project selected (e.g. on page of
63-
// results that wasn't loaded/scrolled down into yet) and user not knowing
64-
// about it.
65-
useEffect(() => {
66-
setSelectedRows([]);
67-
}, [customView.isFirstLoadComplete]);
68-
69-
/** Returns a list of names for fields that have at least 1 filter defined. */
70-
const getFilteredFieldsNames = () => {
71-
const outcome: ProjectFieldName[] = [];
72-
customView.filters.forEach((item: ProjectsFilterDefinition) => {
73-
if (item.fieldName !== undefined) {
74-
outcome.push(item.fieldName);
75-
}
76-
});
77-
return outcome;
78-
};
79-
80-
const exportAllData = () => {
81-
const foundView = projectViews.getView(viewUid);
82-
if (foundView) {
83-
fetchPostUrl(foundView.assets_export, {uid: viewUid}).then(() => {
84-
notify.warning(
85-
t(
86-
"Export is being generated, you will receive an email when it's done"
87-
)
88-
);
89-
}, handleApiFail);
90-
} else {
91-
notify.error(
92-
t(
93-
"We couldn't create the export, please try again later or contact support"
94-
)
95-
);
96-
}
97-
};
98-
99-
const selectedAssets = customView.assets.filter((asset) =>
100-
selectedRows.includes(asset.uid)
101-
);
102-
10328
return (
104-
<section className={styles.root}>
105-
<header className={styles.header}>
106-
<ViewSwitcher selectedViewUid={viewUid} />
107-
108-
<ProjectsFilter
109-
onFiltersChange={customView.setFilters.bind(customView)}
110-
filters={toJS(customView.filters)}
111-
/>
112-
113-
<ProjectsFieldsSelector
114-
onFieldsChange={customView.setFields.bind(customView)}
115-
selectedFields={toJS(customView.fields)}
116-
/>
117-
118-
<Button
119-
type='secondary'
120-
size='s'
121-
startIcon='download'
122-
label={t('Export all data')}
123-
onClick={exportAllData}
124-
/>
125-
126-
{selectedAssets.length === 0 && (
127-
<div className={styles.actions}>
128-
<ProjectQuickActionsEmpty />
129-
</div>
130-
)}
131-
132-
{selectedAssets.length === 1 && (
133-
<div className={styles.actions}>
134-
<ProjectQuickActions
135-
asset={selectedAssets[0]}
136-
/>
137-
</div>
138-
)}
139-
140-
{selectedAssets.length > 1 && (
141-
<div className={styles.actions}>
142-
<ProjectBulkActions assets={selectedAssets} />
143-
</div>
144-
)}
145-
</header>
146-
147-
<LimitNotifications useModal />
148-
149-
<ProjectsTable
150-
assets={customView.assets}
151-
isLoading={!customView.isFirstLoadComplete}
152-
highlightedFields={getFilteredFieldsNames()}
153-
visibleFields={
154-
toJS(customView.fields) || customView.defaultVisibleFields
155-
}
156-
orderableFields={DEFAULT_ORDERABLE_FIELDS}
157-
order={customView.order}
158-
onChangeOrderRequested={customView.setOrder.bind(customView)}
159-
onHideFieldRequested={customView.hideField.bind(customView)}
160-
onRequestLoadNextPage={customView.fetchMoreAssets.bind(customView)}
161-
hasMorePages={customView.hasMoreAssets}
162-
selectedRows={selectedRows}
163-
onRowsSelected={setSelectedRows}
164-
/>
165-
</section>
29+
<UniversalProjectsRoute
30+
viewUid={viewUid}
31+
baseUrl={`${ROOT_URL}/api/v2/project-views/${viewUid}/assets/`}
32+
defaultVisibleFields={DEFAULT_VISIBLE_FIELDS}
33+
includeTypeFilter={false}
34+
defaultOrderableFields={DEFAULT_ORDERABLE_FIELDS}
35+
defaultExcludedFields={DEFAULT_EXCLUDED_FIELDS}
36+
isExportButtonVisible
37+
/>
16638
);
16739
}
168-
169-
export default observer(CustomViewRoute);

jsapp/js/projects/myProjectsRoute.module.scss

Lines changed: 0 additions & 39 deletions
This file was deleted.

0 commit comments

Comments
 (0)