1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-09-15 17:50:48 +02:00

Merge remote-tracking branch 'origin/main' into 1-1263-integrations-frontend-adjustments

This commit is contained in:
Tymoteusz Czech 2023-08-21 11:24:35 +02:00
commit 7466fe7051
No known key found for this signature in database
GPG Key ID: 133555230D88D75F
30 changed files with 466 additions and 124 deletions

View File

@ -1 +1 @@
18.17.0
18.17.1

View File

@ -100,11 +100,11 @@
"react-joyride": "^2.5.3",
"react-linkify": "^1.0.0-alpha",
"react-markdown": "^8.0.4",
"react-router-dom": "6.14.2",
"react-router-dom": "6.15.0",
"react-table": "7.8.0",
"react-test-renderer": "17.0.2",
"react-timeago": "7.1.0",
"sass": "1.64.2",
"sass": "1.65.1",
"semver": "7.5.4",
"swr": "2.2.0",
"tss-react": "4.8.8",

View File

@ -90,7 +90,7 @@ export const adminRoutes: INavigationMenuItem[] = [
{
path: '/admin/admin-invoices',
title: 'Billing & invoices',
menu: { adminSettings: true, mode: ['pro'], billing: true },
menu: { adminSettings: true, billing: true },
group: 'instance',
},
{

View File

@ -115,7 +115,9 @@ const InvoiceList = () => {
</div>
</PageContent>
}
elseShow={<div>{isLoaded && 'No invoices to show.'}</div>}
elseShow={
<PageContent>{isLoaded && 'No invoices to show.'}</PageContent>
}
/>
);
};

View File

@ -55,6 +55,7 @@ export const AdminTabsMenu: VFC = () => {
>
{tabs.map(tab => (
<Tab
sx={{ padding: 0 }}
key={tab.route}
value={tab.route?.split('/')?.[2]}
label={

View File

@ -1,41 +1,21 @@
import { Theme } from '@mui/material';
import React, { FC } from 'react';
import { styled } from '@mui/material';
import { FC } from 'react';
import { NavLink } from 'react-router-dom';
import { useTheme } from '@mui/material/styles';
const createNavLinkStyle = (props: {
isActive: boolean;
theme: Theme;
}): React.CSSProperties => {
const navLinkStyle = {
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
width: '100%',
textDecoration: 'none',
color: 'inherit',
padding: props.theme.spacing(1.5, 3),
};
const activeNavLinkStyle: React.CSSProperties = {
const StyledNavLink = styled(NavLink)(({ theme }) => ({
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
width: '100%',
height: '100%',
textDecoration: 'none',
color: 'inherit',
padding: theme.spacing(0, 5),
'&.active': {
fontWeight: 'bold',
borderRadius: '3px',
padding: props.theme.spacing(1.5, 3),
};
return props.isActive
? { ...navLinkStyle, ...activeNavLinkStyle }
: navLinkStyle;
};
},
}));
export const CenteredNavLink: FC<{ to: string }> = ({ to, children }) => {
const theme = useTheme();
return (
<NavLink
to={to}
style={({ isActive }) => createNavLinkStyle({ isActive, theme })}
>
{children}
</NavLink>
);
return <StyledNavLink to={to}>{children}</StyledNavLink>;
};

View File

@ -43,6 +43,7 @@ export const Network = () => {
<span>{label}</span>
</CenteredNavLink>
}
sx={{ padding: 0 }}
/>
))}
</Tabs>

View File

@ -97,6 +97,7 @@ export const RolesPage = () => {
</span>
</CenteredNavLink>
}
sx={{ padding: 0 }}
/>
))}
</Tabs>

View File

