1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-10-27 11:02:16 +01:00

Merge branch 'main' of github.com:Unleash/unleash into fake-impact-metrics-improvements

This commit is contained in:
kwasniew 2025-10-27 10:37:11 +01:00
commit f9f917eab8
No known key found for this signature in database
GPG Key ID: 43A7CBC24C119560
26 changed files with 751 additions and 313 deletions

View File

@ -1,105 +0,0 @@
import { Box, Chip, styled, type ChipProps } from '@mui/material';
import { useState, type FC } from 'react';
const makeStyledChip = (ariaControlTarget: string) =>
styled(({ ...props }: ChipProps) => (
<Chip variant='outlined' aria-controls={ariaControlTarget} {...props} />
))<ChipProps>(({ theme, label }) => ({
padding: theme.spacing(0.5),
fontSize: theme.typography.body2.fontSize,
height: 'auto',
'&[aria-current="true"]': {
backgroundColor: theme.palette.secondary.light,
fontWeight: 'bold',
borderColor: theme.palette.primary.main,
color: theme.palette.primary.main,
},
':focus-visible': {
outline: `1px solid ${theme.palette.primary.main}`,
borderColor: theme.palette.primary.main,
},
borderRadius: 0,
'&:first-of-type': {
borderTopLeftRadius: theme.shape.borderRadius,
borderBottomLeftRadius: theme.shape.borderRadius,
},
'&:last-of-type': {
borderTopRightRadius: theme.shape.borderRadius,
borderBottomRightRadius: theme.shape.borderRadius,
},
'& .MuiChip-label': {
position: 'relative',
textAlign: 'center',
'&::before': {
content: `'${label}'`,
fontWeight: 'bold',
visibility: 'hidden',
height: 0,
display: 'block',
overflow: 'hidden',
userSelect: 'none',
},
},
}));
export type ChangeRequestQuickFilter = 'Created' | 'Approval Requested';
interface IChangeRequestFiltersProps {
ariaControlTarget: string;
initialSelection?: ChangeRequestQuickFilter;
onSelectionChange: (selection: ChangeRequestQuickFilter) => void;
}
const Wrapper = styled(Box)(({ theme }) => ({
display: 'flex',
justifyContent: 'flex-start',
padding: theme.spacing(1.5, 3, 0, 3),
minHeight: theme.spacing(7),
gap: theme.spacing(2),
}));
const StyledContainer = styled(Box)({
display: 'flex',
alignItems: 'center',
flexWrap: 'wrap',
});
export const ChangeRequestFilters: FC<IChangeRequestFiltersProps> = ({
onSelectionChange,
initialSelection,
ariaControlTarget,
}) => {
const [selected, setSelected] = useState<ChangeRequestQuickFilter>(
initialSelection || 'Created',
);
const handleSelectionChange = (value: ChangeRequestQuickFilter) => () => {
if (value === selected) {
return;
}
setSelected(value);
onSelectionChange(value);
};
const StyledChip = makeStyledChip(ariaControlTarget);
return (
<Wrapper>
<StyledContainer>
<StyledChip
label={'Created'}
aria-current={selected === 'Created'}
onClick={handleSelectionChange('Created')}
title={'Show change requests created by you'}
/>
<StyledChip
label={'Approval Requested'}
aria-current={selected === 'Approval Requested'}
onClick={handleSelectionChange('Approval Requested')}
title={'Show change requests requesting your approval'}
/>
</StyledContainer>
</Wrapper>
);
};

View File

@ -0,0 +1,65 @@
import { Box, Chip, styled, type ChipProps } from '@mui/material';
export const makeStyledChip = (ariaControlTarget: string) =>
styled(({ ...props }: ChipProps) => (
<Chip variant='outlined' aria-controls={ariaControlTarget} {...props} />
))<ChipProps>(({ theme, label }) => ({
padding: theme.spacing(0.5),
fontSize: theme.typography.body2.fontSize,
height: 'auto',
'&[aria-current="true"]': {
backgroundColor: theme.palette.secondary.light,
fontWeight: 'bold',
borderColor: theme.palette.primary.main,
color: theme.palette.primary.main,
},
':focus-visible': {
outline: `1px solid ${theme.palette.primary.main}`,
borderColor: theme.palette.primary.main,
},
borderRadius: 0,
'&:first-of-type': {
borderTopLeftRadius: theme.shape.borderRadius,
borderBottomLeftRadius: theme.shape.borderRadius,
},
'&:last-of-type': {
borderTopRightRadius: theme.shape.borderRadius,
borderBottomRightRadius: theme.shape.borderRadius,
},
'&:not(&[aria-current="true"], :last-of-type)': {
borderRightWidth: 0,
},
'[aria-current="true"] + &': {
borderLeftWidth: 0,
},
'& .MuiChip-label': {
position: 'relative',
textAlign: 'center',
'&::before': {
content: `'${label}'`,
fontWeight: 'bold',
visibility: 'hidden',
height: 0,
display: 'block',
overflow: 'hidden',
userSelect: 'none',
},
},
}));
export const Wrapper = styled(Box)(({ theme }) => ({
display: 'flex',
justifyContent: 'flex-start',
flexFlow: 'row wrap',
padding: theme.spacing(1.5, 3, 0, 3),
minHeight: theme.spacing(7),
gap: theme.spacing(2),
}));
export const StyledContainer = styled(Box)({
display: 'flex',
alignItems: 'center',
});

View File

