diff --git a/services/frontend/src/App.tsx b/services/frontend/src/App.tsx index 1795f05..86abfc3 100644 --- a/services/frontend/src/App.tsx +++ b/services/frontend/src/App.tsx @@ -25,6 +25,7 @@ import ProtectedRoute from "./partials/ProtectedRoute"; import "./index.css"; import { lightTheme } from "./utils/theme"; +import { SuperFormProvider } from "./components/SuperFormProvider"; const queryClient = new QueryClient(); @@ -100,56 +101,60 @@ export default function App() { return ( - - - - - } - /> - - } - /> - - } - /> - } - /> - - } - /> - } - /> - - } - /> - } - /> - - } /> - } /> - } /> - - - + + + + + + } + /> + + } + /> + + } + /> + } + /> + + } + /> + } + /> + + } + /> + } + /> + + } /> + } /> + } + /> + + + diff --git a/services/frontend/src/components/GridColumn.tsx b/services/frontend/src/components/GridColumn.tsx new file mode 100644 index 0000000..03416cd --- /dev/null +++ b/services/frontend/src/components/GridColumn.tsx @@ -0,0 +1,31 @@ +import { styled } from "@mui/joy"; +import { FunctionComponent, ReactElement } from "react"; +import { useSuperForm } from "../hooks"; +import { IFormField } from "../types"; + +interface IRootProps { + spans: number[]; +} + +const Root = styled("div", { + shouldForwardProp: (name) => name !== "spans" +})` + grid-column: span ${({ spans }) => spans[0]}; + @media (max-width: 640px) { + grid-column: span ${({ spans }) => spans[1]}; + } +`; + +export interface IGridColumnProps { + spans: number[]; + fields: IFormField[]; +} + +export const GridColumn: FunctionComponent = ( + props: IGridColumnProps +): ReactElement => { + const { spans, fields } = props; + const { renderField } = useSuperForm(); + + return {fields.map(renderField)}; +}; diff --git a/services/frontend/src/components/GridRow.tsx b/services/frontend/src/components/GridRow.tsx new file mode 100644 index 0000000..b5fdc66 --- /dev/null +++ b/services/frontend/src/components/GridRow.tsx @@ -0,0 +1,30 @@ +import { styled } from "@mui/joy"; +import { FunctionComponent, ReactElement, ReactNode } from "react"; +import { useSuperForm } from "../hooks"; +import { IFormField } from "../types"; + +const Root = styled("div")` + display: grid; + grid-template-columns: repeat(2, 1fr); + grid-column-gap: 0px; + grid-row-gap: 0px; + @media (max-width: 640px) { + grid-template-columns: repeat(1, 1fr); + } + column-gap: ${({ theme }) => theme.spacing(1)}; + width: 100%; +`; + +export interface IGridProps { + fields: IFormField[]; + children?: ReactNode; +} + +export const GridRow: FunctionComponent = ( + props: IGridProps +): ReactElement => { + const { fields } = props; + const { renderField } = useSuperForm(); + + return {fields.map(renderField)}; +}; diff --git a/services/frontend/src/components/SuperForm.tsx b/services/frontend/src/components/SuperForm.tsx new file mode 100644 index 0000000..e1c1273 --- /dev/null +++ b/services/frontend/src/components/SuperForm.tsx @@ -0,0 +1,28 @@ +import { styled } from "@mui/joy"; +import { FunctionComponent, ReactElement } from "react"; +import { useSuperForm } from "../hooks"; +import { IFormField, TFinalFormField } from "../types"; + +const Root = styled("div")` + display: flex; + flex-direction: column; + row-gap: ${({ theme }) => theme.spacing(1)}; + @media (max-width: 640px) { + row-gap: 0; + } +`; + +export interface IRecordFormProps { + fields: T[]; +} + +export const SuperForm: FunctionComponent> = < + T extends IFormField +>( + props: IRecordFormProps +): ReactElement => { + const { fields } = props; + const { renderField } = useSuperForm(); + + return {fields.map(renderField)}; +}; diff --git a/services/frontend/src/components/SuperFormProvider.tsx b/services/frontend/src/components/SuperFormProvider.tsx new file mode 100644 index 0000000..834e9b9 --- /dev/null +++ b/services/frontend/src/components/SuperFormProvider.tsx @@ -0,0 +1,43 @@ +import { FunctionComponent, ReactElement, ReactNode, useMemo } from "react"; +import { SuperFormContext } from "../contexts"; +import { IFormField } from "../types"; +import TextField from "./global/FormElements/TextField"; +import Toggle from "./global/FormElements/Toggle"; +import { GridColumn } from "./GridColumn"; +import { GridRow } from "./GridRow"; +import Records from "./Records"; + +export interface ISuperFormProviderProps { + children?: ReactNode; +} + +const types: Record> = { + text: TextField, + "grid-row": GridRow, + "grid-column": GridColumn, + toggle: Toggle, + records: Records +}; + +export const SuperFormProvider: FunctionComponent = ( + props: ISuperFormProviderProps +): ReactElement => { + const { children } = props; + + const value = useMemo( + () => ({ + types, + renderField: (field: IFormField) => { + const Component = types[field.type]; + return ; + } + }), + [] + ); + + return ( + + {children} + + ); +}; diff --git a/services/frontend/src/components/modals/docker-compose/service/General.tsx b/services/frontend/src/components/modals/docker-compose/service/General.tsx index cf2af17..020a2c0 100644 --- a/services/frontend/src/components/modals/docker-compose/service/General.tsx +++ b/services/frontend/src/components/modals/docker-compose/service/General.tsx @@ -1,7 +1,6 @@ import { styled } from "@mui/joy"; -import TextField from "../../../global/FormElements/TextField"; -import Toggle from "../../../global/FormElements/Toggle"; -import Records from "../../../Records"; +import { TFinalFormField } from "../../../../types"; +import { SuperForm } from "../../../SuperForm"; const Root = styled("div")` display: flex; @@ -12,196 +11,282 @@ const Root = styled("div")` } `; -const Group = styled("div")` - display: grid; - grid-template-columns: repeat(2, 1fr); - grid-column-gap: 0px; - grid-row-gap: 0px; - @media (max-width: 640px) { - grid-template-columns: repeat(1, 1fr); - } - column-gap: ${({ theme }) => theme.spacing(1)}; - width: 100%; -`; - -const SpanTwo = styled("div")` - grid-column: span 2; - @media (max-width: 640px) { - grid-column: span 3; - } -`; - const General = () => { return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - [ + ] + }, { - name: `ports[${index}].hostPort`, - placeholder: "Host port", - required: true, - type: "text" + id: "row3", + type: "grid-row", + fields: [ + { + id: "containerName", + type: "text", + name: "containerName", + label: "Container name" + } + ] }, { - name: `ports[${index}].containerPort`, - placeholder: "Container port", - type: "text" + id: "row4", + type: "grid-row", + fields: [ + { + id: "row4-column1", + type: "grid-column", + spans: [2, 3], + fields: [ + { + id: "command", + type: "text", + name: "command", + label: "Command" + } + ] + } + ] }, { - name: `ports[${index}].protocol`, - type: "toggle", - options: [ + id: "row5", + type: "grid-row", + fields: [ { - value: "tcp", - text: "TCP" - }, + id: "row5-column1", + type: "grid-column", + spans: [2, 3], + fields: [ + { + id: "entrypoint", + type: "text", + name: "entrypoint", + label: "Entrypoint" + } + ] + } + ] + }, + { + id: "row6", + type: "grid-row", + fields: [ { - value: "udp", - text: "UDP" + id: "row6-column1", + type: "grid-column", + spans: [2, 3], + fields: [ + { + id: "envFile", + type: "text", + name: "envFile", + label: "Env file" + } + ] } ] - } - ]} - newValue={{ - hostPort: "", - containerPort: "", - protocol: "" - }} - /> - - [ + }, { - name: `dependsOn[${index}]`, - placeholder: "Service name", - required: false, - type: "text" - } - ]} - newValue={""} - /> - - [ + id: "row7", + type: "grid-row", + fields: [ + { + id: "row7-column1", + type: "grid-column", + spans: [2, 3], + fields: [ + { + id: "workingDir", + type: "text", + name: "workingDir", + label: "Working directory" + } + ] + } + ] + }, { - name: `networks[${index}]`, - placeholder: "Network name", - required: false, - type: "text" - } - ]} - newValue={""} - /> - - [ + id: "row8", + type: "grid-row", + fields: [ + { + id: "row8-column1", + type: "grid-column", + spans: [2, 3], + fields: [ + { + id: "restart", + type: "toggle", + name: "restart", + label: "Restart Policy", + options: [ + { + value: "no", + text: "no" + }, + { + value: "always", + text: "always" + }, + { + value: "on-failure", + text: "on-failure" + }, + { + value: "unless-stopped", + text: "unless-stopped" + } + ] + } + ] + } + ] + }, { - name: `labels[${index}].key`, - placeholder: "Key", - required: true, - type: "text" + id: "ports", + type: "records", + name: "ports", + title: "Ports", + defaultOpen: true, + fields: (index: number): TFinalFormField[] => [ + { + id: `ports[${index}].hostPort`, + type: "text", + name: `ports[${index}].hostPort`, + placeholder: "Host port", + required: true + }, + { + id: `ports[${index}].containerPort`, + type: "text", + name: `ports[${index}].containerPort`, + placeholder: "Container port" + }, + { + id: `ports[${index}].protocol`, + type: "toggle", + name: `ports[${index}].protocol`, + label: "Protocol", + options: [ + { + value: "tcp", + text: "TCP" + }, + { + value: "udp", + text: "UDP" + } + ] + } + ], + newValue: { + hostPort: "", + containerPort: "", + protocol: "" + } }, { - name: `labels[${index}].value`, - placeholder: "Value", - required: true, - type: "text" - } - ]} - newValue={{ key: "", value: "" }} - /> - - [ + id: "dependsOn", + type: "records", + name: "dependsOn", + title: "Depends on", + fields: (index: number): TFinalFormField[] => [ + { + id: `dependsOn[${index}]`, + type: "text", + name: `dependsOn[${index}]`, + placeholder: "Service name", + required: false + } + ], + newValue: "" + }, + { + id: "networks", + type: "records", + title: "Networks", + name: "networks", + fields: (index: number): TFinalFormField[] => [ + { + id: `networks[${index}]`, + type: "text", + name: `networks[${index}]`, + placeholder: "Network name", + required: false + } + ], + newValue: "" + }, + { + id: "labels", + type: "records", + title: "Labels", + name: "labels", + fields: (index: number): TFinalFormField[] => [ + { + id: `labels[${index}].key`, + type: "text", + name: `labels[${index}].key`, + placeholder: "Key", + required: true + }, + { + id: `labels[${index}].value`, + type: "text", + name: `labels[${index}].value`, + placeholder: "Value", + required: true + } + ], + newValue: { key: "", value: "" } + }, { - name: `profiles[${index}]`, - placeholder: "Name", - required: true, - type: "text" + id: "profiles", + type: "records", + title: "Profiles", + name: "profiles", + fields: (index: number): TFinalFormField[] => [ + { + id: `profiles[${index}]`, + name: `profiles[${index}]`, + placeholder: "Name", + required: true, + type: "text" + } + ], + newValue: "" } ]} - newValue={""} /> ); diff --git a/services/frontend/src/contexts/SuperFormContext.tsx b/services/frontend/src/contexts/SuperFormContext.tsx new file mode 100644 index 0000000..856992c --- /dev/null +++ b/services/frontend/src/contexts/SuperFormContext.tsx @@ -0,0 +1,4 @@ +import { createContext } from "react"; +import { ISuperFormContext } from "../types"; + +export const SuperFormContext = createContext(null); diff --git a/services/frontend/src/contexts/index.ts b/services/frontend/src/contexts/index.ts index 8cf84b7..78d9230 100644 --- a/services/frontend/src/contexts/index.ts +++ b/services/frontend/src/contexts/index.ts @@ -1 +1,2 @@ export * from "./TabContext"; +export * from "./SuperFormContext"; diff --git a/services/frontend/src/hooks/index.ts b/services/frontend/src/hooks/index.ts index 78bf33e..016156e 100644 --- a/services/frontend/src/hooks/index.ts +++ b/services/frontend/src/hooks/index.ts @@ -1,3 +1,4 @@ export * from "./useTitle"; export * from "./useAccordionState"; export * from "./useTabContext"; +export * from "./useSuperForm"; diff --git a/services/frontend/src/hooks/useSuperForm.ts b/services/frontend/src/hooks/useSuperForm.ts new file mode 100644 index 0000000..bd2d206 --- /dev/null +++ b/services/frontend/src/hooks/useSuperForm.ts @@ -0,0 +1,12 @@ +import { useContext } from "react"; +import { SuperFormContext } from "../contexts"; +import { ISuperFormContext } from "../types"; + +export const useSuperForm = (): ISuperFormContext => { + const context = useContext(SuperFormContext); + if (!context) { + throw new Error("Cannot find super form context!"); + } + + return context; +}; diff --git a/services/frontend/src/types/index.ts b/services/frontend/src/types/index.ts index 0c62dfe..5ff9989 100644 --- a/services/frontend/src/types/index.ts +++ b/services/frontend/src/types/index.ts @@ -1,5 +1,6 @@ import { AnchorId } from "@jsplumb/common"; import { Dictionary } from "lodash"; +import { FunctionComponent, ReactNode } from "react"; import { KeyValuePair } from "tailwindcss/types/config"; import { string } from "yup"; import { NodeGroupType } from "./enums"; @@ -520,3 +521,85 @@ export interface ITabContext { value: string; onChange: (newValue: string) => void; } + +/* -- SuperForm -- */ + +export interface ISuperFormContext { + types: Record; + renderField: (field: IFormField) => ReactNode; +} + +export interface IFormField { + id: string; + type: + | "grid-row" + | "grid-column" + | "text" + | "integer" + | "toggle" + | "accordion" + | "records"; +} + +export interface IGridRowField extends IFormField { + type: "grid-row"; + fields: T[]; +} + +export interface IGridColumnField extends IFormField { + type: "grid-column"; + spans: number[]; + fields: T[]; +} + +export interface IValueField extends IFormField { + name: string; +} + +export interface ISingleRowField extends IValueField { + help?: string; +} + +export interface ITextField extends ISingleRowField { + type: "text"; + label?: string; + placeholder?: string; + required?: boolean; +} + +export interface IIntegerField extends ISingleRowField { + type: "integer"; + label: string; + required: boolean; +} + +export interface IToggleField extends ISingleRowField { + type: "toggle"; + label: string; + options: { + text: string; + value: string; + }[]; +} + +export interface IRecordsField extends IValueField { + type: "records"; + title: string; + defaultOpen?: boolean; + fields: (index: number) => T[]; + newValue: any; +} + +export interface IAccordionField extends IFormField { + type: "accordion"; + title: string; +} + +export type TFinalFormField = + | IGridColumnField + | IGridRowField + | ITextField + | IIntegerField + | IToggleField + | IRecordsField + | IAccordionField;