portainer/portainer

View on GitHub
app/react/kubernetes/applications/CreateView/application-services/KubeServicesForm.tsx

Summary

Maintainability
B
6 hrs
Test Coverage
import { useEffect, useMemo, useState } from 'react';
import { FormikErrors } from 'formik';

import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import {
  useIngressControllers,
  useIngresses,
} from '@/react/kubernetes/ingresses/queries';

import { FormSection } from '@@/form-components/FormSection';

import { ServiceFormValues, ServiceTypeOption, ServiceType } from './types';
import { generateUniqueName } from './utils';
import { ClusterIpServicesForm } from './cluster-ip/ClusterIpServicesForm';
import { ServiceTabs } from './components/ServiceTabs';
import { NodePortServicesForm } from './node-port/NodePortServicesForm';
import { LoadBalancerServicesForm } from './load-balancer/LoadBalancerServicesForm';
import { ServiceTabLabel } from './components/ServiceTabLabel';
import { PublishingExplaination } from './PublishingExplaination';

interface Props {
  values: ServiceFormValues[];
  onChange: (services: ServiceFormValues[]) => void;
  errors?: FormikErrors<ServiceFormValues[]>;
  appName: string;
  selector: Record<string, string>;
  isEditMode: boolean;
  namespace?: string;
}

export function KubeServicesForm({
  values: services,
  onChange,
  errors,
  appName,
  selector,
  isEditMode,
  namespace,
}: Props) {
  const [selectedServiceType, setSelectedServiceType] =
    useState<ServiceType>('ClusterIP');

  // start loading ingresses and controllers early to reduce perceived loading time
  const environmentId = useEnvironmentId();
  useIngresses(environmentId, namespace ? [namespace] : []);
  useIngressControllers(environmentId, namespace);

  // when the appName changes, update the names for each service
  // and the serviceNames for each service port
  const newServiceNames = useMemo(
    () => getUniqNames(appName, services),
    [appName, services]
  );
  useEffect(() => {
    if (!isEditMode) {
      const newServices = services.map((service, index) => {
        const newServiceName = newServiceNames[index];
        const newServicePorts = service.Ports.map((port) => ({
          ...port,
          serviceName: newServiceName,
        }));
        return { ...service, Name: newServiceName, Ports: newServicePorts };
      });
      onChange(newServices);
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [appName]);

  const serviceTypeCounts = useMemo(
    () => getServiceTypeCounts(services),
    [services]
  );

  const serviceTypeHasErrors = useMemo(
    () => getServiceTypeHasErrors(services, errors),
    [services, errors]
  );

  const serviceTypeOptions: ServiceTypeOption[] = [
    {
      value: 'ClusterIP',
      label: (
        <ServiceTabLabel
          serviceTypeLabel="ClusterIP services"
          serviceTypeCount={serviceTypeCounts.ClusterIP}
          serviceTypeHasErrors={serviceTypeHasErrors.ClusterIP}
        />
      ),
    },
    {
      value: 'NodePort',
      label: (
        <ServiceTabLabel
          serviceTypeLabel="NodePort services"
          serviceTypeCount={serviceTypeCounts.NodePort}
          serviceTypeHasErrors={serviceTypeHasErrors.NodePort}
        />
      ),
    },
    {
      value: 'LoadBalancer',
      label: (
        <ServiceTabLabel
          serviceTypeLabel="LoadBalancer services"
          serviceTypeCount={serviceTypeCounts.LoadBalancer}
          serviceTypeHasErrors={serviceTypeHasErrors.LoadBalancer}
        />
      ),
    },
  ];

  return (
    <div className="flex flex-col">
      <FormSection title="Publishing the application" />
      <PublishingExplaination />
      <ServiceTabs
        serviceTypeOptions={serviceTypeOptions}
        selectedServiceType={selectedServiceType}
        setSelectedServiceType={setSelectedServiceType}
      />
      {selectedServiceType === 'ClusterIP' && (
        <ClusterIpServicesForm
          services={services}
          onChangeService={onChange}
          errors={errors}
          appName={appName}
          selector={selector}
          namespace={namespace}
          isEditMode={isEditMode}
        />
      )}
      {selectedServiceType === 'NodePort' && (
        <NodePortServicesForm
          services={services}
          onChangeService={onChange}
          errors={errors}
          appName={appName}
          selector={selector}
          namespace={namespace}
          isEditMode={isEditMode}
        />
      )}
      {selectedServiceType === 'LoadBalancer' && (
        <LoadBalancerServicesForm
          services={services}
          onChangeService={onChange}
          errors={errors}
          appName={appName}
          selector={selector}
          namespace={namespace}
          isEditMode={isEditMode}
        />
      )}
    </div>
  );
}

function getUniqNames(appName: string, services: ServiceFormValues[]) {
  const sortedServices = [...services].sort((a, b) =>
    a.Name && b.Name ? a.Name.localeCompare(b.Name) : 0
  );

  const uniqueNames = sortedServices.reduce(
    (acc: string[]) => {
      const newIndex =
        acc.findIndex((existingName) => existingName === appName) + 1;
      const uniqName = acc.includes(appName)
        ? generateUniqueName(appName, newIndex, services)
        : appName;
      return [...acc, uniqName];
    },
    [appName]
  );

  return uniqueNames;
}

/**
 * getServiceTypeCounts returns a map of service types to the number of services of that type
 */
function getServiceTypeCounts(
  services: ServiceFormValues[]
): Record<ServiceType, number> {
  return services.reduce(
    (acc, service) => {
      const type = service.Type;
      const count = acc[type];
      return {
        ...acc,
        [type]: count ? count + 1 : 1,
      };
    },
    {} as Record<ServiceType, number>
  );
}

/**
 * getServiceTypeHasErrors returns a map of service types to whether or not they have errors
 */
function getServiceTypeHasErrors(
  services: ServiceFormValues[],
  errors: FormikErrors<ServiceFormValues[] | undefined>
): Record<ServiceType, boolean> {
  return services.reduce(
    (acc, service, index) => {
      const type = service.Type;
      const serviceHasErrors = !!errors?.[index];
      // if the service type already has an error, don't overwrite it
      if (acc[type] === true) return acc;
      // otherwise, set the error to the value of serviceHasErrors
      return {
        ...acc,
        [type]: serviceHasErrors,
      };
    },
    {} as Record<ServiceType, boolean>
  );
}