From 85c8f4884fde3cc9e3fad9c7c48963adb362f684 Mon Sep 17 00:00:00 2001 From: Dane Everitt Date: Sun, 10 Oct 2021 13:13:10 -0700 Subject: [PATCH] Cleanup more of the server screen typings --- resources/scripts/api/admin/egg.ts | 47 ++++++++- resources/scripts/api/admin/index.ts | 8 ++ resources/scripts/api/admin/nest.ts | 25 +++++ .../scripts/api/admin/nests/searchEggs.ts | 24 ----- .../scripts/api/admin/nests/searchNests.ts | 24 ----- resources/scripts/api/admin/server.ts | 3 + resources/scripts/api/admin/transformers.ts | 37 ++++++- resources/scripts/api/admin/user.ts | 10 +- .../scripts/api/admin/users/searchUsers.ts | 25 ----- resources/scripts/api/http.ts | 40 ++++++++ .../components/admin/SubNavigation.tsx | 25 +++-- .../components/admin/servers/EggSelect.tsx | 98 +++++++++---------- .../components/admin/servers/NestSelect.tsx | 28 ------ .../components/admin/servers/NestSelector.tsx | 36 +++++++ .../components/admin/servers/OwnerSelect.tsx | 22 ++--- .../admin/servers/ServerStartupContainer.tsx | 36 +++---- 16 files changed, 285 insertions(+), 203 deletions(-) create mode 100644 resources/scripts/api/admin/nest.ts delete mode 100644 resources/scripts/api/admin/nests/searchEggs.ts delete mode 100644 resources/scripts/api/admin/nests/searchNests.ts delete mode 100644 resources/scripts/api/admin/users/searchUsers.ts delete mode 100644 resources/scripts/components/admin/servers/NestSelect.tsx create mode 100644 resources/scripts/components/admin/servers/NestSelector.tsx diff --git a/resources/scripts/api/admin/egg.ts b/resources/scripts/api/admin/egg.ts index 0f43814d0..740468942 100644 --- a/resources/scripts/api/admin/egg.ts +++ b/resources/scripts/api/admin/egg.ts @@ -1,10 +1,31 @@ -import { Model, UUID } from '@/api/admin/index'; +import { Model, UUID, WithRelationships, withRelationships } from '@/api/admin/index'; +import { Nest } from '@/api/admin/nest'; +import http, { QueryBuilderParams, withQueryBuilderParams } from '@/api/http'; +import { AdminTransformers } from '@/api/admin/transformers'; export interface Egg extends Model { id: number; uuid: UUID; + nestId: number; + author: string; + name: string; + description: string | null; + features: string[] | null; + dockerImages: string[]; + configFiles: Record | null; + configStartup: Record | null; + configStop: string | null; + configFrom: number | null; startup: string; + scriptContainer: string; + copyScriptFrom: number | null; + scriptEntry: string; + scriptIsPrivileged: boolean; + scriptInstall: string | null; + createdAt: Date; + updatedAt: Date; relationships: { + nest?: Nest; variables?: EggVariable[]; }; } @@ -23,3 +44,27 @@ export interface EggVariable extends Model { createdAt: Date; updatedAt: Date; } + +/** + * Gets a single egg from the database and returns it. + */ +export const getEgg = async (id: number | string): Promise> => { + const { data } = await http.get(`/api/application/eggs/${id}`, { + params: { + includes: [ 'nest', 'variables' ], + }, + }); + + return withRelationships(AdminTransformers.toEgg(data.data), 'nest', 'variables'); +}; + +export const searchEggs = async (nestId: number, params: QueryBuilderParams<'name'>): Promise[]> => { + const { data } = await http.get(`/api/application/nests/${nestId}/eggs`, { + params: { + ...withQueryBuilderParams(params), + includes: [ 'variables' ], + }, + }); + + return data.data.map(AdminTransformers.toEgg); +}; diff --git a/resources/scripts/api/admin/index.ts b/resources/scripts/api/admin/index.ts index 6d57288c5..014a207a7 100644 --- a/resources/scripts/api/admin/index.ts +++ b/resources/scripts/api/admin/index.ts @@ -16,6 +16,14 @@ export type WithRelationships = Omit; + */ +export type InferModel any> = ReturnType extends Promise ? U : T; + /** * Helper function that just returns the model you pass in, but types the model * such that TypeScript understands the relationships on it. This is just to help diff --git a/resources/scripts/api/admin/nest.ts b/resources/scripts/api/admin/nest.ts new file mode 100644 index 000000000..c808637fc --- /dev/null +++ b/resources/scripts/api/admin/nest.ts @@ -0,0 +1,25 @@ +import { Model, UUID } from '@/api/admin/index'; +import { Egg } from '@/api/admin/egg'; +import http, { QueryBuilderParams, withQueryBuilderParams } from '@/api/http'; +import { AdminTransformers } from '@/api/admin/transformers'; + +export interface Nest extends Model { + id: number; + uuid: UUID; + author: string; + name: string; + description?: string; + createdAt: Date; + updatedAt: Date; + relationships: { + eggs?: Egg[]; + }; +} + +export const searchNests = async (params: QueryBuilderParams<'name'>): Promise => { + const { data } = await http.get('/api/application/nests', { + params: withQueryBuilderParams(params), + }); + + return data.data.map(AdminTransformers.toNest); +}; diff --git a/resources/scripts/api/admin/nests/searchEggs.ts b/resources/scripts/api/admin/nests/searchEggs.ts deleted file mode 100644 index 8bb0f3185..000000000 --- a/resources/scripts/api/admin/nests/searchEggs.ts +++ /dev/null @@ -1,24 +0,0 @@ -import http from '@/api/http'; -import { Egg, rawDataToEgg } from '@/api/admin/eggs/getEgg'; - -interface Filters { - name?: string; -} - -export default (nestId: number, filters?: Filters, include: string[] = []): Promise => { - const params = {}; - if (filters !== undefined) { - Object.keys(filters).forEach(key => { - // @ts-ignore - params['filter[' + key + ']'] = filters[key]; - }); - } - - return new Promise((resolve, reject) => { - http.get(`/api/application/nests/${nestId}/eggs`, { params: { include: include.join(','), ...params } }) - .then(response => resolve( - (response.data.data || []).map(rawDataToEgg) - )) - .catch(reject); - }); -}; diff --git a/resources/scripts/api/admin/nests/searchNests.ts b/resources/scripts/api/admin/nests/searchNests.ts deleted file mode 100644 index 65cc41f36..000000000 --- a/resources/scripts/api/admin/nests/searchNests.ts +++ /dev/null @@ -1,24 +0,0 @@ -import http from '@/api/http'; -import { Nest, rawDataToNest } from '@/api/admin/nests/getNests'; - -interface Filters { - name?: string; -} - -export default (filters?: Filters): Promise => { - const params = {}; - if (filters !== undefined) { - Object.keys(filters).forEach(key => { - // @ts-ignore - params['filter[' + key + ']'] = filters[key]; - }); - } - - return new Promise((resolve, reject) => { - http.get('/api/application/nests', { params: { ...params } }) - .then(response => resolve( - (response.data.data || []).map(rawDataToNest) - )) - .catch(reject); - }); -}; diff --git a/resources/scripts/api/admin/server.ts b/resources/scripts/api/admin/server.ts index b65f50636..4a0112c44 100644 --- a/resources/scripts/api/admin/server.ts +++ b/resources/scripts/api/admin/server.ts @@ -7,6 +7,7 @@ import { AdminTransformers } from '@/api/admin/transformers'; import { Allocation, Node } from '@/api/admin/node'; import { User } from '@/api/admin/user'; import { Egg, EggVariable } from '@/api/admin/egg'; +import { Nest } from '@/api/admin/nest'; /** * Defines the limits for a server that exists on the Panel. @@ -41,6 +42,7 @@ export interface Server extends Model { nodeId: number; allocationId: number; eggId: number; + nestId: number; limits: ServerLimits; featureLimits: { databases: number; @@ -56,6 +58,7 @@ export interface Server extends Model { updatedAt: Date; relationships: { allocations?: Allocation[]; + nest?: Nest; egg?: Egg; node?: Node; user?: User; diff --git a/resources/scripts/api/admin/transformers.ts b/resources/scripts/api/admin/transformers.ts index a279d1c3a..6447bb04c 100644 --- a/resources/scripts/api/admin/transformers.ts +++ b/resources/scripts/api/admin/transformers.ts @@ -5,6 +5,7 @@ import { FractalResponseData, FractalResponseList } from '@/api/http'; import { User, UserRole } from '@/api/admin/user'; import { Location } from '@/api/admin/location'; import { Egg, EggVariable } from '@/api/admin/egg'; +import { Nest } from '@/api/admin/nest'; const isList = (data: FractalResponseList | FractalResponseData): data is FractalResponseList => data.object === 'list'; @@ -24,7 +25,7 @@ function transform (data: FractalResponseData | FractalResponseList | undefin export class AdminTransformers { static toServer = ({ attributes }: FractalResponseData): Server => { const { oom_disabled, ...limits } = attributes.limits; - const { allocations, egg, node, user, variables } = attributes.relationships || {}; + const { allocations, egg, nest, node, user, variables } = attributes.relationships || {}; return { id: attributes.id, @@ -38,6 +39,7 @@ export class AdminTransformers { nodeId: attributes.node_id, allocationId: attributes.allocation_id, eggId: attributes.egg_id, + nestId: attributes.nest_id, limits: { ...limits, oomDisabled: oom_disabled }, featureLimits: attributes.feature_limits, container: attributes.container, @@ -45,6 +47,7 @@ export class AdminTransformers { updatedAt: new Date(attributes.updated_at), relationships: { allocations: transform(allocations as FractalResponseList | undefined, this.toAllocation), + nest: transform(nest as FractalResponseData | undefined, this.toNest), egg: transform(egg as FractalResponseData | undefined, this.toEgg), node: transform(node as FractalResponseData | undefined, this.toNode), user: transform(user as FractalResponseData | undefined, this.toUser), @@ -132,7 +135,26 @@ export class AdminTransformers { static toEgg = ({ attributes }: FractalResponseData): Egg => ({ id: attributes.id, uuid: attributes.uuid, + nestId: attributes.nest_id, + author: attributes.author, + name: attributes.name, + description: attributes.description, + features: attributes.features, + dockerImages: attributes.docker_images, + configFiles: attributes.config?.files, + configStartup: attributes.config?.startup, + configStop: attributes.config?.stop, + configFrom: attributes.config?.extends, + startup: attributes.startup, + copyScriptFrom: attributes.copy_script_from, + scriptContainer: attributes.script?.container, + scriptEntry: attributes.script?.entry, + scriptIsPrivileged: attributes.script?.privileged, + scriptInstall: attributes.script?.install, + createdAt: new Date(attributes.created_at), + updatedAt: new Date(attributes.updated_at), relationships: { + nest: transform(attributes.relationships?.nest as FractalResponseData, this.toNest), variables: transform(attributes.relationships?.variables as FractalResponseList, this.toEggVariable), }, }); @@ -174,4 +196,17 @@ export class AdminTransformers { return !this.alias ? raw : `${this.alias} (${raw})`; }, }); + + static toNest = ({ attributes }: FractalResponseData): Nest => ({ + id: attributes.id, + uuid: attributes.uuid, + author: attributes.author, + name: attributes.name, + description: attributes.description, + createdAt: new Date(attributes.created_at), + updatedAt: new Date(attributes.updated_at), + relationships: { + eggs: transform(attributes.relationships?.eggs as FractalResponseList, this.toEgg), + }, + }); } diff --git a/resources/scripts/api/admin/user.ts b/resources/scripts/api/admin/user.ts index 7d67e908b..b92315208 100644 --- a/resources/scripts/api/admin/user.ts +++ b/resources/scripts/api/admin/user.ts @@ -1,6 +1,6 @@ import { Model, UUID } from '@/api/admin/index'; import { Server } from '@/api/admin/server'; -import http from '@/api/http'; +import http, { QueryBuilderParams, withQueryBuilderParams } from '@/api/http'; import { AdminTransformers } from '@/api/admin/transformers'; export interface User extends Model { @@ -34,3 +34,11 @@ export const getUser = async (id: string | number): Promise => { return AdminTransformers.toUser(data.data); }; + +export const searchUserAccounts = async (params: QueryBuilderParams<'username' | 'email'>): Promise => { + const { data } = await http.get('/api/application/users', { + params: withQueryBuilderParams(params), + }); + + return data.data.map(AdminTransformers.toUser); +}; diff --git a/resources/scripts/api/admin/users/searchUsers.ts b/resources/scripts/api/admin/users/searchUsers.ts deleted file mode 100644 index 450ca436c..000000000 --- a/resources/scripts/api/admin/users/searchUsers.ts +++ /dev/null @@ -1,25 +0,0 @@ -import http from '@/api/http'; -import { User, rawDataToUser } from '@/api/admin/users/getUsers'; - -interface Filters { - username?: string; - email?: string; -} - -export default (filters?: Filters): Promise => { - const params = {}; - if (filters !== undefined) { - Object.keys(filters).forEach(key => { - // @ts-ignore - params['filter[' + key + ']'] = filters[key]; - }); - } - - return new Promise((resolve, reject) => { - http.get('/api/application/users', { params: { ...params } }) - .then(response => resolve( - (response.data.data || []).map(rawDataToUser) - )) - .catch(reject); - }); -}; diff --git a/resources/scripts/api/http.ts b/resources/scripts/api/http.ts index 4e33541ab..a97f63de6 100644 --- a/resources/scripts/api/http.ts +++ b/resources/scripts/api/http.ts @@ -111,3 +111,43 @@ export function getPaginationSet (data: any): PaginationDataSet { totalPages: data.total_pages, }; } + +type QueryBuilderFilterValue = string | number | boolean | null; + +export interface QueryBuilderParams { + filters?: { + [K in FilterKeys]?: QueryBuilderFilterValue | Readonly; + }; + sorts?: { + [K in SortKeys]?: -1 | 0 | 1 | 'asc' | 'desc' | null; + }; +} + +/** + * Helper function that parses a data object provided and builds query parameters + * for the Laravel Query Builder package automatically. This will apply sorts and + * filters deterministically based on the provided values. + */ +export const withQueryBuilderParams = (data?: QueryBuilderParams): Record => { + if (!data) return {}; + + const filters = Object.keys(data.filters || {}).reduce((obj, key) => { + const value = data.filters?.[key]; + + return !value || value === '' ? obj : { ...obj, [`filter[${key}]`]: value }; + }, {} as NonNullable); + + const sorts = Object.keys(data.sorts || {}).reduce((arr, key) => { + const value = data.sorts?.[key]; + if (!value || ![ 'asc', 'desc', 1, -1 ].includes(value)) { + return arr; + } + + return [ ...arr, (value === -1 || value === 'desc' ? '-' : '') + key ]; + }, [] as string[]); + + return { + ...filters, + sorts: !sorts.length ? undefined : sorts.join(','), + }; +}; diff --git a/resources/scripts/components/admin/SubNavigation.tsx b/resources/scripts/components/admin/SubNavigation.tsx index 857ed3093..42935c8b7 100644 --- a/resources/scripts/components/admin/SubNavigation.tsx +++ b/resources/scripts/components/admin/SubNavigation.tsx @@ -11,7 +11,7 @@ export const SubNavigation = styled.div` & > svg { ${tw`w-6 h-6 mr-2`}; } - + &:active, &.active { ${tw`text-primary-300 border-primary-300`}; } @@ -21,13 +21,20 @@ export const SubNavigation = styled.div` interface Props { to: string; name: string; - icon: React.ComponentType; } -export const SubNavigationLink = ({ to, name, icon: IconComponent }: Props) => { - return ( - - {name} - - ); -}; +interface PropsWithIcon extends Props { + icon: React.ComponentType; + children?: never; +} + +interface PropsWithoutIcon extends Props { + icon?: never; + children: React.ReactNode; +} + +export const SubNavigationLink = ({ to, name, icon: IconComponent, children }: PropsWithIcon | PropsWithoutIcon) => ( + + {IconComponent ? : children}{name} + +); diff --git a/resources/scripts/components/admin/servers/EggSelect.tsx b/resources/scripts/components/admin/servers/EggSelect.tsx index 115633ca0..2d708ba36 100644 --- a/resources/scripts/components/admin/servers/EggSelect.tsx +++ b/resources/scripts/components/admin/servers/EggSelect.tsx @@ -1,70 +1,60 @@ import Label from '@/components/elements/Label'; import Select from '@/components/elements/Select'; -import { useFormikContext } from 'formik'; +import { useField } from 'formik'; import React, { useEffect, useState } from 'react'; -import { Egg } from '@/api/admin/eggs/getEgg'; -import searchEggs from '@/api/admin/nests/searchEggs'; +import { Egg, searchEggs } from '@/api/admin/egg'; +import { WithRelationships } from '@/api/admin'; -export default ({ nestId, egg, setEgg }: { nestId: number | null; egg: Egg | null, setEgg: (value: Egg | null) => void }) => { - const { setFieldValue } = useFormikContext(); +interface Props { + nestId?: number; + selectedEggId?: number; + onEggSelect: (egg: Egg | null) => void; +} - const [ eggs, setEggs ] = useState([]); - - /** - * So you may be asking yourself, "what cluster-fuck of code is this?" - * - * Well, this code makes sure that when the egg changes, that the environment - * object has empty string values instead of undefined so React doesn't think - * the variable fields are uncontrolled. - */ - const setEgg2 = (newEgg: Egg | null) => { - if (newEgg === null) { - setEgg(null); - return; - } - - // Reset all variables to be empty, don't inherit the previous values. - const newVariables = newEgg?.relations.variables; - newVariables?.forEach(v => setFieldValue('environment.' + v.envVariable, '')); - const variables = egg?.relations.variables?.filter(v => newVariables?.find(v2 => v2.envVariable === v.envVariable) === undefined); - - setEgg(newEgg); - - // Clear any variables that don't exist on the new egg. - variables?.forEach(v => setFieldValue('environment.' + v.envVariable, undefined)); - }; +export default ({ nestId, selectedEggId, onEggSelect }: Props) => { + const [ , , { setValue, setTouched } ] = useField>('environment'); + const [ eggs, setEggs ] = useState[] | null>(null); useEffect(() => { - if (nestId === null) { - return; - } + if (!nestId) return setEggs(null); - searchEggs(nestId, {}, [ 'variables' ]) - .then(eggs => { - setEggs(eggs); - if (eggs.length < 1) { - setEgg2(null); - return; - } - setEgg2(eggs[0]); - }) - .catch(error => console.error(error)); + searchEggs(nestId, {}).then(eggs => { + setEggs(eggs); + onEggSelect(eggs[0] || null); + }).catch(error => console.error(error)); }, [ nestId ]); + const onSelectChange = (e: React.ChangeEvent) => { + if (!eggs) return; + + const match = eggs.find(egg => String(egg.id) === e.currentTarget.value); + if (!match) return onEggSelect(null); + + // Ensure that only new egg variables are present in the record storing all + // of the possible variables. This ensures the fields are controlled, rather + // than uncontrolled when a user begins typing in them. + setValue(match.relationships.variables.reduce((obj, value) => ({ + ...obj, + [value.environmentVariable]: undefined, + }), {})); + setTouched(true); + + onEggSelect(match); + }; + return ( <> - + {!eggs ? + + : + eggs.map(v => ( + + )) + } ); diff --git a/resources/scripts/components/admin/servers/NestSelect.tsx b/resources/scripts/components/admin/servers/NestSelect.tsx deleted file mode 100644 index 26e6d8b97..000000000 --- a/resources/scripts/components/admin/servers/NestSelect.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import Label from '@/components/elements/Label'; -import Select from '@/components/elements/Select'; -import React, { useEffect, useState } from 'react'; -import { Nest } from '@/api/admin/nests/getNests'; -import searchNests from '@/api/admin/nests/searchNests'; - -export default ({ nestId, setNestId }: { nestId: number | null; setNestId: (value: number | null) => void }) => { - const [ nests, setNests ] = useState(null); - - useEffect(() => { - searchNests({}) - .then(nests => setNests(nests)) - .catch(error => console.error(error)); - }, []); - - return ( - <> - - - - ); -}; diff --git a/resources/scripts/components/admin/servers/NestSelector.tsx b/resources/scripts/components/admin/servers/NestSelector.tsx new file mode 100644 index 000000000..5b56802c0 --- /dev/null +++ b/resources/scripts/components/admin/servers/NestSelector.tsx @@ -0,0 +1,36 @@ +import Label from '@/components/elements/Label'; +import Select from '@/components/elements/Select'; +import React, { useEffect, useState } from 'react'; +import { Nest, searchNests } from '@/api/admin/nest'; + +interface Props { + selectedNestId?: number; + onNestSelect: (nest: number) => void; +} + +export default ({ selectedNestId, onNestSelect }: Props) => { + const [ nests, setNests ] = useState(null); + + useEffect(() => { + searchNests({}) + .then(setNests) + .catch(error => console.error(error)); + }, []); + + return ( + <> + + + + ); +}; diff --git a/resources/scripts/components/admin/servers/OwnerSelect.tsx b/resources/scripts/components/admin/servers/OwnerSelect.tsx index c29348c69..38a4f8194 100644 --- a/resources/scripts/components/admin/servers/OwnerSelect.tsx +++ b/resources/scripts/components/admin/servers/OwnerSelect.tsx @@ -1,24 +1,18 @@ import React, { useState } from 'react'; import { useFormikContext } from 'formik'; -import { User } from '@/api/admin/users/getUsers'; -import searchUsers from '@/api/admin/users/searchUsers'; import SearchableSelect, { Option } from '@/components/elements/SearchableSelect'; +import { User, searchUserAccounts } from '@/api/admin/user'; -export default ({ selected }: { selected: User | null }) => { +export default ({ selected }: { selected: User }) => { const context = useFormikContext(); const [ user, setUser ] = useState(selected); const [ users, setUsers ] = useState(null); - const onSearch = (query: string): Promise => { - return new Promise((resolve, reject) => { - searchUsers({ username: query, email: query }) - .then((users) => { - setUsers(users); - return resolve(); - }) - .catch(reject); - }); + const onSearch = async (query: string) => { + setUsers( + await searchUserAccounts({ filters: { username: query, email: query } }) + ); }; const onSelect = (user: User | null) => { @@ -26,9 +20,7 @@ export default ({ selected }: { selected: User | null }) => { context.setFieldValue('ownerId', user?.id || null); }; - const getSelectedText = (user: User | null): string => { - return user?.email || ''; - }; + const getSelectedText = (user: User | null): string => user?.email || ''; return ( void, server: Server }) { const { isSubmitting } = useFormikContext(); - const [ nestId, setNestId ] = useState(server.nestId); + const [ nestId, setNestId ] = useState(server.nestId); return ( - - - +
- +
-
- +
-
- +
); @@ -106,7 +99,7 @@ function ServerImageContainer () { } function ServerVariableContainer ({ variable, defaultValue }: { variable: EggVariable, defaultValue: string }) { - const key = 'environment.' + variable.envVariable; + const key = 'environment.' + variable.environmentVariable; const { isSubmitting, setFieldValue } = useFormikContext(); @@ -157,11 +150,11 @@ function ServerStartupForm ({ egg, setEgg, server }: { egg: Egg | null, setEgg:
- {egg?.relations.variables?.map((v, i) => ( + {egg?.relationships.variables?.map((v, i) => ( v.eggId === v2.eggId && v.envVariable === v2.envVariable)?.serverValue || v.defaultValue} + defaultValue={server.relationships?.variables?.find(v2 => v.eggId === v2.eggId && v.environmentVariable === v2.environmentVariable)?.serverValue || v.defaultValue} /> ))}
@@ -179,9 +172,9 @@ function ServerStartupForm ({ egg, setEgg, server }: { egg: Egg | null, setEgg: } export default () => { - const { data: server, mutate } = useServerFromRoute(); + const { data: server } = useServerFromRoute(); const { clearFlashes, clearAndAddHttpError } = useStoreActions((actions: Actions) => actions.flashes); - const [ egg, setEgg ] = useState(null); + const [ egg, setEgg ] = useState | null>(null); useEffect(() => { if (!server) return; @@ -213,7 +206,7 @@ export default () => { initialValues={{ startup: server.container.startup, // Don't ask. - environment: Object.fromEntries(egg?.relations.variables?.map(v => [ v.envVariable, '' ]) || []), + environment: Object.fromEntries(egg?.relationships.variables.map(v => [ v.environmentVariable, '' ]) || []), image: server.container.image, eggId: server.eggId, skipScripts: false, @@ -223,6 +216,7 @@ export default () => { >