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

UI for bulk deleting projects #4404

Merged
merged 15 commits into from
Apr 12, 2023
Merged
Show file tree
Hide file tree
Changes from all 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
7 changes: 6 additions & 1 deletion jsapp/js/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ const fetchData = async <T>(
* Useful if you already have a full URL to be called and there is no point
* adding `ROOT_URL` to it.
*/
prependRootUrl = true,
prependRootUrl = true
) => {
const headers: {[key: string]: string} = {
Accept: JSON_HEADER,
Expand All @@ -35,6 +35,11 @@ const fetchData = async <T>(
headers,
body: JSON.stringify(data),
});

if (!response.ok) {
throw response;
}
Comment on lines +39 to +41
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ahhhh, fetch() error handling 😁


const contentType = response.headers.get('content-type');
if (contentType && contentType.indexOf('application/json') !== -1) {
return (await response.json()) as Promise<T>;
Expand Down
13 changes: 6 additions & 7 deletions jsapp/js/components/common/checkbox-and-radio.scss
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,14 @@ $checkbox-size: sizes.$x20;

&:hover .radio__input:not([disabled]),
&:hover .checkbox__input:not([disabled]) {
&:checked::after { opacity: 0.8; }
&:not(:checked)::after { opacity: 0.1; }
&:checked::after {opacity: 0.8;}
&:not(:checked)::after {opacity: 0.1;}
}

&:active .radio__input:not([disabled]),
&:active .checkbox__input:not([disabled]) {
&:checked::after { opacity: 0.6; }
&:not(:checked)::after { opacity: 0.3; }
&:checked::after {opacity: 0.6;}
&:not(:checked)::after {opacity: 0.3;}
}
}

Expand Down Expand Up @@ -75,7 +75,7 @@ $checkbox-size: sizes.$x20;
border-radius: sizes.$x2;

&::after {
content: "✓";
content: '✓';
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
Expand All @@ -97,7 +97,6 @@ $checkbox-size: sizes.$x20;
border: sizes.$x1 solid colors.$kobo-gray-40;
width: $checkbox-size;
height: $checkbox-size;
outline: 0;
background-color: colors.$kobo-white;
color: colors.$kobo-gray-40;
cursor: pointer;
Expand All @@ -112,7 +111,7 @@ $checkbox-size: sizes.$x20;
&:checked {
border-color: colors.$kobo-blue;

&::after { opacity: 1; }
&::after {opacity: 1;}
}

&[disabled] {
Expand Down
4 changes: 4 additions & 0 deletions jsapp/js/components/modals/koboPrompt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ interface KoboPromptButton {
color?: ButtonColor;
label: string;
onClick: () => void;
isDisabled?: boolean;
isPending?: boolean;
}

const defaultButtonType = 'full';
Expand Down Expand Up @@ -65,6 +67,8 @@ export default function KoboPrompt(props: KoboPromptProps) {
size='m'
label={promptButton.label}
onClick={promptButton.onClick}
isDisabled={promptButton.isDisabled}
isPending={promptButton.isPending}
/>
))}
</KoboModalFooter>
Expand Down
4 changes: 2 additions & 2 deletions jsapp/js/projects/customViewStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,11 +240,11 @@ class CustomViewStore {
}
}

