Update UI to take environment variables from user #6
@ -150,6 +150,19 @@ export class Database {
|
|||||||
return project;
|
return project;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async allProjectsWithoutDeployments(): Promise<Project[]> {
|
||||||
|
const projectRepository = this.dataSource.getRepository(Project);
|
||||||
|
|
||||||
|
const projects = await projectRepository
|
||||||
|
.createQueryBuilder('project')
|
||||||
|
.leftJoinAndSelect('project.deployments', 'deployment', 'deployment.deletedAt IS NULL') // Join only non-soft-deleted deployments
|
||||||
|
.where('deployment.id IS NULL') // Get projects where no deployments are present
|
||||||
|
.andWhere('project.auctionId IS NOT NULL') // Ensure auctionId is not null
|
||||||
|
.getMany();
|
||||||
|
|
||||||
|
return projects;
|
||||||
|
}
|
||||||
|
|
||||||
async getProjectsInOrganization(
|
async getProjectsInOrganization(
|
||||||
userId: string,
|
userId: string,
|
||||||
organizationSlug: string
|
organizationSlug: string
|
||||||
|
@ -416,14 +416,8 @@ export class Registry {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async getCompletedAuctionIds(auctionIds: (string | null | undefined)[]): Promise<string[] | null> {
|
async getCompletedAuctionIds(auctionIds: string[]): Promise<string[]> {
|
||||||
const validAuctionIds = auctionIds.filter((id): id is string => id !== null && id !== undefined);
|
const auctions = await this.registry.getAuctionsByIds(auctionIds);
|
||||||
|
|
||||||
if (!validAuctionIds.length) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const auctions = await this.registry.getAuctionsByIds(validAuctionIds);
|
|
||||||
|
|
||||||
const completedAuctions = auctions
|
const completedAuctions = auctions
|
||||||
.filter((auction: { id: string, status: string }) => auction.status === 'completed')
|
.filter((auction: { id: string, status: string }) => auction.status === 'completed')
|
||||||
|
@ -6,7 +6,7 @@ import { Permission } from './entity/ProjectMember';
|
|||||||
import { Domain } from './entity/Domain';
|
import { Domain } from './entity/Domain';
|
||||||
import { Project } from './entity/Project';
|
import { Project } from './entity/Project';
|
||||||
import { EnvironmentVariable } from './entity/EnvironmentVariable';
|
import { EnvironmentVariable } from './entity/EnvironmentVariable';
|
||||||
import { AddProjectFromTemplateInput, AuctionParams } from './types';
|
import { AddProjectFromTemplateInput, AuctionParams, EnvironmentVariables } from './types';
|
||||||
|
|
||||||
const log = debug('snowball:resolver');
|
const log = debug('snowball:resolver');
|
||||||
|
|
||||||
@ -211,8 +211,15 @@ export const createResolvers = async (service: Service): Promise<any> => {
|
|||||||
organizationSlug,
|
organizationSlug,
|
||||||
data,
|
data,
|
||||||
lrn,
|
lrn,
|
||||||
auctionParams
|
auctionParams,
|
||||||
}: { organizationSlug: string; data: AddProjectFromTemplateInput; lrn: string; auctionParams: AuctionParams },
|
environmentVariables
|
||||||
|
}: {
|
||||||
|
organizationSlug: string;
|
||||||
|
data: AddProjectFromTemplateInput;
|
||||||
|
lrn: string;
|
||||||
|
auctionParams: AuctionParams,
|
||||||
|
environmentVariables: EnvironmentVariables[];
|
||||||
|
},
|
||||||
context: any,
|
context: any,
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
@ -221,7 +228,8 @@ export const createResolvers = async (service: Service): Promise<any> => {
|
|||||||
organizationSlug,
|
organizationSlug,
|
||||||
data,
|
data,
|
||||||
lrn,
|
lrn,
|
||||||
auctionParams
|
auctionParams,
|
||||||
|
environmentVariables
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log(err);
|
log(err);
|
||||||
@ -235,12 +243,26 @@ export const createResolvers = async (service: Service): Promise<any> => {
|
|||||||
organizationSlug,
|
organizationSlug,
|
||||||
data,
|
data,
|
||||||
lrn,
|
lrn,
|
||||||
auctionParams
|
auctionParams,
|
||||||
}: { organizationSlug: string; data: DeepPartial<Project>; lrn: string; auctionParams: AuctionParams },
|
environmentVariables
|
||||||
|
}: {
|
||||||
|
organizationSlug: string;
|
||||||
|
data: DeepPartial<Project>;
|
||||||
|
lrn: string;
|
||||||
|
auctionParams: AuctionParams,
|
||||||
|
environmentVariables: EnvironmentVariables[];
|
||||||
|
},
|
||||||
context: any,
|
context: any,
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
return await service.addProject(context.user, organizationSlug, data, lrn, auctionParams);
|
return await service.addProject(
|
||||||
|
context.user,
|
||||||
|
organizationSlug,
|
||||||
|
data,
|
||||||
|
lrn,
|
||||||
|
auctionParams,
|
||||||
|
environmentVariables
|
||||||
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log(err);
|
log(err);
|
||||||
throw err;
|
throw err;
|
||||||
|
@ -271,12 +271,14 @@ type Mutation {
|
|||||||
data: AddProjectFromTemplateInput
|
data: AddProjectFromTemplateInput
|
||||||
lrn: String
|
lrn: String
|
||||||
auctionParams: AuctionParams
|
auctionParams: AuctionParams
|
||||||
|
environmentVariables: [AddEnvironmentVariableInput!]
|
||||||
): Project!
|
): Project!
|
||||||
addProject(
|
addProject(
|
||||||
organizationSlug: String!
|
organizationSlug: String!
|
||||||
data: AddProjectInput!
|
data: AddProjectInput!
|
||||||
lrn: String
|
lrn: String
|
||||||
auctionParams: AuctionParams
|
auctionParams: AuctionParams
|
||||||
|
environmentVariables: [AddEnvironmentVariableInput!]
|
||||||
): Project!
|
): Project!
|
||||||
updateProject(projectId: String!, data: UpdateProjectInput): Boolean!
|
updateProject(projectId: String!, data: UpdateProjectInput): Boolean!
|
||||||
redeployToProd(deploymentId: String!): Boolean!
|
redeployToProd(deploymentId: String!): Boolean!
|
||||||
|
@ -20,6 +20,7 @@ import {
|
|||||||
AppDeploymentRecord,
|
AppDeploymentRecord,
|
||||||
AppDeploymentRemovalRecord,
|
AppDeploymentRemovalRecord,
|
||||||
AuctionParams,
|
AuctionParams,
|
||||||
|
EnvironmentVariables,
|
||||||
GitPushEventPayload,
|
GitPushEventPayload,
|
||||||
} from './types';
|
} from './types';
|
||||||
import { Role } from './entity/UserOrganization';
|
import { Role } from './entity/UserOrganization';
|
||||||
@ -167,7 +168,7 @@ export class Service {
|
|||||||
async updateDeploymentsWithRecordData(
|
async updateDeploymentsWithRecordData(
|
||||||
records: AppDeploymentRecord[],
|
records: AppDeploymentRecord[],
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// get and update deployments to be updated using request id
|
// Fetch the deployments to be updated using deployment requestId
|
||||||
const deployments = await this.db.getDeployments({
|
const deployments = await this.db.getDeployments({
|
||||||
where: records.map((record) => ({
|
where: records.map((record) => ({
|
||||||
applicationDeploymentRequestId: record.attributes.request,
|
applicationDeploymentRequestId: record.attributes.request,
|
||||||
@ -220,10 +221,10 @@ export class Service {
|
|||||||
|
|
||||||
await Promise.all(deploymentUpdatePromises);
|
await Promise.all(deploymentUpdatePromises);
|
||||||
|
|
||||||
// if iscurrent is true for this deployment then update the old ones
|
// Get deployments that are in production environment
|
||||||
const prodDeployments = Object.values(recordToDeploymentsMap).filter(deployment => deployment.isCurrent);
|
const prodDeployments = Object.values(recordToDeploymentsMap).filter(deployment => deployment.isCurrent);
|
||||||
|
|
||||||
// Get deployment IDs of deployments that are in production environment
|
// Set the isCurrent state to false for the old deployments
|
||||||
for (const deployment of prodDeployments) {
|
for (const deployment of prodDeployments) {
|
||||||
const projectDeployments = await this.db.getDeploymentsByProjectId(deployment.projectId);
|
const projectDeployments = await this.db.getDeploymentsByProjectId(deployment.projectId);
|
||||||
const oldDeployments = projectDeployments
|
const oldDeployments = projectDeployments
|
||||||
@ -236,15 +237,6 @@ export class Service {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get old deployments for ApplicationDeploymentRecords
|
|
||||||
// flter out deps with is current false
|
|
||||||
|
|
||||||
// loop over these deps
|
|
||||||
// get the project
|
|
||||||
// get all the deployemnts in that proj with the same deployer lrn (query filter not above updated dep)
|
|
||||||
// set is current to false
|
|
||||||
|
|
||||||
|
|
||||||
await Promise.all(deploymentUpdatePromises);
|
await Promise.all(deploymentUpdatePromises);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -298,25 +290,12 @@ export class Service {
|
|||||||
* Calls the createDeploymentFromAuction method for deployments with completed auctions
|
* Calls the createDeploymentFromAuction method for deployments with completed auctions
|
||||||
*/
|
*/
|
||||||
async checkAuctionStatus(): Promise<void> {
|
async checkAuctionStatus(): Promise<void> {
|
||||||
const allProjects = await this.db.getProjects({
|
const projects = await this.db.allProjectsWithoutDeployments();
|
||||||
where: {
|
|
||||||
auctionId: Not(IsNull()),
|
|
||||||
},
|
|
||||||
relations: ['deployments'],
|
|
||||||
withDeleted: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Should only check on the first deployment
|
const validAuctionIds = projects.map((project) => project.auctionId!)
|
||||||
const projects = allProjects.filter(project => {
|
.filter((id): id is string => Boolean(id));
|
||||||
if (project.deletedAt !== null) return false;
|
const completedAuctionIds = await this.laconicRegistry.getCompletedAuctionIds(validAuctionIds);
|
||||||
|
|
||||||
return project.deployments.length === 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
const auctionIds = projects.map((project) => project.auctionId);
|
|
||||||
const completedAuctionIds = await this.laconicRegistry.getCompletedAuctionIds(auctionIds);
|
|
||||||
|
|
||||||
if (completedAuctionIds) {
|
|
||||||
const projectsToBedeployed = projects.filter((project) =>
|
const projectsToBedeployed = projects.filter((project) =>
|
||||||
completedAuctionIds.includes(project.auctionId!)
|
completedAuctionIds.includes(project.auctionId!)
|
||||||
);
|
);
|
||||||
@ -338,7 +317,6 @@ export class Service {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
this.auctionStatusCheckTimeout = setTimeout(() => {
|
this.auctionStatusCheckTimeout = setTimeout(() => {
|
||||||
this.checkAuctionStatus();
|
this.checkAuctionStatus();
|
||||||
@ -785,7 +763,8 @@ export class Service {
|
|||||||
organizationSlug: string,
|
organizationSlug: string,
|
||||||
data: AddProjectFromTemplateInput,
|
data: AddProjectFromTemplateInput,
|
||||||
lrn?: string,
|
lrn?: string,
|
||||||
auctionParams?: AuctionParams
|
auctionParams?: AuctionParams,
|
||||||
|
environmentVariables?: EnvironmentVariables[],
|
||||||
): Promise<Project | undefined> {
|
): Promise<Project | undefined> {
|
||||||
try {
|
try {
|
||||||
const octokit = await this.getOctokit(user.id);
|
const octokit = await this.getOctokit(user.id);
|
||||||
@ -816,7 +795,7 @@ export class Service {
|
|||||||
repository: gitRepo.data.full_name,
|
repository: gitRepo.data.full_name,
|
||||||
// TODO: Set selected template
|
// TODO: Set selected template
|
||||||
template: 'webapp',
|
template: 'webapp',
|
||||||
}, lrn, auctionParams);
|
}, lrn, auctionParams, environmentVariables);
|
||||||
|
|
||||||
if (!project || !project.id) {
|
if (!project || !project.id) {
|
||||||
throw new Error('Failed to create project from template');
|
throw new Error('Failed to create project from template');
|
||||||
@ -834,7 +813,8 @@ export class Service {
|
|||||||
organizationSlug: string,
|
organizationSlug: string,
|
||||||
data: DeepPartial<Project>,
|
data: DeepPartial<Project>,
|
||||||
lrn?: string,
|
lrn?: string,
|
||||||
auctionParams?: AuctionParams
|
auctionParams?: AuctionParams,
|
||||||
|
environmentVariables?: EnvironmentVariables[],
|
||||||
): Promise<Project | undefined> {
|
): Promise<Project | undefined> {
|
||||||
const organization = await this.db.getOrganization({
|
const organization = await this.db.getOrganization({
|
||||||
where: {
|
where: {
|
||||||
@ -846,7 +826,10 @@ export class Service {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const project = await this.db.addProject(user, organization.id, data);
|
const project = await this.db.addProject(user, organization.id, data);
|
||||||
log(`Project created ${project.id}`);
|
|
||||||
|
if (environmentVariables) {
|
||||||
|
await this.addEnvironmentVariables(project.id, environmentVariables);
|
||||||
|
}
|
||||||
|
|
||||||
const octokit = await this.getOctokit(user.id);
|
const octokit = await this.getOctokit(user.id);
|
||||||
const [owner, repo] = project.repository.split('/');
|
const [owner, repo] = project.repository.split('/');
|
||||||
|
@ -76,3 +76,9 @@ export interface AuctionParams {
|
|||||||
maxPrice: string,
|
maxPrice: string,
|
||||||
numProviders: number,
|
numProviders: number,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface EnvironmentVariables {
|
||||||
|
environments: string[],
|
||||||
|
key: string,
|
||||||
|
value: string,
|
||||||
|
}
|
||||||
|
@ -1,8 +1,9 @@
|
|||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
import { useForm, Controller, SubmitHandler } from 'react-hook-form';
|
import { useForm, Controller } from 'react-hook-form';
|
||||||
|
import { FormProvider, FieldValues } from 'react-hook-form';
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import { useMediaQuery } from 'usehooks-ts';
|
import { useMediaQuery } from 'usehooks-ts';
|
||||||
import { AuctionParams } from 'gql-client';
|
import { AddEnvironmentVariableInput, AuctionParams } from 'gql-client';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ArrowRightCircleFilledIcon,
|
ArrowRightCircleFilledIcon,
|
||||||
@ -14,15 +15,20 @@ import { Select, SelectOption } from 'components/shared/Select';
|
|||||||
import { Input } from 'components/shared/Input';
|
import { Input } from 'components/shared/Input';
|
||||||
import { useToast } from 'components/shared/Toast';
|
import { useToast } from 'components/shared/Toast';
|
||||||
import { useGQLClient } from '../../../context/GQLClientContext';
|
import { useGQLClient } from '../../../context/GQLClientContext';
|
||||||
|
import EnvironmentVariablesForm from 'pages/org-slug/projects/id/settings/EnvironmentVariablesForm';
|
||||||
|
import { EnvironmentVariablesFormValues } from 'types/types';
|
||||||
|
|
||||||
type ConfigureFormValues = {
|
type ConfigureDeploymentFormValues = {
|
||||||
option: string;
|
option: string;
|
||||||
lrn?: string;
|
lrn?: string;
|
||||||
numProviders?: number;
|
numProviders?: number;
|
||||||
maxPrice?: string;
|
maxPrice?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ConfigureFormValues = ConfigureDeploymentFormValues & EnvironmentVariablesFormValues;
|
||||||
|
|
||||||
const Configure = () => {
|
const Configure = () => {
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const templateId = searchParams.get('templateId');
|
const templateId = searchParams.get('templateId');
|
||||||
const queryParams = new URLSearchParams(location.search);
|
const queryParams = new URLSearchParams(location.search);
|
||||||
@ -40,19 +46,18 @@ const Configure = () => {
|
|||||||
const { toast, dismiss } = useToast();
|
const { toast, dismiss } = useToast();
|
||||||
const client = useGQLClient();
|
const client = useGQLClient();
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const methods = useForm<ConfigureFormValues>({
|
||||||
const { handleSubmit, control, watch } = useForm<ConfigureFormValues>({
|
|
||||||
defaultValues: { option: 'LRN' },
|
defaultValues: { option: 'LRN' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const selectedOption = watch('option');
|
const selectedOption = methods.watch('option');
|
||||||
|
|
||||||
const isTabletView = useMediaQuery('(min-width: 720px)'); // md:
|
const isTabletView = useMediaQuery('(min-width: 720px)'); // md:
|
||||||
const buttonSize = isTabletView ? { size: 'lg' as const } : {};
|
const buttonSize = isTabletView ? { size: 'lg' as const } : {};
|
||||||
|
|
||||||
const onSubmit: SubmitHandler<ConfigureFormValues> = useCallback(
|
const createProject = async (data: FieldValues, envVariables: AddEnvironmentVariableInput[]): Promise<string> => {
|
||||||
async (data) => {
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
let projectId: string | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let lrn: string | undefined;
|
let lrn: string | undefined;
|
||||||
@ -68,7 +73,6 @@ const Configure = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (templateId) {
|
if (templateId) {
|
||||||
// Template-based project creation
|
|
||||||
const projectData: any = {
|
const projectData: any = {
|
||||||
templateOwner,
|
templateOwner,
|
||||||
templateRepo,
|
templateRepo,
|
||||||
@ -81,16 +85,11 @@ const Configure = () => {
|
|||||||
orgSlug!,
|
orgSlug!,
|
||||||
projectData,
|
projectData,
|
||||||
lrn,
|
lrn,
|
||||||
auctionParams
|
auctionParams,
|
||||||
|
envVariables
|
||||||
);
|
);
|
||||||
|
|
||||||
data.option === 'Auction'
|
projectId = addProjectFromTemplate.id;
|
||||||
? navigate(
|
|
||||||
`/${orgSlug}/projects/create/success/${addProjectFromTemplate.id}?isAuction=true`,
|
|
||||||
)
|
|
||||||
: navigate(
|
|
||||||
`/${orgSlug}/projects/create/template/deploy?projectId=${addProjectFromTemplate.id}&templateId=${templateId}`
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
const { addProject } = await client.addProject(
|
const { addProject } = await client.addProject(
|
||||||
orgSlug!,
|
orgSlug!,
|
||||||
@ -101,16 +100,11 @@ const Configure = () => {
|
|||||||
template: 'webapp',
|
template: 'webapp',
|
||||||
},
|
},
|
||||||
lrn,
|
lrn,
|
||||||
auctionParams
|
auctionParams,
|
||||||
|
envVariables
|
||||||
);
|
);
|
||||||
|
|
||||||
data.option === 'Auction'
|
projectId = addProject.id;
|
||||||
? navigate(
|
|
||||||
`/${orgSlug}/projects/create/success/${addProject.id}?isAuction=true`
|
|
||||||
)
|
|
||||||
: navigate(
|
|
||||||
`/${orgSlug}/projects/create/deploy?projectId=${addProject.id}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating project:', error);
|
console.error('Error creating project:', error);
|
||||||
@ -123,13 +117,76 @@ const Configure = () => {
|
|||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (projectId) {
|
||||||
|
return projectId;
|
||||||
|
} else {
|
||||||
|
throw new Error('Project creation failed');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFormSubmit = useCallback(
|
||||||
|
async (createFormData: FieldValues) => {
|
||||||
|
const environmentVariables = createFormData.variables.map((variable: any) => {
|
||||||
|
return {
|
||||||
|
key: variable.key,
|
||||||
|
value: variable.value,
|
||||||
|
environments: Object.entries(createFormData.environment)
|
||||||
|
.filter(([, value]) => value === true)
|
||||||
|
.map(([key]) => key.charAt(0).toUpperCase() + key.slice(1)),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const projectId = await createProject(createFormData, environmentVariables);
|
||||||
|
|
||||||
|
const { environmentVariables: isEnvironmentVariablesAdded } =
|
||||||
|
await client.getEnvironmentVariables(projectId);
|
||||||
|
|
||||||
|
if (isEnvironmentVariablesAdded.length > 0) {
|
||||||
|
toast({
|
||||||
|
id:
|
||||||
|
createFormData.variables.length > 1
|
||||||
|
? 'env_variable_added'
|
||||||
|
: 'env_variables_added',
|
||||||
|
title:
|
||||||
|
createFormData.variables.length > 1
|
||||||
|
? `${createFormData.variables.length} variables added`
|
||||||
|
: `Variable added`,
|
||||||
|
variant: 'success',
|
||||||
|
onDismiss: dismiss,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
id: 'env_variables_not_added',
|
||||||
|
title: 'Environment variables not added',
|
||||||
|
variant: 'error',
|
||||||
|
onDismiss: dismiss,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (templateId) {
|
||||||
|
createFormData.option === 'Auction'
|
||||||
|
? navigate(
|
||||||
|
`/${orgSlug}/projects/create/success/${projectId}?isAuction=true`,
|
||||||
|
)
|
||||||
|
: navigate(
|
||||||
|
`/${orgSlug}/projects/create/template/deploy?projectId=${projectId}&templateId=${templateId}`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
createFormData.option === 'Auction'
|
||||||
|
? navigate(
|
||||||
|
`/${orgSlug}/projects/create/success/${projectId}?isAuction=true`
|
||||||
|
)
|
||||||
|
: navigate(
|
||||||
|
`/${orgSlug}/projects/create/deploy?projectId=${projectId}`
|
||||||
|
);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[client, isPrivate, templateId, navigate, dismiss, toast]
|
[client, createProject, dismiss, toast]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-7">
|
<div className="space-y-7 px-4 py-6">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between mb-6">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Heading as="h4" className="md:text-lg font-medium">
|
<Heading as="h4" className="md:text-lg font-medium">
|
||||||
Configure deployment
|
Configure deployment
|
||||||
@ -142,12 +199,13 @@ const Configure = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit(onSubmit)}>
|
<div className="flex flex-col gap-6 lg:gap-8 w-full">
|
||||||
<div className="flex flex-col gap-4 lg:gap-7 w-full">
|
<FormProvider {...methods}>
|
||||||
<div className="flex flex-col justify-start gap-3">
|
<form onSubmit={methods.handleSubmit(handleFormSubmit)}>
|
||||||
|
<div className="flex flex-col justify-start gap-4 mb-6">
|
||||||
<Controller
|
<Controller
|
||||||
name="option"
|
name="option"
|
||||||
control={control}
|
control={methods.control}
|
||||||
render={({ field: { value, onChange } }) => (
|
render={({ field: { value, onChange } }) => (
|
||||||
<Select
|
<Select
|
||||||
label="Configuration Options"
|
label="Configuration Options"
|
||||||
@ -168,7 +226,7 @@ const Configure = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedOption === 'LRN' && (
|
{selectedOption === 'LRN' && (
|
||||||
<div className="flex flex-col justify-start gap-3">
|
<div className="flex flex-col justify-start gap-4 mb-6">
|
||||||
<Heading as="h5" className="text-sm font-sans text-elements-low-em">
|
<Heading as="h5" className="text-sm font-sans text-elements-low-em">
|
||||||
The app will be deployed by the configured deployer
|
The app will be deployed by the configured deployer
|
||||||
</Heading>
|
</Heading>
|
||||||
@ -177,7 +235,8 @@ const Configure = () => {
|
|||||||
</span>
|
</span>
|
||||||
<Controller
|
<Controller
|
||||||
name="lrn"
|
name="lrn"
|
||||||
control={control}
|
control={methods.control}
|
||||||
|
rules={{ required: true }}
|
||||||
render={({ field: { value, onChange } }) => (
|
render={({ field: { value, onChange } }) => (
|
||||||
<Input value={value} onChange={onChange} />
|
<Input value={value} onChange={onChange} />
|
||||||
)}
|
)}
|
||||||
@ -187,7 +246,7 @@ const Configure = () => {
|
|||||||
|
|
||||||
{selectedOption === 'Auction' && (
|
{selectedOption === 'Auction' && (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col justify-start gap-3">
|
<div className="flex flex-col justify-start gap-4 mb-6">
|
||||||
<Heading as="h5" className="text-sm font-sans text-elements-low-em">
|
<Heading as="h5" className="text-sm font-sans text-elements-low-em">
|
||||||
Set the number of deployers and maximum price for each deployment
|
Set the number of deployers and maximum price for each deployment
|
||||||
</Heading>
|
</Heading>
|
||||||
@ -196,19 +255,21 @@ const Configure = () => {
|
|||||||
</span>
|
</span>
|
||||||
<Controller
|
<Controller
|
||||||
name="numProviders"
|
name="numProviders"
|
||||||
control={control}
|
control={methods.control}
|
||||||
|
rules={{ required: true }}
|
||||||
render={({ field: { value, onChange } }) => (
|
render={({ field: { value, onChange } }) => (
|
||||||
<Input type="number" value={value} onChange={onChange} />
|
<Input type="number" value={value} onChange={onChange} />
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col justify-start gap-3">
|
<div className="flex flex-col justify-start gap-4 mb-6">
|
||||||
<span className="text-sm text-elements-high-em">
|
<span className="text-sm text-elements-high-em">
|
||||||
Maximum Price (alnt)
|
Maximum Price (alnt)
|
||||||
</span>
|
</span>
|
||||||
<Controller
|
<Controller
|
||||||
name="maxPrice"
|
name="maxPrice"
|
||||||
control={control}
|
control={methods.control}
|
||||||
|
rules={{ required: true }}
|
||||||
render={({ field: { value, onChange } }) => (
|
render={({ field: { value, onChange } }) => (
|
||||||
<Input type="number" value={value} onChange={onChange} />
|
<Input type="number" value={value} onChange={onChange} />
|
||||||
)}
|
)}
|
||||||
@ -217,6 +278,13 @@ const Configure = () => {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Heading as="h4" className="md:text-lg font-medium mb-3">
|
||||||
|
Environment Variables
|
||||||
|
</Heading>
|
||||||
|
<div className="p-4 bg-slate-100 rounded-lg mb-6">
|
||||||
|
<EnvironmentVariablesForm />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Button
|
<Button
|
||||||
{...buttonSize}
|
{...buttonSize}
|
||||||
@ -233,8 +301,9 @@ const Configure = () => {
|
|||||||
{isLoading ? 'Deploying repo' : 'Deploy repo'}
|
{isLoading ? 'Deploying repo' : 'Deploy repo'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
|
</FormProvider>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -64,9 +64,9 @@ export const RepositoryList = () => {
|
|||||||
|
|
||||||
// Check if selected account is an organization
|
// Check if selected account is an organization
|
||||||
if (selectedAccount.value === gitUser.login) {
|
if (selectedAccount.value === gitUser.login) {
|
||||||
query = query + ` user:${selectedAccount}`;
|
query = query + ` user:${selectedAccount.value}`;
|
||||||
} else {
|
} else {
|
||||||
query = query + ` org:${selectedAccount}`;
|
query = query + ` org:${selectedAccount.value}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await octokit.rest.search.repos({
|
const result = await octokit.rest.search.repos({
|
||||||
|
@ -163,6 +163,7 @@ const CreateRepo = () => {
|
|||||||
<Controller
|
<Controller
|
||||||
name="repoName"
|
name="repoName"
|
||||||
control={control}
|
control={control}
|
||||||
|
rules={{ required: true }}
|
||||||
render={({ field: { value, onChange } }) => (
|
render={({ field: { value, onChange } }) => (
|
||||||
<Input value={value} onChange={onChange} />
|
<Input value={value} onChange={onChange} />
|
||||||
)}
|
)}
|
||||||
|
@ -1,22 +1,20 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { useFieldArray, useForm } from 'react-hook-form';
|
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
|
|
||||||
import { Collapse, Checkbox } from '@snowballtools/material-tailwind-react-fork';
|
import { Collapse } from '@snowballtools/material-tailwind-react-fork';
|
||||||
|
|
||||||
import AddEnvironmentVariableRow from 'components/projects/project/settings/AddEnvironmentVariableRow';
|
|
||||||
import DisplayEnvironmentVariables from 'components/projects/project/settings/DisplayEnvironmentVariables';
|
import DisplayEnvironmentVariables from 'components/projects/project/settings/DisplayEnvironmentVariables';
|
||||||
import { useGQLClient } from 'context/GQLClientContext';
|
import { useGQLClient } from 'context/GQLClientContext';
|
||||||
import { EnvironmentVariablesFormValues } from '../../../../../types';
|
import { EnvironmentVariablesFormValues } from '../../../../../types';
|
||||||
import HorizontalLine from 'components/HorizontalLine';
|
import HorizontalLine from 'components/HorizontalLine';
|
||||||
import { Heading } from 'components/shared/Heading';
|
import { Heading } from 'components/shared/Heading';
|
||||||
import { Button } from 'components/shared/Button';
|
|
||||||
// import { Checkbox } from 'components/shared/Checkbox';
|
|
||||||
import { PlusIcon } from 'components/shared/CustomIcon';
|
import { PlusIcon } from 'components/shared/CustomIcon';
|
||||||
import { InlineNotification } from 'components/shared/InlineNotification';
|
|
||||||
import { ProjectSettingContainer } from 'components/projects/project/settings/ProjectSettingContainer';
|
import { ProjectSettingContainer } from 'components/projects/project/settings/ProjectSettingContainer';
|
||||||
import { useToast } from 'components/shared/Toast';
|
import { useToast } from 'components/shared/Toast';
|
||||||
import { Environment, EnvironmentVariable } from 'gql-client';
|
import { Environment, EnvironmentVariable } from 'gql-client';
|
||||||
|
import EnvironmentVariablesForm from './EnvironmentVariablesForm';
|
||||||
|
import { FieldValues, FormProvider, useForm } from 'react-hook-form';
|
||||||
|
import { Button } from 'components/shared';
|
||||||
|
|
||||||
export const EnvironmentVariablesTabPanel = () => {
|
export const EnvironmentVariablesTabPanel = () => {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
@ -27,13 +25,9 @@ export const EnvironmentVariablesTabPanel = () => {
|
|||||||
EnvironmentVariable[]
|
EnvironmentVariable[]
|
||||||
>([]);
|
>([]);
|
||||||
|
|
||||||
const {
|
const [createNewVariable, setCreateNewVariable] = useState(false);
|
||||||
handleSubmit,
|
|
||||||
register,
|
const methods = useForm<EnvironmentVariablesFormValues>({
|
||||||
control,
|
|
||||||
reset,
|
|
||||||
formState: { isSubmitSuccessful, errors },
|
|
||||||
} = useForm<EnvironmentVariablesFormValues>({
|
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
variables: [{ key: '', value: '' }],
|
variables: [{ key: '', value: '' }],
|
||||||
environment: {
|
environment: {
|
||||||
@ -43,21 +37,6 @@ export const EnvironmentVariablesTabPanel = () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const [createNewVariable, setCreateNewVariable] = useState(false);
|
|
||||||
|
|
||||||
const { fields, append, remove } = useFieldArray({
|
|
||||||
name: 'variables',
|
|
||||||
control,
|
|
||||||
rules: {
|
|
||||||
required: 'Add at least 1 environment variables',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isSubmitSuccessful) {
|
|
||||||
reset();
|
|
||||||
}
|
|
||||||
}, [isSubmitSuccessful, reset, id]);
|
|
||||||
|
|
||||||
const getEnvironmentVariables = useCallback(
|
const getEnvironmentVariables = useCallback(
|
||||||
(environment: Environment) => {
|
(environment: Environment) => {
|
||||||
@ -68,21 +47,6 @@ export const EnvironmentVariablesTabPanel = () => {
|
|||||||
[environmentVariables, id],
|
[environmentVariables, id],
|
||||||
);
|
);
|
||||||
|
|
||||||
const isFieldEmpty = useMemo(() => {
|
|
||||||
if (errors.variables) {
|
|
||||||
return fields.some((_, index) => {
|
|
||||||
if (
|
|
||||||
errors.variables![index]?.value?.type === 'required' ||
|
|
||||||
errors.variables![index]?.key?.type === 'required'
|
|
||||||
) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}, [fields, errors.variables, id]);
|
|
||||||
|
|
||||||
const fetchEnvironmentVariables = useCallback(
|
const fetchEnvironmentVariables = useCallback(
|
||||||
async (id: string | undefined) => {
|
async (id: string | undefined) => {
|
||||||
if (id) {
|
if (id) {
|
||||||
@ -99,8 +63,8 @@ export const EnvironmentVariablesTabPanel = () => {
|
|||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
const createEnvironmentVariablesHandler = useCallback(
|
const createEnvironmentVariablesHandler = useCallback(
|
||||||
async (createFormData: EnvironmentVariablesFormValues) => {
|
async (createFormData: FieldValues) => {
|
||||||
const environmentVariables = createFormData.variables.map((variable) => {
|
const environmentVariables = createFormData.variables.map((variable: any) => {
|
||||||
return {
|
return {
|
||||||
key: variable.key,
|
key: variable.key,
|
||||||
value: variable.value,
|
value: variable.value,
|
||||||
@ -114,7 +78,7 @@ export const EnvironmentVariablesTabPanel = () => {
|
|||||||
await client.addEnvironmentVariables(id!, environmentVariables);
|
await client.addEnvironmentVariables(id!, environmentVariables);
|
||||||
|
|
||||||
if (isEnvironmentVariablesAdded) {
|
if (isEnvironmentVariablesAdded) {
|
||||||
reset();
|
methods.reset();
|
||||||
setCreateNewVariable((cur) => !cur);
|
setCreateNewVariable((cur) => !cur);
|
||||||
|
|
||||||
fetchEnvironmentVariables(id);
|
fetchEnvironmentVariables(id);
|
||||||
@ -159,59 +123,10 @@ export const EnvironmentVariablesTabPanel = () => {
|
|||||||
</div>
|
</div>
|
||||||
</Heading>
|
</Heading>
|
||||||
<Collapse open={createNewVariable}>
|
<Collapse open={createNewVariable}>
|
||||||
|
<FormProvider {...methods}>
|
||||||
|
<form onSubmit={methods.handleSubmit((data) => createEnvironmentVariablesHandler(data))}>
|
||||||
<div className="p-4 bg-slate-100">
|
<div className="p-4 bg-slate-100">
|
||||||
<form onSubmit={handleSubmit(createEnvironmentVariablesHandler)}>
|
<EnvironmentVariablesForm />
|
||||||
{fields.map((field, index) => {
|
|
||||||
return (
|
|
||||||
<AddEnvironmentVariableRow
|
|
||||||
key={field.id}
|
|
||||||
index={index}
|
|
||||||
register={register}
|
|
||||||
onDelete={() => remove(index)}
|
|
||||||
isDeleteDisabled={fields.length === 1}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
<div className="flex gap-1 p-2">
|
|
||||||
<Button
|
|
||||||
size="md"
|
|
||||||
onClick={() =>
|
|
||||||
append({
|
|
||||||
key: '',
|
|
||||||
value: '',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
+ Add variable
|
|
||||||
</Button>
|
|
||||||
{/* TODO: Implement import environment varible functionality */}
|
|
||||||
<Button size="md" disabled>
|
|
||||||
Import .env
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{isFieldEmpty && (
|
|
||||||
<InlineNotification
|
|
||||||
title="Please ensure no fields are empty before saving."
|
|
||||||
variant="danger"
|
|
||||||
size="md"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div className="flex gap-2 p-2">
|
|
||||||
<Checkbox
|
|
||||||
label="Production"
|
|
||||||
{...register(`environment.production`)}
|
|
||||||
color="blue"
|
|
||||||
/>
|
|
||||||
<Checkbox
|
|
||||||
label="Preview"
|
|
||||||
{...register(`environment.preview`)}
|
|
||||||
color="blue"
|
|
||||||
/>
|
|
||||||
<Checkbox
|
|
||||||
label="Development"
|
|
||||||
{...register(`environment.development`)}
|
|
||||||
color="blue"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="p-2">
|
<div className="p-2">
|
||||||
<Button size="md" type="submit">
|
<Button size="md" type="submit">
|
||||||
@ -219,7 +134,7 @@ export const EnvironmentVariablesTabPanel = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</FormProvider>
|
||||||
</Collapse>
|
</Collapse>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-2">
|
<div className="p-2">
|
||||||
|
@ -0,0 +1,76 @@
|
|||||||
|
import { useEffect, useMemo } from 'react';
|
||||||
|
import { useFieldArray, useFormContext } from 'react-hook-form';
|
||||||
|
|
||||||
|
// TODO: Use custom checkbox component
|
||||||
|
import { Checkbox } from '@snowballtools/material-tailwind-react-fork';
|
||||||
|
|
||||||
|
import { Button } from 'components/shared/Button';
|
||||||
|
import { InlineNotification } from 'components/shared/InlineNotification';
|
||||||
|
import AddEnvironmentVariableRow from 'components/projects/project/settings/AddEnvironmentVariableRow';
|
||||||
|
import { EnvironmentVariablesFormValues } from 'types/types';
|
||||||
|
|
||||||
|
const EnvironmentVariablesForm = () => {
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
control,
|
||||||
|
reset,
|
||||||
|
formState: { isSubmitSuccessful, errors },
|
||||||
|
} = useFormContext<EnvironmentVariablesFormValues>();
|
||||||
|
const { fields, append, remove } = useFieldArray({
|
||||||
|
name: 'variables',
|
||||||
|
control,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isSubmitSuccessful) {
|
||||||
|
reset();
|
||||||
|
}
|
||||||
|
}, [isSubmitSuccessful, reset]);
|
||||||
|
|
||||||
|
const isFieldEmpty = useMemo(() => {
|
||||||
|
if (errors.variables) {
|
||||||
|
return fields.some((_, index) => {
|
||||||
|
if (
|
||||||
|
errors.variables![index]?.value?.type === 'required' ||
|
||||||
|
errors.variables![index]?.key?.type === 'required'
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}, [fields, errors.variables]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{fields.map((field, index) => (
|
||||||
|
<AddEnvironmentVariableRow
|
||||||
|
key={field.id}
|
||||||
|
index={index}
|
||||||
|
register={register}
|
||||||
|
onDelete={() => remove(index)}
|
||||||
|
isDeleteDisabled={fields.length === 0}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<div className="flex gap-1 p-2">
|
||||||
|
<Button size="md" onClick={() => append({ key: '', value: '' })}>
|
||||||
|
+ Add variable
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{isFieldEmpty && (
|
||||||
|
<InlineNotification
|
||||||
|
title="Please ensure no fields are empty before saving."
|
||||||
|
variant="danger"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2 p-2">
|
||||||
|
<Checkbox label="Production" {...register('environment.production')} />
|
||||||
|
<Checkbox label="Preview" {...register('environment.preview')} />
|
||||||
|
<Checkbox label="Development" {...register('environment.development')} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EnvironmentVariablesForm;
|
@ -233,6 +233,7 @@ export class GQLClient {
|
|||||||
data: types.AddProjectFromTemplateInput,
|
data: types.AddProjectFromTemplateInput,
|
||||||
lrn?: string,
|
lrn?: string,
|
||||||
auctionParams?: types.AuctionParams,
|
auctionParams?: types.AuctionParams,
|
||||||
|
environmentVariables?: types.AddEnvironmentVariableInput[]
|
||||||
): Promise<types.AddProjectFromTemplateResponse> {
|
): Promise<types.AddProjectFromTemplateResponse> {
|
||||||
const result = await this.client.mutate({
|
const result = await this.client.mutate({
|
||||||
mutation: mutations.addProjectFromTemplate,
|
mutation: mutations.addProjectFromTemplate,
|
||||||
@ -240,7 +241,8 @@ export class GQLClient {
|
|||||||
organizationSlug,
|
organizationSlug,
|
||||||
data,
|
data,
|
||||||
lrn,
|
lrn,
|
||||||
auctionParams
|
auctionParams,
|
||||||
|
environmentVariables
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -252,6 +254,7 @@ export class GQLClient {
|
|||||||
data: types.AddProjectInput,
|
data: types.AddProjectInput,
|
||||||
lrn?: string,
|
lrn?: string,
|
||||||
auctionParams?: types.AuctionParams,
|
auctionParams?: types.AuctionParams,
|
||||||
|
environmentVariables?: types.AddEnvironmentVariableInput[]
|
||||||
): Promise<types.AddProjectResponse> {
|
): Promise<types.AddProjectResponse> {
|
||||||
const result = await this.client.mutate({
|
const result = await this.client.mutate({
|
||||||
mutation: mutations.addProject,
|
mutation: mutations.addProject,
|
||||||
@ -259,7 +262,8 @@ export class GQLClient {
|
|||||||
organizationSlug,
|
organizationSlug,
|
||||||
data,
|
data,
|
||||||
lrn,
|
lrn,
|
||||||
auctionParams
|
auctionParams,
|
||||||
|
environmentVariables
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -49,16 +49,16 @@ export const updateDeploymentToProd = gql`
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
export const addProjectFromTemplate = gql`
|
export const addProjectFromTemplate = gql`
|
||||||
mutation ($organizationSlug: String!, $data: AddProjectFromTemplateInput, $lrn: String, $auctionParams: AuctionParams) {
|
mutation ($organizationSlug: String!, $data: AddProjectFromTemplateInput, $lrn: String, $auctionParams: AuctionParams, $environmentVariables: [AddEnvironmentVariableInput!]) {
|
||||||
addProjectFromTemplate(organizationSlug: $organizationSlug, data: $data, lrn: $lrn, auctionParams: $auctionParams) {
|
addProjectFromTemplate(organizationSlug: $organizationSlug, data: $data, lrn: $lrn, auctionParams: $auctionParams, environmentVariables: $environmentVariables) {
|
||||||
id
|
id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const addProject = gql`
|
export const addProject = gql`
|
||||||
mutation ($organizationSlug: String!, $data: AddProjectInput!, $lrn: String, $auctionParams: AuctionParams) {
|
mutation ($organizationSlug: String!, $data: AddProjectInput!, $lrn: String, $auctionParams: AuctionParams, $environmentVariables: [AddEnvironmentVariableInput!]) {
|
||||||
addProject(organizationSlug: $organizationSlug, data: $data, lrn: $lrn, auctionParams: $auctionParams) {
|
addProject(organizationSlug: $organizationSlug, data: $data, lrn: $lrn, auctionParams: $auctionParams, environmentVariables: $environmentVariables) {
|
||||||
id
|
id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user