Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor(projects): deduplicate code via UniversalProjectsRoute #5251

Merged
merged 3 commits into from
Nov 12, 2024
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
@use 'scss/colors';

.banner {
display: flex;
justify-content: space-between;
margin: 24px 24px 0;
padding: 12px;
background-color: colors.$kobo-light-blue;
border-radius: 5px;
align-items: center;
}

.bannerIcon {
padding-right: 18px;
}

.bannerButton {
margin-left: auto;
}
64 changes: 64 additions & 0 deletions jsapp/js/components/permissions/transferProjects/inviteBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import React from 'react';
import Icon from 'js/components/common/icon';
import Button from 'js/components/common/button';
import {TransferStatuses} from 'js/components/permissions/transferProjects/transferProjects.api';
import styles from './inviteBanner.module.scss';

export interface InviteState {
valid: boolean;
uid: string;
status: TransferStatuses.Accepted | TransferStatuses.Declined | null;
name: string;
currentOwner: string;
}

interface InviteBannerProps {
invite: InviteState;
onRequestClose: () => void;
}

/**
* Displays a banner about accepting or declining project transfer invitation.
*/
export default function InviteBanner(props: InviteBannerProps) {
magicznyleszek marked this conversation as resolved.
Show resolved Hide resolved
if (props.invite.status) {
return (
<div className={styles.banner}>
<Icon
name='information'
color='blue'
className={styles.bannerIcon}
/>

{props.invite.status === TransferStatuses.Declined && (
<>
{t('You have declined the request of transfer ownership for ##PROJECT_NAME##. ##CURRENT_OWNER_NAME## will receive a notification that the transfer was incomplete.')
.replace('##PROJECT_NAME##', props.invite.name)
.replace('##CURRENT_OWNER_NAME##', props.invite.currentOwner)}
&nbsp;
{t('##CURRENT_OWNER_NAME## will remain the project owner.')
.replace('##CURRENT_OWNER_NAME##', props.invite.currentOwner)}
</>
)}

{props.invite.status === TransferStatuses.Accepted && (
<>
{t('You have accepted project ownership from ##CURRENT_OWNER_NAME## for ##PROJECT_NAME##. This process can take up to a few minutes to complete.')
.replace('##PROJECT_NAME##', props.invite.name)
.replace('##CURRENT_OWNER_NAME##', props.invite.currentOwner)}
</>
)}

<Button
type='text'
size='s'
startIcon='close'
onClick={() => {props.onRequestClose();}}
className={styles.bannerButton}
/>
</div>
);
}

return null;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// Libraries
import React, {useState, useEffect} from 'react';
import {useSearchParams} from 'react-router-dom';

// Partial components
import TransferProjectsInvite from './transferProjectsInvite.component';
import InviteBanner from './inviteBanner';

// Stores, hooks and utilities
import {
isInviteForLoggedInUser,
type TransferStatuses,
} from './transferProjects.api';

// Constants and types
import type {InviteState} from './inviteBanner';

/**
* This is a glue component that displays a modal from `TransferProjectsInvite`
* and a banner from `InviteBanner` as an outcome of the modal action.
*/
export default function TransferModalWithBanner() {
magicznyleszek marked this conversation as resolved.
Show resolved Hide resolved
const [invite, setInvite] = useState<InviteState>({
valid: false,
uid: '',
status: null,
name: '',
currentOwner: '',
});
const [isBannerVisible, setIsBannerVisible] = useState(true);
const [searchParams] = useSearchParams();

useEffect(() => {
const inviteParams = searchParams.get('invite');
if (inviteParams) {
isInviteForLoggedInUser(inviteParams).then((data) => {
setInvite({...invite, valid: data, uid: inviteParams});
});
} else {
setInvite({...invite, valid: false, uid: ''});
}
}, [searchParams]);

const setInviteDetail = (
newStatus: TransferStatuses.Accepted | TransferStatuses.Declined,
name: string,
currentOwner: string
) => {
setInvite({
...invite,
status: newStatus,
name: name,
currentOwner: currentOwner,
});
};

return (
<>
{isBannerVisible &&
<InviteBanner
invite={invite}
onRequestClose={() => {setIsBannerVisible(false);}}
/>
}

{invite.valid && invite.uid !== '' && (
<TransferProjectsInvite
setInvite={setInviteDetail}
inviteUid={invite.uid}
/>
)}
</>
);
}
160 changes: 15 additions & 145 deletions jsapp/js/projects/customViewRoute.tsx
Original file line number Diff line number Diff line change
@@ -1,169 +1,39 @@
// Libraries
import React, {useState, useEffect} from 'react';
import React from 'react';
import {useParams} from 'react-router-dom';
import {observer} from 'mobx-react-lite';
import {toJS} from 'mobx';

// Partial components
import ProjectsFilter from './projectViews/projectsFilter';
import ProjectsFieldsSelector from './projectViews/projectsFieldsSelector';
import ViewSwitcher from './projectViews/viewSwitcher';
import ProjectsTable from 'js/projects/projectsTable/projectsTable';
import Button from 'js/components/common/button';
import ProjectQuickActionsEmpty from './projectsTable/projectQuickActionsEmpty';
import ProjectQuickActions from './projectsTable/projectQuickActions';
import LimitNotifications from 'js/components/usageLimits/limitNotifications.component';
import ProjectBulkActions from './projectsTable/projectBulkActions';

// Stores, hooks and utilities
import {notify} from 'js/utils';
import {handleApiFail, fetchPostUrl} from 'js/api';
import customViewStore from './customViewStore';
import projectViewsStore from './projectViews/projectViewsStore';
import UniversalProjectsRoute from './universalProjectsRoute';

// Constants and types
import type {
ProjectsFilterDefinition,
ProjectFieldName,
} from './projectViews/constants';
import {
DEFAULT_VISIBLE_FIELDS,
DEFAULT_ORDERABLE_FIELDS,
DEFAULT_EXCLUDED_FIELDS,
} from './projectViews/constants';
import {ROOT_URL} from 'js/constants';

// Styles
import styles from './projectViews.module.scss';

/**
* Component responsible for rendering a custom project view route (`#/projects/<vid>`).
*/
function CustomViewRoute() {
export default function CustomViewRoute() {
const {viewUid} = useParams();

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

const [projectViews] = useState(projectViewsStore);
const [customView] = useState(customViewStore);
const [selectedRows, setSelectedRows] = useState<string[]>([]);

useEffect(() => {
customView.setUp(
viewUid,
`${ROOT_URL}/api/v2/project-views/${viewUid}/assets/`,
DEFAULT_VISIBLE_FIELDS,
false
);
}, [viewUid]);

// Whenever we do a full page (of results) reload, we need to clear up
// `selectedRows` to not end up with a project selected (e.g. on page of
// results that wasn't loaded/scrolled down into yet) and user not knowing
// about it.
useEffect(() => {
setSelectedRows([]);
}, [customView.isFirstLoadComplete]);

/** Returns a list of names for fields that have at least 1 filter defined. */
const getFilteredFieldsNames = () => {
const outcome: ProjectFieldName[] = [];
customView.filters.forEach((item: ProjectsFilterDefinition) => {
if (item.fieldName !== undefined) {
outcome.push(item.fieldName);
}
});
return outcome;
};

const exportAllData = () => {
const foundView = projectViews.getView(viewUid);
if (foundView) {
fetchPostUrl(foundView.assets_export, {uid: viewUid}).then(() => {
notify.warning(
t(
"Export is being generated, you will receive an email when it's done"
)
);
}, handleApiFail);
} else {
notify.error(
t(
"We couldn't create the export, please try again later or contact support"
)
);
}
};

const selectedAssets = customView.assets.filter((asset) =>
selectedRows.includes(asset.uid)
);

return (
<section className={styles.root}>
<header className={styles.header}>
<ViewSwitcher selectedViewUid={viewUid} />

<ProjectsFilter
onFiltersChange={customView.setFilters.bind(customView)}
filters={toJS(customView.filters)}
/>

<ProjectsFieldsSelector
onFieldsChange={customView.setFields.bind(customView)}
selectedFields={toJS(customView.fields)}
/>

<Button
type='secondary'
size='s'
startIcon='download'
label={t('Export all data')}
onClick={exportAllData}
/>

{selectedAssets.length === 0 && (
<div className={styles.actions}>
<ProjectQuickActionsEmpty />
</div>
)}

{selectedAssets.length === 1 && (
<div className={styles.actions}>
<ProjectQuickActions
asset={selectedAssets[0]}
/>
</div>
)}

{selectedAssets.length > 1 && (
<div className={styles.actions}>
<ProjectBulkActions assets={selectedAssets} />
</div>
)}
</header>

<LimitNotifications useModal />

<ProjectsTable
assets={customView.assets}
isLoading={!customView.isFirstLoadComplete}
highlightedFields={getFilteredFieldsNames()}
visibleFields={
toJS(customView.fields) || customView.defaultVisibleFields
}
orderableFields={DEFAULT_ORDERABLE_FIELDS}
order={customView.order}
onChangeOrderRequested={customView.setOrder.bind(customView)}
onHideFieldRequested={customView.hideField.bind(customView)}
onRequestLoadNextPage={customView.fetchMoreAssets.bind(customView)}
hasMorePages={customView.hasMoreAssets}
selectedRows={selectedRows}
onRowsSelected={setSelectedRows}
/>
</section>
<UniversalProjectsRoute
viewUid={viewUid}
baseUrl={`${ROOT_URL}/api/v2/project-views/${viewUid}/assets/`}
defaultVisibleFields={DEFAULT_VISIBLE_FIELDS}
includeTypeFilter={false}
defaultOrderableFields={DEFAULT_ORDERABLE_FIELDS}
defaultExcludedFields={DEFAULT_EXCLUDED_FIELDS}
isExportButtonVisible
/>
);
}

export default observer(CustomViewRoute);
39 changes: 0 additions & 39 deletions jsapp/js/projects/myProjectsRoute.module.scss

This file was deleted.

Loading
Loading