From d21629e3f1a1c7c8949674e0f0551e0e6abffe43 Mon Sep 17 00:00:00 2001 From: Mason Hu Date: Mon, 15 Jan 2024 17:27:58 +0200 Subject: [PATCH] feat: implement scrollable form components Signed-off-by: Mason Hu --- src/components/ScrollableContainer.tsx | 41 ++++++ src/components/ScrollableForm.tsx | 21 +++ src/components/ScrollableTable.tsx | 24 ++-- src/components/forms/DiskDeviceForm.tsx | 37 ++--- .../instances/forms/EditInstanceDetails.tsx | 5 +- .../forms/InstanceCreateDetailsForm.tsx | 5 +- .../networks/forms/NetworkFormBridge.tsx | 4 +- src/pages/networks/forms/NetworkFormDns.tsx | 4 +- src/pages/networks/forms/NetworkFormIpv4.tsx | 4 +- src/pages/networks/forms/NetworkFormIpv6.tsx | 4 +- src/pages/networks/forms/NetworkFormMain.tsx | 5 +- .../networks/forms/NetworkForwardForm.tsx | 132 +++++++++--------- .../profiles/forms/ProfileDetailsForm.tsx | 5 +- .../projects/forms/ProjectDetailsForm.tsx | 5 +- .../storage/forms/StoragePoolFormCeph.tsx | 4 +- .../storage/forms/StoragePoolFormMain.tsx | 6 +- .../storage/forms/StorageVolumeFormBlock.tsx | 4 +- .../storage/forms/StorageVolumeFormMain.tsx | 5 +- .../forms/StorageVolumeFormSnapshots.tsx | 4 +- .../storage/forms/StorageVolumeFormZFS.tsx | 4 +- src/sass/_configuration_table.scss | 2 +- src/sass/_forms.scss | 10 +- src/sass/_network_forwards_form.scss | 3 +- src/sass/_scrollable_container.scss | 7 + src/sass/styles.scss | 1 + src/util/helpers.tsx | 14 +- 26 files changed, 226 insertions(+), 134 deletions(-) create mode 100644 src/components/ScrollableContainer.tsx create mode 100644 src/components/ScrollableForm.tsx create mode 100644 src/sass/_scrollable_container.scss diff --git a/src/components/ScrollableContainer.tsx b/src/components/ScrollableContainer.tsx new file mode 100644 index 0000000000..8cccc54925 --- /dev/null +++ b/src/components/ScrollableContainer.tsx @@ -0,0 +1,41 @@ +import React, { DependencyList, FC, ReactNode, useEffect, useRef } from "react"; +import useEventListener from "@use-it/event-listener"; +import { getAbsoluteHeightBelow, getParentsBottomSpacing } from "util/helpers"; + +interface Props { + children: ReactNode; + dependencies: DependencyList; + belowId?: string; +} + +const ScrollableContainer: FC = ({ + dependencies, + children, + belowId = "", +}) => { + const ref = useRef(null); + + const updateChildContainerHeight = () => { + const childContainer = ref.current?.children[0]; + if (!childContainer) { + return; + } + const above = childContainer.getBoundingClientRect().top + 1; + const below = getAbsoluteHeightBelow(belowId); + const parentsBottomSpacing = getParentsBottomSpacing(childContainer); + const offset = Math.ceil(above + below + parentsBottomSpacing); + const style = `height: calc(100vh - ${offset}px); min-height: calc(100vh - ${offset}px)`; + childContainer.setAttribute("style", style); + }; + + useEventListener("resize", updateChildContainerHeight); + useEffect(updateChildContainerHeight, [...dependencies, ref]); + + return ( +
+
{children}
+
+ ); +}; + +export default ScrollableContainer; diff --git a/src/components/ScrollableForm.tsx b/src/components/ScrollableForm.tsx new file mode 100644 index 0000000000..a2c691e72a --- /dev/null +++ b/src/components/ScrollableForm.tsx @@ -0,0 +1,21 @@ +import React, { FC, ReactNode } from "react"; +import ScrollableContainer from "./ScrollableContainer"; +import { useNotify } from "@canonical/react-components"; + +interface Props { + children: ReactNode; +} + +const ScrollableForm: FC = ({ children }) => { + const notify = useNotify(); + return ( + + {children} + + ); +}; + +export default ScrollableForm; diff --git a/src/components/ScrollableTable.tsx b/src/components/ScrollableTable.tsx index 2a702489f5..e2a6c285c0 100644 --- a/src/components/ScrollableTable.tsx +++ b/src/components/ScrollableTable.tsx @@ -1,6 +1,6 @@ import React, { DependencyList, FC, ReactNode, useEffect, useRef } from "react"; import useEventListener from "@use-it/event-listener"; -import { getParentsBottomSpacing } from "util/helpers"; +import { getAbsoluteHeightBelow, getParentsBottomSpacing } from "util/helpers"; interface Props { children: ReactNode; @@ -8,21 +8,13 @@ interface Props { belowId?: string; } -const ScrollableTable: FC = ({ dependencies, children, belowId }) => { +const ScrollableTable: FC = ({ + dependencies, + children, + belowId = "", +}) => { const ref = useRef(null); - const getAbsoluteHeightBelow = () => { - const element = belowId ? document.getElementById(belowId) : undefined; - if (!element) { - return 0; - } - const style = window.getComputedStyle(element); - const margin = parseFloat(style.marginTop) + parseFloat(style.marginBottom); - const padding = - parseFloat(style.paddingTop) + parseFloat(style.paddingBottom); - return element.offsetHeight + margin + padding + 1; - }; - const updateTBodyHeight = () => { const table = ref.current?.children[0]; if (!table || table.children.length !== 2) { @@ -30,8 +22,8 @@ const ScrollableTable: FC = ({ dependencies, children, belowId }) => { } const tBody = table.children[1]; const above = tBody.getBoundingClientRect().top + 1; - const below = getAbsoluteHeightBelow(); - const parentsBottomSpacing = getParentsBottomSpacing(table as HTMLElement); + const below = getAbsoluteHeightBelow(belowId); + const parentsBottomSpacing = getParentsBottomSpacing(table); const offset = Math.ceil(above + below + parentsBottomSpacing); const style = `height: calc(100vh - ${offset}px); min-height: calc(100vh - ${offset}px)`; tBody.setAttribute("style", style); diff --git a/src/components/forms/DiskDeviceForm.tsx b/src/components/forms/DiskDeviceForm.tsx index 7d2a83e2e8..54ce01fc8d 100644 --- a/src/components/forms/DiskDeviceForm.tsx +++ b/src/components/forms/DiskDeviceForm.tsx @@ -11,6 +11,7 @@ import DiskDeviceFormRoot from "./DiskDeviceFormRoot"; import DiskDeviceFormInherited from "./DiskDeviceFormInherited"; import DiskDeviceFormCustom from "./DiskDeviceFormCustom"; import classnames from "classnames"; +import ScrollableForm from "components/ScrollableForm"; interface Props { formik: InstanceAndProfileFormikProps; @@ -58,23 +59,25 @@ const DiskDeviceForm: FC = ({ formik, project }) => { "disk-device-form--edit": !formik.values.readOnly, })} > - {/* hidden submit to enable enter key in inputs */} - - - - + + {/* hidden submit to enable enter key in inputs */} + + + + + ); }; diff --git a/src/pages/instances/forms/EditInstanceDetails.tsx b/src/pages/instances/forms/EditInstanceDetails.tsx index 75428860b8..02ed5fe1a2 100644 --- a/src/pages/instances/forms/EditInstanceDetails.tsx +++ b/src/pages/instances/forms/EditInstanceDetails.tsx @@ -7,6 +7,7 @@ import { useSettings } from "context/useSettings"; import MigrateInstanceBtn from "pages/instances/actions/MigrateInstanceBtn"; import { isClusteredServer } from "util/settings"; import AutoExpandingTextArea from "components/AutoExpandingTextArea"; +import ScrollableForm from "components/ScrollableForm"; export const instanceEditDetailPayload = (values: EditInstanceFormValues) => { return { @@ -28,7 +29,7 @@ const EditInstanceDetails: FC = ({ formik, project }) => { const isClustered = isClusteredServer(settings); return ( -
+ = ({ formik, project }) => { setSelected={(value) => void formik.setFieldValue("profiles", value)} readOnly={readOnly} /> -
+ ); }; diff --git a/src/pages/instances/forms/InstanceCreateDetailsForm.tsx b/src/pages/instances/forms/InstanceCreateDetailsForm.tsx index 8fbd61b1f9..93454af10f 100644 --- a/src/pages/instances/forms/InstanceCreateDetailsForm.tsx +++ b/src/pages/instances/forms/InstanceCreateDetailsForm.tsx @@ -17,6 +17,7 @@ import { LxdImageType, RemoteImage } from "types/image"; import InstanceLocationSelect from "pages/instances/forms/InstanceLocationSelect"; import UseCustomIsoBtn from "pages/images/actions/UseCustomIsoBtn"; import AutoExpandingTextArea from "components/AutoExpandingTextArea"; +import ScrollableForm from "components/ScrollableForm"; export interface InstanceDetailsFormValues { name?: string; @@ -74,7 +75,7 @@ const InstanceCreateDetailsForm: FC = ({ } return ( -
+ = ({ : "" } /> -
+ ); }; diff --git a/src/pages/networks/forms/NetworkFormBridge.tsx b/src/pages/networks/forms/NetworkFormBridge.tsx index fd8a00be99..7f92a4f6ae 100644 --- a/src/pages/networks/forms/NetworkFormBridge.tsx +++ b/src/pages/networks/forms/NetworkFormBridge.tsx @@ -1,9 +1,9 @@ import React, { FC } from "react"; import { Input, Select } from "@canonical/react-components"; import { FormikProps } from "formik/dist/types"; -import ConfigurationTable from "components/ConfigurationTable"; import { getConfigurationRow } from "components/ConfigurationRow"; import { NetworkFormValues } from "pages/networks/forms/NetworkForm"; +import ScrollableConfigurationTable from "components/forms/ScrollableConfigurationTable"; interface Props { formik: FormikProps; @@ -11,7 +11,7 @@ interface Props { const NetworkFormBridge: FC = ({ formik }) => { return ( - ; @@ -11,7 +11,7 @@ interface Props { const NetworkFormDns: FC = ({ formik }) => { return ( - ; @@ -14,7 +14,7 @@ const NetworkFormIpv4: FC = ({ formik }) => { const hasDhcp = formik.values.ipv4_dhcp !== "false"; return ( - ; @@ -14,7 +14,7 @@ const NetworkFormIpv6: FC = ({ formik }) => { const hasDhcp = formik.values.ipv6_dhcp !== "false"; return ( - ; @@ -29,7 +30,7 @@ const NetworkFormMain: FC = ({ formik, project }) => { }; return ( - <> + @@ -145,7 +146,7 @@ const NetworkFormMain: FC = ({ formik, project }) => { : []), ]} /> - + ); }; diff --git a/src/pages/networks/forms/NetworkForwardForm.tsx b/src/pages/networks/forms/NetworkForwardForm.tsx index 65898dfe92..e838ec721b 100644 --- a/src/pages/networks/forms/NetworkForwardForm.tsx +++ b/src/pages/networks/forms/NetworkForwardForm.tsx @@ -22,6 +22,7 @@ import NotificationRow from "components/NotificationRow"; import NetworkForwardFormPorts, { NetworkForwardPortFormValues, } from "pages/networks/forms/NetworkForwardFormPorts"; +import ScrollableForm from "components/ScrollableForm"; export const toNetworkForward = ( values: NetworkForwardFormValues, @@ -110,72 +111,77 @@ const NetworkForwardForm: FC = ({
- {/* hidden submit to enable enter key in inputs */} - - - - - Name: {network?.name} -
- {network?.config["ipv4.address"] && ( + + {/* hidden submit to enable enter key in inputs */} + + + + + Name: {network?.name} +
+ {network?.config["ipv4.address"] && ( + <> + IPv4 subnet: {network?.config["ipv4.address"]} +
+ + )} + {network?.config["ipv6.address"] && ( + <>IPv6 subnet: {network?.config["ipv6.address"]} + )} +
+
+ + - IPv4 subnet: {network?.config["ipv4.address"]} + Fallback target for traffic that does not match a port + specified below.
+ Must be from the network {network?.name}. - )} - {network?.config["ipv6.address"] && ( - <>IPv6 subnet: {network?.config["ipv6.address"]} - )} -
-
- - - Fallback target for traffic that does not match a port specified - below. -
- Must be from the network {network?.name}. - - } - placeholder="Enter IP address" - stacked - /> - - {formik.values.ports.length > 0 && ( - - )} - + } + placeholder="Enter IP address" + stacked + /> + + {formik.values.ports.length > 0 && ( + + )} + +
diff --git a/src/pages/profiles/forms/ProfileDetailsForm.tsx b/src/pages/profiles/forms/ProfileDetailsForm.tsx index 948321e3cb..fa97996444 100644 --- a/src/pages/profiles/forms/ProfileDetailsForm.tsx +++ b/src/pages/profiles/forms/ProfileDetailsForm.tsx @@ -3,6 +3,7 @@ import { Col, Input, Row } from "@canonical/react-components"; import { FormikProps } from "formik/dist/types"; import { CreateProfileFormValues } from "pages/profiles/CreateProfile"; import AutoExpandingTextArea from "components/AutoExpandingTextArea"; +import ScrollableForm from "components/ScrollableForm"; export interface ProfileDetailsFormValues { name: string; @@ -28,7 +29,7 @@ const ProfileDetailsForm: FC = ({ formik, isEdit }) => { const isDefaultProfile = formik.values.name === "default"; return ( -
+ = ({ formik, isEdit }) => { /> -
+ ); }; diff --git a/src/pages/projects/forms/ProjectDetailsForm.tsx b/src/pages/projects/forms/ProjectDetailsForm.tsx index 17062ad600..797974b193 100644 --- a/src/pages/projects/forms/ProjectDetailsForm.tsx +++ b/src/pages/projects/forms/ProjectDetailsForm.tsx @@ -13,6 +13,7 @@ import { getProjectKey } from "util/projectConfigFields"; import { isProjectEmpty } from "util/projects"; import { LxdProject } from "types/project"; import AutoExpandingTextArea from "components/AutoExpandingTextArea"; +import ScrollableForm from "components/ScrollableForm"; export interface ProjectDetailsFormValues { name: string; @@ -106,7 +107,7 @@ const ProjectDetailsForm: FC = ({ formik, project, isEdit }) => { project?.config["features.networks.zones"] === "true"; return ( -
+ = ({ formik, project, isEdit }) => { /> -
+ ); }; diff --git a/src/pages/storage/forms/StoragePoolFormCeph.tsx b/src/pages/storage/forms/StoragePoolFormCeph.tsx index e84adad009..a9a166aa51 100644 --- a/src/pages/storage/forms/StoragePoolFormCeph.tsx +++ b/src/pages/storage/forms/StoragePoolFormCeph.tsx @@ -1,10 +1,10 @@ import { FormikProps } from "formik"; import React, { FC } from "react"; import { StoragePoolFormValues } from "./StoragePoolForm"; -import ConfigurationTable from "components/ConfigurationTable"; import { getConfigurationRow } from "components/ConfigurationRow"; import { Input, Select } from "@canonical/react-components"; import { optionTrueFalse } from "util/instanceOptions"; +import ScrollableConfigurationTable from "components/forms/ScrollableConfigurationTable"; interface Props { formik: FormikProps; @@ -12,7 +12,7 @@ interface Props { const StoragePoolFormCeph: FC = ({ formik }) => { return ( - ; @@ -21,6 +22,7 @@ interface Props { const StoragePoolFormMain: FC = ({ formik }) => { const { data: settings } = useSettings(); + const getFormProps = (id: "name" | "description" | "size" | "source") => { return { id: id, @@ -38,7 +40,7 @@ const StoragePoolFormMain: FC = ({ formik }) => { const storageDriverOptions = getStorageDriverOptions(settings); return ( - <> + = ({ formik }) => { /> - + ); }; diff --git a/src/pages/storage/forms/StorageVolumeFormBlock.tsx b/src/pages/storage/forms/StorageVolumeFormBlock.tsx index c91aff3317..4cda52eabb 100644 --- a/src/pages/storage/forms/StorageVolumeFormBlock.tsx +++ b/src/pages/storage/forms/StorageVolumeFormBlock.tsx @@ -3,7 +3,7 @@ import { Input, Select } from "@canonical/react-components"; import { FormikProps } from "formik/dist/types"; import { StorageVolumeFormValues } from "pages/storage/forms/StorageVolumeForm"; import { getConfigurationRow } from "components/ConfigurationRow"; -import ConfigurationTable from "components/ConfigurationTable"; +import ScrollableConfigurationTable from "components/forms/ScrollableConfigurationTable"; interface Props { formik: FormikProps; @@ -11,7 +11,7 @@ interface Props { const StorageVolumeFormBlock: FC = ({ formik }) => { return ( - ; @@ -18,7 +19,7 @@ interface Props { const StorageVolumeFormMain: FC = ({ formik, project }) => { return ( - <> + {formik.values.isCreating && ( @@ -120,7 +121,7 @@ const StorageVolumeFormMain: FC = ({ formik, project }) => { ]} /> )} - + ); }; diff --git a/src/pages/storage/forms/StorageVolumeFormSnapshots.tsx b/src/pages/storage/forms/StorageVolumeFormSnapshots.tsx index 8b52ce5f03..b5f8650c8c 100644 --- a/src/pages/storage/forms/StorageVolumeFormSnapshots.tsx +++ b/src/pages/storage/forms/StorageVolumeFormSnapshots.tsx @@ -1,7 +1,6 @@ import React, { FC } from "react"; import { Input, Notification } from "@canonical/react-components"; import { FormikProps } from "formik/dist/types"; -import ConfigurationTable from "components/ConfigurationTable"; import { getConfigurationRow } from "components/ConfigurationRow"; import { StorageVolumeFormValues } from "pages/storage/forms/StorageVolumeForm"; import SnapshotScheduleInput from "components/SnapshotScheduleInput"; @@ -9,6 +8,7 @@ import { useDocs } from "context/useDocs"; import { useProject } from "context/project"; import { isSnapshotsDisabled } from "util/snapshots"; import SnapshotDiabledWarningLink from "components/SnapshotDiabledWarningLink"; +import ScrollableConfigurationTable from "components/forms/ScrollableConfigurationTable"; interface Props { formik: FormikProps; @@ -29,7 +29,7 @@ const StorageVolumeFormSnapshots: FC = ({ formik }) => { )} - ; @@ -12,7 +12,7 @@ interface Props { const StorageVolumeFormZFS: FC = ({ formik }) => { return ( - div { + .form-contents > div, + .content-details { margin-left: 0; max-width: 67rem !important; } diff --git a/src/sass/_scrollable_container.scss b/src/sass/_scrollable_container.scss new file mode 100644 index 0000000000..86048d5dfb --- /dev/null +++ b/src/sass/_scrollable_container.scss @@ -0,0 +1,7 @@ +.scrollable-container { + .content-details { + display: block; + overflow: auto; + scrollbar-gutter: stable; + } +} diff --git a/src/sass/styles.scss b/src/sass/styles.scss index 69805f6744..fa558c6a94 100644 --- a/src/sass/styles.scss +++ b/src/sass/styles.scss @@ -82,6 +82,7 @@ $border-thin: 1px solid $color-mid-light !default; @import "progress_bar"; @import "project_select"; @import "rename_header"; +@import "scrollable_container"; @import "scrollable_table"; @import "selectable_main_table"; @import "settings_page"; diff --git a/src/util/helpers.tsx b/src/util/helpers.tsx index a16a35d3e0..2f8dc3c51d 100644 --- a/src/util/helpers.tsx +++ b/src/util/helpers.tsx @@ -191,7 +191,7 @@ export const defaultFirst = (p1: { name: string }, p2: { name: string }) => export const isWidthBelow = (width: number) => window.innerWidth < width; -export const getParentsBottomSpacing = (element: HTMLElement) => { +export const getParentsBottomSpacing = (element: Element) => { let sum = 0; while (element.parentElement) { element = element.parentElement; @@ -247,3 +247,15 @@ export const logout = () => export const capitalizeFirstLetter = (val: string) => val.charAt(0).toUpperCase() + val.slice(1); + +export const getAbsoluteHeightBelow = (belowId: string) => { + const element = belowId ? document.getElementById(belowId) : undefined; + if (!element) { + return 0; + } + const style = window.getComputedStyle(element); + const margin = parseFloat(style.marginTop) + parseFloat(style.marginBottom); + const padding = + parseFloat(style.paddingTop) + parseFloat(style.paddingBottom); + return element.offsetHeight + margin + padding + 1; +};