diff --git a/e2e/explorer/index.spec.ts b/e2e/explorer/index.spec.ts index ff47a9b3b..0201de97f 100644 --- a/e2e/explorer/index.spec.ts +++ b/e2e/explorer/index.spec.ts @@ -112,7 +112,7 @@ test("it is possible to create a folder", async ({ page }) => { const folderName = "awesome-folder"; // create folder - await page.getByTestId("dropdown-trg-new").click(); + await page.getByText("New").first().click(); await page.getByTestId("new-dir").click(); await page.getByPlaceholder("folder-name").fill(folderName); await page.getByRole("button", { name: "Create" }).click(); @@ -168,7 +168,7 @@ test("it is possible to create a workflow", async ({ page }) => { const filename = "awesomeworkflow.yaml"; // create workflow - await page.getByTestId("dropdown-trg-new").click(); + await page.getByText("New").first().click(); await page.getByTestId("new-workflow").click(); await page.getByTestId("new-workflow-name").fill(filename); await page.getByTestId("new-workflow-submit").click(); @@ -229,7 +229,7 @@ test("it is possible to create a workflow without providing the .yaml file exten const filenameWithoutExtension = "awesome-workflow"; // create workflow - await page.getByTestId("dropdown-trg-new").click(); + await page.getByText("New").first().click(); await page.getByTestId("new-workflow").click(); await page.getByTestId("new-workflow-name").fill(filenameWithoutExtension); await page.getByTestId("new-workflow-submit").click(); diff --git a/e2e/explorer/workflow/diagramEditor.spec.ts b/e2e/explorer/workflow/diagramEditor.spec.ts index a36a3d947..1a4003289 100644 --- a/e2e/explorer/workflow/diagramEditor.spec.ts +++ b/e2e/explorer/workflow/diagramEditor.spec.ts @@ -1,7 +1,7 @@ import { Page, expect, test } from "@playwright/test"; import { createNamespace, deleteNamespace } from "../../utils/namespace"; -import { consumeEvent as consumeEventWorkflow } from "~/pages/namespace/Explorer/Tree/NewWorkflow/templates"; +import { consumeEvent as consumeEventWorkflow } from "~/pages/namespace/Explorer/Tree/components/modals/CreateNew/Workflow/templates"; import { createRevision } from "~/api/tree/mutate/createRevision"; import { createWorkflow } from "~/api/tree/mutate/createWorkflow"; import { faker } from "@faker-js/faker"; diff --git a/e2e/explorer/workflow/revisions/revisionsDetail.spec.ts b/e2e/explorer/workflow/revisions/revisionsDetail.spec.ts index 905e74192..1578da8c5 100644 --- a/e2e/explorer/workflow/revisions/revisionsDetail.spec.ts +++ b/e2e/explorer/workflow/revisions/revisionsDetail.spec.ts @@ -2,7 +2,7 @@ import { createNamespace, deleteNamespace } from "../../../utils/namespace"; import { expect, test } from "@playwright/test"; import { headers, radixClick } from "../../../utils/testutils"; -import { noop as basicWorkflow } from "~/pages/namespace/Explorer/Tree/NewWorkflow/templates"; +import { noop as basicWorkflow } from "~/pages/namespace/Explorer/Tree/components/modals/CreateNew/Workflow/templates"; import { createWorkflow } from "~/api/tree/mutate/createWorkflow"; import { createWorkflowWithThreeRevisions } from "../../../utils/revisions"; import { faker } from "@faker-js/faker"; diff --git a/e2e/explorer/workflow/revisions/trafficShaping.spec.ts b/e2e/explorer/workflow/revisions/trafficShaping.spec.ts index f43457af1..34a0b2fd7 100644 --- a/e2e/explorer/workflow/revisions/trafficShaping.spec.ts +++ b/e2e/explorer/workflow/revisions/trafficShaping.spec.ts @@ -1,7 +1,7 @@ import { createNamespace, deleteNamespace } from "../../../utils/namespace"; import { expect, test } from "@playwright/test"; -import { noop as basicWorkflow } from "~/pages/namespace/Explorer/Tree/NewWorkflow/templates"; +import { noop as basicWorkflow } from "~/pages/namespace/Explorer/Tree/components/modals/CreateNew/Workflow/templates"; import { createWorkflow } from "~/api/tree/mutate/createWorkflow"; import { createWorkflowWithThreeRevisions } from "../../../utils/revisions"; import { faker } from "@faker-js/faker"; diff --git a/e2e/explorer/workflow/run.spec.ts b/e2e/explorer/workflow/run.spec.ts index 533b6a02f..8c031bbd7 100644 --- a/e2e/explorer/workflow/run.spec.ts +++ b/e2e/explorer/workflow/run.spec.ts @@ -6,7 +6,7 @@ import { waitForSuccessToast, } from "./utils"; -import { noop as basicWorkflow } from "~/pages/namespace/Explorer/Tree/NewWorkflow/templates"; +import { noop as basicWorkflow } from "~/pages/namespace/Explorer/Tree/components/modals/CreateNew/Workflow/templates"; import { createWorkflow } from "~/api/tree/mutate/createWorkflow"; import { faker } from "@faker-js/faker"; import { getInput } from "~/api/instances/query/input"; diff --git a/e2e/explorer/workflow/settings.spec.ts b/e2e/explorer/workflow/settings.spec.ts index 35c5e06a6..dacbd46b9 100644 --- a/e2e/explorer/workflow/settings.spec.ts +++ b/e2e/explorer/workflow/settings.spec.ts @@ -1,7 +1,7 @@ import { createNamespace, deleteNamespace } from "../../utils/namespace"; import { expect, test } from "@playwright/test"; -import { noop as basicWorkflow } from "~/pages/namespace/Explorer/Tree/NewWorkflow/templates"; +import { noop as basicWorkflow } from "~/pages/namespace/Explorer/Tree/components/modals/CreateNew/Workflow/templates"; import { createWorkflow } from "~/api/tree/mutate/createWorkflow"; import { createWorkflowVariables } from "e2e/utils/variables"; import { faker } from "@faker-js/faker"; diff --git a/package.json b/package.json index f9f61e3dc..307f845b5 100644 --- a/package.json +++ b/package.json @@ -161,4 +161,4 @@ "vitest": "^0.28.2", "yalc": "^1.0.0-pre.53" } -} +} \ No newline at end of file diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index efc00307f..c85e87781 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -49,6 +49,7 @@ "title": "this directory is empty", "createWorkflow": "Create Workflow", "createService": "Create Service", + "createEndpoint": "Create Endpoint", "createDirectory": "Create Directory" } }, @@ -78,12 +79,19 @@ "cancelBtn": "Cancel", "deleteBtn": "Delete" }, - "header": { - "newBtn": "New", - "createLabel": "Create", - "newDirectory": "New Directory", - "newWorkflow": "New Workflow", - "newService": "New Service" + "newFileButton": { + "buttonText": "New", + "label": "Create", + "items": { + "directory": "New Directory", + "workflow": "New Workflow", + "service": "New Service", + "gateway": { + "label": "Gateway", + "route": "New Route", + "consumer": "New Consumer" + } + } }, "newWorkflow": { "title": "Create a new workflow", @@ -110,6 +118,22 @@ "postStartExec": "Post Start Exec" } }, + "newRoute": { + "title": "Create a new route", + "nameLabel": "Name", + "namePlaceholder": "route-name.yaml", + "nameAlreadyExists": "The name already exists", + "cancelBtn": "Cancel", + "createBtn": "Create" + }, + "newConsumer": { + "title": "Create a new consumer", + "nameLabel": "Name", + "namePlaceholder": "consumer-name.yaml", + "nameAlreadyExists": "The name already exists", + "cancelBtn": "Cancel", + "createBtn": "Create" + }, "workflow": { "revisions": { "overview": { @@ -276,6 +300,181 @@ "saveBtn": "Save", "helpTitle": "Example service file" } + }, + "endpoint": { + "goToRoutes": "Go to routes", + "editor": { + "form": { + "serialisationError": "There was an error serializing the form data", + "path": "path", + "timeout": "timeout (optional)", + "methods": "methods", + "allowAnonymous": "allow anonymous", + "plugins": { + "saveBtn": "Save", + "deleteBtn": "Delete", + "moveUpBtn": "Move up", + "moveDownBtn": "Move down", + "target": { + "table": { + "headline": "Target plugin", + "addButton": "set target plugin" + }, + "modal": { + "headline": "Configure target plugin", + "label": "Target plugin", + "placeholder": "Please select a target plugin" + }, + "types": { + "instant-response": "Instant Response", + "target-flow": "Workflow", + "target-flow-var": "Workflow Variable", + "target-namespace-file": "Namespace File", + "target-namespace-var": "Namespace Variable" + }, + "instantResponse": { + "statusCode": "Status Code", + "statusCodePlaceholder": "200", + "contentType": "Content Type (optional)", + "contentTypePlaceholder": "application/json", + "statusMessage": "Status Message (optional)" + }, + "targetFlow": { + "namespace": "Namespace (optional)", + "workflow": "Workflow", + "asynchronous": "Asynchronous", + "contentType": "Content Type (optional)", + "contentTypePlaceholder": "application/json" + }, + "targetFlowVar": { + "namespace": "Namespace (optional)", + "workflow": "Workflow", + "variable": "Variable", + "contentType": "Content Type (optional)", + "contentTypePlaceholder": "application/json" + }, + "targetNamespaceFile": { + "namespace": "Namespace (optional)", + "file": "File", + "contentType": "Content Type (optional)", + "contentTypePlaceholder": "application/json" + }, + "targetNamespaceVariable": { + "namespace": "Namespace (optional)", + "variable": "Variable", + "contentType": "Content Type (optional)", + "contentTypePlaceholder": "application/json" + } + }, + "inbound": { + "modal": { + "headlineAdd": "Add inbound plugin", + "headlineEdit": "Edit inbound plugin", + "label": "Inbound plugin", + "placeholder": "Please select an inbound plugin" + }, + "table": { + "headline": "{{count}} inbound plugins", + "headline_one": "{{count}} inbound plugin", + "addButton": "add inbound plugin" + }, + "types": { + "acl": "Access control list (acl)", + "js-inbound": "JavaScript", + "request-convert": "Request convert" + }, + "jsInbound": { + "script": "JavaScript" + }, + "requestConvert": { + "omitHeaders": "Omit Headers", + "omitQueries": "Omit Queries", + "omitBody": "Omit Body", + "omitConsumer": "Omit Consumer" + }, + "acl": { + "allow_groups": "Allow Groups (optional)", + "deny_groups": "Deny Groups (optional)", + "allow_tags": "Allow tags (optional)", + "deny_tags": "Deny tags (optional)", + "groupPlaceholder": "Enter a group", + "tagPlaceholder": "Enter a tag" + } + }, + "outbound": { + "modal": { + "headlineAdd": "Add outbound plugin", + "headlineEdit": "Edit outbound plugin", + "label": "Outbound plugin", + "placeholder": "Please select an outbound plugin" + }, + "table": { + "headline": "{{count}} outbound plugins", + "headline_one": "{{count}} outbound plugin", + "addButton": "add outbound plugin" + }, + "types": { + "js-outbound": "JavaScript" + }, + "jsOutbound": { + "script": "JavaScript" + } + }, + "auth": { + "modal": { + "headlineAdd": "Add auth plugin", + "headlineEdit": "Edit auth plugin", + "label": "Auth plugin", + "placeholder": "Please select an auth plugin" + }, + "table": { + "headline": "{{count}} auth plugins", + "headline_one": "{{count}} auth plugin", + "addButton": "add auth plugin" + }, + "types": { + "basic-auth": "Basic Auth", + "github-webhook-auth": "Github Webhook", + "key-auth": "Key Auth" + }, + "basciAuth": { + "addUsernameHeader": "Add username header", + "addTagsHeader": "Add tags header", + "addGroupsHeader": "Add groups header" + }, + "githubWebhookAuth": { + "secret": "secret" + }, + "keyAuth": { + "addUsernameHeader": "Add username header", + "addTagsHeader": "Add tags header", + "addGroupsHeader": "Add groups header", + "keyName": "Key name (optional)", + "keyNamePlaceholder": "name of the key" + } + } + } + }, + "saveBtn": "Save", + "unsavedNote": "unsaved changes" + } + }, + "consumer": { + "goToConsumer": "Go to consumers", + "editor": { + "form": { + "serialisationError": "There was an error serializing the form data", + "username": "Username", + "password": "Password", + "api_key": "Api key", + "tags": "Tags (optional)", + "groups": "Groups (optional)", + "tagsPlaceholder": "Enter a tag", + "groupsPlaceholder": "Enter a group" + }, + "saveBtn": "Save", + "unsavedNote": "unsaved changes" + } } }, "events": { @@ -828,7 +1027,7 @@ "title": "Namespace" }, "apiKey": { - "title": "Api Key" + "title": "API Key" }, "version": { "title": "Version" @@ -837,6 +1036,52 @@ "title": "Namespaces" } }, + "gateway": { + "tabs": { + "routes": "Routes", + "consumers": "Consumers" + }, + "routes": { + "columns": { + "filePath": "file path", + "methods": "methods", + "path": "path", + "plugins": "plugins", + "anonymous": "allow anonymous" + }, + "row": { + "allowAnonymous": { + "yes": "yes", + "no": "no" + }, + "errors": { + "count": "{{count}} errors", + "count_one": "{{count}} error" + }, + "warnings": { + "count": "{{count}} warnings", + "count_one": "{{count}} warning" + }, + "plugin": { + "countAll": "{{count}} plugins", + "countAll_one": "{{count}} plugin", + "countType": "{{count}} {{type}} plugins", + "countType_one": "{{count}} {{type}} plugin" + } + }, + "empty": "No routes exist yet" + }, + "consumer": { + "columns": { + "username": "username", + "password": "password", + "apikey": "apikey", + "tags": "tags", + "groups": "groups" + }, + "empty": "No consumers exist yet" + } + }, "jqPlayground": { "title": "jq Playground", "description": "jq Playground is an envrioment where you can quickly test your jq commands against JSON input.", @@ -894,6 +1139,9 @@ "permissionsTokens": "Tokens", "permissionsGroups": "Groups", "settings": "Settings", + "gateway": "Gateway", + "gatewayRoutes": "Routes", + "gatewayConsumers": "Consumer", "jqPlayground": "jq Playground" }, "namespaceEdit": { @@ -985,6 +1233,19 @@ } } }, + "namespaceSelector": { + "placeholder": "Select Namespace", + "loading": "loading...", + "optionDoesNotExists": "{{namespace}} (does not exist)" + }, + "filepicker": { + "buttonText": "Browse Files", + "placeholder": "No File selected", + "error": { + "title": "The path \"{{path}}\" was not found.", + "linkText": "Go back to root directory" + } + }, "userMenu": { "loggedIn": "You are logged in", "logout": "Logout", diff --git a/src/api/gateway/index.ts b/src/api/gateway/index.ts new file mode 100644 index 000000000..6f8a910af --- /dev/null +++ b/src/api/gateway/index.ts @@ -0,0 +1,18 @@ +export const gatewayKeys = { + routes: (namespace: string, { apiKey }: { apiKey?: string }) => + [ + { + scope: "gateway-routes", + apiKey, + namespace, + }, + ] as const, + consumers: (namespace: string, { apiKey }: { apiKey?: string }) => + [ + { + scope: "gateway-consumers", + apiKey, + namespace, + }, + ] as const, +}; diff --git a/src/api/gateway/query/getConsumers.ts b/src/api/gateway/query/getConsumers.ts new file mode 100644 index 000000000..0303c7cf3 --- /dev/null +++ b/src/api/gateway/query/getConsumers.ts @@ -0,0 +1,39 @@ +import { ConsumersListSchema } from "../schema"; +import { QueryFunctionContext } from "@tanstack/react-query"; +import { apiFactory } from "~/api/apiFactory"; +import { gatewayKeys } from ".."; +import { useApiKey } from "~/util/store/apiKey"; +import { useNamespace } from "~/util/store/namespace"; +import useQueryWithPermissions from "~/api/useQueryWithPermissions"; + +export const getConsumers = apiFactory({ + url: ({ baseUrl, namespace }: { baseUrl?: string; namespace: string }) => + `${baseUrl ?? ""}/api/v2/namespaces/${namespace}/gateway/consumers`, + method: "GET", + schema: ConsumersListSchema, +}); + +const fetchConsumers = async ({ + queryKey: [{ apiKey, namespace }], +}: QueryFunctionContext>) => + getConsumers({ + apiKey, + urlParams: { namespace }, + }); + +export const useConsumers = () => { + const apiKey = useApiKey(); + const namespace = useNamespace(); + + if (!namespace) { + throw new Error("namespace is undefined"); + } + + return useQueryWithPermissions({ + queryKey: gatewayKeys.consumers(namespace, { + apiKey: apiKey ?? undefined, + }), + queryFn: fetchConsumers, + enabled: !!namespace, + }); +}; diff --git a/src/api/gateway/query/getRoutes.ts b/src/api/gateway/query/getRoutes.ts new file mode 100644 index 000000000..fb2e8c10b --- /dev/null +++ b/src/api/gateway/query/getRoutes.ts @@ -0,0 +1,39 @@ +import { QueryFunctionContext } from "@tanstack/react-query"; +import { RoutesListSchema } from "../schema"; +import { apiFactory } from "~/api/apiFactory"; +import { gatewayKeys } from ".."; +import { useApiKey } from "~/util/store/apiKey"; +import { useNamespace } from "~/util/store/namespace"; +import useQueryWithPermissions from "~/api/useQueryWithPermissions"; + +export const getRoutes = apiFactory({ + url: ({ baseUrl, namespace }: { baseUrl?: string; namespace: string }) => + `${baseUrl ?? ""}/api/v2/namespaces/${namespace}/gateway/routes`, + method: "GET", + schema: RoutesListSchema, +}); + +const fetchRoutes = async ({ + queryKey: [{ apiKey, namespace }], +}: QueryFunctionContext>) => + getRoutes({ + apiKey, + urlParams: { namespace }, + }); + +export const useRoutes = () => { + const apiKey = useApiKey(); + const namespace = useNamespace(); + + if (!namespace) { + throw new Error("namespace is undefined"); + } + + return useQueryWithPermissions({ + queryKey: gatewayKeys.routes(namespace, { + apiKey: apiKey ?? undefined, + }), + queryFn: fetchRoutes, + enabled: !!namespace, + }); +}; diff --git a/src/api/gateway/schema.ts b/src/api/gateway/schema.ts new file mode 100644 index 000000000..ef6c9bfe6 --- /dev/null +++ b/src/api/gateway/schema.ts @@ -0,0 +1,130 @@ +import { z } from "zod"; + +export const routeMethods = [ + "CONNECT", + "DELETE", + "GET", + "HEAD", + "OPTIONS", + "PATCH", + "POST", + "PUT", + "TRACE", +] as const; + +export const MethodsSchema = z.enum(routeMethods); + +/** + * example + { + "type": "proxy", + "configuration": { + "key1": ["value1", "value2", "value3"], + "key2": "value3" + } + } + */ +const PluginSchema = z.object({ + type: z.string(), + configuration: z.record(z.unknown()).nullable(), +}); + +/** + * example +{ + "file_path": "/test/roottree.yaml", + "path": "/adon/", + "server_path": "/ns/git-test/adon", + "methods": [ + "GET" + ], + "allow_anonymous": true, + "timeout": 0, + "errors": [], + "warnings": [], + "plugins": { + "target": { + "type": "instant-response", + "configuration": { + "content_type": "application/xml", + "status_code": 200, + "status_message": "..." + } + } + } +} + */ + +const filterInvalidEntries = (schema: z.ZodTypeAny) => + z + .array(z.any()) + .transform((entryArr) => + entryArr.filter((entry) => schema.safeParse(entry).success) + ); + +const RouteSchema = z.object({ + methods: filterInvalidEntries(MethodsSchema).nullable(), + file_path: z.string(), + path: z.string().optional(), + server_path: z.string().optional(), + allow_anonymous: z.boolean(), + errors: z.array(z.string()), + warnings: z.array(z.string()), + plugins: z.object({ + // if a user might use an unsupported plugin, they will be parsed out instead of throwing an error + outbound: filterInvalidEntries(PluginSchema).optional(), + inbound: filterInvalidEntries(PluginSchema).optional(), + auth: filterInvalidEntries(PluginSchema).optional(), + target: PluginSchema.optional(), + }), +}); + +export type RouteSchemeType = z.infer; + +/** + * example + { + "data": [{...}, {...}, {...}] + } + */ +export const RoutesListSchema = z.object({ + data: z.array(RouteSchema), +}); + +/** + * example +{ + "data": [ + { + "username": "user", + "password": "pwd", + "api_key": "key", + "tags": [ + "tag1" + ], + "groups": [ + "group1" + ] + } + ] +} + */ +const ConsumerSchema = z.object({ + username: z.string(), + password: z.string(), + api_key: z.string(), + tags: z.array(z.string()).nullable(), + groups: z.array(z.string()).nullable(), +}); + +export type ConsumerSchemaType = z.infer; + +/** + * example + { + "data": [{...}, {...}, {...}] + } + */ +export const ConsumersListSchema = z.object({ + data: z.array(ConsumerSchema), +}); diff --git a/src/api/tree/mutate/updateWorkflow.ts b/src/api/tree/mutate/updateWorkflow.ts index 74f119f35..99f248cb9 100644 --- a/src/api/tree/mutate/updateWorkflow.ts +++ b/src/api/tree/mutate/updateWorkflow.ts @@ -2,6 +2,7 @@ import { NodeListSchemaType, WorkflowCreatedSchema } from "../schema/node"; import { apiFactory } from "~/api/apiFactory"; import { forceLeadingSlash } from "../utils"; +import { gatewayKeys } from "~/api/gateway"; import { getMessageFromApiError } from "~/api/errorHandling"; import { treeKeys } from ".."; import { useApiKey } from "~/util/store/apiKey"; @@ -65,6 +66,12 @@ export const useUpdateWorkflow = ({ }), () => data ); + // if the updated file was a route, we need to invalidate the routes query + queryClient.invalidateQueries( + gatewayKeys.routes(namespace, { + apiKey: apiKey ?? undefined, + }) + ); onSuccess?.(); }, onError: (e) => { diff --git a/src/api/tree/query/node.ts b/src/api/tree/query/node.ts index b89ab4071..fc166e6f0 100644 --- a/src/api/tree/query/node.ts +++ b/src/api/tree/query/node.ts @@ -43,13 +43,17 @@ export const useNodeContent = ({ path, revision, enabled = true, + namespace: givenNamespace, }: { path?: string; revision?: string; enabled?: boolean; + namespace?: string; } = {}) => { + const defaultNamespace = useNamespace(); + + const namespace = givenNamespace ? givenNamespace : defaultNamespace; const apiKey = useApiKey(); - const namespace = useNamespace(); if (!namespace) { throw new Error("namespace is undefined"); diff --git a/src/api/tree/schema/workflowVariable.ts b/src/api/tree/schema/workflowVariable.ts index 1d143e006..9b32b3f3b 100644 --- a/src/api/tree/schema/workflowVariable.ts +++ b/src/api/tree/schema/workflowVariable.ts @@ -41,14 +41,14 @@ export const WorkflowVariableListSchema = z.object({ export const WorkflowVariableContentSchema = z.object({ body: z.string(), headers: z.object({ - "content-type": z.string(), // same as mimeType + "content-type": z.string().optional(), // same as mimeType }), }); export const WorkflowVariableDownloadSchema = z.object({ blob: z.instanceof(Blob), headers: z.object({ - "content-type": z.string(), + "content-type": z.string().optional(), }), }); diff --git a/src/api/tree/utils.ts b/src/api/tree/utils.ts index 27e1f8f0c..07866ffca 100644 --- a/src/api/tree/utils.ts +++ b/src/api/tree/utils.ts @@ -1,5 +1,6 @@ -import { File, Folder, Layers, Network, Play, Users } from "lucide-react"; +import { File, Folder, Layers, Play, Users, Workflow } from "lucide-react"; +import { ExplorerSubpages } from "~/util/router/pages"; import { NodeSchemaType } from "./schema/node"; export const forceLeadingSlash = (path?: string) => { @@ -53,7 +54,7 @@ export const fileTypeToIcon = (type: NodeSchemaType["type"]) => { case "workflow": return Play; case "endpoint": - return Network; + return Workflow; case "consumer": return Users; default: @@ -61,16 +62,21 @@ export const fileTypeToIcon = (type: NodeSchemaType["type"]) => { } }; -export const fileTypeToExplorerSubpage = (type: NodeSchemaType["type"]) => { +export const fileTypeToExplorerSubpage = ( + type: NodeSchemaType["type"] +): ExplorerSubpages | undefined => { switch (type) { case "workflow": return "workflow"; case "service": return "service"; + case "endpoint": + return "endpoint"; + case "consumer": + return "consumer"; default: return undefined; } }; -export const isPreviewable = (type: NodeSchemaType["type"]) => - type === "file" || type === "consumer" || type === "endpoint"; +export const isPreviewable = (type: NodeSchemaType["type"]) => type === "file"; diff --git a/src/api/variables/schema.ts b/src/api/variables/schema.ts index 8e300a511..5ac55d015 100644 --- a/src/api/variables/schema.ts +++ b/src/api/variables/schema.ts @@ -26,14 +26,14 @@ export const VarDeletedSchema = z.null(); export const VarContentSchema = z.object({ body: z.string(), headers: z.object({ - "content-type": z.string(), + "content-type": z.string().optional(), }), }); export const VarDownloadSchema = z.object({ blob: z.instanceof(Blob), headers: z.object({ - "content-type": z.string(), + "content-type": z.string().optional(), }), }); diff --git a/src/componentsNext/Breadcrumb/Gateway/Consumer.tsx b/src/componentsNext/Breadcrumb/Gateway/Consumer.tsx new file mode 100644 index 000000000..aae45b9cb --- /dev/null +++ b/src/componentsNext/Breadcrumb/Gateway/Consumer.tsx @@ -0,0 +1,34 @@ +import { Breadcrumb as BreadcrumbLink } from "~/design/Breadcrumbs"; +import { Link } from "react-router-dom"; +import { Users } from "lucide-react"; +import { pages } from "~/util/router/pages"; +import { useNamespace } from "~/util/store/namespace"; +import { useTranslation } from "react-i18next"; + +const ConsumerBreadcrumb = () => { + const namespace = useNamespace(); + const { isGatewayConsumerPage } = pages.gateway.useParams(); + + const { t } = useTranslation(); + + if (!namespace) return null; + if (!isGatewayConsumerPage) return null; + + return ( + <> + + + + + ); +}; + +export default ConsumerBreadcrumb; diff --git a/src/componentsNext/Breadcrumb/Gateway/Routes.tsx b/src/componentsNext/Breadcrumb/Gateway/Routes.tsx new file mode 100644 index 000000000..b588eeb35 --- /dev/null +++ b/src/componentsNext/Breadcrumb/Gateway/Routes.tsx @@ -0,0 +1,33 @@ +import { Breadcrumb as BreadcrumbLink } from "~/design/Breadcrumbs"; +import { Link } from "react-router-dom"; +import { Workflow } from "lucide-react"; +import { pages } from "~/util/router/pages"; +import { useNamespace } from "~/util/store/namespace"; +import { useTranslation } from "react-i18next"; + +const RoutesBreadcrumb = () => { + const namespace = useNamespace(); + const { isGatewayRoutesPage } = pages.gateway.useParams(); + + const { t } = useTranslation(); + + if (!namespace) return null; + if (!isGatewayRoutesPage) return null; + + return ( + <> + + + + + ); +}; + +export default RoutesBreadcrumb; diff --git a/src/componentsNext/Breadcrumb/Gateway/index.tsx b/src/componentsNext/Breadcrumb/Gateway/index.tsx new file mode 100644 index 000000000..4963b75ff --- /dev/null +++ b/src/componentsNext/Breadcrumb/Gateway/index.tsx @@ -0,0 +1,36 @@ +import { Breadcrumb as BreadcrumbLink } from "~/design/Breadcrumbs"; +import ConsumerBreadcrumb from "./Consumer"; +import { Link } from "react-router-dom"; +import RoutesBreadcrumb from "./Routes"; +import { pages } from "~/util/router/pages"; +import { useNamespace } from "~/util/store/namespace"; +import { useTranslation } from "react-i18next"; + +const GatewayBreadcrumb = () => { + const namespace = useNamespace(); + const { isGatewayPage } = pages.gateway.useParams(); + const { icon: Icon } = pages.gateway; + const { t } = useTranslation(); + + if (!namespace) return null; + if (!isGatewayPage) return null; + + return ( + <> + + + + + + + ); +}; + +export default GatewayBreadcrumb; diff --git a/src/componentsNext/Breadcrumb/index.tsx b/src/componentsNext/Breadcrumb/index.tsx index c651f01fe..8e4719ab9 100644 --- a/src/componentsNext/Breadcrumb/index.tsx +++ b/src/componentsNext/Breadcrumb/index.tsx @@ -2,6 +2,7 @@ import { BreadcrumbRoot } from "~/design/Breadcrumbs"; import EventHistoryBreadcrumb from "./Events/HistoryBreadcrumb"; import EventListenerBreadcrumb from "./Events/ListenerBreadcrumb"; import ExplorerBreadcrumb from "./ExplorerBreadcrumb"; +import GatewayBreadcrumb from "./Gateway"; import InstancesBreadcrumb from "./InstancesBreadcrumb"; import JqPlaygroundBreadcrumb from "./JqPlaygroundBreadcrumb"; import MirrorBreadcrumb from "./MirrorBreadcrumb"; @@ -25,6 +26,7 @@ const Breadcrumb = () => { const { isSettingsPage } = pages.settings.useParams(); const { isJqPlaygroundPage } = pages.jqPlayground.useParams(); const { isMirrorPage } = pages.mirror.useParams(); + const { isGatewayPage } = pages.gateway.useParams(); if (!namespace) return null; @@ -41,6 +43,7 @@ const Breadcrumb = () => { {isSettingsPage && } {isJqPlaygroundPage && } {isMirrorPage && } + {isGatewayPage && } ); }; diff --git a/src/componentsNext/FilePicker/index.tsx b/src/componentsNext/FilePicker/index.tsx new file mode 100644 index 000000000..7c45d184b --- /dev/null +++ b/src/componentsNext/FilePicker/index.tsx @@ -0,0 +1,176 @@ +import { ArrowLeftToLineIcon, FolderUp, Home } from "lucide-react"; +import { Breadcrumb, BreadcrumbRoot } from "~/design/Breadcrumbs"; +import { + Filepicker, + FilepickerClose, + FilepickerHeading, + FilepickerList, + FilepickerListItem, + FilepickerSeparator, +} from "~/design/Filepicker"; +import { Fragment, useState } from "react"; + +import { ButtonBar } from "~/design/ButtonBar"; +import Input from "~/design/Input"; +import { NodeSchemaType } from "~/api/tree/schema/node"; +import { analyzePath } from "~/util/router/utils"; +import { fileTypeToIcon } from "~/api/tree/utils"; +import { twMergeClsx } from "~/util/helpers"; +import { useNodeContent } from "~/api/tree/query/node"; +import { useTranslation } from "react-i18next"; + +const convertFileToPath = (string?: string) => + analyzePath(string).parent?.absolute ?? "/"; + +const FilePicker = ({ + namespace, + defaultPath, + onChange, + selectable, +}: { + namespace?: string; + defaultPath?: string; + onChange?: (filePath: string) => void; + selectable?: (node: NodeSchemaType) => boolean; +}) => { + const [path, setPath] = useState(convertFileToPath(defaultPath)); + const [inputValue, setInputValue] = useState(defaultPath ? defaultPath : ""); + + const { data, isError } = useNodeContent({ + path, + namespace, + }); + + const { t } = useTranslation(); + + const { parent, isRoot, segments } = analyzePath(path); + + const results = data?.children?.results ?? []; + + return ( + + { + setPath(convertFileToPath(inputValue)); + }} + className="w-44" + > + + + { + setPath("/"); + }} + className="h-5 hover:underline" + > + + + {segments.map((file) => { + const isEmpty = file.absolute === ""; + + if (isEmpty) return null; + return ( + { + setPath(file.absolute); + }} + className="h-5 hover:underline" + > + {file.relative} + + ); + })} + + + + {isError && ( +
+ +
+ {t("components.filepicker.error.title", { path })} +
+
+ +
{ + setPath("/"); + }} + className="h-auto w-full cursor-pointer p-0 font-normal text-gray-11 hover:underline focus:bg-transparent focus:ring-0 focus:ring-transparent focus:ring-offset-0 dark:text-gray-dark-11 dark:focus:bg-transparent" + > + + {t("components.filepicker.error.linkText")} + +
+
+
+ )} + {!isRoot && data && ( + <> +
{ + parent ? setPath(parent.absolute) : null; + }} + className="h-auto w-full cursor-pointer p-0 font-normal text-gray-11 hover:underline focus:bg-transparent focus:ring-0 focus:ring-transparent focus:ring-offset-0 dark:text-gray-dark-11 dark:focus:bg-transparent" + > + .. +
+ + + )} + + {results.map((file) => { + const isSelectable = selectable?.(file) ?? true; + return ( + + {file.type === "directory" ? ( +
{ + setPath(file.path); + }} + className="h-auto w-full cursor-pointer text-gray-11 hover:underline focus:bg-transparent focus:ring-0 focus:ring-transparent focus:ring-offset-0 dark:text-gray-dark-11 dark:focus:bg-transparent" + > + + {file.name} + +
+ ) : ( + { + setPath(file.parent); + setInputValue(file.path); + onChange?.(file.path); + }} + > + + {file.name} + + + )} + + +
+ ); + })} +
+
+ { + setInputValue(e.target.value); + onChange?.(e.target.value); + }} + /> +
+ ); +}; + +export default FilePicker; diff --git a/src/componentsNext/FormErrors/index.tsx b/src/componentsNext/FormErrors/index.tsx index 3de1568f7..77a0652ef 100644 --- a/src/componentsNext/FormErrors/index.tsx +++ b/src/componentsNext/FormErrors/index.tsx @@ -3,7 +3,7 @@ import { ComponentProps, FC } from "react"; import Alert from "~/design/Alert"; import { useTranslation } from "react-i18next"; -type errorsType = Record; +export type errorsType = Record; type FormErrorsProps = ComponentProps & { errors: errorsType }; diff --git a/src/componentsNext/NamespaceSelector/index.tsx b/src/componentsNext/NamespaceSelector/index.tsx new file mode 100644 index 000000000..ff53ce3cf --- /dev/null +++ b/src/componentsNext/NamespaceSelector/index.tsx @@ -0,0 +1,67 @@ +import { ComponentProps, FC } from "react"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "~/design/Select"; + +import { Loader2 } from "lucide-react"; +import { useListNamespaces } from "~/api/namespaces/query/get"; +import { useNamespace } from "~/util/store/namespace"; +import { useTranslation } from "react-i18next"; + +export type ButtonProps = ComponentProps & { + defaultValue?: string; + onValueChange?: (value: string) => void; +}; + +const NamespaceSelector: FC = ({ + defaultValue, + onValueChange, + ...props +}) => { + const { t } = useTranslation(); + const namespace = useNamespace(); + const { data: availableNamespaces, isLoading } = useListNamespaces(); + + if (!namespace) return null; + + const defaultDoesNotExist = + defaultValue && + !availableNamespaces?.results.some((ns) => ns.name === defaultValue); + + return ( + + ); +}; +export default NamespaceSelector; diff --git a/src/design/Appshell/index.tsx b/src/design/Appshell/index.tsx index c5424f53d..2051adbfd 100644 --- a/src/design/Appshell/index.tsx +++ b/src/design/Appshell/index.tsx @@ -35,7 +35,7 @@ export const Main: FC = ({ children }) => ( ); export const MainTop: FC = ({ children }) => ( -
+
{children}
); diff --git a/src/design/Dropdown/index.tsx b/src/design/Dropdown/index.tsx index 2dfbc2e78..540b26005 100644 --- a/src/design/Dropdown/index.tsx +++ b/src/design/Dropdown/index.tsx @@ -49,8 +49,8 @@ const DropdownMenuSubContent = React.forwardRef< ref={ref} className={twMergeClsx( "z-50 min-w-[8rem] overflow-hidden rounded-md p-1 shadow-md ring-1", - "bg-gray-1 ring-gray-3", - "dark:bg-gray-dark-1 dark:ring-gray-dark-3", + "bg-gray-1 text-gray-11 ring-gray-3", + "dark:bg-gray-dark-1 dark:text-gray-dark-11 dark:ring-gray-dark-3", className )} {...props} diff --git a/src/design/Editor/__tests__/utils.test.ts b/src/design/Editor/__tests__/utils.test.ts index cc92d2a2d..2008dff25 100644 --- a/src/design/Editor/__tests__/utils.test.ts +++ b/src/design/Editor/__tests__/utils.test.ts @@ -25,6 +25,11 @@ describe("mimeTypeToEditorSyntax", () => { expect(mimeTypeToEditorSyntax("text/whatever")).toBe("plaintext"); }); + test("it must detect javascript", () => { + expect(mimeTypeToEditorSyntax("application/javascript")).toBe("javascript"); + expect(mimeTypeToEditorSyntax("text/javascript")).toBe("javascript"); + }); + test("it must return undefined for unsuported mime types", () => { expect(mimeTypeToEditorSyntax("unsupported")).toBe(undefined); }); diff --git a/src/design/Editor/utils.ts b/src/design/Editor/utils.ts index 7ff053a12..ea90c5f7d 100644 --- a/src/design/Editor/utils.ts +++ b/src/design/Editor/utils.ts @@ -7,6 +7,7 @@ export const supportedLanguages = [ "json", "shell", "plaintext", + "javascript", "yaml", ] as const; @@ -30,6 +31,9 @@ export const editorLanguageSchema = z return "html"; case "text/css": return "css"; + case "application/javascript": + case "text/javascript": + return "javascript"; case "application/json": return "json"; case "application/x-sh": diff --git a/src/design/Filepicker/index.stories.tsx b/src/design/Filepicker/index.stories.tsx index c5e3a36dc..2b54291b2 100644 --- a/src/design/Filepicker/index.stories.tsx +++ b/src/design/Filepicker/index.stories.tsx @@ -1,5 +1,6 @@ import { Breadcrumb, BreadcrumbRoot } from "../Breadcrumbs"; +import { Dialog, DialogContent, DialogTrigger } from "../Dialog"; import { File, Folder, @@ -17,9 +18,14 @@ import { FilepickerListItem, FilepickerSeparator, } from "./"; +import { Fragment, useState } from "react"; import type { Meta, StoryObj } from "@storybook/react"; -import { Fragment } from "react"; + +import Button from "../Button"; +import { ButtonBar } from "../ButtonBar"; + +import Input from "../Input"; const meta = { title: "Components/Filepicker", @@ -33,11 +39,14 @@ export const Default: Story = { render: ({ ...args }) => ( content goes here... ), + args: { + buttonText: "Browse Files", + }, argTypes: {}, }; export const WithFewItems = () => ( - + Collection of Files .. @@ -82,33 +91,119 @@ const items: Listitem[] = [ { filename: "Readme11.txt", icon: File }, ]; -export const WithManyItemsBreadcrumbHeadingAndCloseFunctionAtItemClick = () => ( - - - - - My-namespace - - - My-folder - - - My-subfolder - - - - - .. - - - {items.map((element) => ( - - - {element.filename} - - - - ))} - - -); +export const WithManyItemsBreadcrumbHeadingAndCloseFunctionAtItemClick = () => { + const [inputValue, setInputValue] = useState(""); + + return ( + + + + + + My-namespace + + + My-folder + + + My-subfolder + + + + +
+ .. +
+ + + + {items.map((element) => ( + + { + setInputValue(element.filename); + }} + > + + {element.filename} + + + + + + ))} + +
+ { + setInputValue(e.target.value); + }} + /> +
+ ); +}; + +export const InAModal = () => { + const [inputValue, setInputValue] = useState(""); + const [dialogOpen, setDialogOpen] = useState(false); + return ( + + + + + + + + + + + My-namespace + + + My-folder + + + My-subfolder + + + + +
+ .. +
+ + + + {items.map((element) => ( + + { + setInputValue(element.filename); + }} + > + + {element.filename} + + + + + + ))} + +
+ { + setInputValue(e.target.value); + }} + /> +
+
+
+ ); +}; diff --git a/src/design/Filepicker/index.tsx b/src/design/Filepicker/index.tsx index 6e4ca6181..fdead47f3 100644 --- a/src/design/Filepicker/index.tsx +++ b/src/design/Filepicker/index.tsx @@ -33,13 +33,15 @@ const FilepickerSeparator = React.forwardRef< FilepickerSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; const FilepickerHeading: FC = ({ children }) => ( -
+
{children}
); const FilepickerList: FC = ({ children }) => ( -
{children}
+
+ {children} +
); // asChild only works with exactly one child, so when asChild is true, we can not have a loading property @@ -72,9 +74,7 @@ const FilepickerListItem: FC = ({
-
- {children} -
+
{children}
@@ -83,21 +83,29 @@ const FilepickerListItem: FC = ({ type FilepickerPropsType = PropsWithChildren & { className?: string; + buttonText: string; + onClick?: React.MouseEventHandler; }; -const Filepicker: FC = ({ className, children }) => ( -
- - - - - - {children} - - -
+const Filepicker: FC = ({ + className, + children, + buttonText, + onClick, +}) => ( + + + + + + {children} + + ); export { diff --git a/src/pages/namespace/Explorer/Consumer/ConsumerEditor/Form/index.tsx b/src/pages/namespace/Explorer/Consumer/ConsumerEditor/Form/index.tsx new file mode 100644 index 000000000..2f4913cf0 --- /dev/null +++ b/src/pages/namespace/Explorer/Consumer/ConsumerEditor/Form/index.tsx @@ -0,0 +1,118 @@ +import { ConsumerFormSchema, ConsumerFormSchemaType } from "../schema"; +import { + Controller, + DeepPartialSkipArrayKey, + UseFormReturn, + useForm, + useWatch, +} from "react-hook-form"; + +import { ArrayInput } from "../../../components/ArrayInput"; +import { FC } from "react"; +import { Fieldset } from "../../../components/Fieldset"; +import Input from "~/design/Input"; +import { treatEmptyStringAsUndefined } from "../../../utils"; +import { useTranslation } from "react-i18next"; +import { zodResolver } from "@hookform/resolvers/zod"; + +type FormProps = { + defaultConfig?: ConsumerFormSchemaType; + children: (args: { + formControls: UseFormReturn; + formMarkup: JSX.Element; + values: DeepPartialSkipArrayKey; + }) => JSX.Element; +}; + +export const Form: FC = ({ defaultConfig, children }) => { + const { t } = useTranslation(); + const formControls = useForm({ + resolver: zodResolver(ConsumerFormSchema), + defaultValues: { + ...defaultConfig, + }, + }); + + const values = useWatch({ + control: formControls.control, + }); + + const { register, control } = formControls; + + return children({ + formControls, + values, + formMarkup: ( +
+
+
+ +
+
+ +
+
+
+ +
+
+ ( + { + field.onChange(changedValue); + }} + /> + )} + /> +
+
+ ( + { + field.onChange(changedValue); + }} + /> + )} + /> +
+
+ ), + }); +}; diff --git a/src/pages/namespace/Explorer/Consumer/ConsumerEditor/index.tsx b/src/pages/namespace/Explorer/Consumer/ConsumerEditor/index.tsx new file mode 100644 index 000000000..6ab7ec547 --- /dev/null +++ b/src/pages/namespace/Explorer/Consumer/ConsumerEditor/index.tsx @@ -0,0 +1,118 @@ +import Alert from "~/design/Alert"; +import Button from "~/design/Button"; +import { Card } from "~/design/Card"; +import { ConsumerFormSchemaType } from "./schema"; +import Editor from "~/design/Editor"; +import { FC } from "react"; +import { Form } from "./Form"; +import FormErrors from "~/componentsNext/FormErrors"; +import { Save } from "lucide-react"; +import { ScrollArea } from "~/design/ScrollArea"; +import { jsonToYaml } from "../../utils"; +import { serializeConsumerFile } from "./utils"; +import { useNodeContent } from "~/api/tree/query/node"; +import { useTheme } from "~/util/store/theme"; +import { useTranslation } from "react-i18next"; +import { useUpdateWorkflow } from "~/api/tree/mutate/updateWorkflow"; + +type NodeContentType = ReturnType["data"]; + +type ConsumerEditorProps = { + path: string; + data: NonNullable; +}; + +const ConsumerEditor: FC = ({ data, path }) => { + const { t } = useTranslation(); + const theme = useTheme(); + const consumerFileContent = atob(data.revision?.source ?? ""); + const [consumerConfig, consumerConfigError] = + serializeConsumerFile(consumerFileContent); + const { mutate: updateRoute, isLoading } = useUpdateWorkflow(); + + const save = (data: ConsumerFormSchemaType) => { + const toSave = jsonToYaml(data); + updateRoute({ + path, + fileContent: toSave, + }); + }; + + return ( +
+ {({ + formControls: { + formState: { errors }, + handleSubmit, + }, + formMarkup, + values, + }) => { + const preview = jsonToYaml(values); + const isDirty = !consumerConfigError && preview !== consumerFileContent; + const disableButton = isLoading || !!consumerConfigError; + + return ( + +
+
+ + {consumerConfigError ? ( +
+ + {t( + "pages.explorer.consumer.editor.form.serialisationError" + )} + + +
+                          {JSON.stringify(consumerConfigError, null, 2)}
+                        
+
+
+ ) : ( +
+ + {formMarkup} +
+ )} +
+ + + +
+
+ {isDirty && ( +
+ + {t("pages.explorer.consumer.editor.unsavedNote")} + +
+ )} + +
+
+
+ ); + }} + + ); +}; + +export default ConsumerEditor; diff --git a/src/pages/namespace/Explorer/Consumer/ConsumerEditor/schema.ts b/src/pages/namespace/Explorer/Consumer/ConsumerEditor/schema.ts new file mode 100644 index 000000000..86c434daf --- /dev/null +++ b/src/pages/namespace/Explorer/Consumer/ConsumerEditor/schema.ts @@ -0,0 +1,12 @@ +import { z } from "zod"; + +export const ConsumerFormSchema = z.object({ + direktiv_api: z.literal("consumer/v1"), + username: z.string().nonempty().optional(), + password: z.string().nonempty().optional(), + api_key: z.string().nonempty().optional(), + tags: z.array(z.string()).optional(), + groups: z.array(z.string()).optional(), +}); + +export type ConsumerFormSchemaType = z.infer; diff --git a/src/pages/namespace/Explorer/Consumer/ConsumerEditor/utils.ts b/src/pages/namespace/Explorer/Consumer/ConsumerEditor/utils.ts new file mode 100644 index 000000000..34ad43808 --- /dev/null +++ b/src/pages/namespace/Explorer/Consumer/ConsumerEditor/utils.ts @@ -0,0 +1,31 @@ +import { ConsumerFormSchema, ConsumerFormSchemaType } from "./schema"; + +import { ZodError } from "zod"; +import { jsonToYaml } from "../../utils"; +import yamljs from "js-yaml"; + +type SerializeReturnType = + | [ConsumerFormSchemaType, undefined] + | [undefined, ZodError]; + +export const serializeConsumerFile = (yaml: string): SerializeReturnType => { + let json; + try { + json = yamljs.load(yaml); + } catch (e) { + json = null; + } + + const jsonParsed = ConsumerFormSchema.safeParse(json); + if (jsonParsed.success) { + return [jsonParsed.data, undefined]; + } + + return [undefined, jsonParsed.error]; +}; + +const defaultConsumerFileJson: ConsumerFormSchemaType = { + direktiv_api: "consumer/v1", +}; + +export const defaultConsumerFileYaml = jsonToYaml(defaultConsumerFileJson); diff --git a/src/pages/namespace/Explorer/Consumer/index.tsx b/src/pages/namespace/Explorer/Consumer/index.tsx new file mode 100644 index 000000000..ca90357d9 --- /dev/null +++ b/src/pages/namespace/Explorer/Consumer/index.tsx @@ -0,0 +1,67 @@ +import { FileSymlink, Users } from "lucide-react"; + +import Button from "~/design/Button"; +import { Card } from "~/design/Card"; +import ConsumerEditor from "./ConsumerEditor"; +import { FC } from "react"; +import { Link } from "react-router-dom"; +import { NoPermissions } from "~/design/Table"; +import { analyzePath } from "~/util/router/utils"; +import { pages } from "~/util/router/pages"; +import { useNamespace } from "~/util/store/namespace"; +import { useNodeContent } from "~/api/tree/query/node"; +import { useTranslation } from "react-i18next"; + +const ConsumerPage: FC = () => { + const { path } = pages.explorer.useParams(); + const namespace = useNamespace(); + const { segments } = analyzePath(path); + const filename = segments[segments.length - 1]; + const { t } = useTranslation(); + + const { + isAllowed, + noPermissionMessage, + data: consumerData, + isFetched: isPermissionCheckFetched, + } = useNodeContent({ path }); + + if (!namespace) return null; + if (!path) return null; + if (!consumerData) return null; + if (!isPermissionCheckFetched) return null; + + if (isAllowed === false) + return ( + + {noPermissionMessage} + + ); + + return ( + <> +
+
+

+ + {filename?.relative} +

+ +
+
+ + + ); +}; + +export default ConsumerPage; diff --git a/src/pages/namespace/Explorer/Endpoint/EndpointEditor/Form/index.tsx b/src/pages/namespace/Explorer/Endpoint/EndpointEditor/Form/index.tsx new file mode 100644 index 000000000..b71b1d40a --- /dev/null +++ b/src/pages/namespace/Explorer/Endpoint/EndpointEditor/Form/index.tsx @@ -0,0 +1,141 @@ +import { + Controller, + DeepPartialSkipArrayKey, + UseFormReturn, + useForm, + useWatch, +} from "react-hook-form"; +import { EndpointFormSchema, EndpointFormSchemaType } from "../schema"; + +import { AuthPluginForm } from "./plugins/Auth"; +import Badge from "~/design/Badge"; +import { Checkbox } from "~/design/Checkbox"; +import { FC } from "react"; +import { Fieldset } from "../../../components/Fieldset"; +import { InboundPluginForm } from "./plugins/Inbound"; +import Input from "~/design/Input"; +import { OutboundPluginForm } from "./plugins/Outbound"; +import { Switch } from "~/design/Switch"; +import { TargetPluginForm } from "./plugins/Target"; +import { routeMethods } from "~/api/gateway/schema"; +import { treatAsNumberOrUndefined } from "../../../utils"; +import { useTranslation } from "react-i18next"; +import { zodResolver } from "@hookform/resolvers/zod"; + +type FormProps = { + defaultConfig?: EndpointFormSchemaType; + children: (args: { + formControls: UseFormReturn; + formMarkup: JSX.Element; + values: DeepPartialSkipArrayKey; + }) => JSX.Element; +}; + +export const Form: FC = ({ defaultConfig, children }) => { + const { t } = useTranslation(); + const formControls = useForm({ + resolver: zodResolver(EndpointFormSchema), + defaultValues: { + ...defaultConfig, + }, + }); + + const values = useWatch({ + control: formControls.control, + }); + + const { register, control } = formControls; + + return children({ + formControls, + values, + formMarkup: ( +
+
+
+ +
+
+ +
+
+
+ ( +
+ {routeMethods.map((method) => { + const isChecked = field.value?.includes(method); + return ( + + ); + })} +
+ )} + /> +
+
+ ( + { + field.onChange(value); + }} + id="allow_anonymous" + /> + )} + /> +
+ + + + +
+ ), + }); +}; diff --git a/src/pages/namespace/Explorer/Endpoint/EndpointEditor/Form/plugins/Auth/BasicAuthForm.tsx b/src/pages/namespace/Explorer/Endpoint/EndpointEditor/Form/plugins/Auth/BasicAuthForm.tsx new file mode 100644 index 000000000..e31ea091e --- /dev/null +++ b/src/pages/namespace/Explorer/Endpoint/EndpointEditor/Form/plugins/Auth/BasicAuthForm.tsx @@ -0,0 +1,119 @@ +import { + BasicAuthFormSchema, + BasicAuthFormSchemaType, +} from "../../../schema/plugins/auth/basicAuth"; +import { FC, FormEvent } from "react"; +import FormErrors, { errorsType } from "~/componentsNext/FormErrors"; + +import { Checkbox } from "~/design/Checkbox"; +import { Fieldset } from "~/pages/namespace/Explorer/components/Fieldset"; +import { PluginWrapper } from "../components/Modal"; +import { useForm } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import { zodResolver } from "@hookform/resolvers/zod"; + +type OptionalConfig = Partial; + +const predfinedConfig: OptionalConfig = { + add_groups_header: false, + add_tags_header: false, + add_username_header: false, +}; + +type FormProps = { + formId: string; + defaultConfig?: OptionalConfig; + onSubmit: (data: BasicAuthFormSchemaType) => void; +}; + +export const BasicAuthForm: FC = ({ + defaultConfig, + formId, + onSubmit, +}) => { + const { t } = useTranslation(); + const { + handleSubmit, + setValue, + getValues, + formState: { errors }, + } = useForm({ + resolver: zodResolver(BasicAuthFormSchema), + defaultValues: { + type: "basic-auth", + configuration: { + ...predfinedConfig, + ...defaultConfig, + }, + }, + }); + + const submitForm = (e: FormEvent) => { + e.stopPropagation(); // prevent the parent form from submitting + handleSubmit(onSubmit)(e); + }; + + return ( +
+ + {errors?.configuration && ( + + )} +
+ { + if (typeof value === "boolean") { + setValue("configuration.add_username_header", value); + } + }} + id="add-username-header" + /> +
+
+ { + if (typeof value === "boolean") { + setValue("configuration.add_tags_header", value); + } + }} + id="add-tags-header" + /> +
+
+ { + if (typeof value === "boolean") { + setValue("configuration.add_groups_header", value); + } + }} + id="add-groups-header" + /> +
+
+
+ ); +}; diff --git a/src/pages/namespace/Explorer/Endpoint/EndpointEditor/Form/plugins/Auth/GithubWebhookAuthForm.tsx b/src/pages/namespace/Explorer/Endpoint/EndpointEditor/Form/plugins/Auth/GithubWebhookAuthForm.tsx new file mode 100644 index 000000000..9526c43ca --- /dev/null +++ b/src/pages/namespace/Explorer/Endpoint/EndpointEditor/Form/plugins/Auth/GithubWebhookAuthForm.tsx @@ -0,0 +1,68 @@ +import { FC, FormEvent } from "react"; +import FormErrors, { errorsType } from "~/componentsNext/FormErrors"; +import { + GithubWebhookAuthFormSchema, + GithubWebhookAuthFormSchemaType, +} from "../../../schema/plugins/auth/githubWebhookAuth"; + +import { Fieldset } from "~/pages/namespace/Explorer/components/Fieldset"; +import Input from "~/design/Input"; +import { PluginWrapper } from "../components/Modal"; +import { useForm } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import { zodResolver } from "@hookform/resolvers/zod"; + +type OptionalConfig = Partial; + +type FormProps = { + formId: string; + defaultConfig?: OptionalConfig; + onSubmit: (data: GithubWebhookAuthFormSchemaType) => void; +}; + +export const GithubWebhookAuthForm: FC = ({ + defaultConfig, + onSubmit, + formId, +}) => { + const { t } = useTranslation(); + const { + handleSubmit, + register, + formState: { errors }, + } = useForm({ + resolver: zodResolver(GithubWebhookAuthFormSchema), + defaultValues: { + type: "github-webhook-auth", + configuration: { + ...defaultConfig, + }, + }, + }); + + const submitForm = (e: FormEvent) => { + e.stopPropagation(); // prevent the parent form from submitting + handleSubmit(onSubmit)(e); + }; + + return ( +
+ + {errors?.configuration && ( + + )} +
+ +
+
+
+ ); +}; diff --git a/src/pages/namespace/Explorer/Endpoint/EndpointEditor/Form/plugins/Auth/KeyAuthForm.tsx b/src/pages/namespace/Explorer/Endpoint/EndpointEditor/Form/plugins/Auth/KeyAuthForm.tsx new file mode 100644 index 000000000..47469a3b8 --- /dev/null +++ b/src/pages/namespace/Explorer/Endpoint/EndpointEditor/Form/plugins/Auth/KeyAuthForm.tsx @@ -0,0 +1,138 @@ +import { FC, FormEvent } from "react"; +import FormErrors, { errorsType } from "~/componentsNext/FormErrors"; +import { + KeyAuthFormSchema, + KeyAuthFormSchemaType, +} from "../../../schema/plugins/auth/keyAuth"; + +import { Checkbox } from "~/design/Checkbox"; +import { Fieldset } from "~/pages/namespace/Explorer/components/Fieldset"; +import Input from "~/design/Input"; +import { PluginWrapper } from "../components/Modal"; +import { treatEmptyStringAsUndefined } from "~/pages/namespace/Explorer/utils"; +import { useForm } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import { zodResolver } from "@hookform/resolvers/zod"; + +type OptionalConfig = Partial; + +const predfinedConfig: OptionalConfig = { + add_groups_header: true, + add_tags_header: true, + add_username_header: true, +}; + +type FormProps = { + formId: string; + defaultConfig?: OptionalConfig; + onSubmit: (data: KeyAuthFormSchemaType) => void; +}; + +export const KeyAuthForm: FC = ({ + defaultConfig, + onSubmit, + formId, +}) => { + const { t } = useTranslation(); + const { + handleSubmit, + setValue, + getValues, + register, + formState: { errors }, + } = useForm({ + resolver: zodResolver(KeyAuthFormSchema), + defaultValues: { + type: "key-auth", + configuration: { + ...predfinedConfig, + ...defaultConfig, + }, + }, + }); + + const submitForm = (e: FormEvent) => { + e.stopPropagation(); // prevent the parent form from submitting + handleSubmit(onSubmit)(e); + }; + + return ( +
+ + {errors?.configuration && ( + + )} +
+ { + if (typeof value === "boolean") { + setValue("configuration.add_username_header", value); + } + }} + id="add-username-header" + /> +
+
+ { + if (typeof value === "boolean") { + setValue("configuration.add_tags_header", value); + } + }} + id="add-tags-header" + /> +
+
+ { + if (typeof value === "boolean") { + setValue("configuration.add_groups_header", value); + } + }} + id="add-groups-header" + /> +
+
+ +
+
+
+ ); +}; diff --git a/src/pages/namespace/Explorer/Endpoint/EndpointEditor/Form/plugins/Auth/index.tsx b/src/pages/namespace/Explorer/Endpoint/EndpointEditor/Form/plugins/Auth/index.tsx new file mode 100644 index 000000000..c2d21e6c3 --- /dev/null +++ b/src/pages/namespace/Explorer/Endpoint/EndpointEditor/Form/plugins/Auth/index.tsx @@ -0,0 +1,221 @@ +import { ContextMenu, TableHeader } from "../components/PluginsTable"; +import { Dialog, DialogTrigger } from "~/design/Dialog"; +import { FC, useState } from "react"; +import { ModalWrapper, PluginSelector } from "../components/Modal"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "~/design/Select"; +import { Table, TableBody, TableCell, TableRow } from "~/design/Table"; +import { UseFormReturn, useFieldArray } from "react-hook-form"; +import { + getBasicAuthConfigAtIndex, + getGithubWebhookAuthConfigAtIndex, + getKeyAuthConfigAtIndex, +} from "../utils"; + +import { AuthPluginFormSchemaType } from "../../../schema/plugins/auth/schema"; +import { BasicAuthForm } from "./BasicAuthForm"; +import Button from "~/design/Button"; +import { Card } from "~/design/Card"; +import { EndpointFormSchemaType } from "../../../schema"; +import { GithubWebhookAuthForm } from "./GithubWebhookAuthForm"; +import { KeyAuthForm } from "./KeyAuthForm"; +import { Plus } from "lucide-react"; +import { authPluginTypes } from "../../../schema/plugins/auth"; +import { useTranslation } from "react-i18next"; + +type AuthPluginFormProps = { + formControls: UseFormReturn; +}; + +export const AuthPluginForm: FC = ({ formControls }) => { + const { t } = useTranslation(); + const { control } = formControls; + const { + append: addPlugin, + remove: deletePlugin, + move: movePlugin, + update: editPlugin, + fields, + } = useFieldArray({ + control, + name: "plugins.auth", + }); + const [dialogOpen, setDialogOpen] = useState(false); + const [editIndex, setEditIndex] = useState(); + + const [selectedPlugin, setSelectedPlugin] = + useState(); + + const pluginsCount = fields.length; + const formId = "authPluginForm"; + + return ( + { + if (isOpen === false) setEditIndex(undefined); + setDialogOpen(isOpen); + }} + > + + + + + + + + + {fields.map(({ id, type }, index, srcArray) => { + const canMoveDown = index < srcArray.length - 1; + const canMoveUp = index > 0; + const onMoveUp = canMoveUp + ? () => { + movePlugin(index, index - 1); + } + : undefined; + const onMoveDown = canMoveDown + ? () => { + movePlugin(index, index + 1); + } + : undefined; + const onDelete = () => { + deletePlugin(index); + }; + + return ( + { + setSelectedPlugin(type); + setDialogOpen(true); + setEditIndex(index); + }} + > + + {t( + `pages.explorer.endpoint.editor.form.plugins.auth.types.${type}` + )} + + + + + + ); + })} + +
+
+ + + + + + {selectedPlugin === authPluginTypes.basicAuth && ( + { + setDialogOpen(false); + if (editIndex === undefined) { + addPlugin(configuration); + } else { + editPlugin(editIndex, configuration); + } + setEditIndex(undefined); + }} + /> + )} + {selectedPlugin === authPluginTypes.keyAuth && ( + { + setDialogOpen(false); + if (editIndex === undefined) { + addPlugin(configuration); + } else { + editPlugin(editIndex, configuration); + } + setEditIndex(undefined); + }} + /> + )} + {selectedPlugin === authPluginTypes.githubWebhookAuth && ( + { + setDialogOpen(false); + if (editIndex === undefined) { + addPlugin(configuration); + } else { + editPlugin(editIndex, configuration); + } + setEditIndex(undefined); + }} + /> + )} + +
+ ); +}; diff --git a/src/pages/namespace/Explorer/Endpoint/EndpointEditor/Form/plugins/Inbound/AclForm.tsx b/src/pages/namespace/Explorer/Endpoint/EndpointEditor/Form/plugins/Inbound/AclForm.tsx new file mode 100644 index 000000000..2efd28f07 --- /dev/null +++ b/src/pages/namespace/Explorer/Endpoint/EndpointEditor/Form/plugins/Inbound/AclForm.tsx @@ -0,0 +1,148 @@ +import { + AclFormSchema, + AclFormSchemaType, +} from "../../../schema/plugins/inbound/acl"; +import { Controller, useForm } from "react-hook-form"; +import { FC, FormEvent } from "react"; +import FormErrors, { errorsType } from "~/componentsNext/FormErrors"; + +import { ArrayInput } from "~/pages/namespace/Explorer/components/ArrayInput"; +import { Fieldset } from "~/pages/namespace/Explorer/components/Fieldset"; +import { PluginWrapper } from "../components/Modal"; +import { useTranslation } from "react-i18next"; +import { zodResolver } from "@hookform/resolvers/zod"; + +type OptionalConfig = Partial; + +const predfinedConfig: OptionalConfig = { + allow_groups: [], + deny_groups: [], + allow_tags: [], + deny_tags: [], +}; + +type FormProps = { + formId: string; + defaultConfig?: OptionalConfig; + onSubmit: (data: AclFormSchemaType) => void; +}; + +export const AclForm: FC = ({ defaultConfig, onSubmit, formId }) => { + const { t } = useTranslation(); + const { + handleSubmit, + formState: { errors }, + control, + } = useForm({ + resolver: zodResolver(AclFormSchema), + defaultValues: { + type: "acl", + configuration: { + ...predfinedConfig, + ...defaultConfig, + }, + }, + }); + + const submitForm = (e: FormEvent) => { + e.stopPropagation(); // prevent the parent form from submitting + handleSubmit(onSubmit)(e); + }; + + return ( +
+ {errors?.configuration && ( + + )} + +
+ ( + { + field.onChange(changedValue); + }} + /> + )} + /> +
+
+ ( + { + field.onChange(changedValue); + }} + /> + )} + /> +
+
+ ( + { + field.onChange(changedValue); + }} + /> + )} + /> +
+
+ ( + { + field.onChange(changedValue); + }} + /> + )} + /> +
+
+ + ); +}; diff --git a/src/pages/namespace/Explorer/Endpoint/EndpointEditor/Form/plugins/Inbound/JsInboundForm.tsx b/src/pages/namespace/Explorer/Endpoint/EndpointEditor/Form/plugins/Inbound/JsInboundForm.tsx new file mode 100644 index 000000000..fc48f50a8 --- /dev/null +++ b/src/pages/namespace/Explorer/Endpoint/EndpointEditor/Form/plugins/Inbound/JsInboundForm.tsx @@ -0,0 +1,84 @@ +import { Controller, useForm } from "react-hook-form"; +import { FC, FormEvent } from "react"; +import FormErrors, { errorsType } from "~/componentsNext/FormErrors"; +import { + JsInboundFormSchema, + JsInboundFormSchemaType, +} from "../../../schema/plugins/inbound/jsInbound"; + +import { Card } from "~/design/Card"; +import Editor from "~/design/Editor"; +import { Fieldset } from "~/pages/namespace/Explorer/components/Fieldset"; +import { PluginWrapper } from "../components/Modal"; +import { useTheme } from "~/util/store/theme"; +import { useTranslation } from "react-i18next"; +import { zodResolver } from "@hookform/resolvers/zod"; + +type OptionalConfig = Partial; + +type FormProps = { + formId: string; + defaultConfig?: OptionalConfig; + onSubmit: (data: JsInboundFormSchemaType) => void; +}; + +export const JsInboundForm: FC = ({ + defaultConfig, + onSubmit, + formId, +}) => { + const { t } = useTranslation(); + const { + handleSubmit, + formState: { errors }, + control, + } = useForm({ + resolver: zodResolver(JsInboundFormSchema), + defaultValues: { + type: "js-inbound", + configuration: { + ...defaultConfig, + }, + }, + }); + + const submitForm = (e: FormEvent) => { + e.stopPropagation(); // prevent the parent form from submitting + handleSubmit(onSubmit)(e); + }; + + const theme = useTheme(); + + return ( +
+ {errors?.configuration && ( + + )} + +
+ + ( + + )} + /> + +
+
+ + ); +}; diff --git a/src/pages/namespace/Explorer/Endpoint/EndpointEditor/Form/plugins/Inbound/RequestConvertForm.tsx b/src/pages/namespace/Explorer/Endpoint/EndpointEditor/Form/plugins/Inbound/RequestConvertForm.tsx new file mode 100644 index 000000000..eddae0b10 --- /dev/null +++ b/src/pages/namespace/Explorer/Endpoint/EndpointEditor/Form/plugins/Inbound/RequestConvertForm.tsx @@ -0,0 +1,137 @@ +import { FC, FormEvent } from "react"; +import FormErrors, { errorsType } from "~/componentsNext/FormErrors"; +import { + RequestConvertFormSchema, + RequestConvertFormSchemaType, +} from "../../../schema/plugins/inbound/requestConvert"; + +import { Checkbox } from "~/design/Checkbox"; +import { Fieldset } from "~/pages/namespace/Explorer/components/Fieldset"; +import { PluginWrapper } from "../components/Modal"; +import { useForm } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import { zodResolver } from "@hookform/resolvers/zod"; + +type OptionalConfig = Partial; + +const predfinedConfig: OptionalConfig = { + omit_body: false, + omit_headers: false, + omit_consumer: false, + omit_queries: false, +}; + +type FormProps = { + formId: string; + defaultConfig?: OptionalConfig; + onSubmit: (data: RequestConvertFormSchemaType) => void; +}; + +export const RequestConvertForm: FC = ({ + defaultConfig, + onSubmit, + formId, +}) => { + const { t } = useTranslation(); + const { + handleSubmit, + setValue, + getValues, + formState: { errors }, + } = useForm({ + resolver: zodResolver(RequestConvertFormSchema), + defaultValues: { + type: "request-convert", + configuration: { + ...predfinedConfig, + ...defaultConfig, + }, + }, + }); + + const submitForm = (e: FormEvent) => { + e.stopPropagation(); // prevent the parent form from submitting + handleSubmit(onSubmit)(e); + }; + + return ( +
+ + {errors?.configuration && ( + + )} +
+ { + if (typeof value === "boolean") { + setValue("configuration.omit_headers", value); + } + }} + id="omit-headers" + /> +
+
+ { + if (typeof value === "boolean") { + setValue("configuration.omit_queries", value); + } + }} + id="omit-queries" + /> +
+
+ { + if (typeof value === "boolean") { + setValue("configuration.omit_body", value); + } + }} + id="omit-body" + /> +
+
+ { + if (typeof value === "boolean") { + setValue("configuration.omit_consumer", value); + } + }} + id="omit-consumer" + /> +
+
+
+ ); +}; diff --git a/src/pages/namespace/Explorer/Endpoint/EndpointEditor/Form/plugins/Inbound/index.tsx b/src/pages/namespace/Explorer/Endpoint/EndpointEditor/Form/plugins/Inbound/index.tsx new file mode 100644 index 000000000..8fd078965 --- /dev/null +++ b/src/pages/namespace/Explorer/Endpoint/EndpointEditor/Form/plugins/Inbound/index.tsx @@ -0,0 +1,224 @@ +import { ContextMenu, TableHeader } from "../components/PluginsTable"; +import { Dialog, DialogTrigger } from "~/design/Dialog"; +import { FC, useState } from "react"; +import { ModalWrapper, PluginSelector } from "../components/Modal"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "~/design/Select"; +import { Table, TableBody, TableCell, TableRow } from "~/design/Table"; +import { UseFormReturn, useFieldArray } from "react-hook-form"; +import { + getAclConfigAtIndex, + getJsInboundConfigAtIndex, + getRequestConvertConfigAtIndex, +} from "../utils"; + +import { AclForm } from "./AclForm"; +import Button from "~/design/Button"; +import { Card } from "~/design/Card"; +import { EndpointFormSchemaType } from "../../../schema"; +import { InboundPluginFormSchemaType } from "../../../schema/plugins/inbound/schema"; +import { JsInboundForm } from "./JsInboundForm"; +import { Plus } from "lucide-react"; +import { RequestConvertForm } from "./RequestConvertForm"; +import { inboundPluginTypes } from "../../../schema/plugins/inbound"; +import { useTranslation } from "react-i18next"; + +type InboundPluginFormProps = { + form: UseFormReturn; +}; + +export const InboundPluginForm: FC = ({ form }) => { + const { t } = useTranslation(); + const { control } = form; + const { + append: addPlugin, + remove: deletePlugin, + move: movePlugin, + update: editPlugin, + fields, + } = useFieldArray({ + control, + name: "plugins.inbound", + }); + const [dialogOpen, setDialogOpen] = useState(false); + const [editIndex, setEditIndex] = useState(); + + const [selectedPlugin, setSelectedPlugin] = + useState(); + + const { jsInbound, requestConvert, acl } = inboundPluginTypes; + + const pluginsCount = fields.length; + const formId = "inboundPluginForm"; + + return ( + { + if (isOpen === false) setEditIndex(undefined); + setDialogOpen(isOpen); + }} + > + + + + + + + + + {fields.map(({ id, type }, index, srcArray) => { + const canMoveDown = index < srcArray.length - 1; + const canMoveUp = index > 0; + const onMoveUp = canMoveUp + ? () => { + movePlugin(index, index - 1); + } + : undefined; + const onMoveDown = canMoveDown + ? () => { + movePlugin(index, index + 1); + } + : undefined; + const onDelete = () => { + deletePlugin(index); + }; + + return ( + { + setSelectedPlugin(type); + setDialogOpen(true); + setEditIndex(index); + }} + > + + {t( + `pages.explorer.endpoint.editor.form.plugins.inbound.types.${type}` + )} + + + + + + ); + })} + +
+
+ + + + + + {selectedPlugin === requestConvert && ( + { + setDialogOpen(false); + if (editIndex === undefined) { + addPlugin(configuration); + } else { + editPlugin(editIndex, configuration); + } + setEditIndex(undefined); + }} + /> + )} + {selectedPlugin === jsInbound && ( + { + setDialogOpen(false); + if (editIndex === undefined) { + addPlugin(configuration); + } else { + editPlugin(editIndex, configuration); + } + setEditIndex(undefined); + }} + /> + )} + + {selectedPlugin === acl && ( + { + setDialogOpen(false); + if (editIndex === undefined) { + addPlugin(configuration); + } else { + editPlugin(editIndex, configuration); + } + setEditIndex(undefined); + }} + /> + )} + +
+ ); +}; diff --git a/src/pages/namespace/Explorer/Endpoint/EndpointEditor/Form/plugins/Outbound/JsOutboundForm.tsx b/src/pages/namespace/Explorer/Endpoint/EndpointEditor/Form/plugins/Outbound/JsOutboundForm.tsx new file mode 100644 index 000000000..244b034a6 --- /dev/null +++ b/src/pages/namespace/Explorer/Endpoint/EndpointEditor/Form/plugins/Outbound/JsOutboundForm.tsx @@ -0,0 +1,84 @@ +import { Controller, useForm } from "react-hook-form"; +import { FC, FormEvent } from "react"; +import FormErrors, { errorsType } from "~/componentsNext/FormErrors"; +import { + JsOutboundFormSchema, + JsOutboundFormSchemaType, +} from "../../../schema/plugins/outbound/jsOutbound"; + +import { Card } from "~/design/Card"; +import Editor from "~/design/Editor"; +import { Fieldset } from "~/pages/namespace/Explorer/components/Fieldset"; +import { PluginWrapper } from "../components/Modal"; +import { useTheme } from "~/util/store/theme"; +import { useTranslation } from "react-i18next"; +import { zodResolver } from "@hookform/resolvers/zod"; + +type OptionalConfig = Partial; + +type FormProps = { + formId: string; + defaultConfig?: OptionalConfig; + onSubmit: (data: JsOutboundFormSchemaType) => void; +}; + +export const JsOutboundForm: FC = ({ + defaultConfig, + onSubmit, + formId, +}) => { + const { t } = useTranslation(); + const { + handleSubmit, + formState: { errors }, + control, + } = useForm({ + resolver: zodResolver(JsOutboundFormSchema), + defaultValues: { + type: "js-outbound", + configuration: { + ...defaultConfig, + }, + }, + }); + + const submitForm = (e: FormEvent) => { + e.stopPropagation(); // prevent the parent form from submitting + handleSubmit(onSubmit)(e); + }; + + const theme = useTheme(); + + return ( +
+ + {errors?.configuration && ( + + )} +
+ + ( + + )} + /> + +
+
+
+ ); +}; diff --git a/src/pages/namespace/Explorer/Endpoint/EndpointEditor/Form/plugins/Outbound/index.tsx b/src/pages/namespace/Explorer/Endpoint/EndpointEditor/Form/plugins/Outbound/index.tsx new file mode 100644 index 000000000..77d4e6d66 --- /dev/null +++ b/src/pages/namespace/Explorer/Endpoint/EndpointEditor/Form/plugins/Outbound/index.tsx @@ -0,0 +1,185 @@ +import { ContextMenu, TableHeader } from "../components/PluginsTable"; +import { Dialog, DialogTrigger } from "~/design/Dialog"; +import { FC, useState } from "react"; +import { ModalWrapper, PluginSelector } from "../components/Modal"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "~/design/Select"; +import { Table, TableBody, TableCell, TableRow } from "~/design/Table"; +import { UseFormReturn, useFieldArray } from "react-hook-form"; + +import Button from "~/design/Button"; +import { Card } from "~/design/Card"; +import { EndpointFormSchemaType } from "../../../schema"; +import { JsOutboundForm } from "./JsOutboundForm"; +import { OutboundPluginFormSchemaType } from "../../../schema/plugins/outbound/schema"; +import { Plus } from "lucide-react"; +import { getJsOutboundConfigAtIndex } from "../utils"; +import { outboundPluginTypes } from "../../../schema/plugins/outbound"; +import { useTranslation } from "react-i18next"; + +type OutboundPluginFormProps = { + form: UseFormReturn; +}; + +export const OutboundPluginForm: FC = ({ form }) => { + const { t } = useTranslation(); + const { control } = form; + const { + append: addPlugin, + remove: deletePlugin, + move: movePlugin, + update: editPlugin, + fields, + } = useFieldArray({ + control, + name: "plugins.outbound", + }); + const [dialogOpen, setDialogOpen] = useState(false); + const [editIndex, setEditIndex] = useState(); + + const [selectedPlugin, setSelectedPlugin] = + useState(); + + const pluginsCount = fields.length; + const formId = "outboundPluginForm"; + + return ( + { + if (isOpen === false) setEditIndex(undefined); + setDialogOpen(isOpen); + }} + > + + + + + + + + + {fields.map(({ id, type }, index, srcArray) => { + const canMoveDown = index < srcArray.length - 1; + const canMoveUp = index > 0; + const onMoveUp = canMoveUp + ? () => { + movePlugin(index, index - 1); + } + : undefined; + const onMoveDown = canMoveDown + ? () => { + movePlugin(index, index + 1); + } + : undefined; + const onDelete = () => { + deletePlugin(index); + }; + + return ( + { + setSelectedPlugin(type); + setDialogOpen(true); + setEditIndex(index); + }} + > + + {t( + `pages.explorer.endpoint.editor.form.plugins.outbound.types.${type}` + )} + + + + + + ); + })} + +
+
+ + + + + + {selectedPlugin === outboundPluginTypes.jsOutbound && ( + { + setDialogOpen(false); + if (editIndex === undefined) { + addPlugin(configuration); + } else { + editPlugin(editIndex, configuration); + } + setEditIndex(undefined); + }} + /> + )} + +
+ ); +}; diff --git a/src/pages/namespace/Explorer/Endpoint/EndpointEditor/Form/plugins/Target/InstantResponseForm.tsx b/src/pages/namespace/Explorer/Endpoint/EndpointEditor/Form/plugins/Target/InstantResponseForm.tsx new file mode 100644 index 000000000..dc9ac2f7b --- /dev/null +++ b/src/pages/namespace/Explorer/Endpoint/EndpointEditor/Form/plugins/Target/InstantResponseForm.tsx @@ -0,0 +1,122 @@ +import { Controller, useForm } from "react-hook-form"; +import { FC, FormEvent } from "react"; +import FormErrors, { errorsType } from "~/componentsNext/FormErrors"; +import { + InstantResponseFormSchema, + InstantResponseFormSchemaType, +} from "../../../schema/plugins/target/instantResponse"; + +import { Card } from "~/design/Card"; +import Editor from "~/design/Editor"; +import { Fieldset } from "~/pages/namespace/Explorer/components/Fieldset"; +import Input from "~/design/Input"; +import { PluginWrapper } from "../components/Modal"; +import { treatEmptyStringAsUndefined } from "~/pages/namespace/Explorer/utils"; +import { useTheme } from "~/util/store/theme"; +import { useTranslation } from "react-i18next"; +import { zodResolver } from "@hookform/resolvers/zod"; + +type OptionalConfig = Partial; + +const predfinedConfig: OptionalConfig = { + status_code: 200, +}; + +type FormProps = { + formId: string; + defaultConfig?: OptionalConfig; + onSubmit: (data: InstantResponseFormSchemaType) => void; +}; + +export const InstantResponseForm: FC = ({ + defaultConfig, + onSubmit, + formId, +}) => { + const { t } = useTranslation(); + const { + register, + handleSubmit, + formState: { errors }, + control, + } = useForm({ + resolver: zodResolver(InstantResponseFormSchema), + defaultValues: { + type: "instant-response", + configuration: { + ...predfinedConfig, + ...defaultConfig, + }, + }, + }); + + const submitForm = (e: FormEvent) => { + e.stopPropagation(); // prevent the parent form from submitting + handleSubmit(onSubmit)(e); + }; + + const theme = useTheme(); + + return ( +
+ + {errors?.configuration && ( + + )} +
+ +
+
+ +
+
+ + ( + + )} + /> + +
+
+
+ ); +}; diff --git a/src/pages/namespace/Explorer/Endpoint/EndpointEditor/Form/plugins/Target/TargetFlowForm.tsx b/src/pages/namespace/Explorer/Endpoint/EndpointEditor/Form/plugins/Target/TargetFlowForm.tsx new file mode 100644 index 000000000..97cb3b0af --- /dev/null +++ b/src/pages/namespace/Explorer/Endpoint/EndpointEditor/Form/plugins/Target/TargetFlowForm.tsx @@ -0,0 +1,142 @@ +import { Controller, useForm } from "react-hook-form"; +import { FC, FormEvent } from "react"; +import FormErrors, { errorsType } from "~/componentsNext/FormErrors"; +import { + TargetFlowFormSchema, + TargetFlowFormSchemaType, +} from "../../../schema/plugins/target/targetFlow"; + +import { Checkbox } from "~/design/Checkbox"; +import { Fieldset } from "~/pages/namespace/Explorer/components/Fieldset"; +import FilePicker from "~/componentsNext/FilePicker"; +import Input from "~/design/Input"; +import NamespaceSelector from "~/componentsNext/NamespaceSelector"; +import { PluginWrapper } from "../components/Modal"; +import { treatEmptyStringAsUndefined } from "~/pages/namespace/Explorer/utils"; +import { useTranslation } from "react-i18next"; +import { zodResolver } from "@hookform/resolvers/zod"; + +type OptionalConfig = Partial; + +const predfinedConfig: OptionalConfig = { + async: false, +}; + +type FormProps = { + formId: string; + defaultConfig?: OptionalConfig; + onSubmit: (data: TargetFlowFormSchemaType) => void; +}; + +export const TargetFlowForm: FC = ({ + defaultConfig, + onSubmit, + formId, +}) => { + const { t } = useTranslation(); + const { + register, + handleSubmit, + setValue, + getValues, + watch, + control, + formState: { errors }, + } = useForm({ + resolver: zodResolver(TargetFlowFormSchema), + defaultValues: { + type: "target-flow", + configuration: { + ...predfinedConfig, + ...defaultConfig, + }, + }, + }); + + const submitForm = (e: FormEvent) => { + e.stopPropagation(); // prevent the parent form from submitting + handleSubmit(onSubmit)(e); + }; + + return ( +
+ + {errors?.configuration && ( + + )} +
+ ( + + )} + /> +
+
+ ( + node.type === "workflow"} + /> + )} + /> +
+
+ { + if (typeof value === "boolean") { + setValue("configuration.async", value); + } + }} + id="async" + /> +
+
+ +
+
+
+ ); +}; diff --git a/src/pages/namespace/Explorer/Endpoint/EndpointEditor/Form/plugins/Target/TargetFlowVarForm.tsx b/src/pages/namespace/Explorer/Endpoint/EndpointEditor/Form/plugins/Target/TargetFlowVarForm.tsx new file mode 100644 index 000000000..118a08167 --- /dev/null +++ b/src/pages/namespace/Explorer/Endpoint/EndpointEditor/Form/plugins/Target/TargetFlowVarForm.tsx @@ -0,0 +1,126 @@ +import { Controller, useForm } from "react-hook-form"; +import { FC, FormEvent } from "react"; +import FormErrors, { errorsType } from "~/componentsNext/FormErrors"; +import { + TargetFlowVarFormSchema, + TargetFlowVarFormSchemaType, +} from "../../../schema/plugins/target/targetFlowVar"; + +import { Fieldset } from "~/pages/namespace/Explorer/components/Fieldset"; +import FilePicker from "~/componentsNext/FilePicker"; +import Input from "~/design/Input"; +import NamespaceSelector from "~/componentsNext/NamespaceSelector"; +import { PluginWrapper } from "../components/Modal"; +import { treatEmptyStringAsUndefined } from "~/pages/namespace/Explorer/utils"; +import { useTranslation } from "react-i18next"; +import { zodResolver } from "@hookform/resolvers/zod"; + +type OptionalConfig = Partial; + +type FormProps = { + formId: string; + defaultConfig?: OptionalConfig; + onSubmit: (data: TargetFlowVarFormSchemaType) => void; +}; + +export const TargetFlowVarForm: FC = ({ + defaultConfig, + onSubmit, + formId, +}) => { + const { t } = useTranslation(); + const { + control, + register, + watch, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: zodResolver(TargetFlowVarFormSchema), + defaultValues: { + type: "target-flow-var", + configuration: { + ...defaultConfig, + }, + }, + }); + + const submitForm = (e: FormEvent) => { + e.stopPropagation(); // prevent the parent form from submitting + handleSubmit(onSubmit)(e); + }; + + return ( +
+ + {errors?.configuration && ( + + )} +
+ ( + + )} + /> +
+
+ ( + node.type === "workflow"} + /> + )} + /> +
+
+ +
+
+ +
+
+
+ ); +}; diff --git a/src/pages/namespace/Explorer/Endpoint/EndpointEditor/Form/plugins/Target/TargetNamespaceFileForm.tsx b/src/pages/namespace/Explorer/Endpoint/EndpointEditor/Form/plugins/Target/TargetNamespaceFileForm.tsx new file mode 100644 index 000000000..274251a7a --- /dev/null +++ b/src/pages/namespace/Explorer/Endpoint/EndpointEditor/Form/plugins/Target/TargetNamespaceFileForm.tsx @@ -0,0 +1,119 @@ +import { Controller, useForm } from "react-hook-form"; +import { FC, FormEvent } from "react"; +import FormErrors, { errorsType } from "~/componentsNext/FormErrors"; +import { + TargetNamespaceFileFormSchema, + TargetNamespaceFileFormSchemaType, +} from "../../../schema/plugins/target/targetNamespaceFile"; + +import { Fieldset } from "~/pages/namespace/Explorer/components/Fieldset"; +import FilePicker from "~/componentsNext/FilePicker"; +import Input from "~/design/Input"; +import NamespaceSelector from "~/componentsNext/NamespaceSelector"; +import { PluginWrapper } from "../components/Modal"; +import { treatEmptyStringAsUndefined } from "~/pages/namespace/Explorer/utils"; +import { useTranslation } from "react-i18next"; +import { zodResolver } from "@hookform/resolvers/zod"; + +type OptionalConfig = Partial< + TargetNamespaceFileFormSchemaType["configuration"] +>; + +type FormProps = { + formId: string; + defaultConfig?: OptionalConfig; + onSubmit: (data: TargetNamespaceFileFormSchemaType) => void; +}; + +export const TargetNamespaceFileForm: FC = ({ + defaultConfig, + onSubmit, + formId, +}) => { + const { t } = useTranslation(); + const { + control, + register, + handleSubmit, + watch, + formState: { errors }, + } = useForm({ + resolver: zodResolver(TargetNamespaceFileFormSchema), + defaultValues: { + type: "target-namespace-file", + configuration: { + ...defaultConfig, + }, + }, + }); + + const submitForm = (e: FormEvent) => { + e.stopPropagation(); // prevent the parent form from submitting + handleSubmit(onSubmit)(e); + }; + + return ( +
+ + {errors?.configuration && ( + + )} +
+ ( + + )} + /> +
+
+ ( + node.type === "file"} + /> + )} + /> +
+
+ +
+
+
+ ); +}; diff --git a/src/pages/namespace/Explorer/Endpoint/EndpointEditor/Form/plugins/Target/TargetNamespaceVarForm.tsx b/src/pages/namespace/Explorer/Endpoint/EndpointEditor/Form/plugins/Target/TargetNamespaceVarForm.tsx new file mode 100644 index 000000000..fe95a2339 --- /dev/null +++ b/src/pages/namespace/Explorer/Endpoint/EndpointEditor/Form/plugins/Target/TargetNamespaceVarForm.tsx @@ -0,0 +1,107 @@ +import { Controller, useForm } from "react-hook-form"; +import { FC, FormEvent } from "react"; +import FormErrors, { errorsType } from "~/componentsNext/FormErrors"; +import { + TargetNamespaceVarFormSchema, + TargetNamespaceVarFormSchemaType, +} from "../../../schema/plugins/target/targetNamespaceVar"; + +import { Fieldset } from "~/pages/namespace/Explorer/components/Fieldset"; +import Input from "~/design/Input"; +import NamespaceSelector from "~/componentsNext/NamespaceSelector"; +import { PluginWrapper } from "../components/Modal"; +import { treatEmptyStringAsUndefined } from "~/pages/namespace/Explorer/utils"; +import { useTranslation } from "react-i18next"; +import { zodResolver } from "@hookform/resolvers/zod"; + +type OptionalConfig = Partial< + TargetNamespaceVarFormSchemaType["configuration"] +>; + +type FormProps = { + formId: string; + defaultConfig?: OptionalConfig; + onSubmit: (data: TargetNamespaceVarFormSchemaType) => void; +}; + +export const TargetNamespaceVarForm: FC = ({ + defaultConfig, + onSubmit, + formId, +}) => { + const { t } = useTranslation(); + const { + control, + register, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: zodResolver(TargetNamespaceVarFormSchema), + defaultValues: { + type: "target-namespace-var", + configuration: { + ...defaultConfig, + }, + }, + }); + + const submitForm = (e: FormEvent) => { + e.stopPropagation(); // prevent the parent form from submitting + handleSubmit(onSubmit)(e); + }; + + return ( +
+ + {errors?.configuration && ( + + )} +
+ ( + + )} + /> +
+
+ +
+
+ +
+
+
+ ); +}; diff --git a/src/pages/namespace/Explorer/Endpoint/EndpointEditor/Form/plugins/Target/index.tsx b/src/pages/namespace/Explorer/Endpoint/EndpointEditor/Form/plugins/Target/index.tsx new file mode 100644 index 000000000..78fd08809 --- /dev/null +++ b/src/pages/namespace/Explorer/Endpoint/EndpointEditor/Form/plugins/Target/index.tsx @@ -0,0 +1,195 @@ +import { Dialog, DialogTrigger } from "~/design/Dialog"; +import { FC, useState } from "react"; +import { ModalWrapper, PluginSelector } from "../components/Modal"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "~/design/Select"; +import { Table, TableBody, TableCell, TableRow } from "~/design/Table"; +import { UseFormReturn, useWatch } from "react-hook-form"; + +import Button from "~/design/Button"; +import { Card } from "~/design/Card"; +import { EndpointFormSchemaType } from "../../../schema"; +import { InstantResponseForm } from "./InstantResponseForm"; +import { Settings } from "lucide-react"; +import { TableHeader } from "../components/PluginsTable"; +import { TargetFlowForm } from "./TargetFlowForm"; +import { TargetFlowVarForm } from "./TargetFlowVarForm"; +import { TargetNamespaceFileForm } from "./TargetNamespaceFileForm"; +import { TargetNamespaceVarForm } from "./TargetNamespaceVarForm"; +import { targetPluginTypes } from "../../../schema/plugins/target"; +import { useTranslation } from "react-i18next"; + +type TargetPluginFormProps = { + form: UseFormReturn; +}; + +export const TargetPluginForm: FC = ({ form }) => { + const { control } = form; + const values = useWatch({ control }); + const { t } = useTranslation(); + const [dialogOpen, setDialogOpen] = useState(false); + + const currentType = values.plugins?.target?.type; + const [selectedPlugin, setSelectedPlugin] = useState(currentType); + + const { + instantResponse, + targetFlow, + targetFlowVar, + targetNamespaceFile, + targetNamespaceVar, + } = targetPluginTypes; + + const currentConfiguration = values.plugins?.target?.configuration; + + const currentInstantResponseConfig = + currentType === instantResponse ? currentConfiguration : undefined; + + const currentTargetFlowConfig = + currentType === targetFlow ? currentConfiguration : undefined; + + const currentTargetFlowVarConfig = + currentType === targetFlowVar ? currentConfiguration : undefined; + + const currentTargetNamespaceFileConfig = + currentType === targetNamespaceFile ? currentConfiguration : undefined; + + const currentTargetNamespaceVarConfig = + currentType === targetNamespaceVar ? currentConfiguration : undefined; + + const formId = "targetPluginForm"; + + return ( + + + + + + + + {values.plugins?.target?.type ? ( + +
+ {t( + `pages.explorer.endpoint.editor.form.plugins.target.types.${values.plugins?.target?.type}` + )} +
+
+ ) : ( +
+ + + +
+ )} +
+
+
+
+
+ + + + + + + {selectedPlugin === instantResponse && ( + { + setDialogOpen(false); + form.setValue("plugins.target", configuration); + }} + /> + )} + {selectedPlugin === targetFlow && ( + { + setDialogOpen(false); + form.setValue("plugins.target", configuration); + }} + /> + )} + {selectedPlugin === targetFlowVar && ( + { + setDialogOpen(false); + form.setValue("plugins.target", configuration); + }} + /> + )} + {selectedPlugin === targetNamespaceFile && ( + { + setDialogOpen(false); + form.setValue("plugins.target", configuration); + }} + /> + )} + {selectedPlugin === targetNamespaceVar && ( + { + setDialogOpen(false); + form.setValue("plugins.target", configuration); + }} + /> + )} + +
+ ); +}; diff --git a/src/pages/namespace/Explorer/Endpoint/EndpointEditor/Form/plugins/components/Modal.tsx b/src/pages/namespace/Explorer/Endpoint/EndpointEditor/Form/plugins/components/Modal.tsx new file mode 100644 index 000000000..45e48ff9d --- /dev/null +++ b/src/pages/namespace/Explorer/Endpoint/EndpointEditor/Form/plugins/components/Modal.tsx @@ -0,0 +1,63 @@ +import { + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "~/design/Dialog"; +import { FC, PropsWithChildren } from "react"; + +import Button from "~/design/Button"; +import { Card } from "~/design/Card"; +import { useTranslation } from "react-i18next"; + +type ModalWrapperProps = PropsWithChildren & { + title: string; + formId?: string; + showSaveBtn?: boolean; +}; + +export const ModalWrapper: FC = ({ + title, + showSaveBtn = true, + children, + formId, +}) => { + const { t } = useTranslation(); + return ( + + + {title} + +
+ {children} +
+ {showSaveBtn && ( + + + + )} +
+ ); +}; + +type PluginSelectorProps = PropsWithChildren & { + title: string; +}; + +export const PluginSelector: FC = ({ + title, + children, +}) => ( +
+ + {children} +
+); + +export const PluginWrapper: FC = ({ children }) => ( + + {children} + +); diff --git a/src/pages/namespace/Explorer/Endpoint/EndpointEditor/Form/plugins/components/PluginsTable.tsx b/src/pages/namespace/Explorer/Endpoint/EndpointEditor/Form/plugins/components/PluginsTable.tsx new file mode 100644 index 000000000..47f69cc8f --- /dev/null +++ b/src/pages/namespace/Explorer/Endpoint/EndpointEditor/Form/plugins/components/PluginsTable.tsx @@ -0,0 +1,98 @@ +import { ArrowDown, ArrowUp, MoreVertical, Trash } from "lucide-react"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "~/design/Dropdown"; +import { FC, PropsWithChildren } from "react"; +import { TableHead, TableHeaderCell, TableRow } from "~/design/Table"; + +import Button from "~/design/Button"; +import { DialogTrigger } from "@radix-ui/react-dialog"; +import { useTranslation } from "react-i18next"; + +type TableHeaderProps = PropsWithChildren & { + title: string; +}; + +export const TableHeader: FC = ({ title, children }) => ( + + + {title} + {children} + + +); + +type ContextMenuProps = { + onDelete: () => void; + onMoveUp?: () => void; + onMoveDown?: () => void; +}; +export const ContextMenu: FC = ({ + onDelete, + onMoveDown, + onMoveUp, +}) => { + const { t } = useTranslation(); + return ( + + + + + + {onMoveDown && ( + { + e.stopPropagation(); + e.preventDefault(); + onMoveDown(); + }} + > + + + {t("pages.explorer.endpoint.editor.form.plugins.moveDownBtn")} + + + )} + {onMoveUp && ( + { + e.stopPropagation(); + e.preventDefault(); + onMoveUp(); + }} + > + + + {t("pages.explorer.endpoint.editor.form.plugins.moveUpBtn")} + + + )} + { + e.stopPropagation(); + e.preventDefault(); + onDelete(); + }} + > + + + {t("pages.explorer.endpoint.editor.form.plugins.deleteBtn")} + + + + + ); +}; diff --git a/src/pages/namespace/Explorer/Endpoint/EndpointEditor/Form/plugins/utils.ts b/src/pages/namespace/Explorer/Endpoint/EndpointEditor/Form/plugins/utils.ts new file mode 100644 index 000000000..9510340c5 --- /dev/null +++ b/src/pages/namespace/Explorer/Endpoint/EndpointEditor/Form/plugins/utils.ts @@ -0,0 +1,83 @@ +import { AclFormSchemaType } from "../../schema/plugins/inbound/acl"; +import { AuthPluginFormSchemaType } from "../../schema/plugins/auth/schema"; +import { BasicAuthFormSchemaType } from "../../schema/plugins/auth/basicAuth"; +import { GithubWebhookAuthFormSchemaType } from "../../schema/plugins/auth/githubWebhookAuth"; +import { InboundPluginFormSchemaType } from "../../schema/plugins/inbound/schema"; +import { JsInboundFormSchemaType } from "../../schema/plugins/inbound/jsInbound"; +import { JsOutboundFormSchemaType } from "../../schema/plugins/outbound/jsOutbound"; +import { KeyAuthFormSchemaType } from "../../schema/plugins/auth/keyAuth"; +import { OutboundPluginFormSchemaType } from "../../schema/plugins/outbound/schema"; +import { RequestConvertFormSchemaType } from "../../schema/plugins/inbound/requestConvert"; +import { authPluginTypes } from "../../schema/plugins/auth"; +import { inboundPluginTypes } from "../../schema/plugins/inbound"; +import { outboundPluginTypes } from "../../schema/plugins/outbound"; + +export const getRequestConvertConfigAtIndex = ( + fields: InboundPluginFormSchemaType[] | undefined, + index: number | undefined +): RequestConvertFormSchemaType["configuration"] | undefined => { + const plugin = index !== undefined ? fields?.[index] : undefined; + return plugin?.type === inboundPluginTypes.requestConvert + ? plugin.configuration + : undefined; +}; + +export const getJsInboundConfigAtIndex = ( + fields: InboundPluginFormSchemaType[] | undefined, + index: number | undefined +): JsInboundFormSchemaType["configuration"] | undefined => { + const plugin = index !== undefined ? fields?.[index] : undefined; + return plugin?.type === inboundPluginTypes.jsInbound + ? plugin.configuration + : undefined; +}; + +export const getAclConfigAtIndex = ( + fields: InboundPluginFormSchemaType[] | undefined, + index: number | undefined +): AclFormSchemaType["configuration"] | undefined => { + const plugin = index !== undefined ? fields?.[index] : undefined; + return plugin?.type === inboundPluginTypes.acl + ? plugin.configuration + : undefined; +}; + +export const getJsOutboundConfigAtIndex = ( + fields: OutboundPluginFormSchemaType[] | undefined, + index: number | undefined +): JsOutboundFormSchemaType["configuration"] | undefined => { + const plugin = index !== undefined ? fields?.[index] : undefined; + return plugin?.type === outboundPluginTypes.jsOutbound + ? plugin.configuration + : undefined; +}; + +export const getBasicAuthConfigAtIndex = ( + fields: AuthPluginFormSchemaType[] | undefined, + index: number | undefined +): BasicAuthFormSchemaType["configuration"] | undefined => { + const plugin = index !== undefined ? fields?.[index] : undefined; + return plugin?.type === authPluginTypes.basicAuth + ? plugin.configuration + : undefined; +}; + +export const getKeyAuthConfigAtIndex = ( + fields: AuthPluginFormSchemaType[] | undefined, + index: number | undefined +): KeyAuthFormSchemaType["configuration"] | undefined => { + const plugin = index !== undefined ? fields?.[index] : undefined; + return plugin?.type === authPluginTypes.keyAuth + ? plugin.configuration + : undefined; +}; + +export const getGithubWebhookAuthConfigAtIndex = ( + fields: AuthPluginFormSchemaType[] | undefined, + index: number | undefined +): GithubWebhookAuthFormSchemaType["configuration"] | undefined => { + const plugin = index !== undefined ? fields?.[index] : undefined; + return plugin?.type === authPluginTypes.githubWebhookAuth + ? plugin.configuration + : undefined; +}; diff --git a/src/pages/namespace/Explorer/Endpoint/EndpointEditor/index.tsx b/src/pages/namespace/Explorer/Endpoint/EndpointEditor/index.tsx new file mode 100644 index 000000000..1cb957036 --- /dev/null +++ b/src/pages/namespace/Explorer/Endpoint/EndpointEditor/index.tsx @@ -0,0 +1,120 @@ +import Alert from "~/design/Alert"; +import Button from "~/design/Button"; +import { Card } from "~/design/Card"; +import Editor from "~/design/Editor"; +import { EndpointFormSchemaType } from "./schema"; +import { FC } from "react"; +import { Form } from "./Form"; +import FormErrors from "~/componentsNext/FormErrors"; +import { RouteSchemeType } from "~/api/gateway/schema"; +import { Save } from "lucide-react"; +import { ScrollArea } from "~/design/ScrollArea"; +import { jsonToYaml } from "../../utils"; +import { serializeEndpointFile } from "./utils"; +import { useNodeContent } from "~/api/tree/query/node"; +import { useTheme } from "~/util/store/theme"; +import { useTranslation } from "react-i18next"; +import { useUpdateWorkflow } from "~/api/tree/mutate/updateWorkflow"; + +type NodeContentType = ReturnType["data"]; + +type EndpointEditorProps = { + path: string; + data: NonNullable; + route?: RouteSchemeType; +}; + +const EndpointEditor: FC = ({ data, path }) => { + const { t } = useTranslation(); + const theme = useTheme(); + const endpointFileContent = atob(data.revision?.source ?? ""); + const [endpointConfig, endpointConfigError] = + serializeEndpointFile(endpointFileContent); + const { mutate: updateRoute, isLoading } = useUpdateWorkflow(); + + const save = (data: EndpointFormSchemaType) => { + const toSave = jsonToYaml(data); + updateRoute({ + path, + fileContent: toSave, + }); + }; + + return ( +
+ {({ + formControls: { + formState: { errors }, + handleSubmit, + }, + formMarkup, + values, + }) => { + const preview = jsonToYaml(values); + const isDirty = !endpointConfigError && preview !== endpointFileContent; + const disableButton = isLoading || !!endpointConfigError; + + return ( + +
+
+ + {endpointConfigError ? ( +
+ + {t( + "pages.explorer.endpoint.editor.form.serialisationError" + )} + + +
+                          {JSON.stringify(endpointConfigError, null, 2)}
+                        
+
+
+ ) : ( +
+ + {formMarkup} +
+ )} +
+ + + +
+
+ {isDirty && ( +
+ + {t("pages.explorer.endpoint.editor.unsavedNote")} + +
+ )} + +
+
+
+ ); + }} + + ); +}; + +export default EndpointEditor; diff --git a/src/pages/namespace/Explorer/Endpoint/EndpointEditor/schema/index.ts b/src/pages/namespace/Explorer/Endpoint/EndpointEditor/schema/index.ts new file mode 100644 index 000000000..61592379b --- /dev/null +++ b/src/pages/namespace/Explorer/Endpoint/EndpointEditor/schema/index.ts @@ -0,0 +1,24 @@ +import { AuthPluginFormSchema } from "./plugins/auth/schema"; +import { InboundPluginFormSchema } from "./plugins/inbound/schema"; +import { MethodsSchema } from "~/api/gateway/schema"; +import { OutboundPluginFormSchema } from "./plugins/outbound/schema"; +import { TargetPluginFormSchema } from "./plugins/target/schema"; +import { z } from "zod"; + +export const EndpointFormSchema = z.object({ + direktiv_api: z.literal("endpoint/v1"), + allow_anonymous: z.boolean().optional(), + path: z.string().nonempty().optional(), + timeout: z.number().int().positive().optional(), + methods: z.array(MethodsSchema).nonempty().optional(), + plugins: z + .object({ + target: TargetPluginFormSchema, + inbound: z.array(InboundPluginFormSchema).optional(), + outbound: z.array(OutboundPluginFormSchema).optional(), + auth: z.array(AuthPluginFormSchema).optional(), + }) + .optional(), +}); + +export type EndpointFormSchemaType = z.infer; diff --git a/src/pages/namespace/Explorer/Endpoint/EndpointEditor/schema/plugins/auth/basicAuth.ts b/src/pages/namespace/Explorer/Endpoint/EndpointEditor/schema/plugins/auth/basicAuth.ts new file mode 100644 index 000000000..46c8e1dbd --- /dev/null +++ b/src/pages/namespace/Explorer/Endpoint/EndpointEditor/schema/plugins/auth/basicAuth.ts @@ -0,0 +1,13 @@ +import { authPluginTypes } from "."; +import { z } from "zod"; + +export const BasicAuthFormSchema = z.object({ + type: z.literal(authPluginTypes.basicAuth), + configuration: z.object({ + add_username_header: z.boolean(), + add_tags_header: z.boolean(), + add_groups_header: z.boolean(), + }), +}); + +export type BasicAuthFormSchemaType = z.infer; diff --git a/src/pages/namespace/Explorer/Endpoint/EndpointEditor/schema/plugins/auth/githubWebhookAuth.ts b/src/pages/namespace/Explorer/Endpoint/EndpointEditor/schema/plugins/auth/githubWebhookAuth.ts new file mode 100644 index 000000000..34b40f4f7 --- /dev/null +++ b/src/pages/namespace/Explorer/Endpoint/EndpointEditor/schema/plugins/auth/githubWebhookAuth.ts @@ -0,0 +1,13 @@ +import { authPluginTypes } from "."; +import { z } from "zod"; + +export const GithubWebhookAuthFormSchema = z.object({ + type: z.literal(authPluginTypes.githubWebhookAuth), + configuration: z.object({ + secret: z.string().nonempty(), + }), +}); + +export type GithubWebhookAuthFormSchemaType = z.infer< + typeof GithubWebhookAuthFormSchema +>; diff --git a/src/pages/namespace/Explorer/Endpoint/EndpointEditor/schema/plugins/auth/index.ts b/src/pages/namespace/Explorer/Endpoint/EndpointEditor/schema/plugins/auth/index.ts new file mode 100644 index 000000000..9284279eb --- /dev/null +++ b/src/pages/namespace/Explorer/Endpoint/EndpointEditor/schema/plugins/auth/index.ts @@ -0,0 +1,5 @@ +export const authPluginTypes = { + basicAuth: "basic-auth", + githubWebhookAuth: "github-webhook-auth", + keyAuth: "key-auth", +} as const; diff --git a/src/pages/namespace/Explorer/Endpoint/EndpointEditor/schema/plugins/auth/keyAuth.ts b/src/pages/namespace/Explorer/Endpoint/EndpointEditor/schema/plugins/auth/keyAuth.ts new file mode 100644 index 000000000..505166199 --- /dev/null +++ b/src/pages/namespace/Explorer/Endpoint/EndpointEditor/schema/plugins/auth/keyAuth.ts @@ -0,0 +1,14 @@ +import { authPluginTypes } from "."; +import { z } from "zod"; + +export const KeyAuthFormSchema = z.object({ + type: z.literal(authPluginTypes.keyAuth), + configuration: z.object({ + add_username_header: z.boolean(), + add_tags_header: z.boolean(), + add_groups_header: z.boolean(), + key_name: z.string().optional(), + }), +}); + +export type KeyAuthFormSchemaType = z.infer; diff --git a/src/pages/namespace/Explorer/Endpoint/EndpointEditor/schema/plugins/auth/schema.ts b/src/pages/namespace/Explorer/Endpoint/EndpointEditor/schema/plugins/auth/schema.ts new file mode 100644 index 000000000..73a822934 --- /dev/null +++ b/src/pages/namespace/Explorer/Endpoint/EndpointEditor/schema/plugins/auth/schema.ts @@ -0,0 +1,12 @@ +import { BasicAuthFormSchema } from "./basicAuth"; +import { GithubWebhookAuthFormSchema } from "./githubWebhookAuth"; +import { KeyAuthFormSchema } from "./keyAuth"; +import { z } from "zod"; + +export const AuthPluginFormSchema = z.discriminatedUnion("type", [ + BasicAuthFormSchema, + GithubWebhookAuthFormSchema, + KeyAuthFormSchema, +]); + +export type AuthPluginFormSchemaType = z.infer; diff --git a/src/pages/namespace/Explorer/Endpoint/EndpointEditor/schema/plugins/inbound/acl.ts b/src/pages/namespace/Explorer/Endpoint/EndpointEditor/schema/plugins/inbound/acl.ts new file mode 100644 index 000000000..2a2406491 --- /dev/null +++ b/src/pages/namespace/Explorer/Endpoint/EndpointEditor/schema/plugins/inbound/acl.ts @@ -0,0 +1,14 @@ +import { inboundPluginTypes } from "."; +import { z } from "zod"; + +export const AclFormSchema = z.object({ + type: z.literal(inboundPluginTypes.acl), + configuration: z.object({ + allow_groups: z.array(z.string()).optional(), + deny_groups: z.array(z.string()).optional(), + allow_tags: z.array(z.string()).optional(), + deny_tags: z.array(z.string()).optional(), + }), +}); + +export type AclFormSchemaType = z.infer; diff --git a/src/pages/namespace/Explorer/Endpoint/EndpointEditor/schema/plugins/inbound/index.ts b/src/pages/namespace/Explorer/Endpoint/EndpointEditor/schema/plugins/inbound/index.ts new file mode 100644 index 000000000..1ad1b0b7d --- /dev/null +++ b/src/pages/namespace/Explorer/Endpoint/EndpointEditor/schema/plugins/inbound/index.ts @@ -0,0 +1,5 @@ +export const inboundPluginTypes = { + acl: "acl", + jsInbound: "js-inbound", + requestConvert: "request-convert", +} as const; diff --git a/src/pages/namespace/Explorer/Endpoint/EndpointEditor/schema/plugins/inbound/jsInbound.ts b/src/pages/namespace/Explorer/Endpoint/EndpointEditor/schema/plugins/inbound/jsInbound.ts new file mode 100644 index 000000000..ecb578f44 --- /dev/null +++ b/src/pages/namespace/Explorer/Endpoint/EndpointEditor/schema/plugins/inbound/jsInbound.ts @@ -0,0 +1,11 @@ +import { inboundPluginTypes } from "."; +import { z } from "zod"; + +export const JsInboundFormSchema = z.object({ + type: z.literal(inboundPluginTypes.jsInbound), + configuration: z.object({ + script: z.string(), + }), +}); + +export type JsInboundFormSchemaType = z.infer; diff --git a/src/pages/namespace/Explorer/Endpoint/EndpointEditor/schema/plugins/inbound/requestConvert.ts b/src/pages/namespace/Explorer/Endpoint/EndpointEditor/schema/plugins/inbound/requestConvert.ts new file mode 100644 index 000000000..d41d0fa2d --- /dev/null +++ b/src/pages/namespace/Explorer/Endpoint/EndpointEditor/schema/plugins/inbound/requestConvert.ts @@ -0,0 +1,16 @@ +import { inboundPluginTypes } from "."; +import { z } from "zod"; + +export const RequestConvertFormSchema = z.object({ + type: z.literal(inboundPluginTypes.requestConvert), + configuration: z.object({ + omit_headers: z.boolean(), + omit_queries: z.boolean(), + omit_body: z.boolean(), + omit_consumer: z.boolean(), + }), +}); + +export type RequestConvertFormSchemaType = z.infer< + typeof RequestConvertFormSchema +>; diff --git a/src/pages/namespace/Explorer/Endpoint/EndpointEditor/schema/plugins/inbound/schema.ts b/src/pages/namespace/Explorer/Endpoint/EndpointEditor/schema/plugins/inbound/schema.ts new file mode 100644 index 000000000..bdfb10fbc --- /dev/null +++ b/src/pages/namespace/Explorer/Endpoint/EndpointEditor/schema/plugins/inbound/schema.ts @@ -0,0 +1,14 @@ +import { AclFormSchema } from "./acl"; +import { JsInboundFormSchema } from "./jsInbound"; +import { RequestConvertFormSchema } from "./requestConvert"; +import { z } from "zod"; + +export const InboundPluginFormSchema = z.discriminatedUnion("type", [ + AclFormSchema, + JsInboundFormSchema, + RequestConvertFormSchema, +]); + +export type InboundPluginFormSchemaType = z.infer< + typeof InboundPluginFormSchema +>; diff --git a/src/pages/namespace/Explorer/Endpoint/EndpointEditor/schema/plugins/outbound/index.ts b/src/pages/namespace/Explorer/Endpoint/EndpointEditor/schema/plugins/outbound/index.ts new file mode 100644 index 000000000..312d7af83 --- /dev/null +++ b/src/pages/namespace/Explorer/Endpoint/EndpointEditor/schema/plugins/outbound/index.ts @@ -0,0 +1,3 @@ +export const outboundPluginTypes = { + jsOutbound: "js-outbound", +} as const; diff --git a/src/pages/namespace/Explorer/Endpoint/EndpointEditor/schema/plugins/outbound/jsOutbound.ts b/src/pages/namespace/Explorer/Endpoint/EndpointEditor/schema/plugins/outbound/jsOutbound.ts new file mode 100644 index 000000000..34da9856e --- /dev/null +++ b/src/pages/namespace/Explorer/Endpoint/EndpointEditor/schema/plugins/outbound/jsOutbound.ts @@ -0,0 +1,11 @@ +import { outboundPluginTypes } from "."; +import { z } from "zod"; + +export const JsOutboundFormSchema = z.object({ + type: z.literal(outboundPluginTypes.jsOutbound), + configuration: z.object({ + script: z.string(), + }), +}); + +export type JsOutboundFormSchemaType = z.infer; diff --git a/src/pages/namespace/Explorer/Endpoint/EndpointEditor/schema/plugins/outbound/schema.ts b/src/pages/namespace/Explorer/Endpoint/EndpointEditor/schema/plugins/outbound/schema.ts new file mode 100644 index 000000000..c1e17e11b --- /dev/null +++ b/src/pages/namespace/Explorer/Endpoint/EndpointEditor/schema/plugins/outbound/schema.ts @@ -0,0 +1,10 @@ +import { JsOutboundFormSchema } from "./jsOutbound"; +import { z } from "zod"; + +export const OutboundPluginFormSchema = z.discriminatedUnion("type", [ + JsOutboundFormSchema, +]); + +export type OutboundPluginFormSchemaType = z.infer< + typeof OutboundPluginFormSchema +>; diff --git a/src/pages/namespace/Explorer/Endpoint/EndpointEditor/schema/plugins/target/index.ts b/src/pages/namespace/Explorer/Endpoint/EndpointEditor/schema/plugins/target/index.ts new file mode 100644 index 000000000..5a6c805b4 --- /dev/null +++ b/src/pages/namespace/Explorer/Endpoint/EndpointEditor/schema/plugins/target/index.ts @@ -0,0 +1,7 @@ +export const targetPluginTypes = { + instantResponse: "instant-response", + targetFlow: "target-flow", + targetFlowVar: "target-flow-var", + targetNamespaceFile: "target-namespace-file", + targetNamespaceVar: "target-namespace-var", +} as const; diff --git a/src/pages/namespace/Explorer/Endpoint/EndpointEditor/schema/plugins/target/instantResponse.ts b/src/pages/namespace/Explorer/Endpoint/EndpointEditor/schema/plugins/target/instantResponse.ts new file mode 100644 index 000000000..dd3d69b08 --- /dev/null +++ b/src/pages/namespace/Explorer/Endpoint/EndpointEditor/schema/plugins/target/instantResponse.ts @@ -0,0 +1,15 @@ +import { targetPluginTypes } from "."; +import { z } from "zod"; + +export const InstantResponseFormSchema = z.object({ + type: z.literal(targetPluginTypes.instantResponse), + configuration: z.object({ + content_type: z.string().optional(), + status_code: z.number().int().positive(), + status_message: z.string().optional(), + }), +}); + +export type InstantResponseFormSchemaType = z.infer< + typeof InstantResponseFormSchema +>; diff --git a/src/pages/namespace/Explorer/Endpoint/EndpointEditor/schema/plugins/target/schema.ts b/src/pages/namespace/Explorer/Endpoint/EndpointEditor/schema/plugins/target/schema.ts new file mode 100644 index 000000000..ef4dbdd09 --- /dev/null +++ b/src/pages/namespace/Explorer/Endpoint/EndpointEditor/schema/plugins/target/schema.ts @@ -0,0 +1,14 @@ +import { InstantResponseFormSchema } from "./instantResponse"; +import { TargetFlowFormSchema } from "./targetFlow"; +import { TargetFlowVarFormSchema } from "./targetFlowVar"; +import { TargetNamespaceFileFormSchema } from "./targetNamespaceFile"; +import { TargetNamespaceVarFormSchema } from "./targetNamespaceVar"; +import { z } from "zod"; + +export const TargetPluginFormSchema = z.discriminatedUnion("type", [ + InstantResponseFormSchema, + TargetFlowFormSchema, + TargetFlowVarFormSchema, + TargetNamespaceFileFormSchema, + TargetNamespaceVarFormSchema, +]); diff --git a/src/pages/namespace/Explorer/Endpoint/EndpointEditor/schema/plugins/target/targetFlow.ts b/src/pages/namespace/Explorer/Endpoint/EndpointEditor/schema/plugins/target/targetFlow.ts new file mode 100644 index 000000000..027a7fb5c --- /dev/null +++ b/src/pages/namespace/Explorer/Endpoint/EndpointEditor/schema/plugins/target/targetFlow.ts @@ -0,0 +1,14 @@ +import { targetPluginTypes } from "."; +import { z } from "zod"; + +export const TargetFlowFormSchema = z.object({ + type: z.literal(targetPluginTypes.targetFlow), + configuration: z.object({ + namespace: z.string().optional(), + flow: z.string().nonempty(), + async: z.boolean().optional(), + content_type: z.string().optional(), + }), +}); + +export type TargetFlowFormSchemaType = z.infer; diff --git a/src/pages/namespace/Explorer/Endpoint/EndpointEditor/schema/plugins/target/targetFlowVar.ts b/src/pages/namespace/Explorer/Endpoint/EndpointEditor/schema/plugins/target/targetFlowVar.ts new file mode 100644 index 000000000..74688f66f --- /dev/null +++ b/src/pages/namespace/Explorer/Endpoint/EndpointEditor/schema/plugins/target/targetFlowVar.ts @@ -0,0 +1,16 @@ +import { targetPluginTypes } from "."; +import { z } from "zod"; + +export const TargetFlowVarFormSchema = z.object({ + type: z.literal(targetPluginTypes.targetFlowVar), + configuration: z.object({ + namespace: z.string().optional(), + flow: z.string().nonempty(), + variable: z.string().nonempty(), + content_type: z.string().optional(), + }), +}); + +export type TargetFlowVarFormSchemaType = z.infer< + typeof TargetFlowVarFormSchema +>; diff --git a/src/pages/namespace/Explorer/Endpoint/EndpointEditor/schema/plugins/target/targetNamespaceFile.ts b/src/pages/namespace/Explorer/Endpoint/EndpointEditor/schema/plugins/target/targetNamespaceFile.ts new file mode 100644 index 000000000..1f2c3e771 --- /dev/null +++ b/src/pages/namespace/Explorer/Endpoint/EndpointEditor/schema/plugins/target/targetNamespaceFile.ts @@ -0,0 +1,15 @@ +import { targetPluginTypes } from "."; +import { z } from "zod"; + +export const TargetNamespaceFileFormSchema = z.object({ + type: z.literal(targetPluginTypes.targetNamespaceFile), + configuration: z.object({ + namespace: z.string().optional(), + file: z.string().nonempty(), + content_type: z.string().optional(), + }), +}); + +export type TargetNamespaceFileFormSchemaType = z.infer< + typeof TargetNamespaceFileFormSchema +>; diff --git a/src/pages/namespace/Explorer/Endpoint/EndpointEditor/schema/plugins/target/targetNamespaceVar.ts b/src/pages/namespace/Explorer/Endpoint/EndpointEditor/schema/plugins/target/targetNamespaceVar.ts new file mode 100644 index 000000000..ca3ce1e06 --- /dev/null +++ b/src/pages/namespace/Explorer/Endpoint/EndpointEditor/schema/plugins/target/targetNamespaceVar.ts @@ -0,0 +1,15 @@ +import { targetPluginTypes } from "."; +import { z } from "zod"; + +export const TargetNamespaceVarFormSchema = z.object({ + type: z.literal(targetPluginTypes.targetNamespaceVar), + configuration: z.object({ + namespace: z.string().nonempty().optional(), + variable: z.string().nonempty(), + content_type: z.string().optional(), + }), +}); + +export type TargetNamespaceVarFormSchemaType = z.infer< + typeof TargetNamespaceVarFormSchema +>; diff --git a/src/pages/namespace/Explorer/Endpoint/EndpointEditor/utils.ts b/src/pages/namespace/Explorer/Endpoint/EndpointEditor/utils.ts new file mode 100644 index 000000000..2912242c2 --- /dev/null +++ b/src/pages/namespace/Explorer/Endpoint/EndpointEditor/utils.ts @@ -0,0 +1,31 @@ +import { EndpointFormSchema, EndpointFormSchemaType } from "./schema"; + +import { ZodError } from "zod"; +import { jsonToYaml } from "../../utils"; +import yamljs from "js-yaml"; + +type SerializeReturnType = + | [EndpointFormSchemaType, undefined] + | [undefined, ZodError]; + +export const serializeEndpointFile = (yaml: string): SerializeReturnType => { + let json; + try { + json = yamljs.load(yaml); + } catch (e) { + json = null; + } + + const jsonParsed = EndpointFormSchema.safeParse(json); + if (jsonParsed.success) { + return [jsonParsed.data, undefined]; + } + + return [undefined, jsonParsed.error]; +}; + +const defaultEndpointFileJson: EndpointFormSchemaType = { + direktiv_api: "endpoint/v1", +}; + +export const defaultEndpointFileYaml = jsonToYaml(defaultEndpointFileJson); diff --git a/src/pages/namespace/Explorer/Endpoint/index.tsx b/src/pages/namespace/Explorer/Endpoint/index.tsx new file mode 100644 index 000000000..4b5243d78 --- /dev/null +++ b/src/pages/namespace/Explorer/Endpoint/index.tsx @@ -0,0 +1,83 @@ +import { FileSymlink, Workflow } from "lucide-react"; + +import Button from "~/design/Button"; +import { Card } from "~/design/Card"; +import EndpointEditor from "./EndpointEditor"; +import { FC } from "react"; +import { Link } from "react-router-dom"; +import { NoPermissions } from "~/design/Table"; +import PublicPathInput from "../../Gateway/Routes/Table/Row/PublicPath"; +import { analyzePath } from "~/util/router/utils"; +import { pages } from "~/util/router/pages"; +import { removeLeadingSlash } from "~/api/tree/utils"; +import { useNamespace } from "~/util/store/namespace"; +import { useNodeContent } from "~/api/tree/query/node"; +import { useRoutes } from "~/api/gateway/query/getRoutes"; +import { useTranslation } from "react-i18next"; + +const EndpointPage: FC = () => { + const { path } = pages.explorer.useParams(); + const namespace = useNamespace(); + const { segments } = analyzePath(path); + const filename = segments[segments.length - 1]; + const { t } = useTranslation(); + + const { + isAllowed, + noPermissionMessage, + data: gatewayData, + isFetched: isPermissionCheckFetched, + } = useNodeContent({ path }); + + const { data: routes, isFetched: isRouteListFetched } = useRoutes(); + + if (!namespace) return null; + if (!path) return null; + if (!gatewayData) return null; + if (!isPermissionCheckFetched) return null; + if (!isRouteListFetched) return null; + + if (isAllowed === false) + return ( + + {noPermissionMessage} + + ); + + const matchingRoute = routes?.data.find( + (route) => removeLeadingSlash(route.file_path) === removeLeadingSlash(path) + ); + + const publicPath = matchingRoute?.server_path + ? `${window.location.origin}${matchingRoute.server_path}` + : undefined; + + return ( + <> +
+
+

+ + {filename?.relative} +

+
+ {publicPath && } +
+ +
+
+ + + ); +}; + +export default EndpointPage; diff --git a/src/pages/namespace/Explorer/Service/ServiceHelp.tsx b/src/pages/namespace/Explorer/Service/ServiceHelp.tsx index 84672b4b6..bbeb5a798 100644 --- a/src/pages/namespace/Explorer/Service/ServiceHelp.tsx +++ b/src/pages/namespace/Explorer/Service/ServiceHelp.tsx @@ -47,7 +47,7 @@ const ServiceHelp = () => { />
- + { - const { t } = useTranslation(); const namespace = useNamespace(); const { path } = pages.explorer.useParams(); const { data } = useNodeContent({ path }); const { segments } = analyzePath(path); const [dialogOpen, setDialogOpen] = useState(false); - const [selectedDialog, setSelectedDialog] = useState(); + const [selectedDialog, setSelectedDialog] = useState(); useEffect(() => { if (dialogOpen === false) setSelectedDialog(undefined); @@ -55,7 +44,9 @@ const ExplorerHeader: FC = () => { if (!namespace) return null; - const wideOverlay = selectedDialog !== "new-dir"; + const wideOverlay = + !!selectedDialog && + !["new-dir", "new-route", "new-consumer"].includes(selectedDialog); return (
@@ -92,58 +83,7 @@ const ExplorerHeader: FC = () => {
- - - - - - - {t("pages.explorer.tree.header.createLabel")} - - - - { - setSelectedDialog("new-dir"); - }} - > - - {" "} - {t("pages.explorer.tree.header.newDirectory")} - - - { - setSelectedDialog("new-workflow"); - }} - > - - {" "} - {t("pages.explorer.tree.header.newWorkflow")} - - - { - setSelectedDialog("new-service"); - }} - > - - {" "} - {t("pages.explorer.tree.header.newService")} - - - - - + { close={() => setDialogOpen(false)} /> )} + {selectedDialog === "new-route" && ( + x.name + )} + close={() => setDialogOpen(false)} + /> + )} + {selectedDialog === "new-consumer" && ( + x.name + )} + close={() => setDialogOpen(false)} + /> + )}
diff --git a/src/pages/namespace/Explorer/Tree/NoResult.tsx b/src/pages/namespace/Explorer/Tree/NoResult.tsx index 64c4f49bb..c9b1c7a36 100644 --- a/src/pages/namespace/Explorer/Tree/NoResult.tsx +++ b/src/pages/namespace/Explorer/Tree/NoResult.tsx @@ -1,12 +1,13 @@ -import { Dialog, DialogContent, DialogTrigger } from "~/design/Dialog"; +import { Dialog, DialogContent } from "~/design/Dialog"; import { FC, useEffect, useState } from "react"; -import { Folder, FolderOpen, Layers, Play } from "lucide-react"; +import NewFileButton, { FileTypeSelection } from "./components/NewFileButton"; -import Button from "~/design/Button"; -import { NewDialog } from "./types"; -import NewDirectory from "./NewDirectory"; -import NewService from "./NewService"; -import NewWorkflow from "./NewWorkflow"; +import { FolderOpen } from "lucide-react"; +import NewConsumer from "./components/modals/CreateNew/Gateway/Consumer"; +import NewDirectory from "./components/modals/CreateNew/Directory"; +import NewRoute from "./components/modals/CreateNew/Gateway/Route"; +import NewService from "./components/modals/CreateNew/Service"; +import NewWorkflow from "./components/modals/CreateNew/Workflow"; import { NoResult as NoResultContainer } from "~/design/Table"; import { pages } from "~/util/router/pages"; import { twMergeClsx } from "~/util/helpers"; @@ -14,53 +15,22 @@ import { useTranslation } from "react-i18next"; const EmptyDirectoryButton = () => { const { path } = pages.explorer.useParams(); - const { t } = useTranslation(); const [dialogOpen, setDialogOpen] = useState(false); - const [selectedDialog, setSelectedDialog] = useState(); + const [selectedDialog, setSelectedDialog] = useState(); useEffect(() => { if (dialogOpen === false) setSelectedDialog(undefined); }, [dialogOpen, selectedDialog]); - const wideOverlay = selectedDialog !== "new-dir"; + const wideOverlay = + !!selectedDialog && + !["new-dir", "new-route", "new-consumer"].includes(selectedDialog); return ( -
+
- { - setSelectedDialog("new-workflow"); - }} - > - - - { - setSelectedDialog("new-service"); - }} - > - - - { - setSelectedDialog("new-dir"); - }} - > - - + { {selectedDialog === "new-service" && ( setDialogOpen(false)} /> )} + {selectedDialog === "new-route" && ( + setDialogOpen(false)} /> + )} + {selectedDialog === "new-consumer" && ( + setDialogOpen(false)} /> + )}
diff --git a/src/pages/namespace/Explorer/Tree/components/NewFileButton.tsx b/src/pages/namespace/Explorer/Tree/components/NewFileButton.tsx new file mode 100644 index 000000000..9cefcd123 --- /dev/null +++ b/src/pages/namespace/Explorer/Tree/components/NewFileButton.tsx @@ -0,0 +1,134 @@ +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuPortal, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "~/design/Dropdown"; +import { + Folder, + Layers, + Network, + Play, + PlusCircle, + Users, + Workflow, +} from "lucide-react"; + +import Button from "~/design/Button"; +import { DialogTrigger } from "@radix-ui/react-dialog"; +import { FC } from "react"; +import { RxChevronDown } from "react-icons/rx"; +import { useTranslation } from "react-i18next"; + +export type FileTypeSelection = + | "new-dir" + | "new-workflow" + | "new-service" + | "new-route" + | "new-consumer"; + +type NewFileButtonProps = { + setSelectedDialog: (fileType: FileTypeSelection) => void; +}; + +const NewFileButton: FC = ({ setSelectedDialog }) => { + const { t } = useTranslation(); + return ( + + + + + + + {t("pages.explorer.tree.newFileButton.label")} + + + { + setSelectedDialog("new-dir"); + }} + > + + {" "} + {t("pages.explorer.tree.newFileButton.items.directory")} + + + + + { + setSelectedDialog("new-workflow"); + }} + > + + {" "} + {t("pages.explorer.tree.newFileButton.items.workflow")} + + + { + setSelectedDialog("new-service"); + }} + > + + {" "} + {t("pages.explorer.tree.newFileButton.items.service")} + + + + + + {t("pages.explorer.tree.newFileButton.items.gateway.label")} + + + + { + setSelectedDialog("new-route"); + }} + > + + + {t("pages.explorer.tree.newFileButton.items.gateway.route")} + + + { + setSelectedDialog("new-consumer"); + }} + > + + + {t( + "pages.explorer.tree.newFileButton.items.gateway.consumer" + )} + + + + + + + + + ); +}; + +export default NewFileButton; diff --git a/src/pages/namespace/Explorer/Tree/NewDirectory.tsx b/src/pages/namespace/Explorer/Tree/components/modals/CreateNew/Directory.tsx similarity index 100% rename from src/pages/namespace/Explorer/Tree/NewDirectory.tsx rename to src/pages/namespace/Explorer/Tree/components/modals/CreateNew/Directory.tsx diff --git a/src/pages/namespace/Explorer/Tree/components/modals/CreateNew/Gateway/Consumer.tsx b/src/pages/namespace/Explorer/Tree/components/modals/CreateNew/Gateway/Consumer.tsx new file mode 100644 index 000000000..d2e0ff6af --- /dev/null +++ b/src/pages/namespace/Explorer/Tree/components/modals/CreateNew/Gateway/Consumer.tsx @@ -0,0 +1,133 @@ +import { + DialogClose, + DialogFooter, + DialogHeader, + DialogTitle, +} from "~/design/Dialog"; +import { Network, PlusCircle } from "lucide-react"; +import { SubmitHandler, useForm } from "react-hook-form"; + +import Button from "~/design/Button"; +import FormErrors from "~/componentsNext/FormErrors"; +import Input from "~/design/Input"; +import { addYamlFileExtension } from "../../../../utils"; +import { defaultConsumerFileYaml } from "~/pages/namespace/Explorer/Consumer/ConsumerEditor/utils"; +import { fileNameSchema } from "~/api/tree/schema/node"; +import { pages } from "~/util/router/pages"; +import { useCreateWorkflow } from "~/api/tree/mutate/createWorkflow"; +import { useNamespace } from "~/util/store/namespace"; +import { useNavigate } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { z } from "zod"; +import { zodResolver } from "@hookform/resolvers/zod"; + +type FormInput = { + name: string; + fileContent: string; +}; + +const NewConsumer = ({ + path, + close, + unallowedNames, +}: { + path?: string; + close: () => void; + unallowedNames?: string[]; +}) => { + const { t } = useTranslation(); + const namespace = useNamespace(); + const navigate = useNavigate(); + const { + register, + handleSubmit, + formState: { isDirty, errors, isValid, isSubmitted }, + } = useForm({ + resolver: zodResolver( + z.object({ + name: fileNameSchema + .transform((enteredName) => addYamlFileExtension(enteredName)) + .refine( + (nameWithExtension) => + !(unallowedNames ?? []).some( + (unallowedName) => unallowedName === nameWithExtension + ), + { + message: t("pages.explorer.tree.newConsumer.nameAlreadyExists"), + } + ), + fileContent: z.string(), + }) + ), + defaultValues: { + fileContent: defaultConsumerFileYaml, + }, + }); + + const { mutate: createEndpoint, isLoading } = useCreateWorkflow({ + onSuccess: (data) => { + namespace && + navigate( + pages.explorer.createHref({ + namespace, + path: data.node.path, + subpage: "consumer", + }) + ); + close(); + }, + }); + + const onSubmit: SubmitHandler = ({ name, fileContent }) => { + createEndpoint({ path, name, fileContent }); + }; + + // you can not submit if the form has not changed or if there are any errors and + // you have already submitted the form (errors will first show up after submit) + const disableSubmit = !isDirty || (isSubmitted && !isValid); + + const formId = `new-consumer-${path}`; + return ( + <> + + + {t("pages.explorer.tree.newConsumer.title")} + + + +
+ +
+
+ + +
+
+
+ + + + + + + + ); +}; + +export default NewConsumer; diff --git a/src/pages/namespace/Explorer/Tree/components/modals/CreateNew/Gateway/Route.tsx b/src/pages/namespace/Explorer/Tree/components/modals/CreateNew/Gateway/Route.tsx new file mode 100644 index 000000000..37d94ea85 --- /dev/null +++ b/src/pages/namespace/Explorer/Tree/components/modals/CreateNew/Gateway/Route.tsx @@ -0,0 +1,133 @@ +import { + DialogClose, + DialogFooter, + DialogHeader, + DialogTitle, +} from "~/design/Dialog"; +import { Network, PlusCircle } from "lucide-react"; +import { SubmitHandler, useForm } from "react-hook-form"; + +import Button from "~/design/Button"; +import FormErrors from "~/componentsNext/FormErrors"; +import Input from "~/design/Input"; +import { addYamlFileExtension } from "../../../../utils"; +import { defaultEndpointFileYaml } from "~/pages/namespace/Explorer/Endpoint/EndpointEditor/utils"; +import { fileNameSchema } from "~/api/tree/schema/node"; +import { pages } from "~/util/router/pages"; +import { useCreateWorkflow } from "~/api/tree/mutate/createWorkflow"; +import { useNamespace } from "~/util/store/namespace"; +import { useNavigate } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { z } from "zod"; +import { zodResolver } from "@hookform/resolvers/zod"; + +type FormInput = { + name: string; + fileContent: string; +}; + +const NewRoute = ({ + path, + close, + unallowedNames, +}: { + path?: string; + close: () => void; + unallowedNames?: string[]; +}) => { + const { t } = useTranslation(); + const namespace = useNamespace(); + const navigate = useNavigate(); + const { + register, + handleSubmit, + formState: { isDirty, errors, isValid, isSubmitted }, + } = useForm({ + resolver: zodResolver( + z.object({ + name: fileNameSchema + .transform((enteredName) => addYamlFileExtension(enteredName)) + .refine( + (nameWithExtension) => + !(unallowedNames ?? []).some( + (unallowedName) => unallowedName === nameWithExtension + ), + { + message: t("pages.explorer.tree.newRoute.nameAlreadyExists"), + } + ), + fileContent: z.string(), + }) + ), + defaultValues: { + fileContent: defaultEndpointFileYaml, + }, + }); + + const { mutate: createEndpoint, isLoading } = useCreateWorkflow({ + onSuccess: (data) => { + namespace && + navigate( + pages.explorer.createHref({ + namespace, + path: data.node.path, + subpage: "endpoint", + }) + ); + close(); + }, + }); + + const onSubmit: SubmitHandler = ({ name, fileContent }) => { + createEndpoint({ path, name, fileContent }); + }; + + // you can not submit if the form has not changed or if there are any errors and + // you have already submitted the form (errors will first show up after submit) + const disableSubmit = !isDirty || (isSubmitted && !isValid); + + const formId = `new-route-${path}`; + return ( + <> + + + {t("pages.explorer.tree.newRoute.title")} + + + +
+ +
+
+ + +
+
+
+ + + + + + + + ); +}; + +export default NewRoute; diff --git a/src/pages/namespace/Explorer/Tree/NewService/config.ts b/src/pages/namespace/Explorer/Tree/components/modals/CreateNew/Service/config.ts similarity index 100% rename from src/pages/namespace/Explorer/Tree/NewService/config.ts rename to src/pages/namespace/Explorer/Tree/components/modals/CreateNew/Service/config.ts diff --git a/src/pages/namespace/Explorer/Tree/NewService/index.tsx b/src/pages/namespace/Explorer/Tree/components/modals/CreateNew/Service/index.tsx similarity index 98% rename from src/pages/namespace/Explorer/Tree/NewService/index.tsx rename to src/pages/namespace/Explorer/Tree/components/modals/CreateNew/Service/index.tsx index c0b818d09..cab585a9e 100644 --- a/src/pages/namespace/Explorer/Tree/NewService/index.tsx +++ b/src/pages/namespace/Explorer/Tree/components/modals/CreateNew/Service/index.tsx @@ -22,9 +22,9 @@ import FormErrors from "~/componentsNext/FormErrors"; import Input from "~/design/Input"; import { JSONSchemaForm } from "~/design/JSONschemaForm"; import { ScrollArea } from "~/design/ScrollArea"; -import ServiceHelp from "../../Service/ServiceHelp"; +import ServiceHelp from "~/pages/namespace/Explorer/Service/ServiceHelp"; import { Toggle } from "~/design/Toggle"; -import { addYamlFileExtension } from "../utils"; +import { addYamlFileExtension } from "../../../../utils"; import { fileNameSchema } from "~/api/tree/schema/node"; import { pages } from "~/util/router/pages"; import { stringify } from "json-to-pretty-yaml"; diff --git a/src/pages/namespace/Explorer/Tree/NewWorkflow/index.tsx b/src/pages/namespace/Explorer/Tree/components/modals/CreateNew/Workflow/index.tsx similarity index 99% rename from src/pages/namespace/Explorer/Tree/NewWorkflow/index.tsx rename to src/pages/namespace/Explorer/Tree/components/modals/CreateNew/Workflow/index.tsx index 7a8174613..111a9438d 100644 --- a/src/pages/namespace/Explorer/Tree/NewWorkflow/index.tsx +++ b/src/pages/namespace/Explorer/Tree/components/modals/CreateNew/Workflow/index.tsx @@ -20,7 +20,7 @@ import Editor from "~/design/Editor"; import FormErrors from "~/componentsNext/FormErrors"; import Input from "~/design/Input"; import { Textarea } from "~/design/TextArea"; -import { addYamlFileExtension } from "../utils"; +import { addYamlFileExtension } from "../../../../utils"; import { fileNameSchema } from "~/api/tree/schema/node"; import { pages } from "~/util/router/pages"; import { useCreateWorkflow } from "~/api/tree/mutate/createWorkflow"; diff --git a/src/pages/namespace/Explorer/Tree/NewWorkflow/templates.tsx b/src/pages/namespace/Explorer/Tree/components/modals/CreateNew/Workflow/templates.tsx similarity index 100% rename from src/pages/namespace/Explorer/Tree/NewWorkflow/templates.tsx rename to src/pages/namespace/Explorer/Tree/components/modals/CreateNew/Workflow/templates.tsx diff --git a/src/pages/namespace/Explorer/Tree/Delete.tsx b/src/pages/namespace/Explorer/Tree/components/modals/Delete.tsx similarity index 100% rename from src/pages/namespace/Explorer/Tree/Delete.tsx rename to src/pages/namespace/Explorer/Tree/components/modals/Delete.tsx diff --git a/src/pages/namespace/Explorer/Tree/FileViewer.tsx b/src/pages/namespace/Explorer/Tree/components/modals/FileViewer.tsx similarity index 100% rename from src/pages/namespace/Explorer/Tree/FileViewer.tsx rename to src/pages/namespace/Explorer/Tree/components/modals/FileViewer.tsx diff --git a/src/pages/namespace/Explorer/Tree/Rename.tsx b/src/pages/namespace/Explorer/Tree/components/modals/Rename.tsx similarity index 96% rename from src/pages/namespace/Explorer/Tree/Rename.tsx rename to src/pages/namespace/Explorer/Tree/components/modals/Rename.tsx index d28f5c1a5..28370b332 100644 --- a/src/pages/namespace/Explorer/Tree/Rename.tsx +++ b/src/pages/namespace/Explorer/Tree/components/modals/Rename.tsx @@ -11,7 +11,7 @@ import Button from "~/design/Button"; import FormErrors from "~/componentsNext/FormErrors"; import Input from "~/design/Input"; import { TextCursorInput } from "lucide-react"; -import { addYamlFileExtension } from "./utils"; +import { addYamlFileExtension } from "../../utils"; import { useRenameNode } from "~/api/tree/mutate/renameNode"; import { useTranslation } from "react-i18next"; import { z } from "zod"; @@ -40,7 +40,7 @@ const Rename = ({ z.object({ name: fileNameSchema .transform((enteredName) => { - if (node.type === "workflow") { + if (node.type !== "directory" && node.type !== "file") { return addYamlFileExtension(enteredName); } return enteredName; diff --git a/src/pages/namespace/Explorer/Tree/index.tsx b/src/pages/namespace/Explorer/Tree/index.tsx index f3d68b263..5b4e745a3 100644 --- a/src/pages/namespace/Explorer/Tree/index.tsx +++ b/src/pages/namespace/Explorer/Tree/index.tsx @@ -9,15 +9,15 @@ import { } from "~/design/Table"; import { Card } from "~/design/Card"; -import Delete from "./Delete"; +import Delete from "./components/modals/Delete"; import ExplorerHeader from "./Header"; import FileRow from "./FileRow"; -import FileViewer from "./FileViewer"; +import FileViewer from "./components/modals/FileViewer"; import { FolderUp } from "lucide-react"; import { Link } from "react-router-dom"; import NoResult from "./NoResult"; import { NodeSchemaType } from "~/api/tree/schema/node"; -import Rename from "./Rename"; +import Rename from "./components/modals/Rename"; import { analyzePath } from "~/util/router/utils"; import { pages } from "~/util/router/pages"; import { twMergeClsx } from "~/util/helpers"; diff --git a/src/pages/namespace/Explorer/Tree/types.ts b/src/pages/namespace/Explorer/Tree/types.ts deleted file mode 100644 index 5b970258d..000000000 --- a/src/pages/namespace/Explorer/Tree/types.ts +++ /dev/null @@ -1 +0,0 @@ -export type NewDialog = "new-dir" | "new-workflow" | "new-service" | undefined; diff --git a/src/pages/namespace/Explorer/Workflow/ApiCommands/index.tsx b/src/pages/namespace/Explorer/Workflow/ApiCommands/index.tsx index 3fa5d4086..b82524fc8 100644 --- a/src/pages/namespace/Explorer/Workflow/ApiCommands/index.tsx +++ b/src/pages/namespace/Explorer/Workflow/ApiCommands/index.tsx @@ -160,7 +160,6 @@ const ApiCommands = ({ { if (data && selectedTemplate) { setBody(data); diff --git a/src/pages/namespace/Explorer/Workflow/Settings/Variables/Edit.tsx b/src/pages/namespace/Explorer/Workflow/Settings/Variables/Edit.tsx index f125b334f..50cbdb7d6 100644 --- a/src/pages/namespace/Explorer/Workflow/Settings/Variables/Edit.tsx +++ b/src/pages/namespace/Explorer/Workflow/Settings/Variables/Edit.tsx @@ -96,8 +96,8 @@ const Edit = ({ item, onSuccess, path }: EditProps) => { if (isInitialized) { const contentType = data.headers["content-type"]; const safeParsedContentType = EditorMimeTypeSchema.safeParse(contentType); - setValue("mimeType", contentType); - onMimeTypeChange(contentType); + setValue("mimeType", contentType ?? ""); + onMimeTypeChange(contentType ?? ""); if (safeParsedContentType.success) { setBody(data.body); } else { diff --git a/src/pages/namespace/Explorer/components/ArrayInput.tsx b/src/pages/namespace/Explorer/components/ArrayInput.tsx new file mode 100644 index 000000000..8584d81d1 --- /dev/null +++ b/src/pages/namespace/Explorer/components/ArrayInput.tsx @@ -0,0 +1,106 @@ +import { FC, useState } from "react"; +import { Plus, X } from "lucide-react"; + +import Button from "~/design/Button"; +import { ButtonBar } from "~/design/ButtonBar"; +import Input from "~/design/Input"; + +type ArrayInputProps = { + placeholder?: string; + defaultValue: string[]; + onChange: (newValue: string[]) => void; +}; + +export const ArrayInput: FC = ({ + defaultValue, + onChange, + placeholder, +}) => { + const [stringArr, setStringArr] = useState(defaultValue); + const [inputVal, setInputVal] = useState(""); + + const addToArray = () => { + if (inputVal.length > 0) { + const newStringArr = [...stringArr, inputVal]; + const newStringArrEmptyRemoved = newStringArr.filter(Boolean); + setInputVal(""); + setStringArr(newStringArrEmptyRemoved); + onChange(newStringArrEmptyRemoved); + } + }; + + const changeEntry = (index: number, entry: string) => { + const newStringArr = stringArr.map((oldValue, oldValueIndex) => { + if (oldValueIndex === index) { + return entry; + } + return oldValue; + }); + if (entry) { + onChange(newStringArr); + } + setStringArr(newStringArr); + }; + + const removeEntry = (index: number) => { + const newStringArr = stringArr.filter( + (_, oldValueIndex) => oldValueIndex !== index + ); + const newValueRemovedEmpty = newStringArr.filter(Boolean); + setStringArr(newValueRemovedEmpty); + onChange(newValueRemovedEmpty); + }; + + return ( +
+ {stringArr.map((value, valueIndex) => ( + + { + changeEntry(valueIndex, e.target.value); + }} + /> + + + ))} + + + { + setInputVal(e.target.value); + }} + onKeyDown={(e) => { + if (e.key === "Enter") { + addToArray(); + e.preventDefault(); + } + }} + /> + + +
+ ); +}; diff --git a/src/pages/namespace/Explorer/components/Fieldset.tsx b/src/pages/namespace/Explorer/components/Fieldset.tsx new file mode 100644 index 000000000..de119106f --- /dev/null +++ b/src/pages/namespace/Explorer/components/Fieldset.tsx @@ -0,0 +1,31 @@ +import { FC, PropsWithChildren } from "react"; + +import { twMergeClsx } from "~/util/helpers"; + +type FieldsetProps = PropsWithChildren & { + label: string; + htmlFor?: string; + className?: string; + horizontal?: boolean; +}; + +export const Fieldset: FC = ({ + label, + htmlFor, + children, + className, + horizontal, +}) => ( +
+ + {children} +
+); diff --git a/src/pages/namespace/Explorer/utils.ts b/src/pages/namespace/Explorer/utils.ts new file mode 100644 index 000000000..d0969c10e --- /dev/null +++ b/src/pages/namespace/Explorer/utils.ts @@ -0,0 +1,23 @@ +import { stringify as jsonToPrettyYamlStringify } from "json-to-pretty-yaml"; +/** + * a wrapper around the stringify method of json-to-pretty-yaml + * but it will serialize an empty object to an empty string instead + * of "{}" + */ +export const jsonToYaml = (json: Record) => + Object.keys(json).length === 0 ? "" : jsonToPrettyYamlStringify(json); + +export const treatEmptyStringAsUndefined = (value: unknown) => { + if (value === "") { + return undefined; + } + return value; +}; + +export const treatAsNumberOrUndefined = (value: unknown) => { + const parsed = parseInt(`${value}`, 10); + if (isNaN(parsed)) { + return undefined; + } + return parsed; +}; diff --git a/src/pages/namespace/Gateway/Consumers/Table/Row/SecretInput.tsx b/src/pages/namespace/Gateway/Consumers/Table/Row/SecretInput.tsx new file mode 100644 index 000000000..0fc4ac695 --- /dev/null +++ b/src/pages/namespace/Gateway/Consumers/Table/Row/SecretInput.tsx @@ -0,0 +1,32 @@ +import { Eye, EyeOff } from "lucide-react"; +import { FC, useState } from "react"; + +import Button from "~/design/Button"; +import Input from "~/design/Input"; +import { InputWithButton } from "~/design/InputWithButton"; + +type SecretInputProps = { + secret: string; +}; + +const SecretInput: FC = ({ secret }) => { + const [revealSecret, setRevealSecret] = useState(false); + return ( + + + + + ); +}; + +export default SecretInput; diff --git a/src/pages/namespace/Gateway/Consumers/Table/Row/index.tsx b/src/pages/namespace/Gateway/Consumers/Table/Row/index.tsx new file mode 100644 index 000000000..114897e4d --- /dev/null +++ b/src/pages/namespace/Gateway/Consumers/Table/Row/index.tsx @@ -0,0 +1,42 @@ +import { TableCell, TableRow } from "~/design/Table"; + +import Badge from "~/design/Badge"; +import { ConsumerSchemaType } from "~/api/gateway/schema"; +import { FC } from "react"; +import SecretInput from "./SecretInput"; + +type RowProps = { + consumer: ConsumerSchemaType; +}; + +export const Row: FC = ({ consumer }) => ( + + +
{consumer.username}
+
+ + + + + + + +
+ {consumer.groups?.map((group) => ( + + {group} + + ))} +
+
+ +
+ {consumer.tags?.map((tag) => ( + + {tag} + + ))} +
+
+
+); diff --git a/src/pages/namespace/Gateway/Consumers/Table/index.tsx b/src/pages/namespace/Gateway/Consumers/Table/index.tsx new file mode 100644 index 000000000..dee129ba9 --- /dev/null +++ b/src/pages/namespace/Gateway/Consumers/Table/index.tsx @@ -0,0 +1,77 @@ +import { + NoPermissions, + NoResult, + Table, + TableBody, + TableCell, + TableHead, + TableHeaderCell, + TableRow, +} from "~/design/Table"; + +import { Network } from "lucide-react"; +import { Row } from "./Row"; +import { useConsumers } from "~/api/gateway/query/getConsumers"; +import { useTranslation } from "react-i18next"; + +const ConsumerTable = () => { + const { t } = useTranslation(); + const { + data: consumerList, + isSuccess, + isAllowed, + noPermissionMessage, + } = useConsumers(); + + const noResults = isSuccess && consumerList.data.length === 0; + + return ( + + + + + {t("pages.gateway.consumer.columns.username")} + + + {t("pages.gateway.consumer.columns.password")} + + + {t("pages.gateway.consumer.columns.apikey")} + + + {t("pages.gateway.consumer.columns.groups")} + + + {t("pages.gateway.consumer.columns.tags")} + + + + + {isAllowed ? ( + <> + {noResults ? ( + + + + {t("pages.gateway.consumer.empty")} + + + + ) : ( + consumerList?.data?.map((consumer) => ( + + )) + )} + + ) : ( + + + {noPermissionMessage} + + + )} + +
+ ); +}; +export default ConsumerTable; diff --git a/src/pages/namespace/Gateway/Consumers/index.tsx b/src/pages/namespace/Gateway/Consumers/index.tsx new file mode 100644 index 000000000..34b9b04c9 --- /dev/null +++ b/src/pages/namespace/Gateway/Consumers/index.tsx @@ -0,0 +1,26 @@ +import { Card } from "~/design/Card"; +import ConsumerTable from "./Table"; +import RefreshButton from "~/design/RefreshButton"; +import { useConsumers } from "~/api/gateway/query/getConsumers"; + +const ConsumerPage = () => { + const { isFetching, refetch } = useConsumers(); + + return ( + +
+ { + refetch(); + }} + /> +
+ +
+ ); +}; + +export default ConsumerPage; diff --git a/src/pages/namespace/Gateway/Routes/Table/Row/Anonymous.tsx b/src/pages/namespace/Gateway/Routes/Table/Row/Anonymous.tsx new file mode 100644 index 000000000..d42016359 --- /dev/null +++ b/src/pages/namespace/Gateway/Routes/Table/Row/Anonymous.tsx @@ -0,0 +1,21 @@ +import Badge from "~/design/Badge"; +import { FC } from "react"; +import { useTranslation } from "react-i18next"; + +type AllowAnonymousProps = { + allow: boolean; +}; + +export const AllowAnonymous: FC = ({ allow }) => { + const { t } = useTranslation(); + return ( + + {allow + ? t("pages.gateway.routes.row.allowAnonymous.yes") + : t("pages.gateway.routes.row.allowAnonymous.no")} + + ); +}; diff --git a/src/pages/namespace/Gateway/Routes/Table/Row/MessagesOverlay.tsx b/src/pages/namespace/Gateway/Routes/Table/Row/MessagesOverlay.tsx new file mode 100644 index 000000000..a52c6dcfa --- /dev/null +++ b/src/pages/namespace/Gateway/Routes/Table/Row/MessagesOverlay.tsx @@ -0,0 +1,43 @@ +import Alert, { AlertProps } from "~/design/Alert"; +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from "~/design/HoverCard"; + +import { FC } from "react"; + +export type MessagesOverlayProps = { + messages: string[]; + children: (messages: number) => JSX.Element; + variant?: AlertProps["variant"]; +}; + +const MessagesOverlay: FC = ({ + messages, + children, + variant, +}) => { + const messageCount = messages.length; + + return messageCount > 0 ? ( + + + {children(messageCount)} + + + {messages.map((message, i) => ( + + {message} + + ))} + + + ) : null; +}; + +export default MessagesOverlay; diff --git a/src/pages/namespace/Gateway/Routes/Table/Row/Methods.tsx b/src/pages/namespace/Gateway/Routes/Table/Row/Methods.tsx new file mode 100644 index 000000000..37b4ac7ed --- /dev/null +++ b/src/pages/namespace/Gateway/Routes/Table/Row/Methods.tsx @@ -0,0 +1,17 @@ +import Badge from "~/design/Badge"; +import { FC } from "react"; +import { RouteSchemeType } from "~/api/gateway/schema"; + +type AllowAnonymousProps = { + methods: RouteSchemeType["methods"]; +}; + +export const Methods: FC = ({ methods }) => ( +
+ {methods?.map((method) => ( + + {method} + + ))} +
+); diff --git a/src/pages/namespace/Gateway/Routes/Table/Row/Plugins.tsx b/src/pages/namespace/Gateway/Routes/Table/Row/Plugins.tsx new file mode 100644 index 000000000..65439a83e --- /dev/null +++ b/src/pages/namespace/Gateway/Routes/Table/Row/Plugins.tsx @@ -0,0 +1,78 @@ +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from "~/design/HoverCard"; +import { Table, TableBody, TableCell, TableRow } from "~/design/Table"; + +import { ConditionalWrapper } from "~/util/helpers"; +import { FC } from "react"; +import { RouteSchemeType } from "~/api/gateway/schema"; +import { useTranslation } from "react-i18next"; + +type PluginCountProps = { + number: number; + type: keyof RouteSchemeType["plugins"]; +}; + +const PluginCount: FC = ({ type, number }) => { + const { t } = useTranslation(); + return number > 0 ? ( + + + {t("pages.gateway.routes.row.plugin.countType", { + count: number, + type, + })} + + + ) : null; +}; + +type PluginsProps = { + plugins: RouteSchemeType["plugins"]; +}; + +const Plugins: FC = ({ plugins }) => { + const numberOfInboundPlugins = plugins.inbound?.length ?? 0; + const numberOfAuthPlugins = plugins.auth?.length ?? 0; + const numberOfOutboundPlugins = plugins.outbound?.length ?? 0; + const numberOfTargetPlugin = plugins.target ? 1 : 0; + + const numberOfPlugins = + numberOfInboundPlugins + + numberOfAuthPlugins + + numberOfOutboundPlugins + + numberOfTargetPlugin; + + const { t } = useTranslation(); + + return ( + 0} + wrapper={(children) => ( + + {children} + + + + + + + + +
+
+
+ )} + > +
+ {t("pages.gateway.routes.row.plugin.countAll", { + count: numberOfPlugins, + })} +
+
+ ); +}; + +export default Plugins; diff --git a/src/pages/namespace/Gateway/Routes/Table/Row/PublicPath.tsx b/src/pages/namespace/Gateway/Routes/Table/Row/PublicPath.tsx new file mode 100644 index 000000000..6de142892 --- /dev/null +++ b/src/pages/namespace/Gateway/Routes/Table/Row/PublicPath.tsx @@ -0,0 +1,23 @@ +import CopyButton from "~/design/CopyButton"; +import { FC } from "react"; +import Input from "~/design/Input"; +import { InputWithButton } from "~/design/InputWithButton"; + +type PublicPathInputProps = { + path: string; +}; + +const PublicPathInput: FC = ({ path }) => ( + + + + +); + +export default PublicPathInput; diff --git a/src/pages/namespace/Gateway/Routes/Table/Row/index.tsx b/src/pages/namespace/Gateway/Routes/Table/Row/index.tsx new file mode 100644 index 000000000..5e567c614 --- /dev/null +++ b/src/pages/namespace/Gateway/Routes/Table/Row/index.tsx @@ -0,0 +1,79 @@ +import { TableCell, TableRow } from "~/design/Table"; + +import { AllowAnonymous } from "./Anonymous"; +import Badge from "~/design/Badge"; +import { FC } from "react"; +import { Link } from "react-router-dom"; +import MessagesOverlay from "./MessagesOverlay"; +import { Methods } from "./Methods"; +import Plugins from "./Plugins"; +import PublicPathInput from "./PublicPath"; +import { RouteSchemeType } from "~/api/gateway/schema"; +import { pages } from "~/util/router/pages"; +import { useNamespace } from "~/util/store/namespace"; +import { useTranslation } from "react-i18next"; + +type RowProps = { + gateway: RouteSchemeType; +}; + +export const Row: FC = ({ gateway }) => { + const namespace = useNamespace(); + const { t } = useTranslation(); + if (!namespace) return null; + + const path = gateway.server_path + ? `${window.location.origin}${gateway.server_path}` + : undefined; + + return ( + + +
+ + {gateway.file_path} + +
+ + {(errorCount) => ( + + {t("pages.gateway.routes.row.errors.count", { + count: errorCount, + })} + + )} + + + {(warningCount) => ( + + {t("pages.gateway.routes.row.warnings.count", { + count: warningCount, + })} + + )} + +
+
+
+ + + + + {path && } + + + + + + + +
+ ); +}; diff --git a/src/pages/namespace/Gateway/Routes/Table/index.tsx b/src/pages/namespace/Gateway/Routes/Table/index.tsx new file mode 100644 index 000000000..da2b87785 --- /dev/null +++ b/src/pages/namespace/Gateway/Routes/Table/index.tsx @@ -0,0 +1,77 @@ +import { + NoPermissions, + NoResult, + Table, + TableBody, + TableCell, + TableHead, + TableHeaderCell, + TableRow, +} from "~/design/Table"; + +import { Network } from "lucide-react"; +import { Row } from "./Row"; +import { useRoutes } from "~/api/gateway/query/getRoutes"; +import { useTranslation } from "react-i18next"; + +const RoutesTable = () => { + const { t } = useTranslation(); + const { + data: gatewayList, + isSuccess, + isAllowed, + noPermissionMessage, + } = useRoutes(); + + const noResults = isSuccess && gatewayList.data.length === 0; + + return ( + + + + + {t("pages.gateway.routes.columns.filePath")} + + + {t("pages.gateway.routes.columns.methods")} + + + {t("pages.gateway.routes.columns.path")} + + + {t("pages.gateway.routes.columns.plugins")} + + + {t("pages.gateway.routes.columns.anonymous")} + + + + + {isAllowed ? ( + <> + {noResults ? ( + + + + {t("pages.gateway.routes.empty")} + + + + ) : ( + gatewayList?.data?.map((gateway) => ( + + )) + )} + + ) : ( + + + {noPermissionMessage} + + + )} + +
+ ); +}; +export default RoutesTable; diff --git a/src/pages/namespace/Gateway/Routes/index.tsx b/src/pages/namespace/Gateway/Routes/index.tsx new file mode 100644 index 000000000..e1ed76deb --- /dev/null +++ b/src/pages/namespace/Gateway/Routes/index.tsx @@ -0,0 +1,26 @@ +import { Card } from "~/design/Card"; +import RefreshButton from "~/design/RefreshButton"; +import RoutesTable from "./Table"; +import { useRoutes } from "~/api/gateway/query/getRoutes"; + +const RoutesPage = () => { + const { isFetching, refetch } = useRoutes(); + + return ( + +
+ { + refetch(); + }} + /> +
+ +
+ ); +}; + +export default RoutesPage; diff --git a/src/pages/namespace/Gateway/index.tsx b/src/pages/namespace/Gateway/index.tsx new file mode 100644 index 000000000..5f9ec1902 --- /dev/null +++ b/src/pages/namespace/Gateway/index.tsx @@ -0,0 +1,60 @@ +import { Link, Outlet } from "react-router-dom"; +import { Tabs, TabsList, TabsTrigger } from "~/design/Tabs"; +import { Users, Workflow } from "lucide-react"; + +import { pages } from "~/util/router/pages"; +import { useNamespace } from "~/util/store/namespace"; +import { useTranslation } from "react-i18next"; + +const GatewayPage = () => { + const namespace = useNamespace(); + const { t } = useTranslation(); + const { isGatewayRoutesPage, isGatewayConsumerPage } = + pages.gateway.useParams(); + + if (!namespace) return null; + + const tabs = [ + { + value: "endpoints", + active: isGatewayRoutesPage, + icon: