1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-01-20 00:08:02 +01:00

feat: use api instead of localStorage (#3305)

<!-- Thanks for creating a PR! To make it easier for reviewers and
everyone else to understand what your changes relate to, please add some
relevant content to the headings below. Feel free to ignore or delete
sections that you don't think are relevant. Thank you! ❤️ -->
This PR replaces localStorage with api calls for getting/setting project
scoped stickiness
## About the changes
<!-- Describe the changes introduced. What are they and why are they
being introduced? Feel free to also add screenshots or steps to view the
changes if they're visual. -->

<!-- Does it close an issue? Multiple? -->
Closes #

<!-- (For internal contributors): Does it relate to an issue on public
roadmap? -->
<!--
Relates to [roadmap](https://github.com/orgs/Unleash/projects/10) item:
#
-->

### Important files
<!-- PRs can contain a lot of changes, but not all changes are equally
important. Where should a reviewer start looking to get an overview of
the changes? Are any files particularly important? -->


## Discussion points
<!-- Anything about the PR you'd like to discuss before it gets merged?
Got any questions or doubts? -->

---------

Signed-off-by: andreas-unleash <andreas@getunleash.ai>
This commit is contained in:
andreas-unleash 2023-03-15 16:06:25 +02:00 committed by GitHub
parent e33ebd0229
commit 933455e308
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 253 additions and 189 deletions

View File

@ -19,7 +19,7 @@ import { v4 as uuidv4 } from 'uuid';
import useUnleashContext from 'hooks/api/getters/useUnleashContext/useUnleashContext';
import { updateWeightEdit } from 'component/common/util';
import { StickinessSelect } from 'component/feature/StrategyTypes/FlexibleStrategy/StickinessSelect/StickinessSelect';
import { useDefaultProjectStickiness } from 'hooks/useDefaultProjectStickiness';
import { useDefaultProjectSettings } from 'hooks/useDefaultProjectSettings';
const StyledFormSubtitle = styled('div')(({ theme }) => ({
display: 'flex',
@ -145,7 +145,7 @@ export const EnvironmentVariantsModal = ({
const { uiConfig } = useUiConfig();
const { context } = useUnleashContext();
const { defaultStickiness } = useDefaultProjectStickiness(projectId);
const { defaultStickiness } = useDefaultProjectSettings(projectId);
const { isChangeRequestConfigured } = useChangeRequestsEnabled(projectId);
const { data } = usePendingChangeRequests(projectId);

View File

@ -12,9 +12,9 @@ import {
parseParameterString,
} from 'utils/parseParameter';
import { StickinessSelect } from './StickinessSelect/StickinessSelect';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { useOptionalPathParam } from 'hooks/useOptionalPathParam';
import { useDefaultProjectStickiness } from '../../../../hooks/useDefaultProjectStickiness';
import { useDefaultProjectSettings } from 'hooks/useDefaultProjectSettings';
import Loader from '../../../common/Loader/Loader';
interface IFlexibleStrategyProps {
parameters: IFeatureStrategyParameters;
@ -29,8 +29,7 @@ const FlexibleStrategy = ({
editable = true,
}: IFlexibleStrategyProps) => {
const projectId = useOptionalPathParam('projectId');
const { defaultStickiness } = useDefaultProjectStickiness(projectId);
const { defaultStickiness, loading } = useDefaultProjectSettings(projectId);
const onUpdate = (field: string) => (newValue: string) => {
updateParameter(field, newValue);
};
@ -58,6 +57,10 @@ const FlexibleStrategy = ({
onUpdate('stickiness')(stickiness);
}
if (loading) {
return <Loader />;
}
return (
<div>
<RolloutSlider

View File

@ -10,7 +10,6 @@ import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import useToast from 'hooks/useToast';
import { formatUnknownError } from 'utils/formatUnknownError';
import { GO_BACK } from 'constants/navigate';
import { useDefaultProjectStickiness } from 'hooks/useDefaultProjectStickiness';
const CreateProject = () => {
const { setToastData, setToastApiError } = useToast();
@ -33,10 +32,8 @@ const CreateProject = () => {
errors,
} = useProjectForm();
const { createProject, loading } = useProjectApi();
const { setDefaultProjectStickiness } =
useDefaultProjectStickiness(projectId);
const { createProject, setDefaultProjectStickiness, loading } =
useProjectApi();
const handleSubmit = async (e: Event) => {
e.preventDefault();
@ -48,7 +45,10 @@ const CreateProject = () => {
const payload = getProjectPayload();
try {
await createProject(payload);
setDefaultProjectStickiness(payload.projectStickiness);
setDefaultProjectStickiness(
projectId,
payload.projectStickiness
);
refetchUser();
navigate(`/projects/${projectId}`);
setToastData({

View File

@ -14,7 +14,7 @@ import { useContext } from 'react';
import AccessContext from 'contexts/AccessContext';
import { Alert } from '@mui/material';
import { GO_BACK } from 'constants/navigate';
import { useDefaultProjectStickiness } from 'hooks/useDefaultProjectStickiness';
import { useDefaultProjectSettings } from 'hooks/useDefaultProjectSettings';
const EditProject = () => {
const { uiConfig } = useUiConfig();
@ -22,8 +22,8 @@ const EditProject = () => {
const { hasAccess } = useContext(AccessContext);
const id = useRequiredPathParam('projectId');
const { project } = useProject(id);
const { defaultStickiness, setDefaultProjectStickiness } =
useDefaultProjectStickiness(id);
const { setDefaultProjectStickiness } = useProjectApi();
const { defaultStickiness } = useDefaultProjectSettings(id);
const navigate = useNavigate();
const {
@ -68,7 +68,10 @@ const EditProject = () => {
if (validName) {
try {
await editProject(id, payload);
setDefaultProjectStickiness(payload.projectStickiness);
setDefaultProjectStickiness(
projectId,
payload.projectStickiness
);
refetch();
navigate(`/projects/${id}`);
setToastData({

View File

@ -1,7 +1,7 @@
import { useEffect, useState } from 'react';
import useProjectApi from 'hooks/api/actions/useProjectApi/useProjectApi';
import { formatUnknownError } from 'utils/formatUnknownError';
import { useDefaultProjectStickiness } from 'hooks/useDefaultProjectStickiness';
import { useDefaultProjectSettings } from 'hooks/useDefaultProjectSettings';
const useProjectForm = (
initialProjectId = '',
@ -10,7 +10,7 @@ const useProjectForm = (
initialProjectStickiness = 'default'
) => {
const [projectId, setProjectId] = useState(initialProjectId);
const { defaultStickiness } = useDefaultProjectStickiness(projectId);
const { defaultStickiness } = useDefaultProjectSettings(projectId);
const [projectName, setProjectName] = useState(initialProjectName);
const [projectDesc, setProjectDesc] = useState(initialProjectDesc);

View File

@ -202,6 +202,19 @@ const useProjectApi = () => {
return makeRequest(req.caller, req.id);
};
const setDefaultProjectStickiness = (
projectId: string,
stickiness: string
) => {
const path = `api/admin/projects/${projectId}/stickiness`;
const req = createRequest(path, {
method: 'POST',
body: JSON.stringify({ stickiness }),
});
return makeRequest(req.caller, req.id);
};
return {
createProject,
validateId,
@ -217,6 +230,7 @@ const useProjectApi = () => {
errors,
loading,
searchProjectUser,
setDefaultProjectStickiness,
};
};

View File

@ -0,0 +1,62 @@
import useUiConfig from './api/getters/useUiConfig/useUiConfig';
import useSWR, { SWRConfiguration } from 'swr';
import { useCallback } from 'react';
import handleErrorResponses from './api/getters/httpErrorResponseHandler';
export interface IStickinessResponse {
status: number;
body?: {
defaultStickiness: string;
mode?: string;
};
}
const DEFAULT_STICKINESS = 'default';
export const useDefaultProjectSettings = (
projectId?: string,
options?: SWRConfiguration
) => {
const { uiConfig } = useUiConfig();
const PATH = `/api/admin/projects/${projectId}/settings`;
const { projectScopedStickiness } = uiConfig.flags;
const { data, error, mutate } = useSWR<IStickinessResponse>(
['useDefaultProjectSettings', PATH],
() => fetcher(PATH),
options
);
const defaultStickiness =
Boolean(projectScopedStickiness) && data?.body != null && projectId
? data.body.defaultStickiness
: DEFAULT_STICKINESS;
const refetch = useCallback(() => {
mutate().catch(console.warn);
}, [mutate]);
return {
defaultStickiness,
refetch,
loading: !error && !data,
status: data?.status,
error,
};
};
export const fetcher = async (path: string): Promise<IStickinessResponse> => {
const res = await fetch(path);
if (res.status === 404) {
return { status: 404 };
}
if (!res.ok) {
await handleErrorResponses('Project stickiness data')(res);
}
return {
status: res.status,
body: await res.json(),
};
};

View File

@ -1,35 +0,0 @@
import useUiConfig from './api/getters/useUiConfig/useUiConfig';
import { usePlausibleTracker } from './usePlausibleTracker';
const DEFAULT_STICKINESS = 'default';
export const useDefaultProjectStickiness = (projectId?: string) => {
const { trackEvent } = usePlausibleTracker();
const { uiConfig } = useUiConfig();
const key = `defaultStickiness.${projectId}`;
const { projectScopedStickiness } = uiConfig.flags;
const projectStickiness = localStorage.getItem(key);
const defaultStickiness =
Boolean(projectScopedStickiness) &&
projectStickiness != null &&
projectId
? projectStickiness
: DEFAULT_STICKINESS;
const setDefaultProjectStickiness = (stickiness: string) => {
if (
Boolean(projectScopedStickiness) &&
projectId &&
stickiness !== ''
) {
localStorage.setItem(key, stickiness);
trackEvent('project_stickiness_set');
}
};
return {
defaultStickiness,
setDefaultProjectStickiness,
};
};

View File

@ -133,7 +133,7 @@ import {
importTogglesSchema,
importTogglesValidateSchema,
importTogglesValidateItemSchema,
stickinessSchema,
projectSettingsSchema,
} from './spec';
import { IServerOption } from '../types';
import { mapValues, omitKeys } from '../util';
@ -257,7 +257,7 @@ export const schemas = {
stateSchema,
strategiesSchema,
strategySchema,
stickinessSchema,
projectSettingsSchema,
tagsBulkAddSchema,
tagSchema,
tagsSchema,

View File

@ -129,7 +129,7 @@ export * from './project-overview-schema';
export * from './import-toggles-validate-item-schema';
export * from './import-toggles-validate-schema';
export * from './import-toggles-schema';
export * from './stickiness-schema';
export * from './project-settings-schema';
export * from './tags-bulk-add-schema';
export * from './upsert-segment-schema';
export * from './batch-features-schema';

View File

@ -0,0 +1,23 @@
import { FromSchema } from 'json-schema-to-ts';
export const projectSettingsSchema = {
$id: '#/components/schemas/projectSettingsSchema',
type: 'object',
additionalProperties: false,
required: ['defaultStickiness'],
properties: {
defaultStickiness: {
type: 'string',
example: 'userId',
nullable: true,
},
mode: {
type: 'string',
enum: ['open', 'protected', 'private'],
nullable: true,
},
},
components: {},
} as const;
export type ProjectSettingsSchema = FromSchema<typeof projectSettingsSchema>;

View File

@ -1,17 +0,0 @@
import { FromSchema } from 'json-schema-to-ts';
export const stickinessSchema = {
$id: '#/components/schemas/stickinessSchema',
type: 'object',
additionalProperties: false,
required: ['stickiness'],
properties: {
stickiness: {
type: 'string',
example: 'userId',
},
},
components: {},
} as const;
export type StickinessSchema = FromSchema<typeof stickinessSchema>;

View File

@ -1,31 +1,32 @@
import { Response } from 'express';
import Controller from '../../controller';
import { IUnleashConfig } from '../../../types/option';
import { IUnleashServices } from '../../../types/services';
import {
IArchivedQuery,
IProjectParam,
IUnleashConfig,
IUnleashServices,
NONE,
serializeDates,
UPDATE_PROJECT,
} from '../../../types';
import ProjectFeaturesController from './project-features';
import EnvironmentsController from './environments';
import ProjectHealthReport from './health-report';
import ProjectService from '../../../services/project-service';
import VariantsController from './variants';
import { NONE, UPDATE_PROJECT } from '../../../types/permissions';
import {
projectsSchema,
ProjectsSchema,
} from '../../../openapi/spec/projects-schema';
import { OpenApiService } from '../../../services/openapi-service';
import { serializeDates } from '../../../types/serialize-dates';
import { createResponseSchema } from '../../../openapi/util/create-response-schema';
import { IAuthRequest } from '../../unleash-types';
import {
createResponseSchema,
emptyResponse,
ProjectOverviewSchema,
projectOverviewSchema,
stickinessSchema,
StickinessSchema,
} from '../../../../lib/openapi';
import { IArchivedQuery, IProjectParam } from '../../../types/model';
ProjectSettingsSchema,
projectSettingsSchema,
projectsSchema,
ProjectsSchema,
} from '../../../openapi';
import { OpenApiService, SettingService } from '../../../services';
import { IAuthRequest } from '../../unleash-types';
import { ProjectApiTokenController } from './api-token';
import { SettingService } from '../../../services';
import ProjectArchiveController from './project-archive';
const STICKINESS_KEY = 'stickiness';
@ -78,15 +79,15 @@ export default class ProjectApi extends Controller {
this.route({
method: 'get',
path: '/:projectId/stickiness',
handler: this.getProjectDefaultStickiness,
path: '/:projectId/settings',
handler: this.getProjectSettings,
permission: NONE,
middleware: [
services.openApiService.validPath({
tags: ['Projects'],
operationId: 'getProjectDefaultStickiness',
operationId: 'getProjectSettings',
responses: {
200: createResponseSchema('stickinessSchema'),
200: createResponseSchema('projectSettingsSchema'),
404: emptyResponse,
},
}),
@ -95,15 +96,15 @@ export default class ProjectApi extends Controller {
this.route({
method: 'post',
path: '/:projectId/stickiness',
handler: this.setProjectDefaultStickiness,
path: '/:projectId/settings',
handler: this.setProjectSettings,
permission: UPDATE_PROJECT,
middleware: [
services.openApiService.validPath({
tags: ['Projects'],
operationId: 'setProjectDefaultStickiness',
operationId: 'setProjectSettings',
responses: {
200: createResponseSchema('stickinessSchema'),
200: createResponseSchema('projectSettingsSchema'),
404: emptyResponse,
},
}),
@ -158,9 +159,9 @@ export default class ProjectApi extends Controller {
);
}
async getProjectDefaultStickiness(
async getProjectSettings(
req: IAuthRequest<IProjectParam, unknown, unknown, unknown>,
res: Response<StickinessSchema>,
res: Response<ProjectSettingsSchema>,
): Promise<void> {
if (!this.config.flagResolver.isEnabled('projectScopedStickiness')) {
res.status(404);
@ -176,33 +177,33 @@ export default class ProjectApi extends Controller {
this.openApiService.respondWithValidation(
200,
res,
stickinessSchema.$id,
{ stickiness: stickinessSettings[projectId] },
projectSettingsSchema.$id,
{ defaultStickiness: stickinessSettings[projectId] },
);
}
async setProjectDefaultStickiness(
async setProjectSettings(
req: IAuthRequest<
IProjectParam,
StickinessSchema,
StickinessSchema,
ProjectSettingsSchema,
ProjectSettingsSchema,
unknown
>,
res: Response<StickinessSchema>,
res: Response<ProjectSettingsSchema>,
): Promise<void> {
if (!this.config.flagResolver.isEnabled('projectScopedStickiness')) {
res.status(404);
return Promise.resolve();
}
const { projectId } = req.params;
const { stickiness } = req.body;
const { defaultStickiness } = req.body;
const stickinessSettings = await this.settingService.get<{}>(
STICKINESS_KEY,
{
[projectId]: DEFAULT_STICKINESS,
},
);
stickinessSettings[projectId] = stickiness;
stickinessSettings[projectId] = defaultStickiness;
await this.settingService.insert(
STICKINESS_KEY,
stickinessSettings,
@ -212,8 +213,8 @@ export default class ProjectApi extends Controller {
this.openApiService.respondWithValidation(
200,
res,
stickinessSchema.$id,
{ stickiness: stickiness },
projectSettingsSchema.$id,
{ defaultStickiness },
);
}
}

View File

@ -46,15 +46,15 @@ test('Should store and retrieve default project stickiness', async () => {
},
},
});
const reqBody = { stickiness: 'userId' };
const reqBody = { defaultStickiness: 'userId' };
await appWithDefaultStickiness.request
.post('/api/admin/projects/default/stickiness')
.post('/api/admin/projects/default/settings')
.send(reqBody)
.expect(200);
const { body } = await appWithDefaultStickiness.request
.get('/api/admin/projects/default/stickiness')
.get('/api/admin/projects/default/settings')
.expect(200)
.expect('Content-Type', /json/);

View File

@ -2863,6 +2863,29 @@ exports[`should serve the OpenAPI spec 1`] = `
],
"type": "object",
},
"projectSettingsSchema": {
"additionalProperties": false,
"properties": {
"defaultStickiness": {
"example": "userId",
"nullable": true,
"type": "string",
},
"mode": {
"enum": [
"open",
"protected",
"private",
],
"nullable": true,
"type": "string",
},
},
"required": [
"defaultStickiness",
],
"type": "object",
},
"projectStatsSchema": {
"additionalProperties": false,
"description": "Statistics for a project, including the average time to production, number of features created, the project activity and more.
@ -3546,19 +3569,6 @@ Stats are divided into current and previous **windows**.
],
"type": "object",
},
"stickinessSchema": {
"additionalProperties": false,
"properties": {
"stickiness": {
"example": "userId",
"type": "string",
},
},
"required": [
"stickiness",
],
"type": "object",
},
"strategiesSchema": {
"additionalProperties": false,
"properties": {
@ -7484,6 +7494,70 @@ If the provided project does not exist, the list of events will be empty.",
],
},
},
"/api/admin/projects/{projectId}/settings": {
"get": {
"operationId": "getProjectSettings",
"parameters": [
{
"in": "path",
"name": "projectId",
"required": true,
"schema": {
"type": "string",
},
},
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/projectSettingsSchema",
},
},
},
"description": "projectSettingsSchema",
},
"404": {
"description": "This response has no body.",
},
},
"tags": [
"Projects",
],
},
"post": {
"operationId": "setProjectSettings",
"parameters": [
{
"in": "path",
"name": "projectId",
"required": true,
"schema": {
"type": "string",
},
},
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/projectSettingsSchema",
},
},
},
"description": "projectSettingsSchema",
},
"404": {
"description": "This response has no body.",
},
},
"tags": [
"Projects",
],
},
},
"/api/admin/projects/{projectId}/stale": {
"post": {
"description": "This endpoint stales the specified features.",
@ -7520,70 +7594,6 @@ If the provided project does not exist, the list of events will be empty.",
],
},
},
"/api/admin/projects/{projectId}/stickiness": {
"get": {
"operationId": "getProjectDefaultStickiness",
"parameters": [
{
"in": "path",
"name": "projectId",
"required": true,
"schema": {
"type": "string",
},
},
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/stickinessSchema",
},
},
},
"description": "stickinessSchema",
},
"404": {
"description": "This response has no body.",
},
},
"tags": [
"Projects",
],
},
"post": {
"operationId": "setProjectDefaultStickiness",
"parameters": [
{
"in": "path",
"name": "projectId",
"required": true,
"schema": {
"type": "string",
},
},
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/stickinessSchema",
},
},
},
"description": "stickinessSchema",
},
"404": {
"description": "This response has no body.",
},
},
"tags": [
"Projects",
],
},
},
"/api/admin/splash/{id}": {
"post": {
"operationId": "updateSplashSettings",