public handleAssetDeleted(deletedAssetUid: string) {
public handleAssetsDeleted(deletedAssetsUids: string[]) {
// When asset is deleted, we simply remove it from loaded assets list as it
// seems there is no need to fetch all the data again
this.assets = this.assets.filter(
(asset: ProjectViewAsset) => asset.uid !== deletedAssetUid
(asset: ProjectViewAsset) => !deletedAssetsUids.includes(asset.uid)
);
}

Expand Down
9 changes: 8 additions & 1 deletion jsapp/js/projects/myProjectsRoute.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import routeStyles from './myProjectsRoute.module.scss';
import {toJS} from 'mobx';
import {COMMON_QUERIES, ROOT_URL} from 'js/constants';
import ProjectQuickActions from './projectsTable/projectQuickActions';
import ProjectBulkActions from './projectsTable/projectBulkActions';
import Dropzone from 'react-dropzone';
import {validFileTypes} from 'js/utils';
import Icon from 'js/components/common/icon';
Expand Down Expand Up @@ -90,10 +91,16 @@ function MyProjectsRoute() {
/>

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

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

<ProjectsTable
Expand Down
2 changes: 1 addition & 1 deletion jsapp/js/projects/projectViews.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
padding: sizes.$x30 sizes.$x30 sizes.$x40;
}

.quickActions {
.actions {
@include mixins.centerRowFlex;
flex: 1;
justify-content: flex-end;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
@use 'scss/sizes';

.promptContent {
display: flex;
flex-direction: column;
gap: sizes.$x20;

:global p {
margin: 0;
}
}
110 changes: 110 additions & 0 deletions jsapp/js/projects/projectsTable/bulkActions/bulkDeletePrompt.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import React, {useState} from 'react';
import {fetchPost} from 'js/api';
import {handleApiFail, notify} from 'js/utils';
import KoboPrompt from 'js/components/modals/koboPrompt';
import Checkbox from 'js/components/common/checkbox';
import styles from './bulkDeletePrompt.module.scss';
import customViewStore from 'js/projects/customViewStore';
import {searches} from 'js/searches';

type AssetsBulkAction = 'archive' | 'delete' | 'unarchive';
interface AssetsBulkResponse {
detail: string;
}

interface BulkDeletePromptProps {
assetUids: string[];
/** Being used by the parent component to close the prompt. */
onRequestClose: () => void;
}

export default function BulkDeletePrompt(props: BulkDeletePromptProps) {
const [isDataChecked, setIsDataChecked] = useState(false);
const [isFormChecked, setIsFormChecked] = useState(false);
const [isRecoverChecked, setIsRecoverChecked] = useState(false);
const [isConfirmDeletePending, setIsConfirmDeletePending] = useState(false);

function onConfirmDelete() {
setIsConfirmDeletePending(true);

const payload: {asset_uids: string[]; action: AssetsBulkAction} = {
asset_uids: props.assetUids,
action: 'delete',
};

fetchPost<AssetsBulkResponse>('/api/v2/assets/bulk/', {payload: payload})
.then((response) => {
props.onRequestClose();
customViewStore.handleAssetsDeleted(props.assetUids);

// Temporarily we do this hacky thing to update the sidebar list of
// projects. After the Bookmarked Projects feature is done (see the
// https://github.com/kobotoolbox/kpi/issues/4220 for history of
// discussion and more details) we would remove this code.
searches.forceRefreshFormsList();

notify(response.detail);
})
.catch(handleApiFail);
}

return (
<KoboPrompt
// This is always open, because parent is conditionally rendering this component
isOpen
onRequestClose={props.onRequestClose}
title={t('Delete ##count## projects').replace(
'##count##',
String(props.assetUids.length)
)}
buttons={[
{
type: 'frame',
color: 'storm',
label: 'Cancel',
onClick: props.onRequestClose,
isDisabled: isConfirmDeletePending,
},
{
type: 'full',
color: 'red',
label: 'Delete',
onClick: onConfirmDelete,
isDisabled: !isDataChecked || !isFormChecked || !isRecoverChecked,
isPending: isConfirmDeletePending,
},
]}
>
<div className={styles.promptContent}>
<p>
{t('You are about to permanently delete ##count## projects').replace(
'##count##',
String(props.assetUids.length)
)}
</p>

<Checkbox
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not at all blocking, since it's an upstream issue, but the Checkbox component should probably have a focus state. When tab-navigating, you can't tell which the checkbox is the currently selected element. Would be a small accessibility improvement.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I pushed a small commit that adds focus styles for these :)

checked={isDataChecked}
onChange={setIsDataChecked}
label={t('All data gathered for these projects will be deleted')}
/>

<Checkbox
checked={isFormChecked}
onChange={setIsFormChecked}
label={t('Forms associated with these projects will be deleted')}
/>

<strong>
<Checkbox
checked={isRecoverChecked}
onChange={setIsRecoverChecked}
label={t(
'I understand that if I delete these projects I will not be able to recover them'
)}
/>
</strong>
</div>
</KoboPrompt>
);
}
38 changes: 38 additions & 0 deletions jsapp/js/projects/projectsTable/projectBulkActions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import React, {useState} from 'react';
import type {AssetResponse, ProjectViewAsset} from 'js/dataInterface';
import Button from 'js/components/common/button';
import actionsStyles from './projectActions.module.scss';
import BulkDeletePrompt from './bulkActions/bulkDeletePrompt';

interface ProjectBulkActionsProps {
/** A list of selected assets for bulk operations. */
assets: Array<AssetResponse | ProjectViewAsset>;
}

export default function ProjectBulkActions(props: ProjectBulkActionsProps) {
const [isDeletePromptOpen, setIsDeletePromptOpen] = useState(false);

return (
<div className={actionsStyles.root}>
<Button
type='bare'
color='storm'
size='s'
startIcon='trash'
tooltip={t('Delete ##count## projects').replace(
'##count##',
String(props.assets.length)
)}
onClick={() => setIsDeletePromptOpen(true)}
classNames={['right-tooltip']}
/>

{isDeletePromptOpen && (
<BulkDeletePrompt
assetUids={props.assets.map((asset) => asset.uid)}
onRequestClose={() => setIsDeletePromptOpen(false)}
/>
)}
</div>
);
}
4 changes: 2 additions & 2 deletions jsapp/js/projects/projectsTable/projectQuickActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type {
import {ASSET_TYPES} from 'js/constants';
import Button from 'js/components/common/button';
import KoboDropdown from 'jsapp/js/components/common/koboDropdown';
import styles from './projectQuickActions.module.scss';
import styles from './projectActions.module.scss';
import {getAssetDisplayName} from 'jsapp/js/assetUtils';
import {
archiveAsset,
Expand Down Expand Up @@ -116,7 +116,7 @@ export default function ProjectQuickActions(props: ProjectQuickActionsProps) {
props.asset,
getAssetDisplayName(props.asset).final,
(deletedAssetUid: string) => {
customViewStore.handleAssetDeleted(deletedAssetUid);
customViewStore.handleAssetsDeleted([deletedAssetUid]);
}
)
}
Expand Down
3 changes: 3 additions & 0 deletions jsapp/js/searches.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export namespace searches {
const forceRefreshFormsList: () => void;
}
9 changes: 9 additions & 0 deletions jsapp/js/searches.es6
Original file line number Diff line number Diff line change
Expand Up @@ -545,8 +545,17 @@ function getSearchContext(name, opts={}) {
return contexts[name];
}

/** A temporary function for glueing things together with `BulkDeletePrompt`. */
function forceRefreshFormsList() {
const formsContext = getSearchContext('forms');
if (formsContext?.mixin?.searchSemaphore) {
formsContext.mixin.searchSemaphore();
}
}

export const searches = {
getSearchContext: getSearchContext,
common: commonMethods,
isSearchContext: isSearchContext,
forceRefreshFormsList: forceRefreshFormsList,
};
1 change: 0 additions & 1 deletion jsapp/scss/libs/_mdl.buttons.scss
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@
position: relative;
display: inline-block;
overflow: hidden;
outline: none;
cursor: pointer;
color: colors.$kobo-gray-40;

Expand Down
2 changes: 0 additions & 2 deletions jsapp/scss/libs/react-tagsinput.scss
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,10 @@
background: transparent;
border: 0 none;
color: colors.$kobo-gray-40;
font-family: sans-serif;
font-size: 13px;
font-weight: 400;
margin-bottom: 2px;
margin-top: 1px;
outline: none;
padding: 5px;
min-width: 90px;
}
Expand Down
5 changes: 3 additions & 2 deletions jsapp/scss/stylesheets/partials/_base.scss
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
@use "scss/_variables";
@use 'scss/sizes';
@use 'scss/_variables';
@use '~kobo-common/src/styles/colors';

/* ==========================================================================
Expand Down Expand Up @@ -82,7 +83,7 @@ textarea {
}

:focus {
outline: 1px solid colors.$kobo-blue;
outline: sizes.$x1 solid colors.$kobo-blue;
}

i {
Expand Down