Skip to content

Form input logic

In the orchestrator core, form input elements are now class based and subclass the FormPage class in the core.

from orchestrator.forms import FormPage, ReadOnlyField

And the validators module exposes validators that also function as "input type widgets":

from orchestrator.forms.validators import CustomerId, choice_list, Choice

It's worth poking around in that module to see the various input types the core library exposes.

Form examples

Here is a relatively simple input form:

equipment = get_planned_equipment()
choices = [f"{eq['name']}" for eq in equipment]

EquipmentList = choice_list(
    Choice("EquipmentEnum", zip(choices, choices)),
    min_items=1,
    max_items=1,
    unique_items=True,
)


class CreateNodeEnrollmentForm(FormPage):
    class Config:
        title = product_name

    customer_id: CustomerId = ReadOnlyField(CustomerId(ESNET_ORG_UUID))
    select_node_choice: EquipmentList


# Don't call like this CreateNodeEnrollmentForm() or you'll
# get a vague error.
user_input = yield CreateNodeEnrollmentForm

It has a read only ORG ID and exposes a list of devices pulled from ESDB for the user to choose from.

Choice widgets

Of note: min_items and max_items do not refer to the number of elements in the list. This UI construct allows for an arbitrary number of choices to be made - there are + and - options exposed in the UI allowing for multiple choices selected by the user. So min 1 / max 1 tells the UI to display one pull down list of choices one of which must be selected, and additional choices can not be added.

If one defined something like min 1 / max 3 it would display one pulldown box by default and expose a + element in the UI. The user could click on it to arbitrarily add a second or a third pulldown list. min 0 would not display any list by default but the user could use + to add some, etc.

Since multiple choices are allowed, the results are returned as a list even if there is only a single choice element:

eq_name = user_input.select_node_choice[0]

The zip() maneuver takes the list and makes it into a dict with the same keys and values. So the display text doesn't have to be the same as the value returned.

Accept actions

Confirming actions is a common bit of functionality. This bit of code displays some read only NSO payload and lets the user ok the dry run:

from orchestrator.forms import FormPage, ReadOnlyField
from orchestrator.forms.validators import Accept, LongText


def confirm_dry_run_results(dry_run_results: str) -> State:
    class ConfirmDryRun(FormPage):
        nso_dry_run_results: LongText = ReadOnlyField(dry_run_results)
        confirm_dry_run_results: Accept

    user_input = yield ConfirmDryRun

    return user_input

Generic python types

It is possible to mix generic python types in the with the defined validation fields:

class CreateLightPathForm(FormPage):
    class Config:
        title = product_name

    customer_id: CustomerId
    contact_persons: ContactPersonList = []  # type: ignore
    ticket_id: JiraTicketId = ""  # type: ignore
    service_ports: ListOfTwo[ServicePort]  # type: ignore
    service_speed: bandwidth("service_ports")  # type: ignore # noqa: F821
    speed_policer: bool = False
    remote_port_shutdown: bool = True

Multi step form input

Similar to the original "list based" form Input elements, to do a multistep form flow yield multiple times and then combine the results at the end:

def initial_input_form_generator(product: UUIDstr, product_name: str) -> FormGenerator:
    class CreateNodeForm(FormPage):
        class Config:
            title = product_name

        customer_id: CustomerId = ReadOnlyField(CustomerId(SURFNET_NETWORK_UUID))
        location_code: LocationCode
        ticket_id: JiraTicketId = ""  # type:ignore

    user_input = yield CreateNodeForm

    class NodeIdForm(FormPage):
        class Config:
            title = product_name

        ims_node_id: ims_node_id(
            user_input.location_code, node_status="PL"
        )  # type:ignore # noqa: F821

    user_input_node = yield NodeIdForm

    return {**user_input.dict(), **user_input_node.dict()}

custom form field

You can create a custom field component in the frontend. The components in orchestrator-gui/src/lib/uniforms-surfnet/src can be used to study reference implementations for a couple of custom form field types.

