Feat: Feature toggle admin page frontend write UI and InteractiveTable sorting (#73533)

* updates

* make save button always visible but disabled

* only reset toggles if there isn't an error response

* make linters happy

* update post body to match backend

* fix linter again

* be smarter about sorting of empty descriptions

* run prettier

* fix payload

* Re-add disabled to switch

---------

Co-authored-by: Joao Calisto <joao.santana.calisto@gmail.com>
Co-authored-by: Michael Mandrus <michael.mandrus@grafana.com>
This commit is contained in:
Ibrahim 2023-08-30 20:02:58 +04:00 committed by GitHub
parent 025b2f3011
commit 2794b8628e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 110 additions and 12 deletions

View File

@ -102,7 +102,7 @@ func (s *ServiceImpl) getAdminNode(c *contextmodel.ReqContext) (*navtree.NavLink
if s.features.IsEnabled(featuremgmt.FlagFeatureToggleAdminPage) && hasAccess(ac.EvalPermission(ac.ActionFeatureManagementRead)) { if s.features.IsEnabled(featuremgmt.FlagFeatureToggleAdminPage) && hasAccess(ac.EvalPermission(ac.ActionFeatureManagementRead)) {
configNodes = append(configNodes, &navtree.NavLink{ configNodes = append(configNodes, &navtree.NavLink{
Text: "Feature Toggles", SubTitle: "View feature toggles", Id: "feature-toggles", Url: s.cfg.AppSubURL + "/admin/featuretoggles", Icon: "toggle-on", Text: "Feature Toggles", SubTitle: "View and edit feature toggles", Id: "feature-toggles", Url: s.cfg.AppSubURL + "/admin/featuretoggles", Icon: "toggle-on",
}) })
} }

View File

@ -3,11 +3,23 @@ import { lastValueFrom } from 'rxjs';
import { getBackendSrv } from '@grafana/runtime'; import { getBackendSrv } from '@grafana/runtime';
type QueryArgs = {
url: string;
method?: string;
body?: { featureToggles: FeatureToggle[] };
};
const backendSrvBaseQuery = const backendSrvBaseQuery =
({ baseUrl }: { baseUrl: string }): BaseQueryFn<{ url: string }> => ({ baseUrl }: { baseUrl: string }): BaseQueryFn<QueryArgs> =>
async ({ url }) => { async ({ url, method = 'GET', body }) => {
try { try {
const { data } = await lastValueFrom(getBackendSrv().fetch({ url: baseUrl + url })); const { data } = await lastValueFrom(
getBackendSrv().fetch({
url: baseUrl + url,
method,
data: body,
})
);
return { data }; return { data };
} catch (error) { } catch (error) {
return { error }; return { error };
@ -21,14 +33,22 @@ export const togglesApi = createApi({
getFeatureToggles: builder.query<FeatureToggle[], void>({ getFeatureToggles: builder.query<FeatureToggle[], void>({
query: () => ({ url: '/featuremgmt' }), query: () => ({ url: '/featuremgmt' }),
}), }),
updateFeatureToggles: builder.mutation<void, FeatureToggle[]>({
query: (updatedToggles) => ({
url: '/featuremgmt',
method: 'POST',
body: { featureToggles: updatedToggles },
}),
}),
}), }),
}); });
type FeatureToggle = { type FeatureToggle = {
name: string; name: string;
description?: string;
enabled: boolean; enabled: boolean;
description: string; readOnly?: boolean;
}; };
export const { useGetFeatureTogglesQuery } = togglesApi; export const { useGetFeatureTogglesQuery, useUpdateFeatureTogglesMutation } = togglesApi;
export type { FeatureToggle }; export type { FeatureToggle };

View File

