Skip to content

Commit

Permalink
fix(1-3173): clear "removed tags" when you bulk update tags (#8952)
Browse files Browse the repository at this point in the history
This PR fixes a bug wherein the list of tags to remove from a group of
tags wouldn't be correctly updated.

## Repro steps
- Add a console log line to
`frontend/src/component/feature/FeatureView/FeatureOverview/ManageTagsDialog/ManageBulkTagsDialog.tsx`'s
`ManagebulkTagsDialog`. Log the value of the`payload` variable.
- Pick a flag with no tags.
- Add tag A -> before submitting, you should have one added tag and zero
removed flags. After submitting, both should be empty.
- Now remove tag A -> before submitting, you should have one removed tag
and zero added tag. After submitting, both should be empty
- Notice that removed flags hasn't been emptied, but still contains tag
A.
- Now add tab B -> before submitting, you should have tag B in added and
nothing in removed. Notice that tag A is still in removed.



## Discussion points

This gives us both a `clear` and a `reset` event, which is unfortunate
because they sound like they do the same thing. I'd suggest renaming the
`clear` event (because it doesn't really clear the state completely),
but I'm not sure to what. Happy to do that if you have a suggestion.

I have not tested that submission of the form actually resets the state.
I spent about 45 minutes looking at it, but couldn't find a way that was
sensible and worked (considered spying: couldn't make it work;
considered refactoring and extracting components: think that's too much
of a change). I think this is benign enough that it can go without a
test for that thing actually being called.

I did, however, test the different reducer commands.
  • Loading branch information
thomasheartman authored Dec 12, 2024
1 parent 37a3ec9 commit 7a43634
Show file tree
Hide file tree
Showing 4 changed files with 120 additions and 9 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { payloadReducer } from './ManageBulkTagsDialog';

describe('payloadReducer', () => {
it('should add a tag to addedTags and remove it from removedTags', () => {
const initialState = {
addedTags: [{ type: 'simple', value: 'A' }],
removedTags: [
{ type: 'simple', value: 'B' },
{ type: 'simple', value: 'C' },
],
};

const action = {
type: 'add' as const,
payload: { type: 'simple', value: 'B' },
};

const newState = payloadReducer(initialState, action);

expect(newState).toMatchObject({
addedTags: [
{ type: 'simple', value: 'A' },
{ type: 'simple', value: 'B' },
],
removedTags: [{ type: 'simple', value: 'C' }],
});
});

it('should remove a tag from addedTags and add it to removedTags', () => {
const initialState = {
addedTags: [
{ type: 'simple', value: 'A' },
{ type: 'simple', value: 'B' },
],
removedTags: [{ type: 'simple', value: 'C' }],
};

const action = {
type: 'remove' as const,
payload: { type: 'simple', value: 'B' },
};

const newState = payloadReducer(initialState, action);

expect(newState).toMatchObject({
addedTags: [{ type: 'simple', value: 'A' }],
removedTags: [
{ type: 'simple', value: 'C' },
{ type: 'simple', value: 'B' },
],
});
});

it('should empty addedTags and set removedTags to the payload on clear', () => {
const initialState = {
addedTags: [{ type: 'simple', value: 'A' }],
removedTags: [{ type: 'simple', value: 'B' }],
};

const action = {
type: 'clear' as const,
payload: [{ type: 'simple', value: 'C' }],
};

const newState = payloadReducer(initialState, action);

expect(newState).toMatchObject({
addedTags: [],
removedTags: [{ type: 'simple', value: 'C' }],
});
});

it('should empty both addedTags and removedTags on reset', () => {
const initialState = {
addedTags: [{ type: 'simple', value: 'test' }],
removedTags: [{ type: 'simple', value: 'test2' }],
};

const action = {
type: 'reset' as const,
};

const newState = payloadReducer(initialState, action);
expect(newState).toMatchObject({
addedTags: [],
removedTags: [],
});
});
});
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useReducer, useState, type VFC } from 'react';
import { type FC, useEffect, useReducer, useState } from 'react';
import { Link as RouterLink } from 'react-router-dom';
import {
type AutocompleteProps,
Expand Down Expand Up @@ -46,7 +46,7 @@ const mergeTags = (tags: ITag[], newTag: ITag) => [
const filterTags = (tags: ITag[], tag: ITag) =>
tags.filter((x) => !(x.value === tag.value && x.type === tag.type));

const payloadReducer = (
export const payloadReducer = (
state: Payload,
action:
| {
Expand All @@ -56,7 +56,8 @@ const payloadReducer = (
| {
type: 'clear';
payload: ITag[];
},
}
| { type: 'reset' },
) => {
switch (action.type) {
case 'add':
Expand All @@ -76,6 +77,11 @@ const payloadReducer = (
addedTags: [],
removedTags: action.payload,
};
case 'reset':
return {
addedTags: [],
removedTags: [],
};
default:
return state;
}
Expand All @@ -87,7 +93,7 @@ const emptyTagType = {
icon: '',
};

export const ManageBulkTagsDialog: VFC<IManageBulkTagsDialogProps> = ({
export const ManageBulkTagsDialog: FC<IManageBulkTagsDialogProps> = ({
open,
initialValues,
initialIndeterminateValues,
Expand All @@ -106,6 +112,11 @@ export const ManageBulkTagsDialog: VFC<IManageBulkTagsDialogProps> = ({
removedTags: [],
});

const submitAndReset = () => {
onSubmit(payload);
dispatch({ type: 'reset' });
};

const resetTagType = (
tagType: ITagType = tagTypes.length > 0 ? tagTypes[0] : emptyTagType,
) => {
Expand Down Expand Up @@ -230,7 +241,7 @@ export const ManageBulkTagsDialog: VFC<IManageBulkTagsDialogProps> = ({
secondaryButtonText='Cancel'
primaryButtonText='Save tags'
title='Update feature flag tags'
onClick={() => onSubmit(payload)}
onClick={submitAndReset}
disabledPrimaryButton={
payload.addedTags.length === 0 &&
payload.removedTags.length === 0
Expand All @@ -244,7 +255,7 @@ export const ManageBulkTagsDialog: VFC<IManageBulkTagsDialogProps> = ({
>
Tags allow you to group features together
</Typography>
<form id={formId} onSubmit={() => onSubmit(payload)}>
<form id={formId} onSubmit={submitAndReset}>
<StyledDialogFormContent>
<TagTypeSelect
key={tagTypesLoading ? 'loading' : tagTypes.length}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
useTheme,
} from '@mui/material';
import type { ITagType } from 'interfaces/tags';
import type { HTMLAttributes } from 'react';

interface ITagSelect {
options: ITagType[];
Expand Down Expand Up @@ -37,8 +38,15 @@ export const TagTypeSelect = ({
disableClearable
value={value}
getOptionLabel={(option) => option.name}
renderOption={(props, option) => (
renderOption={(
{
key,
...props
}: JSX.IntrinsicAttributes & HTMLAttributes<HTMLLIElement>,
option,
) => (
<ListItem
key={key}
{...props}
style={{
alignItems: 'flex-start',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,10 @@ export const TagsInput = ({
};

const renderOption = (
props: JSX.IntrinsicAttributes &
{
key,
...props
}: JSX.IntrinsicAttributes &
React.ClassAttributes<HTMLLIElement> &
React.LiHTMLAttributes<HTMLLIElement>,
option: TagOption,
Expand All @@ -64,7 +67,7 @@ export const TagsInput = ({
indeterminateOption.title === option.title,
) ?? false;
return (
<li {...props}>
<li key={key} {...props}>
<ConditionallyRender
condition={Boolean(option.inputValue)}
show={<Add sx={{ mr: (theme) => theme.spacing(0.5) }} />}
Expand Down

0 comments on commit 7a43634

Please sign in to comment.