For it to show up in the form, you have to do 2 things, a pydantic type/class in the backend and add the component to the AutoFieldLoader.tsx.

as an example I will create a custom field with name field and group select field.

pydantic type/class in backend

Create a pydantic type/class.

from uuid import UUID


class ChooseUser(str):
    group_id: UUID  # type:ignore

    @classmethod
    def __modify_schema__(cls, field_schema: dict[str, Any]) -> None:
        uniforms: dict[str, Any] = {}

        if cls.group_id:
            uniforms["groupId"] = cls.group_id

        field_schema.update(format="ChooseUser", uniforms=uniforms)

And add it to a form:

def initial_input_form_generator(product: UUIDstr, product_name: str) -> FormGenerator:
    class ChoseUserForm(FormPage):
        class Config:
            title = product_name

        user: ChooseUser

    user_input = yield ChoseUserForm

To prefill the user_id, you need to add the value to the prop, for prefilling the group_id you need to create a new class:

def user_choice(group_id: int | None = None) -> type:
    namespace = {"group_id": group_id}
    return new_class(
        "ChooseUserValue", (ChooseUser,), {}, lambda ns: ns.update(namespace)
    )


def initial_input_form_generator(product: UUIDstr, product_name: str) -> FormGenerator:
    class ChoseUserForm(FormPage):
        class Config:
            title = product_name

        user: user_choice("group_id_1") = "user_id_1"

    user_input = yield ChoseUserForm

auto field loader

The auto field loader is for loading the correct field component in the form. It has switches that check the field type and the field format. You have to add your new form field here.

for this example, we would need to add to a ChooseUser case to the String switch:

...
import ChooseUserField from "custom/uniforms/ChooseUserField";

export function autoFieldFunction(props: GuaranteedProps<unknown> & Record<string, any>, uniforms: Context<unknown>) {
    const { allowedValues, checkboxes, fieldType, field } = props;
    const { format } = field;

    switch (fieldType) {
        ...
        case String:
            switch (format) {
               ...
                case "ChooseUser":
                    return ChooseUserField;
            }
            break;
    }

    ...
}

custom field example

example custom field to select a user by group.

import { EuiFlexItem, EuiFormRow, EuiText } from "@elastic/eui";
import { FieldProps } from "lib/uniforms-surfnet/src/types";
import React, { useCallback, useContext, useEffect, useState } from "react";
import { WrappedComponentProps, injectIntl } from "react-intl";
import ReactSelect, { SingleValue } from "react-select";
import { getReactSelectTheme } from "stylesheets/emotion/utils";
import { connectField, filterDOMProps } from "uniforms";
import ApplicationContext from "utils/ApplicationContext";
import { Option } from "utils/types";
import { css } from "@emotion/core";

export const ChoosePersonFieldStyling = css`
    section.group-user {
        display: flex;
        flex-direction: row;
        flex-wrap: wrap;

        div.group-select {
            width: 50%;
        }
        div.user-select {
            width: 50%;
            padding-left: 5px;
        }
    }
`;

interface Group {
    id: string;
    name: string;
}

interface User {
    id: string;
    name: string;
    age: number;
}


export type ChooseUserFieldProps = FieldProps<
    string,
    {
        groupId?: string;
    } & WrappedComponentProps
>;

const groupToOption = (group: Group): Option => {
    return {
        value: group.id,
        label: `${group.id.substring(0, 8)} ${group.name}`,
    };
}

const userToOption = (user: User): Option => {
    return {
        value: user.id,
        label: `${user.name} (${user.age})`,
    };
}

declare module "uniforms" {
    interface FilterDOMProps {
        groupId: never;
    }
}
filterDOMProps.register("groupId");

