From c739ea71cfd1d15b434078a025b93cf1fe322561 Mon Sep 17 00:00:00 2001 From: Thomas Heartman Date: Fri, 6 Jun 2025 13:01:16 +0200 Subject: [PATCH] Fix(1-3804)/store flag creation form state (#10089) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds caching via localstorage to the flag creation form, so that if you (accidentally) close the form before submitting it, you'll retain (most) of the same data when you reopen it. Specifically, we'll store: - name - description - type - tags - impression data We can't store the project as it is now, because it gets overridden by whatever is in the URL. However, this is probably a good thing. It means that if you navigate to a different project and open the feature creation form there, it'll retain everything from the last one, but it'll use the current project. The stored data is cleared when you successfully create a feature, so that you don't get dangling data. The data is also stored in a shared cache for all projects, so that you don't have different caches per project. The behavior of seeding the form is hidden behind a flag (that doesn't exist yet). We'll still read and write to the cache if the flag is off, but we won't use it to populate the feature form, so it has no discernible impact on the user. ## Bug detected 🐛 ... and squashed Working on this, I came to realize that there was a bug in how the config button and use feature form hooks interacted. We (in this case probably me) have assumed that it's fine to use a set for any option checking in the config buttons. Also, we're using a set to store tags in the feature form. But objects aren't compared by value in JS, so the set will happily accept multiple instances of the same tag. Likewise, these tags won't show up as selected in the dropdown because when the dropdown checks if the set `has` the value, it's using reference equality. To get around this, I have normalized the values of the Tags set to strings (`:`), which are easily comparable. We can iterate on this later if we need to. ## `useLocalStorageState` In doing this, I have also made a change to the useLocalStorageState hook: the exposed "setState" function now writes to the localstorage immediately. This is because the useEffect method might not have time to save the data if the component unmounts (this was the case with the flag dialog). However, I have kept the useEffect because it gets run on component mount and then only when it changes. This means that we will get double saves to localstorage, but it'll be with the same data, so it's benign. I've tried out two other uses of the hook (event timeline props and environment columns in the project flags table) and see no discernible difference in behavior. ## `useFeatureForm` I have also made a change to the useFeatureForm hook and removed a `useEffect` that would reset the name to whatever you passed in as the initial name if you cleared it out. This essentially meant that you couldn't clear the name completely, because it would just refill with the initial name. As far as I can tell, there is no need to have this sticking around anymore. The hook is only used in two places: the flag creation dialog and the flag edit page. The flag edit page doesn't allow you to change the name anyway and it was causing issues in the dialog. It's likely a holdover from the way something worked 3 years ago. Both the dialog and the edit screen seem to work just fine with this change. I have also changed the function parameters from ordered parameters to an object. There's so many of them that even you don't think it's a good idea to use objects when you have multiple params with the same type, it's just nigh-impossible to remember the order by now. ## Minor changes Additionally, I came across three issues that were causing react errors, and have fixed them. 1. we'd forgotten to interpolate a variable and just used the variable name in a string instead 2. an html attribute that doesn't exist (`aria-role` instead of `role`) 3. Providing a disabled button inside a tooltip. I've seen this one around for ages and it prevented tooltips from working on disabled buttons. The solution was wrapping it in a span. --- .../ConfigButtons/DropdownList.tsx | 2 +- .../ConfigButtons/ToggleConfigButton.tsx | 2 +- .../PermissionButton/PermissionButton.tsx | 26 +++++---- .../feature/EditFeature/EditFeature.tsx | 14 ++--- .../component/feature/hooks/useFeatureForm.ts | 32 ++++++----- .../CreateFeatureDialog.tsx | 56 ++++++++++++++++--- .../__snapshots__/TagTypeList.test.tsx.snap | 52 ++++++++--------- frontend/src/hooks/useLocalStorageState.ts | 10 +++- frontend/src/interfaces/uiConfig.ts | 1 + src/lib/types/experimental.ts | 5 ++ 10 files changed, 130 insertions(+), 70 deletions(-) diff --git a/frontend/src/component/common/DialogFormTemplate/ConfigButtons/DropdownList.tsx b/frontend/src/component/common/DialogFormTemplate/ConfigButtons/DropdownList.tsx index d72d99ffdc..78a11c2a26 100644 --- a/frontend/src/component/common/DialogFormTemplate/ConfigButtons/DropdownList.tsx +++ b/frontend/src/component/common/DialogFormTemplate/ConfigButtons/DropdownList.tsx @@ -107,7 +107,7 @@ export function DropdownList({ return ( + + + diff --git a/frontend/src/hooks/useLocalStorageState.ts b/frontend/src/hooks/useLocalStorageState.ts index 309023f489..f6cd993208 100644 --- a/frontend/src/hooks/useLocalStorageState.ts +++ b/frontend/src/hooks/useLocalStorageState.ts @@ -13,7 +13,13 @@ export const useLocalStorageState = ( useEffect(() => { setStoredValue(localValue); - }, [localValue]); + }, [localValue, setStoredValue]); - return [localValue, setLocalValue] as const; + return [ + localValue, + (value: T | ((prevState: T) => T)) => { + setStoredValue(value); + setLocalValue(value); + }, + ] as const; }; diff --git a/frontend/src/interfaces/uiConfig.ts b/frontend/src/interfaces/uiConfig.ts index 0482c226c8..3074dadfce 100644 --- a/frontend/src/interfaces/uiConfig.ts +++ b/frontend/src/interfaces/uiConfig.ts @@ -90,6 +90,7 @@ export type UiFlags = { registerFrontendClient?: boolean; customMetrics?: boolean; lifecycleMetrics?: boolean; + createFlagDialogCache?: boolean; sideMenuCleanup?: boolean; }; diff --git a/src/lib/types/experimental.ts b/src/lib/types/experimental.ts index 939bb7c1e5..85f82ea891 100644 --- a/src/lib/types/experimental.ts +++ b/src/lib/types/experimental.ts @@ -63,6 +63,7 @@ export type IFlagKey = | 'newGettingStartedEmail' | 'lifecycleMetrics' | 'customMetrics' + | 'createFlagDialogCache' | 'sideMenuCleanup'; export type IFlags = Partial<{ [key in IFlagKey]: boolean | Variant }>; @@ -292,6 +293,10 @@ const flags: IFlags = { process.env.UNLEASH_EXPERIMENTAL_LIFECYCLE_METRICS, false, ), + createFlagDialogCache: parseEnvVarBoolean( + process.env.UNLEASH_EXPERIMENTAL_CREATE_FLAG_DIALOG_CACHE, + false, + ), sideMenuCleanup: parseEnvVarBoolean( process.env.UNLEASH_EXPERIMENTAL_SIDE_MENU_CLEANUP, false,