diff --git a/app/react/components/InlineLoader/InlineLoader.stories.tsx b/app/react/components/InlineLoader/InlineLoader.stories.tsx new file mode 100644 index 000000000..2ae8810be --- /dev/null +++ b/app/react/components/InlineLoader/InlineLoader.stories.tsx @@ -0,0 +1,19 @@ +import { Story, Meta } from '@storybook/react'; +import { PropsWithChildren } from 'react'; + +import { InlineLoader, Props } from './InlineLoader'; + +export default { + title: 'Components/InlineLoader', + component: InlineLoader, +} as Meta; + +function Template({ className, children }: PropsWithChildren) { + return {children}; +} + +export const Primary: Story> = Template.bind({}); +Primary.args = { + className: 'test-class', + children: 'Loading...', +}; diff --git a/app/react/components/InlineLoader/InlineLoader.tsx b/app/react/components/InlineLoader/InlineLoader.tsx new file mode 100644 index 000000000..7870eda8b --- /dev/null +++ b/app/react/components/InlineLoader/InlineLoader.tsx @@ -0,0 +1,23 @@ +import { Loader2 } from 'lucide-react'; +import { PropsWithChildren } from 'react'; +import clsx from 'clsx'; + +import { Icon } from '@@/Icon'; + +export type Props = { + className: string; +}; + +export function InlineLoader({ + children, + className, +}: PropsWithChildren) { + return ( +
+ + {children} +
+ ); +} diff --git a/app/react/components/InlineLoader/index.ts b/app/react/components/InlineLoader/index.ts new file mode 100644 index 000000000..ba0a16981 --- /dev/null +++ b/app/react/components/InlineLoader/index.ts @@ -0,0 +1 @@ +export { InlineLoader } from './InlineLoader'; diff --git a/app/react/components/Widget/Widget.stories.tsx b/app/react/components/Widget/Widget.stories.tsx index ab38a23d9..eff60c63b 100644 --- a/app/react/components/Widget/Widget.stories.tsx +++ b/app/react/components/Widget/Widget.stories.tsx @@ -17,7 +17,7 @@ interface WidgetProps { } const meta: Meta = { - title: 'Widget', + title: 'Components/Widget', component: Widget, args: { loading: false, diff --git a/app/react/components/form-components/ReactSelect.css b/app/react/components/form-components/ReactSelect.css index fa75db5f7..b1c6c8489 100644 --- a/app/react/components/form-components/ReactSelect.css +++ b/app/react/components/form-components/ReactSelect.css @@ -31,6 +31,22 @@ display: none; } +.portainer-selector-root .portainer-selector__group-heading { + text-transform: none !important; + font-size: 85% !important; +} + +.input-group .portainer-selector-root:last-child .portainer-selector__control { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + border-top-right-radius: 5px; + border-bottom-right-radius: 5px; +} + +.input-group .portainer-selector-root:not(:first-child):not(:last-child) .portainer-selector__control { + border-radius: 0; +} + /* input style */ .portainer-selector-root .portainer-selector__control { border-color: var(--border-form-control-color); diff --git a/app/react/kubernetes/applications/CreateView/application-services/ingress/AppIngressPathForm.tsx b/app/react/kubernetes/applications/CreateView/application-services/ingress/AppIngressPathForm.tsx index fc2efbf30..215470d16 100644 --- a/app/react/kubernetes/applications/CreateView/application-services/ingress/AppIngressPathForm.tsx +++ b/app/react/kubernetes/applications/CreateView/application-services/ingress/AppIngressPathForm.tsx @@ -111,10 +111,6 @@ export function AppIngressPathForm({ value={selectedIngress} defaultValue={ingressHostOptions[0]} placeholder="Select a hostname..." - theme={(theme) => ({ - ...theme, - borderRadius: 0, - })} size="sm" onChange={(ingressOption) => { setSelectedIngress(ingressOption); diff --git a/app/react/kubernetes/ingresses/CreateIngressView/CreateIngressView.tsx b/app/react/kubernetes/ingresses/CreateIngressView/CreateIngressView.tsx index dfca276e0..6250b0f1d 100644 --- a/app/react/kubernetes/ingresses/CreateIngressView/CreateIngressView.tsx +++ b/app/react/kubernetes/ingresses/CreateIngressView/CreateIngressView.tsx @@ -7,7 +7,7 @@ import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; import { useConfigurations } from '@/react/kubernetes/configs/queries'; import { useNamespaces } from '@/react/kubernetes/namespaces/queries'; import { useServices } from '@/react/kubernetes/networks/services/queries'; -import { notifySuccess, notifyError } from '@/portainer/services/notifications'; +import { notifyError, notifySuccess } from '@/portainer/services/notifications'; import { useAuthorizations } from '@/react/hooks/useUser'; import { Link } from '@@/Link'; @@ -23,8 +23,7 @@ import { useIngressControllers, } from '../queries'; -import { Annotation } from './Annotations/types'; -import { Rule, Path, Host } from './types'; +import { Rule, Path, Host, GroupedServiceOptions } from './types'; import { IngressForm } from './IngressForm'; import { prepareTLS, @@ -33,6 +32,7 @@ import { prepareRuleFromIngress, checkIfPathExistsWithHost, } from './utils'; +import { Annotation } from './Annotations/types'; export function CreateIngressView() { const environmentId = useEnvironmentId(); @@ -58,31 +58,22 @@ export function CreateIngressView() { {} as Record ); - const namespacesResults = useNamespaces(environmentId); + const { data: namespaces, ...namespacesQuery } = useNamespaces(environmentId); - const servicesResults = useServices(environmentId, namespace); + const { data: allServices } = useServices(environmentId, namespace); const configResults = useConfigurations(environmentId, namespace); const ingressesResults = useIngresses( environmentId, - namespacesResults.data ? Object.keys(namespacesResults?.data || {}) : [] + namespaces ? Object.keys(namespaces || {}) : [] ); - const ingressControllersResults = useIngressControllers( + const ingressControllersQuery = useIngressControllers( environmentId, - namespace, - 0 + namespace ); const createIngressMutation = useCreateIngress(); const updateIngressMutation = useUpdateIngress(); - const isLoading = - (servicesResults.isLoading && - configResults.isLoading && - namespacesResults.isLoading && - ingressesResults.isLoading && - ingressControllersResults.isLoading) || - (isEdit && !ingressRule.IngressName); - const [ingressNames, ingresses, ruleCounterByNamespace, hostWithTLS] = useMemo((): [ string[], @@ -122,40 +113,51 @@ export function CreateIngressView() { ]; }, [ingressesResults.data, namespace]); - const namespacesOptions: Option[] = [ - { label: 'Select a namespace', value: '' }, - ]; - Object.entries(namespacesResults?.data || {}).forEach(([ns, val]) => { - if (!val.IsSystem) { - namespacesOptions.push({ - label: ns, - value: ns, - }); - } - }); - - const clusterIpServices = useMemo( - () => servicesResults.data?.filter((s) => s.Type === 'ClusterIP'), - [servicesResults.data] - ); - const servicesOptions = useMemo( + const namespaceOptions = useMemo( () => - clusterIpServices?.map((service) => ({ - label: service.Name, - value: service.Name, - })), - [clusterIpServices] + Object.entries(namespaces || {}) + .filter(([, nsValue]) => !nsValue.IsSystem) + .map(([nsKey]) => ({ + label: nsKey, + value: nsKey, + })), + [namespaces] ); - const serviceOptions = [ - { label: 'Select a service', value: '' }, - ...(servicesOptions || []), - ]; + const serviceOptions: GroupedServiceOptions = useMemo(() => { + const groupedOptions: GroupedServiceOptions = ( + allServices?.reduce( + (groupedOptions, service) => { + // add a new option to the group that matches the service type + const newGroupedOptions = groupedOptions.map((group) => { + if (group.label === service.Type) { + return { + ...group, + options: [ + ...group.options, + { label: service.Name, value: service.Name }, + ], + }; + } + return group; + }); + return newGroupedOptions; + }, + [ + { label: 'ClusterIP', options: [] }, + { label: 'NodePort', options: [] }, + { label: 'LoadBalancer', options: [] }, + ] as GroupedServiceOptions + ) || [] + ).filter((group) => group.options.length > 0); + return groupedOptions; + }, [allServices]); + const servicePorts = useMemo( () => - clusterIpServices + allServices ? Object.fromEntries( - clusterIpServices?.map((service) => [ + allServices?.map((service) => [ service.Name, service.Ports.map((port) => ({ label: String(port.Port), @@ -164,33 +166,35 @@ export function CreateIngressView() { ]) ) : {}, - [clusterIpServices] + [allServices] ); const existingIngressClass = useMemo( () => - ingressControllersResults.data?.find( + ingressControllersQuery.data?.find( (i) => i.ClassName === ingressRule.IngressClassName || (i.Type === 'custom' && ingressRule.IngressClassName === '') ), - [ingressControllersResults.data, ingressRule.IngressClassName] + [ingressControllersQuery.data, ingressRule.IngressClassName] + ); + + const ingressClassOptions: Option[] = useMemo( + () => + ingressControllersQuery.data + ?.filter((cls) => cls.Availability) + .map((cls) => ({ + label: cls.ClassName, + value: cls.ClassName, + })) || [], + [ingressControllersQuery.data] ); - const ingressClassOptions: Option[] = [ - { label: 'Select an ingress class', value: '' }, - ...(ingressControllersResults.data - ?.filter((cls) => cls.Availability) - .map((cls) => ({ - label: cls.ClassName, - value: cls.ClassName, - })) || []), - ]; if ( (!existingIngressClass || (existingIngressClass && !existingIngressClass.Availability)) && ingressRule.IngressClassName && - !ingressControllersResults.isLoading + !ingressControllersQuery.isLoading ) { const optionLabel = !ingressRule.IngressType ? `${ingressRule.IngressClassName} - NOT FOUND` @@ -222,15 +226,15 @@ export function CreateIngressView() { !!params.name && ingressesResults.data && !ingressRule.IngressName && - !ingressControllersResults.isLoading && - !ingressControllersResults.isLoading + !ingressControllersQuery.isLoading && + !ingressControllersQuery.isLoading ) { // if it is an edit screen, prepare the rule from the ingress const ing = ingressesResults.data?.find( (ing) => ing.Name === params.name && ing.Namespace === params.namespace ); if (ing) { - const type = ingressControllersResults.data?.find( + const type = ingressControllersQuery.data?.find( (c) => c.ClassName === ing.ClassName || (c.Type === 'custom' && !ing.ClassName) @@ -244,7 +248,7 @@ export function CreateIngressView() { }, [ params.name, ingressesResults.data, - ingressControllersResults.data, + ingressControllersQuery.data, ingressRule.IngressName, params.namespace, ]); @@ -292,7 +296,7 @@ export function CreateIngressView() { ( ingressRule: Rule, ingressNames: string[], - serviceOptions: Option[], + groupedServiceOptions: GroupedServiceOptions, existingIngressClass?: IngressController ) => { const errors: Record = {}; @@ -314,7 +318,7 @@ export function CreateIngressView() { errors.ingressName = 'Ingress name already exists'; } - if (!rule.IngressClassName) { + if (!ingressClassOptions.length && ingressControllersQuery.isSuccess) { errors.className = 'Ingress class is required'; } } @@ -398,10 +402,14 @@ export function CreateIngressView() { 'Service name is required'; } + const availableServiceNames = groupedServiceOptions.flatMap( + (optionGroup) => optionGroup.options.map((option) => option.value) + ); + if ( isEdit && path.ServiceName && - !serviceOptions.find((s) => s.value === path.ServiceName) + !availableServiceNames.find((sn) => sn === path.ServiceName) ) { errors[`hosts[${hi}].paths[${pi}].servicename`] = ( @@ -456,26 +464,32 @@ export function CreateIngressView() { } return true; }, - [ingresses, environmentId, isEdit, params.name] + [ + isEdit, + ingressClassOptions, + ingressControllersQuery.isSuccess, + environmentId, + ingresses, + params.name, + ] ); - const debouncedValidate = useMemo(() => debounce(validate, 300), [validate]); + const debouncedValidate = useMemo(() => debounce(validate, 500), [validate]); useEffect(() => { if (namespace.length > 0) { debouncedValidate( ingressRule, ingressNames || [], - servicesOptions || [], + serviceOptions || [], existingIngressClass ); } - // eslint-disable-next-line react-hooks/exhaustive-deps }, [ ingressRule, namespace, ingressNames, - servicesOptions, + serviceOptions, existingIngressClass, debouncedValidate, ]); @@ -498,10 +512,10 @@ export function CreateIngressView() {
- {namespace && !isLoading && ( + {namespace && (
@@ -180,16 +213,35 @@ export function IngressForm({ Ingress class
- + handleIngressChange( + 'IngressClassName', + ingressClassOption?.value || '' + ) + } + /> + {errors.className && ( + + {errors.className} + + )} + + )} {errors.className && ( {errors.className} @@ -300,9 +352,9 @@ export function IngressForm({ {namespace && rule?.Hosts?.map((host, hostIndex) => ( -
-
-
+ +
+
{!host.NoHost ? 'Rule' : 'Fallback rule'}
@@ -323,11 +375,9 @@ export function IngressForm({ {!host.NoHost && (
-
- - Hostname - - + Hostname + -
+ {errors[`hosts[${hostIndex}].host`] && ( {errors[`hosts[${hostIndex}].host`]} @@ -346,17 +396,19 @@ export function IngressForm({
-
- TLS secret + + TLS secret ) => + value={{ + value: path.ServiceName, + label: path.ServiceName || 'Select a service', + }} + onChange={(serviceOption) => handlePathChange( hostIndex, pathIndex, 'ServiceName', - e.target.value + serviceOption?.value || '' ) } - defaultValue={path.ServiceName} + size="sm" /> -
+ {errors[ `hosts[${hostIndex}].paths[${pathIndex}].servicename` ] && ( @@ -449,35 +503,41 @@ export function IngressForm({
{servicePorts && ( <> -
- + + Service port - + + Path type + > key={servicePorts.toString() + path.PathType} name={`ingress_pathType_${hostIndex}_${pathIndex}`} options={ - pathTypes - ? pathTypes.map((type) => ({ - label: type, - value: type, - })) - : [] + pathTypes?.map((type) => ({ + label: type, + value: type, + })) || [] } - onChange={(e: ChangeEvent) => + onChange={(option) => handlePathChange( hostIndex, pathIndex, 'PathType', - e.target.value + option?.value || '' ) } - defaultValue={path.PathType} + value={{ + label: path.PathType || 'Select a path type', + value: path.PathType || '', + }} + size="sm" /> -
+ {errors[ `hosts[${hostIndex}].paths[${pathIndex}].pathType` ] && ( @@ -532,9 +594,9 @@ export function IngressForm({
-
- Path - + Path + -
+ {errors[ `hosts[${hostIndex}].paths[${pathIndex}].path` ] && ( @@ -592,7 +654,7 @@ export function IngressForm({
-
+ ))} {namespace && ( diff --git a/app/react/kubernetes/ingresses/CreateIngressView/types.ts b/app/react/kubernetes/ingresses/CreateIngressView/types.ts index f8850c2e3..417053466 100644 --- a/app/react/kubernetes/ingresses/CreateIngressView/types.ts +++ b/app/react/kubernetes/ingresses/CreateIngressView/types.ts @@ -31,3 +31,8 @@ export interface Rule { export interface ServicePorts { [serviceName: string]: Option[]; } + +export type GroupedServiceOptions = { + label: string; + options: Option[]; +}[]; diff --git a/app/react/kubernetes/ingresses/queries.ts b/app/react/kubernetes/ingresses/queries.ts index fe887892a..78940173f 100644 --- a/app/react/kubernetes/ingresses/queries.ts +++ b/app/react/kubernetes/ingresses/queries.ts @@ -177,8 +177,7 @@ export function useDeleteIngresses() { */ export function useIngressControllers( environmentId: EnvironmentId, - namespace?: string, - cacheTime?: number + namespace?: string ) { return useQuery( [ @@ -193,7 +192,6 @@ export function useIngressControllers( namespace ? getIngressControllers(environmentId, namespace) : [], { enabled: !!namespace, - cacheTime, ...withError('Unable to get ingress controllers'), } );