diff --git a/jsapp/js/components/permissions/transferProjects/projectOwnershipTransferModalWithBanner.tsx b/jsapp/js/components/permissions/transferProjects/projectOwnershipTransferModalWithBanner.tsx new file mode 100644 index 0000000000..017ce08ba4 --- /dev/null +++ b/jsapp/js/components/permissions/transferProjects/projectOwnershipTransferModalWithBanner.tsx @@ -0,0 +1,75 @@ +// Libraries +import React, {useState, useEffect} from 'react'; +import {useSearchParams} from 'react-router-dom'; + +// Partial components +import TransferProjectsInvite from './transferProjectsInvite.component'; +import ProjectTransferInviteBanner from './projectTransferInviteBanner'; + +// Stores, hooks and utilities +import { + isInviteForLoggedInUser, + type TransferStatuses, +} from './transferProjects.api'; + +// Constants and types +import type {TransferInviteState} from './projectTransferInviteBanner'; + +/** + * This is a glue component that displays a modal from `TransferProjectsInvite` + * and a banner from `ProjectTransferInviteBanner` as an outcome of the modal + * action. + */ +export default function ProjectOwnershipTransferModalWithBanner() { + const [invite, setInvite] = useState({ + 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 && + {setIsBannerVisible(false);}} + /> + } + + {invite.valid && invite.uid !== '' && ( + + )} + + ); +} diff --git a/jsapp/js/components/permissions/transferProjects/projectTransferInviteBanner.module.scss b/jsapp/js/components/permissions/transferProjects/projectTransferInviteBanner.module.scss new file mode 100644 index 0000000000..7231dd0e5a --- /dev/null +++ b/jsapp/js/components/permissions/transferProjects/projectTransferInviteBanner.module.scss @@ -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; +} diff --git a/jsapp/js/components/permissions/transferProjects/projectTransferInviteBanner.tsx b/jsapp/js/components/permissions/transferProjects/projectTransferInviteBanner.tsx new file mode 100644 index 0000000000..29048ebea7 --- /dev/null +++ b/jsapp/js/components/permissions/transferProjects/projectTransferInviteBanner.tsx @@ -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 './projectTransferInviteBanner.module.scss'; + +export interface TransferInviteState { + valid: boolean; + uid: string; + status: TransferStatuses.Accepted | TransferStatuses.Declined | null; + name: string; + currentOwner: string; +} + +interface ProjectTransferInviteBannerProps { + invite: TransferInviteState; + onRequestClose: () => void; +} + +/** + * Displays a banner about accepting or declining project transfer invitation. + */ +export default function ProjectTransferInviteBanner(props: ProjectTransferInviteBannerProps) { + if (props.invite.status) { + return ( +
+ + + {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)} +   + {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)} + + )} + +
+ ); + } + + return null; +} diff --git a/jsapp/js/projects/customViewRoute.tsx b/jsapp/js/projects/customViewRoute.tsx index 111da6cdaf..c6121ba61f 100644 --- a/jsapp/js/projects/customViewRoute.tsx +++ b/jsapp/js/projects/customViewRoute.tsx @@ -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/`). */ -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([]); - - 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 ( -
-
- - - - - - -
- - - - -
+ ); } - -export default observer(CustomViewRoute); diff --git a/jsapp/js/projects/myProjectsRoute.module.scss b/jsapp/js/projects/myProjectsRoute.module.scss deleted file mode 100644 index 146c6d7365..0000000000 --- a/jsapp/js/projects/myProjectsRoute.module.scss +++ /dev/null @@ -1,39 +0,0 @@ -@use 'scss/z-indexes'; -@use 'scss/mixins'; -@use 'scss/colors'; -@use 'sass:color'; - -.dropzone { - width: 100%; - height: 100%; - position: relative; -} - -.dropzoneOverlay { - display: none; -} - -.dropzoneActive .dropzoneOverlay { - @include mixins.centerRowFlex; - justify-content: center; - flex-wrap: wrap; - text-align: center; - background-color: color.change(colors.$kobo-white, $alpha: 0.5); - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - z-index: z-indexes.$z-dropzone; - color: colors.$kobo-blue; - border: 6px dashed currentcolor; - - :global { - h1 { - width: 100%; - margin: 6px 0 0; - font-size: 18px; - font-weight: normal; - } - } -} diff --git a/jsapp/js/projects/myProjectsRoute.tsx b/jsapp/js/projects/myProjectsRoute.tsx index b0d137f9b0..15564f23a8 100644 --- a/jsapp/js/projects/myProjectsRoute.tsx +++ b/jsapp/js/projects/myProjectsRoute.tsx @@ -1,37 +1,10 @@ // Libraries -import React, {useState, useEffect} from 'react'; -import {observer} from 'mobx-react-lite'; -import {toJS} from 'mobx'; -import Dropzone from 'react-dropzone'; -import {useSearchParams} from 'react-router-dom'; +import React from 'react'; // 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 ProjectQuickActionsEmpty from './projectsTable/projectQuickActionsEmpty'; -import ProjectQuickActions from './projectsTable/projectQuickActions'; -import ProjectBulkActions from './projectsTable/projectBulkActions'; -import Icon from 'js/components/common/icon'; -import LimitNotifications from 'js/components/usageLimits/limitNotifications.component'; -import TransferProjectsInvite from 'js/components/permissions/transferProjects/transferProjectsInvite.component'; -import Button from 'js/components/common/button'; - -// Stores, hooks and utilities -import customViewStore from './customViewStore'; -import {validFileTypes} from 'js/utils'; -import {dropImportXLSForms} from 'js/dropzone.utils'; -import { - isInviteForLoggedInUser, - TransferStatuses, -} from 'js/components/permissions/transferProjects/transferProjects.api'; +import UniversalProjectsRoute from './universalProjectsRoute'; // Constants and types -import type { - ProjectsFilterDefinition, - ProjectFieldName, -} from './projectViews/constants'; import { HOME_VIEW, HOME_ORDERABLE_FIELDS, @@ -40,213 +13,19 @@ import { } from './projectViews/constants'; import {ROOT_URL} from 'js/constants'; -// Styles -import styles from './projectViews.module.scss'; -import routeStyles from './myProjectsRoute.module.scss'; - -interface InviteState { - valid: boolean; - uid: string; - status: TransferStatuses.Accepted | TransferStatuses.Declined | null; - name: string; - currentOwner: string; -} - /** * Component responsible for rendering "My projects" route (`#/projects/home`). */ -function MyProjectsRoute() { - const [customView] = useState(customViewStore); - const [selectedRows, setSelectedRows] = useState([]); - const [invite, setInvite] = useState({ - valid: false, - uid: '', - status: null, - name: '', - currentOwner: '', - }); - const [banner, setBanner] = useState(true); - const [searchParams] = useSearchParams(); - - useEffect(() => { - customView.setUp( - HOME_VIEW.uid, - `${ROOT_URL}/api/v2/assets/`, - HOME_DEFAULT_VISIBLE_FIELDS - ); - - const inviteParams = searchParams.get('invite'); - if (inviteParams) { - isInviteForLoggedInUser(inviteParams).then((data) => { - setInvite({...invite, valid: data, uid: inviteParams}); - }); - } else { - setInvite({...invite, valid: false, uid: ''}); - } - }, [searchParams]); - - // 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 selectedAssets = customView.assets.filter((asset) => - selectedRows.includes(asset.uid) - ); - - /** Filters out excluded fields */ - const getTableVisibleFields = () => { - const outcome = toJS(customView.fields) || customView.defaultVisibleFields; - return outcome.filter( - (fieldName) => !HOME_EXCLUDED_FIELDS.includes(fieldName) - ); - }; - - const setInviteDetail = ( - newStatus: TransferStatuses.Accepted | TransferStatuses.Declined, - name: string, - currentOwner: string - ) => { - setInvite({ - ...invite, - status: newStatus, - name: name, - currentOwner: currentOwner, - }); - }; - +export default function MyProjectsRoute() { return ( - -
- -