@ -0,0 +1,35 @@
import type { FC } from 'react';
import type { TableState } from '../ChangeRequests.types';
import { makeStyledChip, Wrapper } from './ChangeRequestFilters.styles';
import { UserFilterChips } from './UserFilterChips.tsx';
import { StateFilterChips } from './StateFilterChips.tsx';
import type { ChangeRequestFiltersProps } from './ChangeRequestFilters.types.ts';
export const ChangeRequestFilters: FC<ChangeRequestFiltersProps> = ({
tableState,
setTableState,
userId,
ariaControlTarget,
}) => {
const updateTableState = (update: Partial<TableState>) => {
setTableState({ ...tableState, ...update, offset: 0 });
};
const StyledChip = makeStyledChip(ariaControlTarget);
return (
<Wrapper>
<UserFilterChips
tableState={tableState}
setTableState={updateTableState}
userId={userId}
StyledChip={StyledChip}
/>
<StateFilterChips
tableState={tableState}
setTableState={updateTableState}
StyledChip={StyledChip}
/>
</Wrapper>
);
};

View File

@ -0,0 +1,15 @@
import type { TableState } from '../ChangeRequests.types';
import type { makeStyledChip } from './ChangeRequestFilters.styles';
export type ChangeRequestFiltersProps = {
tableState: Readonly<TableState>;
setTableState: (update: Partial<TableState>) => void;
userId: number;
ariaControlTarget: string;
};
export type FilterChipsProps = {
tableState: ChangeRequestFiltersProps['tableState'];
setTableState: ChangeRequestFiltersProps['setTableState'];
StyledChip: ReturnType<typeof makeStyledChip>;
};

View File

@ -0,0 +1,48 @@
import type { FC } from 'react';
import { StyledContainer } from './ChangeRequestFilters.styles';
import type { FilterChipsProps } from './ChangeRequestFilters.types';
type StateFilterType = 'open' | 'closed';
const getStateFilter = (
stateValue: string | undefined,
): StateFilterType | undefined => {
if (stateValue === 'open') {
return 'open';
}
if (stateValue === 'closed') {
return 'closed';
}
};
export const StateFilterChips: FC<FilterChipsProps> = ({
tableState,
setTableState,
StyledChip,
}) => {
const activeStateFilter = getStateFilter(tableState.state?.values?.[0]);
const handleStateFilterChange = (filter: StateFilterType) => () => {
if (filter === activeStateFilter) {
return;
}
setTableState({ state: { operator: 'IS' as const, values: [filter] } });
};
return (
<StyledContainer>
<StyledChip
label={'Open'}
aria-current={activeStateFilter === 'open'}
onClick={handleStateFilterChange('open')}
title={'Show open change requests'}
/>
<StyledChip
label={'Closed'}
aria-current={activeStateFilter === 'closed'}
onClick={handleStateFilterChange('closed')}
title={'Show closed change requests'}
/>
</StyledContainer>
);
};

View File

@ -0,0 +1,82 @@
import type { FC } from 'react';
import type { TableState } from '../ChangeRequests.types';
import { StyledContainer } from './ChangeRequestFilters.styles';
import type { FilterChipsProps } from './ChangeRequestFilters.types';
type UserFilterType = 'created' | 'approval requested';
type UserFilterChipsProps = FilterChipsProps & { userId: number };
const getUserFilter = (
tableState: TableState,
userId: string,
): UserFilterType | undefined => {
if (
!tableState.requestedApproverId &&
tableState.createdBy?.values.length === 1 &&
tableState.createdBy.values[0] === userId
) {
return 'created';
}
if (
!tableState.createdBy &&
tableState.requestedApproverId?.values.length === 1 &&
tableState.requestedApproverId.values[0] === userId
) {
return 'approval requested';
}
};
export const UserFilterChips: FC<UserFilterChipsProps> = ({
tableState,
setTableState,
userId,
StyledChip,
}) => {
const userIdString = userId.toString();
const activeUserFilter: UserFilterType | undefined = getUserFilter(
tableState,
userIdString,
);
const handleUserFilterChange = (filter: UserFilterType) => () => {
if (filter === activeUserFilter) {
return;
}
const [targetProperty, otherProperty] =
filter === 'created'
? (['createdBy', 'requestedApproverId'] as const)
: (['requestedApproverId', 'createdBy'] as const);
setTableState({
[targetProperty]: {
operator: 'IS' as const,
values: [userIdString],
},
[otherProperty]:
tableState[otherProperty]?.values.length === 1 &&
tableState[otherProperty]?.values[0] === userIdString
? null
: tableState[otherProperty],
});
};
return (
<StyledContainer>
<StyledChip
label={'Created'}
aria-current={activeUserFilter === 'created'}
onClick={handleUserFilterChange('created')}
title={'Show change requests created by you'}
/>
<StyledChip
label={'Approval Requested'}
aria-current={activeUserFilter === 'approval requested'}
onClick={handleUserFilterChange('approval requested')}
title={'Show change requests requesting your approval'}
/>
</StyledContainer>
);
};

View File