@ -10,8 +10,19 @@ export const useAdminRoutes = () => {
const showEnterpriseOptionsInPro = Boolean(
uiConfig?.flags?.frontendNavigationUpdate
);
const routes = [...adminRoutes];
return adminRoutes
if (uiConfig.flags.UNLEASH_CLOUD) {
const adminBillingMenuItem = adminRoutes.findIndex(
route => route.title === 'Billing & invoices'
);
routes[adminBillingMenuItem] = {
...routes[adminBillingMenuItem],
path: '/admin/billing',
};
}
return routes
.filter(filterByConfig(uiConfig))
.filter(route =>
filterAdminRoutes(

View File

@ -1,41 +1,37 @@
import { useEffect, useMemo, useState } from 'react';
import { CircularProgress, Link } from '@mui/material';
import { useMemo } from 'react';
import {
Avatar,
CircularProgress,
Icon,
Link,
styled,
Typography,
useTheme,
} from '@mui/material';
import { Warning } from '@mui/icons-material';
import { AppsLinkList, styles as themeStyles } from 'component/common';
import { styles as themeStyles } from 'component/common';
import { PageContent } from 'component/common/PageContent/PageContent';
import { PageHeader } from 'component/common/PageHeader/PageHeader';
import useApplications from 'hooks/api/getters/useApplications/useApplications';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { useSearchParams } from 'react-router-dom';
import { Search } from 'component/common/Search/Search';
import { safeRegExp } from '@server/util/escape-regex';
type PageQueryType = Partial<Record<'search', string>>;
import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
import {
SortableTableHeader,
Table,
TableBody,
TableCell,
TableRow,
} from 'component/common/Table';
import { useGlobalFilter, useSortBy, useTable } from 'react-table';
import { sortTypes } from 'utils/sortTypes';
import { IconCell } from 'component/common/Table/cells/IconCell/IconCell';
import { LinkCell } from 'component/common/Table/cells/LinkCell/LinkCell';
import { TextCell } from 'component/common/Table/cells/TextCell/TextCell';
export const ApplicationList = () => {
const { applications, loading } = useApplications();
const [searchParams, setSearchParams] = useSearchParams();
const [searchValue, setSearchValue] = useState(
searchParams.get('search') || ''
);
useEffect(() => {
const tableState: PageQueryType = {};
if (searchValue) {
tableState.search = searchValue;
}
setSearchParams(tableState, {
replace: true,
});
}, [searchValue, setSearchParams]);
const filteredApplications = useMemo(() => {
const regExp = safeRegExp(searchValue, 'i');
return searchValue
? applications?.filter(a => regExp.test(a.appName))
: applications;
}, [applications, searchValue]);
const { applications: data, loading } = useApplications();
const theme = useTheme();
const renderNoApplications = () => (
<>
@ -56,25 +52,115 @@ export const ApplicationList = () => {
</>
);
if (!filteredApplications) {
const initialState = useMemo(
() => ({
sortBy: [{ id: 'name', desc: false }],
hiddenColumns: ['description', 'sortOrder'],
}),
[]
);
const columns = useMemo(
() => [
{
id: 'Icon',
Cell: ({
row: {
original: { icon },
},
}: any) => (
<IconCell
icon={
<Avatar>
<Icon>{icon}</Icon>
</Avatar>
}
/>
),
disableGlobalFilter: true,
},
{
Header: 'Name',
accessor: 'appName',
width: '50%',
Cell: ({
row: {
original: { appName, description },
},
}: any) => (
<LinkCell
title={appName}
to={`/applications/${appName}`}
subtitle={description}
/>
),
sortType: 'alphanumeric',
},
{
Header: 'Project(environment)',
accessor: 'usage',
width: '50%',
Cell: () => (
<TextCell>
<Typography
variant="body2"
color={theme.palette.text.secondary}
>
not connected
</Typography>
</TextCell>
),
sortType: 'alphanumeric',
},
{
accessor: 'description',
disableSortBy: true,
},
{
accessor: 'sortOrder',
disableGlobalFilter: true,
sortType: 'number',
},
],
[]
);
const {
getTableProps,
getTableBodyProps,
headerGroups,
rows,
prepareRow,
state: { globalFilter },
setGlobalFilter,
} = useTable(
{
columns: columns as any[], // TODO: fix after `react-table` v8 update
data,
initialState,
sortTypes,
autoResetGlobalFilter: false,
autoResetSortBy: false,
disableSortRemove: true,
},
useGlobalFilter,
useSortBy
);
if (!data) {
return <CircularProgress variant="indeterminate" />;
}
let applicationCount =
filteredApplications.length < applications.length
? `${filteredApplications.length} of ${applications.length}`
: applications.length;
return (
<>
<PageContent
header={
<PageHeader
title={`Applications (${applicationCount})`}
title={`Applications (${rows.length})`}
actions={
<Search
initialValue={searchValue}
onChange={setSearchValue}
initialValue={globalFilter}
onChange={setGlobalFilter}
/>
}
/>
@ -82,8 +168,37 @@ export const ApplicationList = () => {
>
<div className={themeStyles.fullwidth}>
<ConditionallyRender
condition={filteredApplications.length > 0}
show={<AppsLinkList apps={filteredApplications} />}
condition={data.length > 0}
show={
<SearchHighlightProvider value={globalFilter}>
<Table {...getTableProps()}>
<SortableTableHeader
headerGroups={headerGroups}
/>
<TableBody {...getTableBodyProps()}>
{rows.map(row => {
prepareRow(row);
return (
<TableRow
hover
{...row.getRowProps()}
>
{row.cells.map(cell => (
<TableCell
{...cell.getCellProps()}
>
{cell.render(
'Cell'
)}
</TableCell>
))}
</TableRow>
);
})}
</TableBody>
</Table>
</SearchHighlightProvider>
}
elseShow={
<ConditionallyRender
condition={loading}

View File

@ -0,0 +1,99 @@
import { useEffect, useMemo, useState } from 'react';
import { CircularProgress, Link } from '@mui/material';
import { Warning } from '@mui/icons-material';
import { AppsLinkList, styles as themeStyles } from 'component/common';
import { PageContent } from 'component/common/PageContent/PageContent';
import { PageHeader } from 'component/common/PageHeader/PageHeader';
import useApplications from 'hooks/api/getters/useApplications/useApplications';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { useSearchParams } from 'react-router-dom';
import { Search } from 'component/common/Search/Search';
import { safeRegExp } from '@server/util/escape-regex';
type PageQueryType = Partial<Record<'search', string>>;
export const OldApplicationList = () => {
const { applications, loading } = useApplications();
const [searchParams, setSearchParams] = useSearchParams();
const [searchValue, setSearchValue] = useState(
searchParams.get('search') || ''
);
useEffect(() => {
const tableState: PageQueryType = {};
if (searchValue) {
tableState.search = searchValue;
}
setSearchParams(tableState, {
replace: true,
});
}, [searchValue, setSearchParams]);
const filteredApplications = useMemo(() => {
const regExp = safeRegExp(searchValue, 'i');
return searchValue
? applications?.filter(a => regExp.test(a.appName))
: applications;
}, [applications, searchValue]);
const renderNoApplications = () => (
<>
<section style={{ textAlign: 'center' }}>
<Warning titleAccess="Warning" /> <br />
<br />
Oh snap, it does not seem like you have connected any
applications. To connect your application to Unleash you will
require a Client SDK.
<br />
<br />
You can read more about how to use Unleash in your application
in the{' '}
<Link href="https://docs.getunleash.io/docs/sdks/">
documentation.
</Link>
</section>
</>
);
if (!filteredApplications) {
return <CircularProgress variant="indeterminate" />;
}
let applicationCount =
filteredApplications.length < applications.length
? `${filteredApplications.length} of ${applications.length}`
: applications.length;
return (
<>
<PageContent
header={
<PageHeader
title={`Applications (${applicationCount})`}
actions={
<Search
initialValue={searchValue}
onChange={setSearchValue}
/>
}
/>
}
>
<div className={themeStyles.fullwidth}>
<ConditionallyRender
condition={filteredApplications.length > 0}
show={<AppsLinkList apps={filteredApplications} />}
elseShow={
<ConditionallyRender
condition={loading}
show={<div>...loading</div>}
elseShow={renderNoApplications()}
/>
}
/>
</div>
</PageContent>
</>
);
};

View File

@ -0,0 +1,36 @@
import { useMemo } from 'react';
import { Avatar, CircularProgress, Icon, Link } from '@mui/material';
import { Warning } from '@mui/icons-material';
import { styles as themeStyles } from 'component/common';
import { PageContent } from 'component/common/PageContent/PageContent';
import { PageHeader } from 'component/common/PageHeader/PageHeader';
import useApplications from 'hooks/api/getters/useApplications/useApplications';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { Search } from 'component/common/Search/Search';
import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
import {
SortableTableHeader,
Table,
TableBody,
TableCell,
TableRow,
} from '../../common/Table';
import { useGlobalFilter, useSortBy, useTable } from 'react-table';
import { sortTypes } from 'utils/sortTypes';
import { IconCell } from 'component/common/Table/cells/IconCell/IconCell';
import { LinkCell } from 'component/common/Table/cells/LinkCell/LinkCell';
import useUiConfig from '../../../hooks/api/getters/useUiConfig/useUiConfig';
import { ApplicationList } from './ApplicationList';
import { OldApplicationList } from './OldApplicationList';
export const TemporaryApplicationListWrapper = () => {
const { uiConfig } = useUiConfig();
return (
<ConditionallyRender
condition={Boolean(uiConfig.flags.newApplicationList)}
show={<ApplicationList />}
elseShow={<OldApplicationList />}
/>
);
};

View File

@ -98,9 +98,9 @@ export const NavigationMenu = ({
}
arrow
placement="left"
key={option.path}
>
<MenuItem
key={option.path}
component={StyledLink}
to={option.path}
onClick={handleClose}

View File

@ -19,7 +19,6 @@ import EditProject from 'component/project/Project/EditProject/EditProject';
import CreateFeature from 'component/feature/CreateFeature/CreateFeature';
import EditFeature from 'component/feature/EditFeature/EditFeature';
import { ApplicationEdit } from 'component/application/ApplicationEdit/ApplicationEdit';
import { ApplicationList } from 'component/application/ApplicationList/ApplicationList';
import ContextList from 'component/context/ContextList/ContextList/ContextList';
import RedirectFeatureView from 'component/feature/RedirectFeatureView/RedirectFeatureView';
import { CreateIntegration } from 'component/integrations/CreateIntegration/CreateIntegration';
@ -44,6 +43,7 @@ import { LazyAdmin } from 'component/admin/LazyAdmin';
import { LazyProject } from 'component/project/Project/LazyProject';
import { LoginHistory } from 'component/loginHistory/LoginHistory';
import { FeatureTypesList } from 'component/featureTypes/FeatureTypesList';
import { TemporaryApplicationListWrapper } from 'component/application/ApplicationList/TemporaryApplicationListWrapper';
export const routes: IRoute[] = [
// Splash
@ -179,7 +179,7 @@ export const routes: IRoute[] = [
{
path: '/applications',
title: 'Applications',
component: ApplicationList,
component: TemporaryApplicationListWrapper,
type: 'protected',
menu: { mobile: true, advanced: true },
},

View File

@ -58,6 +58,7 @@ export interface IFlags {
segmentChangeRequests?: boolean;
changeRequestReject?: boolean;
lastSeenByEnvironment?: boolean;
newApplicationList?: boolean;
integrationsRework?: boolean;
}

View File

@ -2275,10 +2275,10 @@
resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.6.tgz#cee20bd55e68a1720bdab363ecf0c821ded4cd45"
integrity sha512-50/17A98tWUfQ176raKiOGXuYpLyyVMkxxG6oylzL3BPOlA6ADGdK7EYunSa4I064xerltq9TGXs8HmOk5E+vw==
"@remix-run/router@1.7.2":
version "1.7.2"
resolved "https://registry.yarnpkg.com/@remix-run/router/-/router-1.7.2.tgz#cba1cf0a04bc04cb66027c51fa600e9cbc388bc8"
integrity sha512-7Lcn7IqGMV+vizMPoEl5F0XDshcdDYtMI6uJLQdQz5CfZAwy3vvGKYSUk789qndt5dEC4HfSjviSYlSoHGL2+A==
"@remix-run/router@1.8.0":
version "1.8.0"
resolved "https://registry.yarnpkg.com/@remix-run/router/-/router-1.8.0.tgz#e848d2f669f601544df15ce2a313955e4bf0bafc"
integrity sha512-mrfKqIHnSZRyIzBcanNJmVQELTnX+qagEDlcKO90RgRBVOZGSGvZKeDihTRfWcqoDn5N/NkUcwWTccnpN18Tfg==
"@rollup/plugin-commonjs@~22.0.2":
version "22.0.2"
@ -8261,20 +8261,20 @@ react-refresh@^0.14.0:
resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.14.0.tgz#4e02825378a5f227079554d4284889354e5f553e"
integrity sha512-wViHqhAd8OHeLS/IRMJjTSDHF3U9eWi62F/MledQGPdJGDhodXJ9PBLNGr6WWL7qlH12Mt3TyTpbS+hGXMjCzQ==
react-router-dom@6.14.2:
version "6.14.2"
resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-6.14.2.tgz#88f520118b91aa60233bd08dbd3fdcaea3a68488"
integrity sha512-5pWX0jdKR48XFZBuJqHosX3AAHjRAzygouMTyimnBPOLdY3WjzUSKhus2FVMihUFWzeLebDgr4r8UeQFAct7Bg==
react-router-dom@6.15.0:
version "6.15.0"
resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-6.15.0.tgz#6da7db61e56797266fbbef0d5e324d6ac443ee40"
integrity sha512-aR42t0fs7brintwBGAv2+mGlCtgtFQeOzK0BM1/OiqEzRejOZtpMZepvgkscpMUnKb8YO84G7s3LsHnnDNonbQ==
dependencies:
"@remix-run/router" "1.7.2"
react-router "6.14.2"
"@remix-run/router" "1.8.0"
react-router "6.15.0"
react-router@6.14.2:
version "6.14.2"
resolved "https://registry.yarnpkg.com/react-router/-/react-router-6.14.2.tgz#1f60994d8c369de7b8ba7a78d8f7ec23df76b300"
integrity sha512-09Zss2dE2z+T1D03IheqAFtK4UzQyX8nFPWx6jkwdYzGLXd5ie06A6ezS2fO6zJfEb/SpG6UocN2O1hfD+2urQ==
react-router@6.15.0:
version "6.15.0"
resolved "https://registry.yarnpkg.com/react-router/-/react-router-6.15.0.tgz#bf2cb5a4a7ed57f074d4ea88db0d95033f39cac8"
integrity sha512-NIytlzvzLwJkCQj2HLefmeakxxWHWAP+02EGqWEZy+DgfHHKQMUoBBjUQLOtFInBMhWtb3hiUy6MfFgwLjXhqg==
dependencies:
"@remix-run/router" "1.7.2"
"@remix-run/router" "1.8.0"
react-shallow-renderer@^16.13.1:
version "16.15.0"
@ -8617,10 +8617,10 @@ safe-stable-stringify@^1.1:
resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a"
integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
sass@1.64.2:
version "1.64.2"
resolved "https://registry.yarnpkg.com/sass/-/sass-1.64.2.tgz#0d9805ad6acf31c59c3acc725fcfb91b7fcc6909"
integrity sha512-TnDlfc+CRnUAgLO9D8cQLFu/GIjJIzJCGkE7o4ekIGQOH7T3GetiRR/PsTWJUHhkzcSPrARkPI+gNWn5alCzDg==
sass@1.65.1:
version "1.65.1"
resolved "https://registry.yarnpkg.com/sass/-/sass-1.65.1.tgz#8f283b0c26335a88246a448d22e1342ba2ea1432"
integrity sha512-9DINwtHmA41SEd36eVPQ9BJKpn7eKDQmUHmpI0y5Zv2Rcorrh0zS+cFrt050hdNbmmCNKTW3hV5mWfuegNRsEA==
dependencies:
chokidar ">=3.0.0 <4.0.0"
immutable "^4.0.0"

View File

@ -57,6 +57,14 @@ const slackAppDefinition: IAddonDefinition = {
required: false,
sensitive: false,
},
{
name: 'alwaysPostToDefault',
displayName: 'Always post to default channels',
description: `If set to 'true' or 'yes', the app will always post events to the default channels, even if the feature toggle has slack tags`,
type: 'text',
required: false,
sensitive: false,
},
],
events: [
FEATURE_CREATED,

View File

@ -23,6 +23,7 @@ import { IEvent } from '../types/events';
interface ISlackAppAddonParameters {
accessToken: string;
defaultChannels: string;
alwaysPostToDefault: string;
}
export default class SlackAppAddon extends Addon {
@ -45,16 +46,26 @@ export default class SlackAppAddon extends Addon {
parameters: ISlackAppAddonParameters,
): Promise<void> {
try {
const { accessToken, defaultChannels } = parameters;
const { accessToken, defaultChannels, alwaysPostToDefault } =
parameters;
if (!accessToken) {
this.logger.warn('No access token provided.');
return;
}
let postToDefault =
alwaysPostToDefault === 'true' || alwaysPostToDefault === 'yes';
this.logger.debug(`Post to default was set to ${postToDefault}`);
const taggedChannels = this.findTaggedChannels(event);
const eventChannels = taggedChannels.length
? taggedChannels
: this.getDefaultChannels(defaultChannels);
let eventChannels: string[];
if (postToDefault) {
eventChannels = taggedChannels.concat(
this.getDefaultChannels(defaultChannels),
);
} else {
eventChannels = taggedChannels.length
? taggedChannels
: this.getDefaultChannels(defaultChannels);
}
if (!eventChannels.length) {
this.logger.debug(

View File

@ -335,6 +335,11 @@ test('validate import data', async () => {
'The following features will not be imported as they are currently archived. To import them, please unarchive them first:',
affectedItems: [archivedFeature],
},
{
message:
'The following features already exist in this project and will be overwritten:',
affectedItems: ['existing_feature'],
},
],
permissions: [
{

View File

@ -151,6 +151,7 @@ export default class ExportImportService {
unsupportedContextFields,
archivedFeatures,
otherProjectFeatures,
existingProjectFeatures,
missingPermissions,
] = await Promise.all([
this.getUnsupportedStrategies(dto),
@ -158,6 +159,7 @@ export default class ExportImportService {
this.getUnsupportedContextFields(dto),
this.getArchivedFeatures(dto),
this.getOtherProjectFeatures(dto),
this.getExistingProjectFeatures(dto),
this.importPermissionsService.getMissingPermissions(
dto,
user,
@ -176,6 +178,7 @@ export default class ExportImportService {
const warnings = ImportValidationMessages.compileWarnings(
usedCustomStrategies,
archivedFeatures,
existingProjectFeatures,
);
const permissions =
ImportValidationMessages.compilePermissionErrors(
@ -299,7 +302,7 @@ export default class ExportImportService {
this.contextService.createContextField(
{
name: contextField.name,
description: contextField.description,
description: contextField.description || '',
legalValues: contextField.legalValues,
stickiness: contextField.stickiness,
},
@ -529,6 +532,15 @@ export default class ExportImportService {
);
}
private async getExistingProjectFeatures(dto: ImportTogglesSchema) {
const existingProjectsFeatures =
await this.importTogglesStore.getFeaturesInProject(
dto.data.features.map((feature) => feature.name),
dto.project,
);
return existingProjectsFeatures;
}
private async getNewTagTypes(dto: ImportTogglesSchema) {
const existingTagTypes = (await this.tagTypeService.getAll()).map(
(tagType) => tagType.name,

View File

@ -11,6 +11,11 @@ export interface IImportTogglesStore {
project: string,
): Promise<{ name: string; project: string }[]>;
getFeaturesInProject(
featureNames: string[],
project: string,
): Promise<string[]>;
deleteTagsForFeatures(tags: string[]): Promise<void>;
strategiesExistForFeatures(

View File

@ -74,6 +74,18 @@ export class ImportTogglesStore implements IImportTogglesStore {
return rows.map((row) => ({ name: row.name, project: row.project }));
}
async getFeaturesInProject(
featureNames: string[],
project: string,
): Promise<string[]> {
const rows = await this.db(T.features)
.select(['name', 'project'])
.where('project', project)
.where('archived_at', null)
.whereIn('name', featureNames);
return rows.map((row) => row.name);
}
async deleteTagsForFeatures(features: string[]): Promise<void> {
return this.db(T.featureTag).whereIn('feature_name', features).del();
}

View File

@ -73,6 +73,7 @@ export class ImportValidationMessages {
static compileWarnings(
usedCustomStrategies: string[],
archivedFeatures: string[],
existingFeatures: string[],
): ImportTogglesValidateItemSchema[] {
const warnings: ImportTogglesValidateItemSchema[] = [];
if (usedCustomStrategies.length > 0) {
@ -89,6 +90,13 @@ export class ImportValidationMessages {
affectedItems: archivedFeatures,
});
}
if (existingFeatures.length > 0) {
warnings.push({
message:
'The following features already exist in this project and will be overwritten:',
affectedItems: existingFeatures,
});
}
return warnings;
}
}

View File

@ -31,6 +31,7 @@ export type IFlagKey =
| 'segmentChangeRequests'
| 'changeRequestReject'
| 'customRootRolesKillSwitch'
| 'newApplicationList'
| 'integrationsRework';
export type IFlags = Partial<{ [key in IFlagKey]: boolean | Variant }>;

View File

@ -0,0 +1,24 @@
'use strict';
exports.up = function (db, callback) {
db.runSql(
`
CREATE TABLE IF NOT EXISTS client_applications_usage (
app_name VARCHAR(255) REFERENCES client_applications(app_name) ON DELETE CASCADE,
project VARCHAR(255) REFERENCES projects(id) ON DELETE CASCADE,
environment VARCHAR(100) REFERENCES environments(name) ON DELETE CASCADE,
PRIMARY KEY(app_name, project, environment)
) ;
`,
callback,
);
};
exports.down = function (db, callback) {
db.runSql(
`
DROP TABLE IF EXISTS client_applications_usage;
`,
callback,
);
};

View File

@ -45,6 +45,7 @@ process.nextTick(async () => {
frontendNavigationUpdate: true,
lastSeenByEnvironment: true,
segmentChangeRequests: true,
newApplicationList: true,
},
},
authentication: {

View File

@ -193,7 +193,7 @@ test('all tags are listed in the root "tags" list', async () => {
expect(invalidTags).toStrictEqual({});
});
test('all API operations have summaries and descriptions', async () => {
test('all API operations have non-empty summaries and descriptions', async () => {
const { body: spec } = await app.request
.get('/docs/openapi.json')
.expect('Content-Type', /json/)
@ -203,8 +203,8 @@ test('all API operations have summaries and descriptions', async () => {
return Object.entries(data)
.map(([verb, operationDescription]) => {
if (
'summary' in operationDescription &&
'description' in operationDescription
operationDescription.summary &&
operationDescription.description
) {
return undefined;
} else {

View File

@ -8,13 +8,21 @@ This website is built using [Docusaurus 2](https://docusaurus.io/), a modern sta
yarn install
```
## Generate Open API docs
```console
yarn generate
```
Generate the Open API docs that live at Reference documentation > APIs > OpenAPI
## Local Development
```console
yarn start
```
This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server.
Start a local development server and opens up a browser window. Most changes are reflected live without having to restart the server.
## Build

View File

@ -81,7 +81,7 @@
"@tsconfig/docusaurus": "2.0.0",
"babel-loader": "9.1.3",
"enhanced-resolve": "5.15.0",
"react-router": "6.14.2",
"react-router": "6.15.0",
"replace-in-file": "7.0.1",
"storybook-addon-root-attribute": "1.0.2",
"typescript": "4.8.4"

View File

@ -3291,10 +3291,10 @@
redux-thunk "^2.4.2"
reselect "^4.1.7"
"@remix-run/router@1.7.2":
version "1.7.2"
resolved "https://registry.yarnpkg.com/@remix-run/router/-/router-1.7.2.tgz#cba1cf0a04bc04cb66027c51fa600e9cbc388bc8"
integrity sha512-7Lcn7IqGMV+vizMPoEl5F0XDshcdDYtMI6uJLQdQz5CfZAwy3vvGKYSUk789qndt5dEC4HfSjviSYlSoHGL2+A==
"@remix-run/router@1.8.0":
version "1.8.0"
resolved "https://registry.yarnpkg.com/@remix-run/router/-/router-1.8.0.tgz#e848d2f669f601544df15ce2a313955e4bf0bafc"
integrity sha512-mrfKqIHnSZRyIzBcanNJmVQELTnX+qagEDlcKO90RgRBVOZGSGvZKeDihTRfWcqoDn5N/NkUcwWTccnpN18Tfg==
"@sideway/address@^4.1.3":
version "4.1.4"
@ -13714,12 +13714,12 @@ react-router@5.3.4, react-router@^5.3.3:
tiny-invariant "^1.0.2"
tiny-warning "^1.0.0"
react-router@6.14.2:
version "6.14.2"
resolved "https://registry.yarnpkg.com/react-router/-/react-router-6.14.2.tgz#1f60994d8c369de7b8ba7a78d8f7ec23df76b300"
integrity sha512-09Zss2dE2z+T1D03IheqAFtK4UzQyX8nFPWx6jkwdYzGLXd5ie06A6ezS2fO6zJfEb/SpG6UocN2O1hfD+2urQ==
react-router@6.15.0:
version "6.15.0"
resolved "https://registry.yarnpkg.com/react-router/-/react-router-6.15.0.tgz#bf2cb5a4a7ed57f074d4ea88db0d95033f39cac8"
integrity sha512-NIytlzvzLwJkCQj2HLefmeakxxWHWAP+02EGqWEZy+DgfHHKQMUoBBjUQLOtFInBMhWtb3hiUy6MfFgwLjXhqg==
dependencies:
"@remix-run/router" "1.7.2"
"@remix-run/router" "1.8.0"
react-textarea-autosize@^8.3.2:
version "8.4.0"