function ChoosePerson({
    id,
    name,
    label,
    description,
    onChange,
    value,
    disabled,
    placeholder,
    readOnly,
    error,
    showInlineError,
    errorMessage,
    groupId,
    intl,
    ...props
}: ChooseUserFieldProps) {
    const { apiClient, customApiClient, theme } = useContext(ApplicationContext);

    const [groups, setGroups] = useState<Group[]>([]);
    const [selectedGroupId, setGroupId] = useState<number | string | undefined>(groupId);
    const [users, setUsers] = useState<User[]>([]);
    const [loading, setLoading] = useState(true);

    const onChangeGroup = useCallback(
        (option: SingleValue<Option>) => {
            let value = option?.value;
            if (value === undefined) return;

            setLoading(true);
            setGroupId(value);
            setUsers([]);

            // do api call to get users by group id and set users with the fetched data.
            setTimeout(() => {
                let users = [{ id: "user_id_1", name: "user 1", age: 25 }, { id: "user_id_2", name: "user 2", age: 30 }]
                if (value == "group_id_2") {
                    users = [{ id: "user_id_3", name: "user 3", age: 35 }]
                } else if (value == "group_id_3") {
                    users = [{ id: "user_id_4", name: "user 4", age: 40 }, { id: "user_id_5", name: "user 5", age: 45 }]
                }
                setUsers(users)
                setLoading(false);
            }, 1000)
        },
        [customApiClient]
    );

    useEffect(() => {
        setLoading(true);

        // do api call to get groups for the first select.

        setTimeout(() => {
            setGroups([
                { id: "group_id_1", name: "group 1" },
                { id: "group_id_2", name: "group 2" },
                { id: "group_id_3", name: "group 3" }
            ]);
            setLoading(false);

            if (groupId) {
                onChangeGroup({ value: groupId } as Option);
            }
        }, 1000)
    }, [onChangeGroup, apiClient, groupId]);

    // use i18n translations.
    const groupsPlaceholder = loading ? "Loading..." : "Select a group";
    const userPlaceholder = loading ? "Loading..." : selectedGroupId ? "Select a user" : "Select a group first";

    const group_options: Option[] = (groups as Group[])
        .map(groupToOption)
        .sort((x, y) => x.label.localeCompare(y.label));
    const group_value = group_options.find((option) => option.value === selectedGroupId?.toString());

    const user_options: Option<string>[] = users
        .map(userToOption)
        .sort((x, y) => x.label.localeCompare(y.label));
    const user_value = user_options.find((option) => option.value === value);

    const customStyles = getReactSelectTheme(theme);

    return (
        <EuiFlexItem css={ChoosePersonFieldStyling}>
            <section {...filterDOMProps(props)}>
                <EuiFormRow
                    label={label}
                    labelAppend={<EuiText size="m">{description}</EuiText>}
                    error={showInlineError ? errorMessage : false}
                    isInvalid={error}
                    id={id}
                    fullWidth
                >
                    <section className="group-user">
                        <div className="group-select">
                            <EuiFormRow label="Group" id={`${id}.group`} fullWidth>
                                <ReactSelect<Option, false>
                                    inputId={`${id}.group.search`}
                                    name={`${name}.group`}
                                    onChange={onChangeGroup}
                                    options={group_options}
                                    placeholder={groupsPlaceholder}
                                    value={group_value}
                                    isSearchable={true}
                                    isDisabled={disabled || readOnly || groups.length === 0}
                                    styles={customStyles}
                                />
                            </EuiFormRow>
                        </div>
                        <div className="user-select">
                            <EuiFormRow label="User" id={id} fullWidth>
                                <ReactSelect<Option<string>, false>
                                    inputId={`${id}.search`}
                                    name={name}
                                    onChange={(selected) => {
                                        onChange(selected?.value);
                                    }}
                                    options={user_options}
                                    placeholder={userPlaceholder}
                                    value={user_value}
                                    isSearchable={true}
                                    isDisabled={disabled || readOnly || users.length === 0}
                                    styles={customStyles}
                                />
                            </EuiFormRow>
                        </div>
                    </section>
                </EuiFormRow>
            </section>
        </EuiFlexItem>
    );
}

export default connectField(injectIntl(ChoosePerson), { kind: "leaf" });

TBA