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

feat: add bulk tag delete / enable / disable options #38647

Merged
merged 2 commits into from
Mar 19, 2024
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
5 changes: 5 additions & 0 deletions src/CONST.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1477,6 +1477,11 @@ const CONST = {
DISABLE: 'disable',
ENABLE: 'enable',
},
TAGS_BULK_ACTION_TYPES: {
DELETE: 'delete',
DISABLE: 'disable',
ENABLE: 'enable',
},
DISTANCE_RATES_BULK_ACTION_TYPES: {
DELETE: 'delete',
DISABLE: 'disable',
Expand Down
5 changes: 5 additions & 0 deletions src/languages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1847,6 +1847,9 @@ export default {
requiresTag: 'Members must tag all spend',
customTagName: 'Custom tag name',
enableTag: 'Enable tag',
enableTags: 'Enable tags',
disableTag: 'Disable tag',
disableTags: 'Disable tags',
addTag: 'Add tag',
editTag: 'Edit tag',
subtitle: 'Tags add more detailed ways to classify costs.',
Expand All @@ -1855,7 +1858,9 @@ export default {
subtitle: 'Add a tag to track projects, locations, departments, and more.',
},
deleteTag: 'Delete tag',
deleteTags: 'Delete tags',
deleteTagConfirmation: 'Are you sure that you want to delete this tag?',
deleteTagsConfirmation: 'Are you sure that you want to delete these tags?',
deleteFailureMessage: 'An error occurred while deleting the tag, please try again.',
tagRequiredError: 'Tag name is required.',
existingTagError: 'A tag with this name already exists.',
Expand Down
5 changes: 5 additions & 0 deletions src/languages/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1871,6 +1871,9 @@ export default {
requiresTag: 'Los miembros deben etiquetar todos los gastos',
customTagName: 'Nombre de etiqueta personalizada',
enableTag: 'Habilitar etiqueta',
enableTags: 'Habilitar etiquetas',
disableTag: 'Desactivar etiqueta',
disableTags: 'Desactivar etiquetas',
addTag: 'Añadir etiqueta',
editTag: 'Editar etiqueta',
subtitle: 'Las etiquetas añaden formas más detalladas de clasificar los costos.',
Expand All @@ -1879,7 +1882,9 @@ export default {
subtitle: 'Añade una etiqueta para realizar el seguimiento de proyectos, ubicaciones, departamentos y otros.',
},
deleteTag: 'Eliminar etiqueta',
deleteTags: 'Eliminar etiquetas',
deleteTagConfirmation: '¿Estás seguro de que quieres eliminar esta etiqueta?',
deleteTagsConfirmation: '¿Estás seguro de que quieres eliminar estas etiquetas?',
deleteFailureMessage: 'Se ha producido un error al intentar eliminar la etiqueta. Por favor, inténtalo más tarde.',
tagRequiredError: 'Lo nombre de la etiqueta es obligatorio.',
existingTagError: 'Ya existe una etiqueta con este nombre.',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,7 @@ function WorkspaceCategoriesPage({policy, policyCategories, route}: WorkspaceCat
buttonSize={CONST.DROPDOWN_BUTTON_SIZE.MEDIUM}
customText={translate('workspace.common.selected', {selectedNumber: selectedCategoriesArray.length})}
options={options}
style={[isSmallScreenWidth && styles.w50, isSmallScreenWidth && styles.mb3]}
style={[isSmallScreenWidth && styles.flexGrow1, isSmallScreenWidth && styles.mb3]}
/>
);
}
Expand Down
144 changes: 123 additions & 21 deletions src/pages/workspace/tags/WorkspaceTagsPage.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import type {StackScreenProps} from '@react-navigation/stack';
import React, {useEffect, useMemo, useState} from 'react';
import React, {useEffect, useMemo, useRef, useState} from 'react';
import {ActivityIndicator, View} from 'react-native';
import {withOnyx} from 'react-native-onyx';
import type {OnyxEntry} from 'react-native-onyx';
import Button from '@components/Button';
import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu';
import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types';
import ConfirmModal from '@components/ConfirmModal';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import Icon from '@components/Icon';
import * as Expensicons from '@components/Icon/Expensicons';
Expand Down Expand Up @@ -31,13 +34,15 @@ import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
import type SCREENS from '@src/SCREENS';
import type * as OnyxTypes from '@src/types/onyx';
import type DeepValueOf from '@src/types/utils/DeepValueOf';

type PolicyForList = {
value: string;
text: string;
keyForList: string;
isSelected: boolean;
rightElement: React.ReactNode;
enabled: boolean;
};

type PolicyOption = ListItem & {
Expand All @@ -58,6 +63,8 @@ function WorkspaceTagsPage({policyTags, route}: WorkspaceTagsPageProps) {
const theme = useTheme();
const {translate} = useLocalize();
const [selectedTags, setSelectedTags] = useState<Record<string, boolean>>({});
const dropdownButtonRef = useRef(null);
const [deleteTagsConfirmModalVisible, setDeleteTagsConfirmModalVisible] = useState(false);

function fetchTags() {
Policy.openPolicyTagsPage(route.params.policyID);
Expand All @@ -84,6 +91,7 @@ function WorkspaceTagsPage({policyTags, route}: WorkspaceTagsPageProps) {
isSelected: !!selectedTags[value.name],
pendingAction: value.pendingAction,
errors: value.errors ?? undefined,
enabled: value.enabled,
rightElement: (
<View style={styles.flexRow}>
<Text style={[styles.textSupporting, styles.alignSelfCenter, styles.pl2, styles.label]}>
Expand All @@ -103,6 +111,11 @@ function WorkspaceTagsPage({policyTags, route}: WorkspaceTagsPageProps) {
[policyTagLists, selectedTags, styles.alignSelfCenter, styles.flexRow, styles.label, styles.p1, styles.pl2, styles.textSupporting, theme.icon, translate],
);

const tagListKeyedByName = tagList.reduce<Record<string, PolicyForList>>((acc, tag) => {
acc[tag.value] = tag;
return acc;
}, {});

const toggleTag = (tag: PolicyForList) => {
setSelectedTags((prev) => ({
...prev,
Expand Down Expand Up @@ -135,29 +148,108 @@ function WorkspaceTagsPage({policyTags, route}: WorkspaceTagsPageProps) {
Navigation.navigate(ROUTES.WORKSPACE_TAG_SETTINGS.getRoute(route.params.policyID, tag.keyForList));
};

const selectedTagsArray = Object.keys(selectedTags).filter((key) => selectedTags[key]);

const handleDeleteTags = () => {
setSelectedTags({});
Policy.deletePolicyTags(route.params.policyID, selectedTagsArray);
setDeleteTagsConfirmModalVisible(false);
};

const isLoading = !isOffline && policyTags === undefined;

const headerButtons = (
<View style={[styles.w100, styles.flexRow, isSmallScreenWidth && styles.mb3]}>
<Button
medium
success
onPress={navigateToCreateTagPage}
icon={Expensicons.Plus}
text={translate('workspace.tags.addTag')}
style={[styles.mr3, isSmallScreenWidth && styles.w50]}
/>
{policyTags && (
const getHeaderButtons = () => {
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
const getHeaderButtons = () => {
const renderHeaderButtons = () => {

const options: Array<DropdownOption<DeepValueOf<typeof CONST.POLICY.TAGS_BULK_ACTION_TYPES>>> = [];

if (selectedTagsArray.length > 0) {
options.push({
icon: Expensicons.Trashcan,
text: translate(selectedTagsArray.length === 1 ? 'workspace.tags.deleteTag' : 'workspace.tags.deleteTags'),
value: CONST.POLICY.TAGS_BULK_ACTION_TYPES.DELETE,
onSelected: () => setDeleteTagsConfirmModalVisible(true),
});

const enabledTags = selectedTagsArray.filter((tagName) => tagListKeyedByName?.[tagName]?.enabled);
if (enabledTags.length > 0) {
const tagsToDisable = selectedTagsArray
.filter((tagName) => tagListKeyedByName?.[tagName]?.enabled)
.reduce<Record<string, {name: string; enabled: boolean}>>((acc, tagName) => {
acc[tagName] = {
name: tagName,
enabled: false,
};
return acc;
}, {});

options.push({
icon: Expensicons.DocumentSlash,
text: translate(enabledTags.length === 1 ? 'workspace.tags.disableTag' : 'workspace.tags.disableTags'),
value: CONST.POLICY.TAGS_BULK_ACTION_TYPES.DISABLE,
onSelected: () => {
setSelectedTags({});
Policy.setWorkspaceTagEnabled(route.params.policyID, tagsToDisable);
},
});
}

const disabledTags = selectedTagsArray.filter((tagName) => !tagListKeyedByName?.[tagName]?.enabled);
if (disabledTags.length > 0) {
const tagsToEnable = selectedTagsArray
.filter((tagName) => !tagListKeyedByName?.[tagName]?.enabled)
.reduce<Record<string, {name: string; enabled: boolean}>>((acc, tagName) => {
acc[tagName] = {
name: tagName,
enabled: true,
};
return acc;
}, {});
options.push({
icon: Expensicons.Document,
text: translate(disabledTags.length === 1 ? 'workspace.tags.enableTag' : 'workspace.tags.enableTags'),
value: CONST.POLICY.TAGS_BULK_ACTION_TYPES.ENABLE,
onSelected: () => {
setSelectedTags({});
Policy.setWorkspaceTagEnabled(route.params.policyID, tagsToEnable);
},
});
}

return (
<ButtonWithDropdownMenu
buttonRef={dropdownButtonRef}
onPress={() => null}
shouldAlwaysShowDropdownMenu
pressOnEnter
buttonSize={CONST.DROPDOWN_BUTTON_SIZE.MEDIUM}
customText={translate('workspace.common.selected', {selectedNumber: selectedTagsArray.length})}
options={options}
style={[isSmallScreenWidth && styles.flexGrow1, isSmallScreenWidth && styles.mb3]}
/>
);
}

return (
<View style={[styles.w100, styles.flexRow, isSmallScreenWidth && styles.mb3]}>
<Button
medium
onPress={navigateToTagsSettings}
icon={Expensicons.Gear}
text={translate('common.settings')}
style={[isSmallScreenWidth && styles.w50]}
success
onPress={navigateToCreateTagPage}
icon={Expensicons.Plus}
text={translate('workspace.tags.addTag')}
style={[styles.mr3, isSmallScreenWidth && styles.w50]}
/>
)}
</View>
);
{policyTags && (
<Button
medium
onPress={navigateToTagsSettings}
icon={Expensicons.Gear}
text={translate('common.settings')}
style={[isSmallScreenWidth && styles.w50]}
/>
)}
</View>
);
};

return (
<AdminPolicyAccessOrNotFoundWrapper policyID={route.params.policyID}>
Expand All @@ -173,9 +265,19 @@ function WorkspaceTagsPage({policyTags, route}: WorkspaceTagsPageProps) {
title={translate('workspace.common.tags')}
shouldShowBackButton={isSmallScreenWidth}
>
{!isSmallScreenWidth && headerButtons}
{!isSmallScreenWidth && getHeaderButtons()}
</HeaderWithBackButton>
{isSmallScreenWidth && <View style={[styles.pl5, styles.pr5]}>{headerButtons}</View>}
<ConfirmModal
isVisible={deleteTagsConfirmModalVisible}
onConfirm={handleDeleteTags}
onCancel={() => setDeleteTagsConfirmModalVisible(false)}
title={translate(selectedTagsArray.length === 1 ? 'workspace.tags.deleteTag' : 'workspace.tags.deleteTags')}
prompt={translate(selectedTagsArray.length === 1 ? 'workspace.tags.deleteTagConfirmation' : 'workspace.tags.deleteTagsConfirmation')}
confirmText={translate('common.delete')}
cancelText={translate('common.cancel')}
danger
/>
{isSmallScreenWidth && <View style={[styles.pl5, styles.pr5]}>{getHeaderButtons()}</View>}
<View style={[styles.ph5, styles.pb5, styles.pt3]}>
<Text style={[styles.textNormal, styles.colorMuted]}>{translate('workspace.tags.subtitle')}</Text>
</View>
Expand Down
Loading