useRemoteConfigSettings.tsx 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178
  1. import {useCallback, useEffect, useMemo, useReducer} from 'react';
  2. import type {ApiResult} from 'sentry/api';
  3. import type {Organization} from 'sentry/types';
  4. import type {RemoteConfig} from 'sentry/types/remoteConfig';
  5. import replaceAtArrayIndex from 'sentry/utils/array/replaceAtArrayIndex';
  6. import {
  7. type ApiQueryKey,
  8. fetchMutation,
  9. setApiQueryData,
  10. useApiQuery,
  11. useMutation,
  12. useQueryClient,
  13. } from 'sentry/utils/queryClient';
  14. import type RequestError from 'sentry/utils/requestError/requestError';
  15. import useApi from 'sentry/utils/useApi';
  16. interface Props {
  17. organization: Organization;
  18. projectId: string;
  19. }
  20. const DEFAULT_CONFIG_VALUE: RemoteConfig = {
  21. data: {
  22. features: [],
  23. options: {
  24. sample_rate: 0,
  25. traces_sample_rate: 0,
  26. },
  27. },
  28. };
  29. function makeResponseValue(payload: RemoteConfig): ApiResult<RemoteConfig> {
  30. return [payload, '', undefined];
  31. }
  32. type DispatchAction =
  33. | {data: RemoteConfig; type: 'revertStaged'}
  34. | {key: string; type: 'updateOption'; value: string | number}
  35. | {key: string; type: 'addFeature'; value: string}
  36. | {key: string; type: 'updateFeature'; value: string}
  37. | {key: string; type: 'removeFeature'};
  38. type TVariables = ['DELETE', undefined] | ['POST', RemoteConfig];
  39. type TContext = unknown;
  40. export default function useRemoteConfigSettings({organization, projectId}: Props) {
  41. const queryKey: ApiQueryKey = useMemo(
  42. () => [`/projects/${organization.slug}/${projectId}/configuration/`],
  43. [organization.slug, projectId]
  44. );
  45. const api = useApi({persistInFlight: false});
  46. const queryClient = useQueryClient();
  47. const fetchResult = useApiQuery(queryKey, {
  48. initialData: makeResponseValue(DEFAULT_CONFIG_VALUE),
  49. staleTime: 0,
  50. retry(failureCount: number, error: RequestError) {
  51. return failureCount < 3 && error.status !== 404;
  52. },
  53. });
  54. const [staged, dispatch] = useReducer(reducer, DEFAULT_CONFIG_VALUE);
  55. useEffect(() => {
  56. if (fetchResult.data) {
  57. dispatch({type: 'revertStaged', data: fetchResult.data});
  58. }
  59. }, [fetchResult.data]);
  60. const mutation = useMutation<RemoteConfig, RequestError, TVariables, TContext>({
  61. mutationFn([method, payload]) {
  62. return fetchMutation(api)([method, queryKey[0], {}, payload ?? {}]);
  63. },
  64. onMutate([_method, payload]) {
  65. queryClient.setQueryData(
  66. queryKey,
  67. makeResponseValue(payload ?? DEFAULT_CONFIG_VALUE)
  68. );
  69. },
  70. });
  71. const handleSave = useCallback(
  72. (onSuccess: () => void, onError: () => void) => {
  73. mutation.mutate(['POST', staged], {
  74. onSuccess(data, _variables, _context) {
  75. dispatch({type: 'revertStaged', data});
  76. onSuccess();
  77. },
  78. onError() {
  79. queryClient.invalidateQueries(queryKey);
  80. onError();
  81. },
  82. });
  83. },
  84. [mutation, queryClient, queryKey, staged]
  85. );
  86. const handleDelete = useCallback(
  87. (onSuccess: () => void, onError: () => void) => {
  88. mutation.mutate(['DELETE', undefined], {
  89. onSuccess(data, _variables, _context) {
  90. dispatch({type: 'revertStaged', data});
  91. onSuccess();
  92. },
  93. onError() {
  94. setApiQueryData(queryClient, queryKey, DEFAULT_CONFIG_VALUE);
  95. onError();
  96. },
  97. });
  98. },
  99. [mutation, queryClient, queryKey]
  100. );
  101. return {
  102. result: fetchResult,
  103. staged,
  104. dispatch,
  105. handleDelete,
  106. handleSave,
  107. };
  108. }
  109. function reducer(state: RemoteConfig, action: DispatchAction): RemoteConfig {
  110. switch (action.type) {
  111. case 'revertStaged':
  112. return action.data;
  113. case 'updateOption':
  114. return {
  115. data: {
  116. ...state.data,
  117. options: {
  118. ...state.data.options,
  119. [action.key]: action.value,
  120. },
  121. },
  122. };
  123. case 'addFeature':
  124. return {
  125. data: {
  126. ...state.data,
  127. features: [...state.data.features, {key: action.key, value: action.value}],
  128. },
  129. };
  130. case 'updateFeature': {
  131. const features = state.data.features || [];
  132. const index = features.findIndex(feature => feature.key === action.key);
  133. if (features.at(index)?.value === action.value) {
  134. return state;
  135. }
  136. return {
  137. data: {
  138. ...state.data,
  139. features: replaceAtArrayIndex(features, index, {
  140. key: action.key,
  141. value: action.value,
  142. }),
  143. },
  144. };
  145. }
  146. case 'removeFeature':
  147. return {
  148. data: {
  149. ...state.data,
  150. features: state.data.features.filter(feature => feature.key !== action.key),
  151. },
  152. };
  153. default:
  154. throw new Error(`Unexpected remote config action type`);
  155. }
  156. }