@ -14,25 +14,16 @@ import { useUiFlag } from 'hooks/useUiFlag.js';
import { withTableState } from 'utils/withTableState';
import {
useChangeRequestSearch,
DEFAULT_PAGE_LIMIT,
type SearchChangeRequestsInput,
} from 'hooks/api/getters/useChangeRequestSearch/useChangeRequestSearch';
import type { ChangeRequestSearchItemSchema } from 'openapi';
import {
NumberParam,
StringParam,
withDefault,
useQueryParams,
encodeQueryParams,
} from 'use-query-params';
import { useQueryParams, encodeQueryParams } from 'use-query-params';
import useLoading from 'hooks/useLoading';
import { styles as themeStyles } from 'component/common';
import { FilterItemParam } from 'utils/serializeQueryParams';
import {
ChangeRequestFilters,
type ChangeRequestQuickFilter,
} from './ChangeRequestFilters.js';
import { ChangeRequestFilters } from './ChangeRequestFilters/ChangeRequestFilters.js';
import { useAuthUser } from 'hooks/api/getters/useAuth/useAuthUser.js';
import type { IUser } from 'interfaces/user.js';
import { stateConfig } from './ChangeRequests.types.js';
const columnHelper = createColumnHelper<ChangeRequestSearchItemSchema>();
@ -49,46 +40,33 @@ const StyledPaginatedTable = styled(
},
}));
const ChangeRequestsInner = () => {
const { user } = useAuthUser();
const defaultTableState = (user: IUser) => ({
createdBy: {
operator: 'IS' as const,
values: [user.id.toString()],
},
state: {
operator: 'IS' as const,
values: ['open'],
},
});
const ChangeRequestsInner = ({ user }: { user: IUser }) => {
const urlParams = new URLSearchParams(window.location.search);
const shouldApplyDefaults =
user &&
!urlParams.has('createdBy') &&
!urlParams.has('requestedApproverId');
const initialFilter = urlParams.has('requestedApproverId')
? 'Approval Requested'
: 'Created';
const shouldApplyDefaults = !urlParams.toString();
const stateConfig = {
offset: withDefault(NumberParam, 0),
limit: withDefault(NumberParam, DEFAULT_PAGE_LIMIT),
sortBy: withDefault(StringParam, 'createdAt'),
sortOrder: withDefault(StringParam, 'desc'),
createdBy: FilterItemParam,
requestedApproverId: FilterItemParam,
};
const initialState = shouldApplyDefaults
? {
createdBy: {
operator: 'IS' as const,
values: [user.id.toString()],
},
}
: {};
const initialState = shouldApplyDefaults ? defaultTableState(user) : {};
const [tableState, setTableState] = useQueryParams(stateConfig, {
updateType: 'replaceIn',
});
const effectiveTableState = useMemo(
() => ({
...tableState,
...initialState,
}),
[initialState, tableState],
);
const effectiveTableState = shouldApplyDefaults
? {
...tableState,
...initialState,
}
: tableState;
const {
changeRequests: data,
@ -172,30 +150,6 @@ const ChangeRequestsInner = () => {
}),
);
const tableId = useId();
const handleQuickFilterChange = (filter: ChangeRequestQuickFilter) => {
if (!user) {
// todo (globalChangeRequestList): handle this somehow? Or just ignore.
return;
}
const [targetProperty, otherProperty] =
filter === 'Created'
? ['createdBy', 'requestedApproverId']
: ['requestedApproverId', 'createdBy'];
// todo (globalChangeRequestList): extract and test the logic for wiping out createdby/requestedapproverid
setTableState((state) => ({
[targetProperty]: {
operator: 'IS',
values: [user.id.toString()],
},
[otherProperty]:
state[otherProperty]?.values.length === 1 &&
state[otherProperty].values[0] === user.id.toString()
? null
: state[otherProperty],
}));
};
const bodyLoadingRef = useLoading(loading);
return (
@ -205,8 +159,9 @@ const ChangeRequestsInner = () => {
>
<ChangeRequestFilters
ariaControlTarget={tableId}
initialSelection={initialFilter}
onSelectionChange={handleQuickFilterChange}
tableState={effectiveTableState}
setTableState={setTableState}
userId={user.id}
/>
<div
@ -231,6 +186,7 @@ const ChangeRequestsInner = () => {
};
export const ChangeRequests = () => {
const { user } = useAuthUser();
if (!useUiFlag('globalChangeRequestList')) {
return (
<PageContent header={<PageHeader title='Change requests' />}>
@ -239,5 +195,16 @@ export const ChangeRequests = () => {
);
}
return <ChangeRequestsInner />;
if (!user) {
return (
<PageContent header={<PageHeader title='Change requests' />}>
<p>
Failed to get your user information. Please refresh. If the
problem persists, get in touch.
</p>
</PageContent>
);
}
return <ChangeRequestsInner user={user} />;
};

View File

@ -0,0 +1,20 @@
import { DEFAULT_PAGE_LIMIT } from 'hooks/api/getters/useChangeRequestSearch/useChangeRequestSearch';
import {
NumberParam,
StringParam,
withDefault,
type DecodedValueMap,
} from 'use-query-params';
import { FilterItemParam } from 'utils/serializeQueryParams';
export const stateConfig = {
offset: withDefault(NumberParam, 0),
limit: withDefault(NumberParam, DEFAULT_PAGE_LIMIT),
sortBy: withDefault(StringParam, 'createdAt'),
sortOrder: withDefault(StringParam, 'desc'),
createdBy: FilterItemParam,
requestedApproverId: FilterItemParam,
state: FilterItemParam,
};
export type TableState = DecodedValueMap<typeof stateConfig>;

View File

@ -6,6 +6,7 @@ import { PageHeader } from 'component/common/PageHeader/PageHeader.tsx';
import { useImpactMetricsMetadata } from 'hooks/api/getters/useImpactMetricsMetadata/useImpactMetricsMetadata';
import { ChartConfigModal } from './ChartConfigModal/ChartConfigModal.tsx';
import { ChartItem } from './ChartItem.tsx';
import { PlausibleChartItem } from './PlausibleChartItem.tsx';
import { GridLayoutWrapper, type GridItem } from './GridLayoutWrapper.tsx';
import { useImpactMetricsState } from './hooks/useImpactMetricsState.ts';
import type { ChartConfig } from './types.ts';
@ -13,6 +14,7 @@ import useToast from 'hooks/useToast';
import { formatUnknownError } from 'utils/formatUnknownError';
import PermissionButton from 'component/common/PermissionButton/PermissionButton.tsx';
import { ADMIN } from '../providers/AccessProvider/permissions.ts';
import { useUiFlag } from 'hooks/useUiFlag';
const StyledEmptyState = styled(Paper)(({ theme }) => ({
textAlign: 'center',
@ -39,6 +41,7 @@ export const ImpactMetrics: FC = () => {
const [modalOpen, setModalOpen] = useState(false);
const [editingChart, setEditingChart] = useState<ChartConfig | undefined>();
const { setToastApiError } = useToast();
const plausibleMetricsEnabled = useUiFlag('plausibleMetrics');
const {
charts,
@ -100,33 +103,49 @@ export const ImpactMetrics: FC = () => {
[deleteChart],
);
const gridItems: GridItem[] = useMemo(
() =>
charts.map((config, index) => {
const existingLayout = layout?.find(
(item) => item.i === config.id,
);
return {
id: config.id,
component: (
<ChartItem
config={config}
onEdit={handleEditChart}
onDelete={handleDeleteChart}
/>
),
w: existingLayout?.w ?? 6,
h: existingLayout?.h ?? 4,
x: existingLayout?.x,
y: existingLayout?.y,
minW: 4,
minH: 2,
maxW: 12,
maxH: 8,
};
}),
[charts, layout, handleEditChart, handleDeleteChart],
);
const gridItems: GridItem[] = useMemo(() => {
const items: GridItem[] = [];
if (plausibleMetricsEnabled) {
const plausibleChartItem: GridItem = {
id: 'plausible-analytics',
component: <PlausibleChartItem />,
w: 6,
h: 2,
};
items.push(plausibleChartItem);
}
const impactMetricsItems: GridItem[] = charts.map((config, index) => {
const existingLayout = layout?.find((item) => item.i === config.id);
return {
id: config.id,
component: (
<ChartItem
config={config}
onEdit={handleEditChart}
onDelete={handleDeleteChart}
/>
),
w: existingLayout?.w ?? 6,
h: existingLayout?.h ?? 4,
x: existingLayout?.x,
y: existingLayout?.y,
minW: 4,
minH: 2,
maxW: 12,
maxH: 8,
};
});
return [...items, ...impactMetricsItems];
}, [
charts,
layout,
handleEditChart,
handleDeleteChart,
plausibleMetricsEnabled,
]);
const hasError = metadataError || settingsError;
const isLoading = metadataLoading || settingsLoading;
@ -156,7 +175,7 @@ export const ImpactMetrics: FC = () => {
{charts.length === 0 && !isLoading && !hasError ? (
<StyledEmptyState>
<Typography variant='h6' gutterBottom>
No charts configured
No impact metrics charts configured
</Typography>
<Typography
variant='body2'
@ -175,7 +194,7 @@ export const ImpactMetrics: FC = () => {
Add Chart
</Button>
</StyledEmptyState>
) : charts.length > 0 ? (
) : gridItems.length > 0 ? (
<GridLayoutWrapper items={gridItems} />
) : null}

View File

@ -0,0 +1,173 @@
import type { FC, ReactNode } from 'react';
import { useMemo } from 'react';
import { Alert, Box } from '@mui/material';
import {
LineChart,
NotEnoughData,
} from '../insights/components/LineChart/LineChart.tsx';
import { usePlausibleMetrics } from 'hooks/api/getters/usePlausibleMetrics/usePlausibleMetrics';
import { usePlaceholderData } from '../insights/hooks/usePlaceholderData.js';
import { formatLargeNumbers } from './metricsFormatters.js';
import type { ChartData } from 'chart.js';
type PlausibleChartProps = {
aspectRatio?: number;
overrideOptions?: Record<string, unknown>;
errorTitle?: string;
emptyDataDescription?: string;
noSeriesPlaceholder?: ReactNode;
isPreview?: boolean;
};
export const PlausibleChart: FC<PlausibleChartProps> = ({
aspectRatio,
overrideOptions = {},
errorTitle = 'Failed to load Plausible metrics.',
emptyDataDescription = 'No Plausible analytics data available.',
noSeriesPlaceholder,
isPreview,
}) => {
const {
data: plausibleData,
loading: dataLoading,
error: dataError,
} = usePlausibleMetrics();
const placeholderData = usePlaceholderData({
fill: true,
type: 'constant',
});
const chartData: ChartData<'line'> = useMemo(() => {
if (!plausibleData?.data || plausibleData.data.length === 0) {
return {
labels: [],
datasets: [],
};
}
const sortedData = [...plausibleData.data].sort(
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime(),
);
return {
labels: sortedData.map((item) => item.date),
datasets: [
{
label: 'Events',
data: sortedData.map((item) => item.count),
borderColor: 'rgb(129, 122, 254)',
backgroundColor: 'rgba(129, 122, 254, 0.1)',
fill: true,
tension: 0.1,
},
],
};
}, [plausibleData]);
const hasError = !!dataError;
const isLoading = dataLoading;
const notEnoughData = useMemo(
() =>
!isLoading &&
(!plausibleData?.data ||
plausibleData.data.length === 0 ||
!chartData.datasets.some((d) => d.data.length > 1)),
[chartData, isLoading, plausibleData],
);
const placeholder = noSeriesPlaceholder ? (
noSeriesPlaceholder
) : (
<NotEnoughData
title='No Plausible data available'
description={emptyDataDescription}
/>
);
const cover = notEnoughData ? placeholder : isLoading;
const chartOptions = {
...overrideOptions,
scales: {
x: {
type: 'time' as const,
time: {
unit: 'hour' as const,
displayFormats: {
hour: 'MMM dd, HH:mm',
},
tooltipFormat: 'PPpp',
},
ticks: {
maxRotation: 45,
minRotation: 45,
maxTicksLimit: 8,
},
},
y: {
beginAtZero: true,
title: {
display: true,
text: 'Events',
},
ticks: {
precision: 0,
callback: (value: unknown): string | number =>
typeof value === 'number'
? formatLargeNumbers(value)
: (value as number),
},
},
},
plugins: {
legend: {
display: true,
position: 'bottom' as const,
labels: {
usePointStyle: true,
boxWidth: 8,
padding: 12,
},
},
},
animations: {
x: { duration: 0 },
y: { duration: 0 },
},
};
return (
<>
<Box
sx={
!isPreview
? {
height: '100%',
width: '100%',
'& > div': {
height: '100% !important',
width: '100% !important',
},
}
: {}
}
>
<LineChart
data={
notEnoughData || isLoading ? placeholderData : chartData
}
aspectRatio={aspectRatio}
overrideOptions={chartOptions}
cover={
hasError ? (
<Alert severity='error'>{errorTitle}</Alert>
) : (
cover
)
}
/>
</Box>
</>
);
};

View File

@ -0,0 +1,69 @@
import type { FC } from 'react';
import { Box, Typography, styled, Paper } from '@mui/material';
import { PlausibleChart } from './PlausibleChart.tsx';
const StyledWidget = styled(Paper)(({ theme }) => ({
borderRadius: `${theme.shape.borderRadiusMedium}px`,
boxShadow: 'none',
display: 'flex',
flexDirection: 'column',
height: '100%',
}));
const StyledChartContent = styled(Box)({
flex: 1,
display: 'flex',
flexDirection: 'column',
minHeight: 0,
});
const StyledImpactChartContainer = styled(Box)(({ theme }) => ({
position: 'relative',
minWidth: 0,
flexGrow: 1,
height: '100%',
display: 'flex',
flexDirection: 'column',
margin: 'auto 0',
padding: theme.spacing(3),
}));
const StyledHeader = styled(Box)(({ theme }) => ({
display: 'flex',
gap: theme.spacing(2),
alignItems: 'center',
padding: theme.spacing(1.5, 2),
borderBottom: `1px solid ${theme.palette.divider}`,
}));
const StyledChartTitle = styled(Box)(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
justifyContent: 'flex-end',
flexGrow: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
}));
export const PlausibleChartItem: FC = () => (
<StyledWidget>
<StyledHeader>
<StyledChartTitle>
<Typography variant='h6'>Plausible Analytics</Typography>
<Typography variant='body2' color='text.secondary'>
Favorite events from Plausible analytics
</Typography>
</StyledChartTitle>
</StyledHeader>
<StyledChartContent>
<StyledImpactChartContainer>
<PlausibleChart
aspectRatio={1.5}
overrideOptions={{ maintainAspectRatio: false }}
emptyDataDescription='No Plausible analytics data available for favorite events.'
/>
</StyledImpactChartContainer>
</StyledChartContent>
</StyledWidget>
);

View File

@ -0,0 +1,23 @@
import { fetcher, useApiGetter } from '../useApiGetter/useApiGetter.js';
import { formatApiPath } from 'utils/formatPath';
import type { PlausibleMetricsResponseSchema } from 'openapi/models/plausibleMetricsResponseSchema.js';
export const usePlausibleMetrics = () => {
const PATH = 'api/admin/impact-metrics/plausible';
const { data, refetch, loading, error } =
useApiGetter<PlausibleMetricsResponseSchema>(
formatApiPath(PATH),
() => fetcher(formatApiPath(PATH), 'Plausible metrics'),
{
refreshInterval: 30 * 1_000,
revalidateOnFocus: true,
},
);
return {
data: data || { data: [] },
refetch,
loading,
error,
};
};

View File

@ -85,6 +85,7 @@ export type UiFlags = {
edgeObservability?: boolean;
customMetrics?: boolean;
impactMetrics?: boolean;
plausibleMetrics?: boolean;
lifecycleGraphs?: boolean;
newStrategyModal?: boolean;
globalChangeRequestList?: boolean;

View File

@ -1303,6 +1303,7 @@ export * from './searchChangeRequests401.js';
export * from './searchChangeRequests403.js';
export * from './searchChangeRequests404.js';
export * from './searchChangeRequestsParams.js';
export * from './searchChangeRequestsState.js';
export * from './searchEventsParams.js';
export * from './searchFeatures401.js';
export * from './searchFeatures403.js';

View File

@ -3,6 +3,7 @@
* Do not edit manually.
* See `gen:api` script in package.json
*/
import type { SearchChangeRequestsState } from './searchChangeRequestsState.js';
export type SearchChangeRequestsParams = {
/**
@ -13,6 +14,10 @@ export type SearchChangeRequestsParams = {
* Filter by requested approver user ID
*/
requestedApproverId?: string;
/**
* Filter by open / closed change requests. Change requests that are in 'draft', 'in review', 'approved', or 'scheduled' states are considered open. Change requests that are in 'cancelled', 'applied', or 'rejected' states are considered closed.
*/
state?: SearchChangeRequestsState;
/**
* The number of change requests to skip when returning a page. By default it is set to 0.
*/

View File

@ -0,0 +1,14 @@
/**
* Generated by Orval
* Do not edit manually.
* See `gen:api` script in package.json
*/
export type SearchChangeRequestsState =
(typeof SearchChangeRequestsState)[keyof typeof SearchChangeRequestsState];
// eslint-disable-next-line @typescript-eslint/no-redeclare
export const SearchChangeRequestsState = {
'IS:open': 'IS:open',
'IS:closed': 'IS:closed',
} as const;

View File

@ -9,7 +9,7 @@ export const permissionSchema = {
properties: {
permission: {
description:
'[Project](https://docs.getunleash.io/reference/rbac#project-permissions) or [environment](https://docs.getunleash.io/reference/rbac#environment-permissions) permission name',
'[Project](https://docs.getunleash.io/reference/rbac#project-level-permissions) or [environment](https://docs.getunleash.io/reference/rbac#environment-level-permissions) permission name',
type: 'string',
example: 'UPDATE_FEATURE_STRATEGY',
},

View File

@ -1,5 +1,5 @@
---
title: Environment import and export
title: Import and export
---
@ -16,147 +16,154 @@ import VideoContent from '@site/src/components/VideoContent.jsx'
:::
You can import and export feature flag configurations between environments within an instance or between different Unleash instances.
This lets you migrate between self-hosted and cloud-hosted instances, move from open source version to Unleash Enterprise, or use import/export as part of a backup and restore strategy.
On the project-level, you can import and export:
- [Feature flags](/reference/feature-toggles)
- [Feature flag tags](/reference/feature-toggles#tags)
- [Feature flag dependencies](/reference/feature-toggles#feature-flag-dependencies)
On the environment-level, you can import and export:
- [Activation strategies](/reference/activation-strategies) including [constraints](/reference/activation-strategies#constraints) and references to [segments](/reference/segments)
- [Strategy variants](/reference/feature-toggle-variants)
- Feature flag status (enabled/disabled)
For additional global configuration, you can import and export:
- [Custom context fields](/reference/unleash-context#custom-context-fields)
- [Feature flag tag types](/reference/feature-toggles#tags)
Not included in the export:
- [Segments](/reference/segments) and [custom strategies](/reference/custom-activation-strategies): Only references are included in the export. If an import file contains references that don't exist in the target, the import process will stop. You must create the segments or custom strategies manually in the target instance before running the import again.
- [Release plans](/reference/release-templates): You must manually recreate any release plans for imported feature flags.
<VideoContent videoUrls={["https://www.youtube.com/embed/Bs73l2fZxJ4"]}/>
Environment export and import lets you copy feature configurations from one environment to another and even copy features from one Unleash instance to another.
## Export feature flags
When exporting, you select a set of features and **one** environment to export the configuration from. The environment must be the same for all features.
### Export all flags from a project
Then, when you import, you must select **one** environment and **one** project to import into. All features are imported into that project in that environment. If Unleash is unable to import the configuration safely, it will tell you why the import failed and what you need to fix it (read more about [import requirements](#import-requirements).
1. Go to **Projects** and open the project you want to export from.
2. Click **Export all project flags**.
3. Select the environment to export flag configurations from.
4. Click **Export selection**.
## Import & Export items
![Export all flags from a project](/img/export-all-project-flags.png)
When you export features, the export will contain both feature-specific configuration and global configuration.
As a result, you'll have a JSON export that includes the selected flags in the chosen environment.
On the project-level these items are exported:
### Export a selection of flags from a project
* [the feature itself](/reference/feature-toggles)
* [feature tags](/reference/feature-toggles#tags)
* [feature dependencies](/reference/feature-toggles#feature-flag-dependencies)
1. Go to **Projects** and open the project you want to export from.
2. [Optional] Use search or filtering to narrow the list.
3. Select one or more flags. You can use the header checkbox to select all rows in the current view (up to 100).
4. Click **Export**.
5. Select the environment to export flag configurations from.
6. Click **Export selection**.
On the environment-level, these items are exported for the chosen environment:
![Export a selection of flags from a project.](/img/export-flag-selection.png)
* [activation strategies](/reference/activation-strategies) including [constraints](/reference/activation-strategies#constraints) and references to [segments](/reference/segments)
* [variants](/reference/feature-toggle-variants)
* enabled/disabled
As a result, you'll have a JSON export that includes the selected flags in the chosen environment.
Additionally, these global configuration items are exported:
* [custom context fields](/reference/unleash-context#custom-context-fields)
* [feature tag types](/reference/feature-toggles#tags)
Importantly, while references to [segments](/reference/segments) are exported, the segments themselves are **not** exported. Consult the [import requirements](#import-requirements) section for more information.
### Export flags from the flags overview
## Export
1. Go to **Flags overview**.
2. [Optional] Use search or filtering to narrow the list.
3. Click **Export flags**.
4. Select the environment to export flag configurations from.
5. Click **Export selection**.
You can export features either from a project search or the global feature search. Use the search functionality to narrow the results to the list of features you want to export and use the export button to select environment. All features included in your search results will be included in the export.
![Export flags from the flags overview](/img/export-flags-overview.png)
![Feature flag lists can be filtered using the search bar](/img/export.png)
As a result, you'll have a JSON export that includes the selected flags in the chosen environment.
## Import
Import is a 3 stage process designed to be efficient and error-resistant.
### Import stages
* **upload** - you can upload previously exported JSON file or copy-paste export data from the exported JSON file into the code editor
* **validation** - you will get feedback on any errors or warnings before you do the actual import. This ensures your feature flags configurations are correct. You will not be able to finish the import if you have errors. Warnings don't stop you from importing.
* **import** - the actual import that creates a new configuration in the target environment or creates a [change request](/reference/change-requests) when the environment has change requests enabled
![The import UI. It has three stages: import, file, validate configuration, finish import.](/img/import.png)
## Import feature flags
### Import requirements
Unleash will reject an import if it discovers conflicts between the data to be imported and what already exists on the Unleash instance. The import tool will tell you about why an import is rejected in these cases.
Before you begin an import, ensure that you have met the following requirements:
- The import file size limit is **500 kB**. Larger files fail with `413 Payload Too Large`. [Export in batches](#export-a-selection-of-flags-from-a-project) to stay under the limit.
- The project and environment you are importing into must not have any pending [change requests](/reference/change-requests).
- Your import file must pass all [validation rules](#validation-rules).
The following sections describe requirements that must be met for Unleash not to stop the import job.
### Import stages
#### Context fields
The import process runs in three stages: import file, validate configuration, and finish import.
When you import a custom context field **with legal values defined**:
![The import UI. It has three stages: import file, validate configuration, finish import.](/img/import-steps.png)
If a custom context field with the same name already exists in the target instance, then the pre-existing context field must have **at least** those legal values defined. In other words, the imported context field legal values must be a subset of the already existing context field's legal values.
#### Import file
When you import custom context fields without legal values or custom context fields that don't already exist in the target instance, then there are no requirements.
1. Go to **Projects** and open the target project.
2. Click **Import**.
3. In the **Import options** section, select the target environment.
4. Do one of the following:
- In the **Upload file** tab, click **Select file** and choose the exported JSON, or
- In the **Code editor** tab, paste the exported JSON into the editor.
5. Click **Validate**.
Custom context fields that don't already exist in the target instance will be created upon import.
![The import UI. It has three stages: import file, validate configuration, finish import.](/img/import.png)
#### Segments
#### Validate configuration
If your import has segments, then a segment with the same name must already exist in the Unleash instance you are trying to import into. Only the name must be the same: the constraints in the segment can be different.
1. Review any errors or warnings.
- **Errors** must be fixed before you can continue.
- **Warnings** do not block import, but review them carefully.
2. If you see errors about **segments** or **custom strategies**, create those items in the target instance and restart the import.
3. If you see errors about **context fields** or **legal values**, add or update those in the target instance and restart the import.
#### Dependencies
See the full list of validation rules [here](#validation-rules).
If your import has a parent feature dependency, then the parent feature must already exist in the target Unleash instance or be part of the import data. The existing feature is identified by name.
#### Finish import
#### Custom strategies
When validation passes with no errors, you can commit the import by clicking **Import configuration**.
If your import contains custom strategies, then custom strategies with the same names must already exist in the target Unleash instance. The custom strategy definitions (including strategy parameters) that exist in the target instance do not otherwise need to match the custom strategy definitions in the instance the import came from.
The target environment now contains the imported configuration. If [change requests](/reference/change-requests) are enabled for the environment, Unleash creates a draft change request with all changes instead of applying them immediately.
#### Existing features
### Validation rules
If any of the features you import already exist in the target Unleash instance, then they must exist within the project you're importing into. If any features you are attempting to import exist in a **different** project on the target instance, the import will be rejected.
Unleash enforces the following validation rules during import:
#### Pending change requests
- **Context fields:**
If the import [contains context](/reference/unleash-context#custom-context-fields) fields with defined legal values, matching context fields must already exist in the target instance. The imported legal values must be a subset of the existing ones.
The project and environment you are importing into must **not have any pending [change requests](/reference/change-requests)**.
- **Segments:**
[Segments](/reference/segments) referenced by name must already exist in the target instance. If not present, the import stops.
### Import warnings
- **Dependencies:**
[Parent features](/reference/feature-toggles#feature-flag-dependencies) must already exist in the target instance or be included in the import file.
The import validation system will warn you about potential problems it finds in the import data. These warnings do not prevent you from importing the data, but you should read them carefully to ensure that Unleash does as you intend.
- **Custom strategies:**
If your import contains [custom strategies](/reference/activation-strategies#custom-activation-strategies), then custom strategies with the same names must already exist in the target Unleash instance. Note that Unleash doesn't verify that the [configuration parameters](/reference/custom-activation-strategies#parameters) of the strategies match.
The following sections list things that the import tool will warn you about.
- **Existing features in other projects:**
If a feature exists in a different project on the target instance, the import is rejected.
#### Archived features
The import tool will not import any features that have already been archived on the target Unleash instance. Because features are identified by their name, that means that if a feature called `feature-a` has been created and archived on the target Unleash instance, then a feature with the same name (`feature-a`) will not be imported.
If you permanently delete the archived `feature-a` from the target instance, then the new `feature-a` (in the import data) **will** be imported.
#### Custom strategies
:::caution
[Custom activation strategies](/reference/custom-activation-strategies) are deprecated. Please use the [default activation strategy](/reference/activation-strategies) with constraints.
:::
Unleash will verify that any custom strategies you are trying to import have already been defined on the target instance. However, it only does this verification by name. It does **not** validate that the definitions are otherwise the same or that they have the same [configuration parameters](/reference/custom-activation-strategies.md#parameters).
- **Archived features:**
You can't import any features that have already been [archived](/reference/feature-toggles#archive-a-feature-flag) on the target Unleash instance. You must either permanently delete those archived features from the target instance, or revive them.
### Required permissions
To import features, you will **always** require the **update feature flags** permission.
Additionally, depending on the actions your import job would trigger, you may also require any of the following permissions:
* **Create feature flags**: when the import would create new features
* **Update tag types**: when the import would create new tag types
* **Create context fields**: when the import would create new context fields
* **Create activation strategies**: when the import would add activation strategies to a feature and change requests are disabled
* **Delete activation strategies**: when import would remove existing activation strategies from a feature and change requests are disabled
* **Update variants**: when the import would update variants and change requests are disabled
* **Update feature dependency**: when the import would add feature dependencies and change requests are disabled
To run an import you always need the **Update feature flags** [permission](/reference/rbac#project-level-permissions). Depending on what your import changes, you may also need:
### Import and change requests
- Create feature flags
- Update tag types
- Create context fields
- Create activation strategies
- Delete activation strategies
- Update variants
- Update feature dependency
If change requests are enabled for the target project and environment, the import will not be fully applied. Any new features will be created, but all feature configuration will be added to a new change request.
If [change requests](/reference/change-requests) are enabled, permissions for activation strategies or variants are not required.
If change requests are enabled, any permissions for **Create activation strategies**, **Delete activation strategies** and **Update variants** are not required.
## Import on startup
## Environment import/export vs the instance import/export API
You can also import on startup by using an import file in a JSON format either through configuration parameters and environment variables.
Environment import/export has some similarities to the [instance import/export API](/reference/environment-import-export), but they serve different purposes.
The instance import/export API was designed to export all feature flags (optionally with strategies and projects) from one Unleash instance to another. When it was developed, Unleash had much fewer features than it does now. As such, the API lacks support for some of the more recent features in Unleash.
On the other hand, the environment import/export feature was designed to export a selection of features based on search criteria. It can only export data from a single environment and only import it to a single environment. It also only supports importing into a single project (although it can export features from multiple projects).
Further, the environment import/export comes with a much more stringent validation and will attempt to stop any corrupted data imports.
## Startup import {#startup-import}
You can also import on startup by using an import file in JSON format and the import will be applied on top of existing data. Currently the startup import supports the same data supported in OSS import.
Unleash lets you do this both via configuration parameters and environment variables. The relevant parameters/variables are:
| config parameter | environment variable | default | value |
| Config parameter | Environment variable | Default | Value |
|--------------------|-------------------------|---------------|---------------------------------------|
| `file` | `IMPORT_FILE` | none | path to the configuration file |
| `project` | `IMPORT_PROJECT` | `default` | which project to import into |
| `environment` | `IMPORT_ENVIRONMENT` | `development` | which environment to import for |
| `file` | `IMPORT_FILE` | none | Path to the configuration file. |
| `project` | `IMPORT_PROJECT` | `default` | The project to import into. |
| `environment` | `IMPORT_ENVIRONMENT` | `development` | The environment to import for. |

View File

@ -213,9 +213,9 @@ Once you have the role set up, you can assign it to individual users inside a pr
2. For **Role**, select the custom project roles you want to apply.
3. Click **Save**.
### Project permissions
### Project-level permissions
You can assign the following project permissions. These permissions are valid across all of the [project](./projects)'s environments.
You can assign the following project-level permissions. These permissions are valid across all of the [project](./projects)'s environments.
#### API tokens
| Permission Name | Description |
@ -253,7 +253,7 @@ You can assign the following project permissions. These permissions are valid ac
| Read settings | View other project settings (included in _Update project_). | |
| Write settings | Edit other project settings (included in _Update project_).
| Delete the project | Delete the project. |
### Environment permissions
### Environment-level permissions
You can assign the following permissions on a per-environment level within the project:

View File

@ -79,7 +79,6 @@ const sidebars: SidebarsConfig = {
'reference/projects',
'reference/project-collaboration-mode',
'reference/environments',
'how-to/how-to-environment-import-export',
],
},
{
@ -179,6 +178,11 @@ const sidebars: SidebarsConfig = {
'reference/maintenance-mode',
],
},
{
type: 'doc',
label: 'Import and export',
id: 'how-to/how-to-environment-import-export',
},
],
},
{
@ -330,11 +334,6 @@ const sidebars: SidebarsConfig = {
label: 'Examples',
id: 'feature-flag-tutorials/react/examples',
},
{
type: 'doc',
label: 'Manage feature flags in code',
id: 'feature-flag-tutorials/use-cases/manage-feature-flags-in-code',
},
],
},
{

Binary file not shown.

After

Width:  |  Height:  |  Size: 407 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 509 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 310 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 391 KiB

After

Width:  |  Height:  |  Size: 384 KiB