{t('Drop files to upload')}

-
- - - -
- {invite.status && banner && ( -
- - - {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##', invite.name) - .replace('##CURRENT_OWNER_NAME##', invite.currentOwner)} -   - {t( - '##CURRENT_OWNER_NAME## will remain the project owner.' - ).replace('##CURRENT_OWNER_NAME##', invite.currentOwner)} - - )} - {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##', invite.name) - .replace('##CURRENT_OWNER_NAME##', invite.currentOwner)} - - )} - -
- )} - -
- - - - - - - {selectedAssets.length === 0 && ( -
- -
- )} - - {selectedAssets.length === 1 && ( -
- -
- )} - - {selectedAssets.length > 1 && ( -
- -
- )} -
- - - - {invite.valid && invite.uid !== '' && ( - - )} -
-
+ ); } - -export default observer(MyProjectsRoute); diff --git a/jsapp/js/projects/projectViews.module.scss b/jsapp/js/projects/projectViews.module.scss index 043ea87aca..d75ece4fa9 100644 --- a/jsapp/js/projects/projectViews.module.scss +++ b/jsapp/js/projects/projectViews.module.scss @@ -1,5 +1,7 @@ +@use 'scss/z-indexes'; @use 'scss/mixins'; @use 'scss/colors'; +@use 'sass:color'; .root { display: flex; @@ -20,20 +22,37 @@ justify-content: flex-end; } -.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; +.dropzone { + width: 100%; + height: 100%; + position: relative; } -.bannerIcon { - padding-right: 18px; +.dropzoneOverlay { + display: none; } -.bannerButton { - margin-left: auto; +.dropzoneActive .dropzoneOverlay { + @include mixins.centerRowFlex; + justify-content: center; + flex-wrap: wrap; + text-align: center; + background-color: color.change(colors.$kobo-white, $alpha: 0.5); + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: z-indexes.$z-dropzone; + color: colors.$kobo-blue; + border: 6px dashed currentcolor; + + :global { + h1 { + width: 100%; + margin: 6px 0 0; + font-size: 18px; + font-weight: normal; + } + } } diff --git a/jsapp/js/projects/projectViews/constants.ts b/jsapp/js/projects/projectViews/constants.ts index 03f2b16339..58ee83b2c5 100644 --- a/jsapp/js/projects/projectViews/constants.ts +++ b/jsapp/js/projects/projectViews/constants.ts @@ -336,6 +336,8 @@ export const HOME_ORDERABLE_FIELDS: ProjectFieldName[] = [ 'ownerUsername', ]; +export const DEFAULT_EXCLUDED_FIELDS: ProjectFieldName[] = []; + /** * The inital fields that are going to be displayed. We also use them with * "reset" fields button. diff --git a/jsapp/js/projects/universalProjectsRoute.tsx b/jsapp/js/projects/universalProjectsRoute.tsx new file mode 100644 index 0000000000..1c8626c8ac --- /dev/null +++ b/jsapp/js/projects/universalProjectsRoute.tsx @@ -0,0 +1,205 @@ +// Libraries +import React, {useState, useEffect} from 'react'; +import {observer} from 'mobx-react-lite'; +import {toJS} from 'mobx'; +import Dropzone from 'react-dropzone'; + +// 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 ProjectQuickActionsEmpty from './projectsTable/projectQuickActionsEmpty'; +import ProjectQuickActions from './projectsTable/projectQuickActions'; +import ProjectBulkActions from './projectsTable/projectBulkActions'; +import LimitNotifications from 'js/components/usageLimits/limitNotifications.component'; +import Icon from 'js/components/common/icon'; +import ProjectOwnershipTransferModalWithBanner from 'js/components/permissions/transferProjects/projectOwnershipTransferModalWithBanner'; +import Button from 'js/components/common/button'; + +// Stores, hooks and utilities +import customViewStore from './customViewStore'; +import {validFileTypes, notify} from 'js/utils'; +import {dropImportXLSForms} from 'js/dropzone.utils'; +import {handleApiFail, fetchPostUrl} from 'js/api'; +import projectViewsStore from './projectViews/projectViewsStore'; + +// Constants and types +import type { + ProjectsFilterDefinition, + ProjectFieldName, +} from './projectViews/constants'; + +// Styles +import styles from './projectViews.module.scss'; + +interface UniversalProjectsRouteProps { + // Props to satisfy `customViewStore.setUp` function + viewUid: string; + baseUrl: string; + defaultVisibleFields: ProjectFieldName[]; + includeTypeFilter: boolean; + // Props for filtering and ordering + defaultOrderableFields: ProjectFieldName[]; + defaultExcludedFields: ProjectFieldName[]; + /** Pass this to display export button */ + isExportButtonVisible: boolean; +} + +/** + * Component responsible for rendering every possible projects route. It relies + * heavily on `customViewStore` and has the least possible amount of custom code, + * assuming every route wants the same functionalities. + */ +function UniversalProjectsRoute(props: UniversalProjectsRouteProps) { + const [projectViews] = useState(projectViewsStore); + const [customView] = useState(customViewStore); + const [selectedRows, setSelectedRows] = useState([]); + + useEffect(() => { + customView.setUp( + props.viewUid, + props.baseUrl, + props.defaultVisibleFields, + props.includeTypeFilter + ); + }, [ + customView, + props.viewUid, + props.baseUrl, + props.defaultVisibleFields, + props.includeTypeFilter + ]); + + // 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; + }; + + /** + * Note: for now the export function only supports custom proejct views. + */ + const exportAllData = () => { + const foundView = projectViews.getView(props.viewUid); + if (foundView) { + fetchPostUrl(foundView.assets_export, {uid: props.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) + ); + + /** Filters out excluded fields */ + const getTableVisibleFields = () => { + const outcome = toJS(customView.fields) || customView.defaultVisibleFields; + return outcome.filter( + (fieldName) => !props.defaultExcludedFields.includes(fieldName) + ); + }; + + return ( + +
+ +

{t('Drop files to upload')}

+
+ +
+ + +
+ + + + + + + {props.isExportButtonVisible && +
+ + + + +
+
+ ); +} + +export default observer(UniversalProjectsRoute);