mirror of
				https://github.com/Unleash/unleash.git
				synced 2025-10-27 11:02:16 +01:00 
			
		
		
		
	feat: project applications server side paging and sorting and filtering (#6236)
Uses exactly same pattern as search-store. Nothing too crazy here. Most code is in tests.
This commit is contained in:
		
							parent
							
								
									6a8f903bcf
								
							
						
					
					
						commit
						3d77825493
					
				@ -7,7 +7,6 @@ import {
 | 
			
		||||
    LineChart,
 | 
			
		||||
    NotEnoughData,
 | 
			
		||||
} from '../LineChart/LineChart';
 | 
			
		||||
import { type ScriptableContext } from 'chart.js';
 | 
			
		||||
 | 
			
		||||
interface IUsersChartProps {
 | 
			
		||||
    userTrends: ExecutiveSummarySchema['userTrends'];
 | 
			
		||||
 | 
			
		||||
@ -16,6 +16,7 @@ import {
 | 
			
		||||
    IFeatureSearchParams,
 | 
			
		||||
    IQueryParam,
 | 
			
		||||
} from '../feature-toggle/types/feature-toggle-strategies-store-type';
 | 
			
		||||
import { applyGenericQueryParams, applySearchFilters } from './search-utils';
 | 
			
		||||
 | 
			
		||||
const sortEnvironments = (overview: IFeatureOverview[]) => {
 | 
			
		||||
    return overview.map((data: IFeatureOverview) => ({
 | 
			
		||||
@ -87,28 +88,10 @@ class FeatureSearchStore implements IFeatureSearchStore {
 | 
			
		||||
                query.from('features');
 | 
			
		||||
 | 
			
		||||
                applyQueryParams(query, queryParams);
 | 
			
		||||
 | 
			
		||||
                const hasSearchParams = searchParams?.length;
 | 
			
		||||
                if (hasSearchParams) {
 | 
			
		||||
                    const sqlParameters = searchParams.map(
 | 
			
		||||
                        (item) => `%${item}%`,
 | 
			
		||||
                    );
 | 
			
		||||
                    const sqlQueryParameters = sqlParameters
 | 
			
		||||
                        .map(() => '?')
 | 
			
		||||
                        .join(',');
 | 
			
		||||
 | 
			
		||||
                    query.where((builder) => {
 | 
			
		||||
                        builder
 | 
			
		||||
                            .orWhereRaw(
 | 
			
		||||
                                `(??) ILIKE ANY (ARRAY[${sqlQueryParameters}])`,
 | 
			
		||||
                                ['features.name', ...sqlParameters],
 | 
			
		||||
                            )
 | 
			
		||||
                            .orWhereRaw(
 | 
			
		||||
                                `(??) ILIKE ANY (ARRAY[${sqlQueryParameters}])`,
 | 
			
		||||
                                ['features.description', ...sqlParameters],
 | 
			
		||||
                            );
 | 
			
		||||
                    });
 | 
			
		||||
                }
 | 
			
		||||
                applySearchFilters(query, searchParams, [
 | 
			
		||||
                    'features.name',
 | 
			
		||||
                    'features.description',
 | 
			
		||||
                ]);
 | 
			
		||||
 | 
			
		||||
                if (type) {
 | 
			
		||||
                    query.whereIn('features.type', type);
 | 
			
		||||
@ -282,7 +265,6 @@ class FeatureSearchStore implements IFeatureSearchStore {
 | 
			
		||||
            )
 | 
			
		||||
            .joinRaw('CROSS JOIN total_features')
 | 
			
		||||
            .whereBetween('final_rank', [offset + 1, offset + limit]);
 | 
			
		||||
 | 
			
		||||
        const rows = await finalQuery;
 | 
			
		||||
        stopTimer();
 | 
			
		||||
        if (rows.length > 0) {
 | 
			
		||||
@ -417,30 +399,6 @@ const applyQueryParams = (
 | 
			
		||||
    );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const applyGenericQueryParams = (
 | 
			
		||||
    query: Knex.QueryBuilder,
 | 
			
		||||
    queryParams: IQueryParam[],
 | 
			
		||||
): void => {
 | 
			
		||||
    queryParams.forEach((param) => {
 | 
			
		||||
        switch (param.operator) {
 | 
			
		||||
            case 'IS':
 | 
			
		||||
            case 'IS_ANY_OF':
 | 
			
		||||
                query.whereIn(param.field, param.values);
 | 
			
		||||
                break;
 | 
			
		||||
            case 'IS_NOT':
 | 
			
		||||
            case 'IS_NONE_OF':
 | 
			
		||||
                query.whereNotIn(param.field, param.values);
 | 
			
		||||
                break;
 | 
			
		||||
            case 'IS_BEFORE':
 | 
			
		||||
                query.where(param.field, '<', param.values[0]);
 | 
			
		||||
                break;
 | 
			
		||||
            case 'IS_ON_OR_AFTER':
 | 
			
		||||
                query.where(param.field, '>=', param.values[0]);
 | 
			
		||||
                break;
 | 
			
		||||
        }
 | 
			
		||||
    });
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const applyMultiQueryParams = (
 | 
			
		||||
    query: Knex.QueryBuilder,
 | 
			
		||||
    queryParams: IQueryParam[],
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										47
									
								
								src/lib/features/feature-search/search-utils.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								src/lib/features/feature-search/search-utils.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,47 @@
 | 
			
		||||
import { Knex } from 'knex';
 | 
			
		||||
import { IQueryParam } from '../feature-toggle/types/feature-toggle-strategies-store-type';
 | 
			
		||||
 | 
			
		||||
export const applySearchFilters = (
 | 
			
		||||
    qb: Knex.QueryBuilder,
 | 
			
		||||
    searchParams: string[] | undefined,
 | 
			
		||||
    columns: string[],
 | 
			
		||||
): void => {
 | 
			
		||||
    const hasSearchParams = searchParams?.length;
 | 
			
		||||
    if (hasSearchParams) {
 | 
			
		||||
        const sqlParameters = searchParams.map((item) => `%${item}%`);
 | 
			
		||||
        const sqlQueryParameters = sqlParameters.map(() => '?').join(',');
 | 
			
		||||
 | 
			
		||||
        qb.where((builder) => {
 | 
			
		||||
            columns.forEach((column) => {
 | 
			
		||||
                builder.orWhereRaw(
 | 
			
		||||
                    `(${column}) ILIKE ANY (ARRAY[${sqlQueryParameters}])`,
 | 
			
		||||
                    sqlParameters,
 | 
			
		||||
                );
 | 
			
		||||
            });
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const applyGenericQueryParams = (
 | 
			
		||||
    query: Knex.QueryBuilder,
 | 
			
		||||
    queryParams: IQueryParam[],
 | 
			
		||||
): void => {
 | 
			
		||||
    queryParams.forEach((param) => {
 | 
			
		||||
        switch (param.operator) {
 | 
			
		||||
            case 'IS':
 | 
			
		||||
            case 'IS_ANY_OF':
 | 
			
		||||
                query.whereIn(param.field, param.values);
 | 
			
		||||
                break;
 | 
			
		||||
            case 'IS_NOT':
 | 
			
		||||
            case 'IS_NONE_OF':
 | 
			
		||||
                query.whereNotIn(param.field, param.values);
 | 
			
		||||
                break;
 | 
			
		||||
            case 'IS_BEFORE':
 | 
			
		||||
                query.where(param.field, '<', param.values[0]);
 | 
			
		||||
                break;
 | 
			
		||||
            case 'IS_ON_OR_AFTER':
 | 
			
		||||
                query.where(param.field, '>=', param.values[0]);
 | 
			
		||||
                break;
 | 
			
		||||
        }
 | 
			
		||||
    });
 | 
			
		||||
};
 | 
			
		||||
@ -89,7 +89,8 @@ test('should return applications', async () => {
 | 
			
		||||
        .expect('Content-Type', /json/)
 | 
			
		||||
        .expect(200);
 | 
			
		||||
 | 
			
		||||
    expect(body).toMatchObject([
 | 
			
		||||
    expect(body).toMatchObject({
 | 
			
		||||
        applications: [
 | 
			
		||||
            {
 | 
			
		||||
                environments: ['default'],
 | 
			
		||||
                instances: ['instanceId'],
 | 
			
		||||
@ -101,7 +102,9 @@ test('should return applications', async () => {
 | 
			
		||||
                    },
 | 
			
		||||
                ],
 | 
			
		||||
            },
 | 
			
		||||
    ]);
 | 
			
		||||
        ],
 | 
			
		||||
        total: 1,
 | 
			
		||||
    });
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
test('should return applications if sdk was not in database', async () => {
 | 
			
		||||
@ -128,14 +131,17 @@ test('should return applications if sdk was not in database', async () => {
 | 
			
		||||
        .expect('Content-Type', /json/)
 | 
			
		||||
        .expect(200);
 | 
			
		||||
 | 
			
		||||
    expect(body).toMatchObject([
 | 
			
		||||
    expect(body).toMatchObject({
 | 
			
		||||
        applications: [
 | 
			
		||||
            {
 | 
			
		||||
                environments: ['default'],
 | 
			
		||||
                instances: ['instanceId'],
 | 
			
		||||
                name: 'appName',
 | 
			
		||||
                sdks: [],
 | 
			
		||||
            },
 | 
			
		||||
    ]);
 | 
			
		||||
        ],
 | 
			
		||||
        total: 1,
 | 
			
		||||
    });
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
test('should return application without version if sdk has just name', async () => {
 | 
			
		||||
@ -163,7 +169,8 @@ test('should return application without version if sdk has just name', async ()
 | 
			
		||||
        .expect('Content-Type', /json/)
 | 
			
		||||
        .expect(200);
 | 
			
		||||
 | 
			
		||||
    expect(body).toMatchObject([
 | 
			
		||||
    expect(body).toMatchObject({
 | 
			
		||||
        applications: [
 | 
			
		||||
            {
 | 
			
		||||
                environments: ['default'],
 | 
			
		||||
                instances: ['instanceId'],
 | 
			
		||||
@ -175,5 +182,209 @@ test('should return application without version if sdk has just name', async ()
 | 
			
		||||
                    },
 | 
			
		||||
                ],
 | 
			
		||||
            },
 | 
			
		||||
    ]);
 | 
			
		||||
        ],
 | 
			
		||||
        total: 1,
 | 
			
		||||
    });
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
test('should sort by appName descending', async () => {
 | 
			
		||||
    await app.createFeature('toggle-name-1');
 | 
			
		||||
 | 
			
		||||
    await app.request.post('/api/client/register').send({
 | 
			
		||||
        appName: metrics.appName,
 | 
			
		||||
        instanceId: metrics.instanceId,
 | 
			
		||||
        strategies: ['default'],
 | 
			
		||||
        sdkVersion: 'unleash-client-test',
 | 
			
		||||
        started: Date.now(),
 | 
			
		||||
        interval: 10,
 | 
			
		||||
    });
 | 
			
		||||
    const secondApp = 'second-app';
 | 
			
		||||
    await app.request.post('/api/client/register').send({
 | 
			
		||||
        appName: secondApp,
 | 
			
		||||
        instanceId: metrics.instanceId,
 | 
			
		||||
        strategies: ['default'],
 | 
			
		||||
        sdkVersion: 'unleash-client-test',
 | 
			
		||||
        started: Date.now(),
 | 
			
		||||
        interval: 10,
 | 
			
		||||
    });
 | 
			
		||||
    await app.services.clientInstanceService.bulkAdd();
 | 
			
		||||
    await app.request
 | 
			
		||||
        .post('/api/client/metrics')
 | 
			
		||||
        .set('Authorization', defaultToken.secret)
 | 
			
		||||
        .send(metrics)
 | 
			
		||||
        .expect(202);
 | 
			
		||||
 | 
			
		||||
    await app.request
 | 
			
		||||
        .post('/api/client/metrics')
 | 
			
		||||
        .set('Authorization', defaultToken.secret)
 | 
			
		||||
        .send({
 | 
			
		||||
            ...metrics,
 | 
			
		||||
            appName: secondApp,
 | 
			
		||||
        })
 | 
			
		||||
        .expect(202);
 | 
			
		||||
 | 
			
		||||
    await app.services.clientMetricsServiceV2.bulkAdd();
 | 
			
		||||
 | 
			
		||||
    const { body } = await app.request
 | 
			
		||||
        .get('/api/admin/projects/default/applications?sortOrder=desc')
 | 
			
		||||
        .expect('Content-Type', /json/)
 | 
			
		||||
        .expect(200);
 | 
			
		||||
 | 
			
		||||
    expect(body).toMatchObject({
 | 
			
		||||
        applications: [
 | 
			
		||||
            {
 | 
			
		||||
                environments: ['default'],
 | 
			
		||||
                instances: ['instanceId'],
 | 
			
		||||
                name: 'second-app',
 | 
			
		||||
                sdks: [
 | 
			
		||||
                    {
 | 
			
		||||
                        name: 'unleash-client-test',
 | 
			
		||||
                        versions: [],
 | 
			
		||||
                    },
 | 
			
		||||
                ],
 | 
			
		||||
            },
 | 
			
		||||
            {
 | 
			
		||||
                environments: ['default'],
 | 
			
		||||
                instances: ['instanceId'],
 | 
			
		||||
                name: 'appName',
 | 
			
		||||
                sdks: [
 | 
			
		||||
                    {
 | 
			
		||||
                        name: 'unleash-client-test',
 | 
			
		||||
                        versions: [],
 | 
			
		||||
                    },
 | 
			
		||||
                ],
 | 
			
		||||
            },
 | 
			
		||||
        ],
 | 
			
		||||
        total: 2,
 | 
			
		||||
    });
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
test('should filter by sdk', async () => {
 | 
			
		||||
    await app.createFeature('toggle-name-1');
 | 
			
		||||
 | 
			
		||||
    await app.request.post('/api/client/register').send({
 | 
			
		||||
        appName: metrics.appName,
 | 
			
		||||
        instanceId: metrics.instanceId,
 | 
			
		||||
        strategies: ['default'],
 | 
			
		||||
        sdkVersion: 'unleash-java-test',
 | 
			
		||||
        started: Date.now(),
 | 
			
		||||
        interval: 10,
 | 
			
		||||
    });
 | 
			
		||||
    const secondApp = 'second-app';
 | 
			
		||||
    await app.request.post('/api/client/register').send({
 | 
			
		||||
        appName: secondApp,
 | 
			
		||||
        instanceId: metrics.instanceId,
 | 
			
		||||
        strategies: ['default'],
 | 
			
		||||
        sdkVersion: 'unleash-client-test',
 | 
			
		||||
        started: Date.now(),
 | 
			
		||||
        interval: 10,
 | 
			
		||||
    });
 | 
			
		||||
    await app.services.clientInstanceService.bulkAdd();
 | 
			
		||||
    await app.request
 | 
			
		||||
        .post('/api/client/metrics')
 | 
			
		||||
        .set('Authorization', defaultToken.secret)
 | 
			
		||||
        .send(metrics)
 | 
			
		||||
        .expect(202);
 | 
			
		||||
 | 
			
		||||
    await app.request
 | 
			
		||||
        .post('/api/client/metrics')
 | 
			
		||||
        .set('Authorization', defaultToken.secret)
 | 
			
		||||
        .send({
 | 
			
		||||
            ...metrics,
 | 
			
		||||
            appName: secondApp,
 | 
			
		||||
        })
 | 
			
		||||
        .expect(202);
 | 
			
		||||
 | 
			
		||||
    await app.services.clientMetricsServiceV2.bulkAdd();
 | 
			
		||||
 | 
			
		||||
    const { body } = await app.request
 | 
			
		||||
        .get('/api/admin/projects/default/applications?&query=java')
 | 
			
		||||
        .expect('Content-Type', /json/)
 | 
			
		||||
        .expect(200);
 | 
			
		||||
 | 
			
		||||
    expect(body).toMatchObject({
 | 
			
		||||
        applications: [
 | 
			
		||||
            {
 | 
			
		||||
                environments: ['default'],
 | 
			
		||||
                instances: ['instanceId'],
 | 
			
		||||
                name: 'appName',
 | 
			
		||||
                sdks: [
 | 
			
		||||
                    {
 | 
			
		||||
                        name: 'unleash-java-test',
 | 
			
		||||
                        versions: [],
 | 
			
		||||
                    },
 | 
			
		||||
                ],
 | 
			
		||||
            },
 | 
			
		||||
        ],
 | 
			
		||||
        total: 1,
 | 
			
		||||
    });
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
test('should show correct number of total', async () => {
 | 
			
		||||
    await app.createFeature('toggle-name-1');
 | 
			
		||||
 | 
			
		||||
    await app.request.post('/api/client/register').send({
 | 
			
		||||
        appName: metrics.appName,
 | 
			
		||||
        instanceId: metrics.instanceId,
 | 
			
		||||
        strategies: ['default'],
 | 
			
		||||
        sdkVersion: 'unleash-client-test',
 | 
			
		||||
        started: Date.now(),
 | 
			
		||||
        interval: 10,
 | 
			
		||||
    });
 | 
			
		||||
    await app.request.post('/api/client/register').send({
 | 
			
		||||
        appName: metrics.appName,
 | 
			
		||||
        instanceId: 'another-instance',
 | 
			
		||||
        strategies: ['default'],
 | 
			
		||||
        sdkVersion: 'unleash-client-test',
 | 
			
		||||
        started: Date.now(),
 | 
			
		||||
        interval: 10,
 | 
			
		||||
    });
 | 
			
		||||
    const secondApp = 'second-app';
 | 
			
		||||
    await app.request.post('/api/client/register').send({
 | 
			
		||||
        appName: secondApp,
 | 
			
		||||
        instanceId: metrics.instanceId,
 | 
			
		||||
        strategies: ['default'],
 | 
			
		||||
        sdkVersion: 'unleash-client-test',
 | 
			
		||||
        started: Date.now(),
 | 
			
		||||
        interval: 10,
 | 
			
		||||
    });
 | 
			
		||||
    await app.services.clientInstanceService.bulkAdd();
 | 
			
		||||
    await app.request
 | 
			
		||||
        .post('/api/client/metrics')
 | 
			
		||||
        .set('Authorization', defaultToken.secret)
 | 
			
		||||
        .send(metrics)
 | 
			
		||||
        .expect(202);
 | 
			
		||||
 | 
			
		||||
    await app.request
 | 
			
		||||
        .post('/api/client/metrics')
 | 
			
		||||
        .set('Authorization', defaultToken.secret)
 | 
			
		||||
        .send({
 | 
			
		||||
            ...metrics,
 | 
			
		||||
            appName: secondApp,
 | 
			
		||||
        })
 | 
			
		||||
        .expect(202);
 | 
			
		||||
 | 
			
		||||
    await app.services.clientMetricsServiceV2.bulkAdd();
 | 
			
		||||
 | 
			
		||||
    const { body } = await app.request
 | 
			
		||||
        .get('/api/admin/projects/default/applications?sortOrder=desc&limit=1')
 | 
			
		||||
        .expect('Content-Type', /json/)
 | 
			
		||||
        .expect(200);
 | 
			
		||||
 | 
			
		||||
    expect(body).toMatchObject({
 | 
			
		||||
        applications: [
 | 
			
		||||
            {
 | 
			
		||||
                environments: ['default'],
 | 
			
		||||
                instances: ['instanceId'],
 | 
			
		||||
                name: 'second-app',
 | 
			
		||||
                sdks: [
 | 
			
		||||
                    {
 | 
			
		||||
                        name: 'unleash-client-test',
 | 
			
		||||
                        versions: [],
 | 
			
		||||
                    },
 | 
			
		||||
                ],
 | 
			
		||||
            },
 | 
			
		||||
        ],
 | 
			
		||||
        total: 2,
 | 
			
		||||
    });
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
@ -267,10 +267,30 @@ export default class ProjectController extends Controller {
 | 
			
		||||
            throw new NotFoundError();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const { query, offset, limit = '50', sortOrder, sortBy } = req.query;
 | 
			
		||||
 | 
			
		||||
        const { projectId } = req.params;
 | 
			
		||||
 | 
			
		||||
        const applications =
 | 
			
		||||
            await this.projectService.getApplications(projectId);
 | 
			
		||||
        const normalizedQuery = query
 | 
			
		||||
            ?.split(',')
 | 
			
		||||
            .map((query) => query.trim())
 | 
			
		||||
            .filter((query) => query);
 | 
			
		||||
 | 
			
		||||
        const normalizedLimit =
 | 
			
		||||
            Number(limit) > 0 && Number(limit) <= 100 ? Number(limit) : 25;
 | 
			
		||||
        const normalizedOffset = Number(offset) > 0 ? Number(offset) : 0;
 | 
			
		||||
        const normalizedSortBy: string = sortBy ? sortBy : 'appName';
 | 
			
		||||
        const normalizedSortOrder =
 | 
			
		||||
            sortOrder === 'asc' || sortOrder === 'desc' ? sortOrder : 'asc';
 | 
			
		||||
 | 
			
		||||
        const applications = await this.projectService.getApplications({
 | 
			
		||||
            searchParams: normalizedQuery,
 | 
			
		||||
            project: projectId,
 | 
			
		||||
            offset: normalizedOffset,
 | 
			
		||||
            limit: normalizedLimit,
 | 
			
		||||
            sortBy: normalizedSortBy,
 | 
			
		||||
            sortOrder: normalizedSortOrder,
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        this.openApiService.respondWithValidation(
 | 
			
		||||
            200,
 | 
			
		||||
 | 
			
		||||
@ -42,8 +42,8 @@ import {
 | 
			
		||||
    IProjectUpdate,
 | 
			
		||||
    IProjectHealth,
 | 
			
		||||
    SYSTEM_USER,
 | 
			
		||||
    IProjectApplication,
 | 
			
		||||
    IProjectStore,
 | 
			
		||||
    IProjectApplications,
 | 
			
		||||
} from '../../types';
 | 
			
		||||
import {
 | 
			
		||||
    IProjectAccessModel,
 | 
			
		||||
@ -65,6 +65,7 @@ import { checkFeatureNamingData } from '../feature-naming-pattern/feature-naming
 | 
			
		||||
import { IPrivateProjectChecker } from '../private-project/privateProjectCheckerType';
 | 
			
		||||
import EventService from '../events/event-service';
 | 
			
		||||
import {
 | 
			
		||||
    IProjectApplicationsSearchParams,
 | 
			
		||||
    IProjectEnterpriseSettingsUpdate,
 | 
			
		||||
    IProjectQuery,
 | 
			
		||||
} from './project-store-type';
 | 
			
		||||
@ -901,9 +902,11 @@ export default class ProjectService {
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async getApplications(projectId: string): Promise<IProjectApplication[]> {
 | 
			
		||||
    async getApplications(
 | 
			
		||||
        searchParams: IProjectApplicationsSearchParams,
 | 
			
		||||
    ): Promise<IProjectApplications> {
 | 
			
		||||
        const applications =
 | 
			
		||||
            await this.projectStore.getApplicationsByProject(projectId);
 | 
			
		||||
            await this.projectStore.getApplicationsByProject(searchParams);
 | 
			
		||||
        return applications;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -7,7 +7,7 @@ import {
 | 
			
		||||
    IEnvironment,
 | 
			
		||||
    IFeatureNaming,
 | 
			
		||||
    IProject,
 | 
			
		||||
    IProjectApplication,
 | 
			
		||||
    IProjectApplications,
 | 
			
		||||
    IProjectWithCount,
 | 
			
		||||
    ProjectMode,
 | 
			
		||||
} from '../../types/model';
 | 
			
		||||
@ -55,6 +55,15 @@ export type ProjectEnvironment = {
 | 
			
		||||
    defaultStrategy?: CreateFeatureStrategySchema;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export interface IProjectApplicationsSearchParams {
 | 
			
		||||
    searchParams?: string[];
 | 
			
		||||
    project?: string;
 | 
			
		||||
    offset: number;
 | 
			
		||||
    limit: number;
 | 
			
		||||
    sortBy: string;
 | 
			
		||||
    sortOrder: 'asc' | 'desc';
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface IProjectStore extends Store<IProject, string> {
 | 
			
		||||
    hasProject(id: string): Promise<boolean>;
 | 
			
		||||
 | 
			
		||||
@ -122,5 +131,7 @@ export interface IProjectStore extends Store<IProject, string> {
 | 
			
		||||
    isFeatureLimitReached(id: string): Promise<boolean>;
 | 
			
		||||
 | 
			
		||||
    getProjectModeCounts(): Promise<ProjectModeCount[]>;
 | 
			
		||||
    getApplicationsByProject(projectId: string): Promise<IProjectApplication[]>;
 | 
			
		||||
    getApplicationsByProject(
 | 
			
		||||
        searchParams: IProjectApplicationsSearchParams,
 | 
			
		||||
    ): Promise<IProjectApplications>;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -7,6 +7,7 @@ import {
 | 
			
		||||
    IFlagResolver,
 | 
			
		||||
    IProject,
 | 
			
		||||
    IProjectApplication,
 | 
			
		||||
    IProjectApplications,
 | 
			
		||||
    IProjectUpdate,
 | 
			
		||||
    IProjectWithCount,
 | 
			
		||||
    ProjectMode,
 | 
			
		||||
@ -19,6 +20,7 @@ import {
 | 
			
		||||
    IProjectEnterpriseSettingsUpdate,
 | 
			
		||||
    IProjectStore,
 | 
			
		||||
    ProjectEnvironment,
 | 
			
		||||
    IProjectApplicationsSearchParams,
 | 
			
		||||
} from '../../features/project/project-store-type';
 | 
			
		||||
import { DEFAULT_ENV } from '../../util';
 | 
			
		||||
import metricsHelper from '../../util/metrics-helper';
 | 
			
		||||
@ -27,6 +29,7 @@ import EventEmitter from 'events';
 | 
			
		||||
import { Db } from '../../db/db';
 | 
			
		||||
import Raw = Knex.Raw;
 | 
			
		||||
import { CreateFeatureStrategySchema } from '../../openapi';
 | 
			
		||||
import { applySearchFilters } from '../feature-search/search-utils';
 | 
			
		||||
 | 
			
		||||
const COLUMNS = [
 | 
			
		||||
    'id',
 | 
			
		||||
@ -579,32 +582,66 @@ class ProjectStore implements IProjectStore {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async getApplicationsByProject(
 | 
			
		||||
        projectId: string,
 | 
			
		||||
    ): Promise<IProjectApplication[]> {
 | 
			
		||||
        params: IProjectApplicationsSearchParams,
 | 
			
		||||
    ): Promise<IProjectApplications> {
 | 
			
		||||
        const { project, limit, sortOrder, sortBy, searchParams, offset } =
 | 
			
		||||
            params;
 | 
			
		||||
        const validatedSortOrder =
 | 
			
		||||
            sortOrder === 'asc' || sortOrder === 'desc' ? sortOrder : 'asc';
 | 
			
		||||
        const query = this.db
 | 
			
		||||
            .with('applications', (qb) => {
 | 
			
		||||
                qb.select('project', 'app_name', 'environment')
 | 
			
		||||
                    .distinct()
 | 
			
		||||
                    .from('client_metrics_env as cme')
 | 
			
		||||
                    .leftJoin('features as f', 'cme.feature_name', 'f.name')
 | 
			
		||||
                    .where('project', projectId);
 | 
			
		||||
                    .where('project', project);
 | 
			
		||||
            })
 | 
			
		||||
            .select(
 | 
			
		||||
            .with('ranked', (qb) => {
 | 
			
		||||
                applySearchFilters(qb, searchParams, [
 | 
			
		||||
                    'a.app_name',
 | 
			
		||||
                    'a.environment',
 | 
			
		||||
                    'ci.instance_id',
 | 
			
		||||
                    'ci.sdk_version',
 | 
			
		||||
                ]);
 | 
			
		||||
 | 
			
		||||
                qb.select(
 | 
			
		||||
                    'a.app_name',
 | 
			
		||||
                    'a.environment',
 | 
			
		||||
                    'ci.instance_id',
 | 
			
		||||
                    'ci.sdk_version',
 | 
			
		||||
                    this.db.raw(
 | 
			
		||||
                        `DENSE_RANK() OVER (ORDER BY a.app_name ${validatedSortOrder}) AS rank`,
 | 
			
		||||
                    ),
 | 
			
		||||
                )
 | 
			
		||||
                    .from('applications as a')
 | 
			
		||||
                    .leftJoin('client_instances as ci', function () {
 | 
			
		||||
                this.on('ci.app_name', 'a.app_name').andOn(
 | 
			
		||||
                        this.on('ci.app_name', '=', 'a.app_name').andOn(
 | 
			
		||||
                            'ci.environment',
 | 
			
		||||
                            '=',
 | 
			
		||||
                            'a.environment',
 | 
			
		||||
                        );
 | 
			
		||||
                    });
 | 
			
		||||
            })
 | 
			
		||||
            .with(
 | 
			
		||||
                'final_ranks',
 | 
			
		||||
                this.db.raw(
 | 
			
		||||
                    'select row_number() over (order by min(rank)) as final_rank from ranked group by app_name',
 | 
			
		||||
                ),
 | 
			
		||||
            )
 | 
			
		||||
            .with(
 | 
			
		||||
                'total',
 | 
			
		||||
                this.db.raw('select count(*) as total from final_ranks'),
 | 
			
		||||
            )
 | 
			
		||||
            .select('*')
 | 
			
		||||
            .from('ranked')
 | 
			
		||||
            .joinRaw('CROSS JOIN total')
 | 
			
		||||
            .whereBetween('rank', [offset + 1, offset + limit]);
 | 
			
		||||
        const rows = await query;
 | 
			
		||||
        const applications = this.getAggregatedApplicationsData(rows);
 | 
			
		||||
        return applications;
 | 
			
		||||
        return {
 | 
			
		||||
            applications,
 | 
			
		||||
            total: Number(rows[0].total) || 0,
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async getDefaultStrategy(
 | 
			
		||||
@ -751,7 +788,10 @@ class ProjectStore implements IProjectStore {
 | 
			
		||||
                let sdk = entry.sdks.find((sdk) => sdk.name === sdkName);
 | 
			
		||||
 | 
			
		||||
                if (!sdk) {
 | 
			
		||||
                    sdk = { name: sdkName, versions: [] };
 | 
			
		||||
                    sdk = {
 | 
			
		||||
                        name: sdkName,
 | 
			
		||||
                        versions: [],
 | 
			
		||||
                    };
 | 
			
		||||
                    entry.sdks.push(sdk);
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -2,7 +2,9 @@ import { validateSchema } from '../validate';
 | 
			
		||||
import { ProjectApplicationsSchema } from './project-applications-schema';
 | 
			
		||||
 | 
			
		||||
test('projectApplicationsSchema', () => {
 | 
			
		||||
    const data: ProjectApplicationsSchema = [
 | 
			
		||||
    const data: ProjectApplicationsSchema = {
 | 
			
		||||
        total: 55,
 | 
			
		||||
        applications: [
 | 
			
		||||
            {
 | 
			
		||||
                name: 'my-weba-app',
 | 
			
		||||
                environments: ['development', 'production'],
 | 
			
		||||
@ -14,7 +16,8 @@ test('projectApplicationsSchema', () => {
 | 
			
		||||
                    },
 | 
			
		||||
                ],
 | 
			
		||||
            },
 | 
			
		||||
    ];
 | 
			
		||||
        ],
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    expect(
 | 
			
		||||
        validateSchema('#/components/schemas/projectApplicationsSchema', data),
 | 
			
		||||
 | 
			
		||||
@ -4,11 +4,23 @@ import { projectApplicationSdkSchema } from './project-application-sdk-schema';
 | 
			
		||||
 | 
			
		||||
export const projectApplicationsSchema = {
 | 
			
		||||
    $id: '#/components/schemas/projectApplicationsSchema',
 | 
			
		||||
    type: 'array',
 | 
			
		||||
    type: 'object',
 | 
			
		||||
    description: 'A list of project applications',
 | 
			
		||||
    required: ['total', 'applications'],
 | 
			
		||||
    properties: {
 | 
			
		||||
        total: {
 | 
			
		||||
            type: 'integer',
 | 
			
		||||
            example: 50,
 | 
			
		||||
            description: 'The total number of project applications.',
 | 
			
		||||
        },
 | 
			
		||||
        applications: {
 | 
			
		||||
            type: 'array',
 | 
			
		||||
            items: {
 | 
			
		||||
                $ref: '#/components/schemas/projectApplicationSchema',
 | 
			
		||||
            },
 | 
			
		||||
            description: 'All applications defined for a specific project.',
 | 
			
		||||
        },
 | 
			
		||||
    },
 | 
			
		||||
    components: {
 | 
			
		||||
        schemas: {
 | 
			
		||||
            projectApplicationSchema,
 | 
			
		||||
 | 
			
		||||
@ -478,6 +478,11 @@ export interface IProject {
 | 
			
		||||
    featureNaming?: IFeatureNaming;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface IProjectApplications {
 | 
			
		||||
    applications: IProjectApplication[];
 | 
			
		||||
    total: number;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface IProjectApplication {
 | 
			
		||||
    name: string;
 | 
			
		||||
    environments: string[];
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										7
									
								
								src/test/fixtures/fake-project-store.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										7
									
								
								src/test/fixtures/fake-project-store.ts
									
									
									
									
										vendored
									
									
								
							@ -1,7 +1,7 @@
 | 
			
		||||
import {
 | 
			
		||||
    IEnvironment,
 | 
			
		||||
    IProject,
 | 
			
		||||
    IProjectApplication,
 | 
			
		||||
    IProjectApplications,
 | 
			
		||||
    IProjectStore,
 | 
			
		||||
    IProjectWithCount,
 | 
			
		||||
} from '../../lib/types';
 | 
			
		||||
@ -13,6 +13,7 @@ import {
 | 
			
		||||
} from '../../lib/features/project/project-store';
 | 
			
		||||
import { CreateFeatureStrategySchema } from '../../lib/openapi';
 | 
			
		||||
import {
 | 
			
		||||
    IProjectApplicationsSearchParams,
 | 
			
		||||
    IProjectHealthUpdate,
 | 
			
		||||
    IProjectInsert,
 | 
			
		||||
    ProjectEnvironment,
 | 
			
		||||
@ -209,8 +210,8 @@ export default class FakeProjectStore implements IProjectStore {
 | 
			
		||||
    }
 | 
			
		||||
    // eslint-disable-next-line @typescript-eslint/no-unused-vars
 | 
			
		||||
    getApplicationsByProject(
 | 
			
		||||
        projectId: string,
 | 
			
		||||
    ): Promise<IProjectApplication[]> {
 | 
			
		||||
        searchParams: IProjectApplicationsSearchParams,
 | 
			
		||||
    ): Promise<IProjectApplications> {
 | 
			
		||||
        throw new Error('Method not implemented.');
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
		Loading…
	
		Reference in New Issue
	
	Block a user