@ -1,35 +1,113 @@
import React from 'react'; import React, { useState } from 'react';
import { Switch, InteractiveTable, type CellProps } from '@grafana/ui'; import { Switch, InteractiveTable, type CellProps, Button, type SortByFn } from '@grafana/ui';
import { type FeatureToggle } from './AdminFeatureTogglesAPI'; import { type FeatureToggle, useUpdateFeatureTogglesMutation } from './AdminFeatureTogglesAPI';
interface Props { interface Props {
featureToggles: FeatureToggle[]; featureToggles: FeatureToggle[];
} }
const sortByName: SortByFn<FeatureToggle> = (a, b) => {
return a.original.name.localeCompare(b.original.name);
};
const sortByDescription: SortByFn<FeatureToggle> = (a, b) => {
if (!a.original.description && !b.original.description) {
return 0;
} else if (!a.original.description) {
return 1;
} else if (!b.original.description) {
return -1;
}
return a.original.description.localeCompare(b.original.description);
};
const sortByEnabled: SortByFn<FeatureToggle> = (a, b) => {
return a.original.enabled === b.original.enabled ? 0 : a.original.enabled ? 1 : -1;
};
export function AdminFeatureTogglesTable({ featureToggles }: Props) { export function AdminFeatureTogglesTable({ featureToggles }: Props) {
const [localToggles, setLocalToggles] = useState<FeatureToggle[]>(featureToggles);
const [updateFeatureToggles] = useUpdateFeatureTogglesMutation();
const [modifiedToggles, setModifiedToggles] = useState<FeatureToggle[]>([]);
const handleToggleChange = (toggle: FeatureToggle, newValue: boolean) => {
const updatedToggle = { ...toggle, enabled: newValue };
// Update the local state
const updatedToggles = localToggles.map((t) => (t.name === toggle.name ? updatedToggle : t));
setLocalToggles(updatedToggles);
// Check if the toggle exists in modifiedToggles
const existingToggle = modifiedToggles.find((t) => t.name === toggle.name);
// If it exists and its state is the same as the updated one, remove it from modifiedToggles
if (existingToggle && existingToggle.enabled === newValue) {
setModifiedToggles((prev) => prev.filter((t) => t.name !== toggle.name));
} else {
// Else, add/update the toggle in modifiedToggles
setModifiedToggles((prev) => {
const newToggles = prev.filter((t) => t.name !== toggle.name);
newToggles.push(updatedToggle);
return newToggles;
});
}
};
const handleSaveChanges = async () => {
const resp = await updateFeatureToggles(modifiedToggles);
// Reset modifiedToggles after successful update
if (!('error' in resp)) {
setModifiedToggles([]);
}
};
const hasModifications = () => {
// Check if there are any differences between the original toggles and the local toggles
return featureToggles.some((originalToggle) => {
const modifiedToggle = localToggles.find((t) => t.name === originalToggle.name);
return modifiedToggle && modifiedToggle.enabled !== originalToggle.enabled;
});
};
const columns = [ const columns = [
{ {
id: 'name', id: 'name',
header: 'Name', header: 'Name',
cell: ({ cell: { value } }: CellProps<FeatureToggle, string>) => <div>{value}</div>, cell: ({ cell: { value } }: CellProps<FeatureToggle, string>) => <div>{value}</div>,
sortType: sortByName,
}, },
{ {
id: 'description', id: 'description',
header: 'Description', header: 'Description',
cell: ({ cell: { value } }: CellProps<FeatureToggle, string>) => <div>{value}</div>, cell: ({ cell: { value } }: CellProps<FeatureToggle, string>) => <div>{value}</div>,
sortType: sortByDescription,
}, },
{ {
id: 'enabled', id: 'enabled',
header: 'State', header: 'State',
cell: ({ cell: { value } }: CellProps<FeatureToggle, boolean>) => ( cell: ({ row }: CellProps<FeatureToggle, boolean>) => (
<div> <div>
<Switch value={value} disabled={true} /> <Switch
value={row.original.enabled}
disabled={row.original.readOnly}
onChange={(e) => handleToggleChange(row.original, e.currentTarget.checked)}
/>
</div> </div>
), ),
sortType: sortByEnabled,
}, },
]; ];
return <InteractiveTable columns={columns} data={featureToggles} getRowId={(featureToggle) => featureToggle.name} />; return (
<>
<div style={{ display: 'flex', justifyContent: 'flex-end', padding: '0 0 5px 0' }}>
<Button disabled={!hasModifications()} onClick={handleSaveChanges}>
Save Changes
</Button>
</div>
<InteractiveTable columns={columns} data={localToggles} getRowId={(featureToggle) => featureToggle.name} />
</>
);
} }