From 841ca1ebd43bb227a4f38832604dcfe3ad0139c2 Mon Sep 17 00:00:00 2001 From: Ali <83188384+testA113@users.noreply.github.com> Date: Sun, 27 Aug 2023 23:01:35 +0200 Subject: [PATCH] feat(app): migrate app parent view to react [EE-5361] (#10086) Co-authored-by: testa113 --- app/kubernetes/__module.js | 4 +- app/kubernetes/react/components/index.ts | 9 +- app/kubernetes/react/views/index.ts | 8 + .../views/applications/edit/application.html | 83 ---- .../views/applications/edit/application.js | 9 - .../edit/applicationController.js | 266 ------------ app/portainer/react/components/index.ts | 5 +- app/react/components/Badge/Badge.stories.tsx | 8 +- app/react/components/Badge/Badge.tsx | 100 +++-- app/react/components/CodeEditor.tsx | 1 + .../components/InlineLoader/InlineLoader.tsx | 18 +- app/react/components/Note/Note.tsx | 89 ++++ app/react/components/Note/index.ts | 1 + app/react/components/Widget/WidgetTabs.tsx | 14 +- .../AppYAMLEditor/ApplicationYAMLEditor.tsx | 44 ++ .../AppYAMLEditor/useApplicationYAML.ts | 86 ++++ .../ApplicationContainersDatatable.tsx | 2 +- .../DetailsView/ApplicationDetailsView.tsx | 132 ++++++ .../ApplicationAutoScalingTable.tsx | 4 +- .../ApplicationDetailsWidget.tsx | 180 +++++---- .../DetailsView/ApplicationSummaryWidget.tsx | 381 +++++++++--------- .../PlacementsDatatable.tsx | 55 +-- .../PlacementsDatatableSubRow.tsx | 43 +- .../PlacementsDatatable/columns/helper.ts | 5 + .../PlacementsDatatable/columns/index.tsx | 6 +- .../PlacementsDatatable/columns/status.tsx | 2 +- .../DetailsView/PlacementsDatatable/index.ts | 5 + .../usePlacementTableData.tsx | 249 ++++++++++++ .../{ItemView => DetailsView}/types.ts | 22 +- .../useApplicationEventsTableData.tsx | 88 ++++ .../PlacementsDatatable/columns/helper.ts | 5 - .../ItemView/PlacementsDatatable/index.ts | 1 - .../applications/application.queries.ts | 37 +- .../applications/application.service.ts | 37 +- .../applications/autoscaling.service.ts | 84 +++- .../kubernetes/applications/pod.service.ts | 12 +- .../cluster/HomeView/nodes.service.ts | 70 ++++ .../EventsDatatable.tsx | 2 +- .../kubernetes/components/YAMLInspector.tsx | 9 +- .../CreateIngressView/CreateIngressView.tsx | 4 +- .../kubernetes/networks/services/queries.ts | 5 +- app/react/kubernetes/services/service.ts | 73 +++- 42 files changed, 1448 insertions(+), 810 deletions(-) delete mode 100644 app/kubernetes/views/applications/edit/application.html delete mode 100644 app/kubernetes/views/applications/edit/application.js delete mode 100644 app/kubernetes/views/applications/edit/applicationController.js create mode 100644 app/react/components/Note/Note.tsx create mode 100644 app/react/components/Note/index.ts create mode 100644 app/react/kubernetes/applications/DetailsView/AppYAMLEditor/ApplicationYAMLEditor.tsx create mode 100644 app/react/kubernetes/applications/DetailsView/AppYAMLEditor/useApplicationYAML.ts create mode 100644 app/react/kubernetes/applications/DetailsView/ApplicationDetailsView.tsx rename app/react/kubernetes/applications/{ItemView => DetailsView}/PlacementsDatatable/PlacementsDatatable.tsx (57%) rename app/react/kubernetes/applications/{ItemView => DetailsView}/PlacementsDatatable/PlacementsDatatableSubRow.tsx (77%) create mode 100644 app/react/kubernetes/applications/DetailsView/PlacementsDatatable/columns/helper.ts rename app/react/kubernetes/applications/{ItemView => DetailsView}/PlacementsDatatable/columns/index.tsx (63%) rename app/react/kubernetes/applications/{ItemView => DetailsView}/PlacementsDatatable/columns/status.tsx (88%) create mode 100644 app/react/kubernetes/applications/DetailsView/PlacementsDatatable/index.ts create mode 100644 app/react/kubernetes/applications/DetailsView/PlacementsDatatable/usePlacementTableData.tsx rename app/react/kubernetes/applications/{ItemView => DetailsView}/types.ts (54%) create mode 100644 app/react/kubernetes/applications/DetailsView/useApplicationEventsTableData.tsx delete mode 100644 app/react/kubernetes/applications/ItemView/PlacementsDatatable/columns/helper.ts delete mode 100644 app/react/kubernetes/applications/ItemView/PlacementsDatatable/index.ts create mode 100644 app/react/kubernetes/cluster/HomeView/nodes.service.ts diff --git a/app/kubernetes/__module.js b/app/kubernetes/__module.js index 86fc7dc8a..c80cbf1fb 100644 --- a/app/kubernetes/__module.js +++ b/app/kubernetes/__module.js @@ -151,10 +151,10 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo const application = { name: 'kubernetes.applications.application', - url: '/:namespace/:name?resource-type', + url: '/:namespace/:name?resource-type&tab', views: { 'content@': { - component: 'kubernetesApplicationView', + component: 'applicationDetailsView', }, }, }; diff --git a/app/kubernetes/react/components/index.ts b/app/kubernetes/react/components/index.ts index f745f280c..41056bb2b 100644 --- a/app/kubernetes/react/components/index.ts +++ b/app/kubernetes/react/components/index.ts @@ -20,7 +20,6 @@ import { import { ApplicationContainersDatatable } from '@/react/kubernetes/applications/DetailsView/ApplicationContainersDatatable'; import { withFormValidation } from '@/react-tools/withFormValidation'; import { withCurrentUser } from '@/react-tools/withCurrentUser'; -import { PlacementsDatatable } from '@/react/kubernetes/applications/ItemView/PlacementsDatatable'; import { YAMLInspector } from '@/react/kubernetes/components/YAMLInspector'; export const ngModule = angular @@ -102,6 +101,7 @@ export const ngModule = angular r2a(withUIRouter(withReactQuery(withCurrentUser(YAMLInspector))), [ 'identifier', 'data', + 'hideMessage', ]) ) .component( @@ -133,13 +133,6 @@ export const ngModule = angular withUIRouter(withReactQuery(withCurrentUser(ApplicationEventsDatatable))), [] ) - ) - .component( - 'kubernetesApplicationPlacementsDatatable', - r2a(withUIRouter(withCurrentUser(PlacementsDatatable)), [ - 'dataset', - 'onRefresh', - ]) ); export const componentsModule = ngModule.name; diff --git a/app/kubernetes/react/views/index.ts b/app/kubernetes/react/views/index.ts index 981d6ef3d..38323dc5e 100644 --- a/app/kubernetes/react/views/index.ts +++ b/app/kubernetes/react/views/index.ts @@ -10,6 +10,7 @@ import { DashboardView } from '@/react/kubernetes/dashboard/DashboardView'; import { ServicesView } from '@/react/kubernetes/services/ServicesView'; import { ConsoleView } from '@/react/kubernetes/applications/ConsoleView'; import { ConfigmapsAndSecretsView } from '@/react/kubernetes/configs/ListView/ConfigmapsAndSecretsView'; +import { ApplicationDetailsView } from '@/react/kubernetes/applications/DetailsView/ApplicationDetailsView'; export const viewsModule = angular .module('portainer.kubernetes.react.views', []) @@ -35,6 +36,13 @@ export const viewsModule = angular [] ) ) + .component( + 'applicationDetailsView', + r2a( + withUIRouter(withReactQuery(withCurrentUser(ApplicationDetailsView))), + [] + ) + ) .component( 'kubernetesDashboardView', r2a(withUIRouter(withReactQuery(withCurrentUser(DashboardView))), []) diff --git a/app/kubernetes/views/applications/edit/application.html b/app/kubernetes/views/applications/edit/application.html deleted file mode 100644 index daf140a83..000000000 --- a/app/kubernetes/views/applications/edit/application.html +++ /dev/null @@ -1,83 +0,0 @@ - - - - - -
-
-
- - - - - Application - - - - - - Placement -
- - warning -
-
-
- - The placement component helps you understand whether or not this application can be deployed on a specific node. -
- - -
- - - - Events -
- - {{ ctrl.state.eventWarningCount }} warning(s) -
-
- -
- - - YAML -
- -
-
-
-
-
-
-
- -
-
- -
-
- -
- -
-
diff --git a/app/kubernetes/views/applications/edit/application.js b/app/kubernetes/views/applications/edit/application.js deleted file mode 100644 index 43b19b784..000000000 --- a/app/kubernetes/views/applications/edit/application.js +++ /dev/null @@ -1,9 +0,0 @@ -angular.module('portainer.kubernetes').component('kubernetesApplicationView', { - templateUrl: './application.html', - controller: 'KubernetesApplicationController', - controllerAs: 'ctrl', - bindings: { - $transition$: '<', - endpoint: '<', - }, -}); diff --git a/app/kubernetes/views/applications/edit/applicationController.js b/app/kubernetes/views/applications/edit/applicationController.js deleted file mode 100644 index 2771cf7b9..000000000 --- a/app/kubernetes/views/applications/edit/applicationController.js +++ /dev/null @@ -1,266 +0,0 @@ -import angular from 'angular'; -import _ from 'lodash-es'; -import * as JsonPatch from 'fast-json-patch'; -import { FeatureId } from '@/react/portainer/feature-flags/enums'; - -import { - KubernetesApplicationDataAccessPolicies, - KubernetesApplicationDeploymentTypes, - KubernetesApplicationTypes, - KubernetesDeploymentTypes, -} from 'Kubernetes/models/application/models'; -import KubernetesEventHelper from 'Kubernetes/helpers/eventHelper'; -import { KubernetesServiceTypes } from 'Kubernetes/models/service/models'; -import { KubernetesPodNodeAffinityNodeSelectorRequirementOperators } from 'Kubernetes/pod/models'; -import { KubernetesPodContainerTypes } from 'Kubernetes/pod/models/index'; -import KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper'; - -function computeTolerations(nodes, application) { - const pod = application.Pods[0]; - _.forEach(nodes, (n) => { - n.AcceptsApplication = true; - n.Expanded = false; - if (!pod) { - return; - } - n.UnmetTaints = []; - _.forEach(n.Taints, (t) => { - const matchKeyMatchValueMatchEffect = _.find(pod.Tolerations, { Key: t.Key, Operator: 'Equal', Value: t.Value, Effect: t.Effect }); - const matchKeyAnyValueMatchEffect = _.find(pod.Tolerations, { Key: t.Key, Operator: 'Exists', Effect: t.Effect }); - const matchKeyMatchValueAnyEffect = _.find(pod.Tolerations, { Key: t.Key, Operator: 'Equal', Value: t.Value, Effect: '' }); - const matchKeyAnyValueAnyEffect = _.find(pod.Tolerations, { Key: t.Key, Operator: 'Exists', Effect: '' }); - const anyKeyAnyValueAnyEffect = _.find(pod.Tolerations, { Key: '', Operator: 'Exists', Effect: '' }); - - if (!matchKeyMatchValueMatchEffect && !matchKeyAnyValueMatchEffect && !matchKeyMatchValueAnyEffect && !matchKeyAnyValueAnyEffect && !anyKeyAnyValueAnyEffect) { - n.AcceptsApplication = false; - n.UnmetTaints.push(t); - } else { - n.AcceptsApplication = true; - } - }); - }); - return nodes; -} - -// For node requirement format depending on operator value -// see https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.18/#nodeselectorrequirement-v1-core -// Some operators require empty "values" field, some only one element in "values" field, etc - -function computeAffinities(nodes, application) { - if (!application.Pods || application.Pods.length === 0) { - return nodes; - } - - const pod = application.Pods[0]; - _.forEach(nodes, (n) => { - if (pod.NodeSelector) { - const patch = JsonPatch.compare(n.Labels, pod.NodeSelector); - _.remove(patch, { op: 'remove' }); - n.UnmatchedNodeSelectorLabels = _.map(patch, (i) => { - return { key: _.trimStart(i.path, '/'), value: i.value }; - }); - if (n.UnmatchedNodeSelectorLabels.length) { - n.AcceptsApplication = false; - } - } - - if (pod.Affinity.nodeAffinity.requiredDuringSchedulingIgnoredDuringExecution) { - const unmatchedTerms = _.map(pod.Affinity.nodeAffinity.requiredDuringSchedulingIgnoredDuringExecution.nodeSelectorTerms, (t) => { - const unmatchedExpressions = _.map(t.matchExpressions, (e) => { - const exists = {}.hasOwnProperty.call(n.Labels, e.key); - const isIn = exists && _.includes(e.values, n.Labels[e.key]); - if ( - (e.operator === KubernetesPodNodeAffinityNodeSelectorRequirementOperators.EXISTS && exists) || - (e.operator === KubernetesPodNodeAffinityNodeSelectorRequirementOperators.DOES_NOT_EXIST && !exists) || - (e.operator === KubernetesPodNodeAffinityNodeSelectorRequirementOperators.IN && isIn) || - (e.operator === KubernetesPodNodeAffinityNodeSelectorRequirementOperators.NOT_IN && !isIn) || - (e.operator === KubernetesPodNodeAffinityNodeSelectorRequirementOperators.GREATER_THAN && exists && parseInt(n.Labels[e.key], 10) > parseInt(e.values[0], 10)) || - (e.operator === KubernetesPodNodeAffinityNodeSelectorRequirementOperators.LOWER_THAN && exists && parseInt(n.Labels[e.key], 10) < parseInt(e.values[0], 10)) - ) { - return; - } - return e; - }); - return _.without(unmatchedExpressions, undefined); - }); - _.remove(unmatchedTerms, (i) => i.length === 0); - n.UnmatchedNodeAffinities = unmatchedTerms; - if (n.UnmatchedNodeAffinities.length) { - n.AcceptsApplication = false; - } - } - }); - return nodes; -} - -function computePlacements(nodes, application) { - nodes = computeTolerations(nodes, application); - nodes = computeAffinities(nodes, application); - return nodes; -} - -class KubernetesApplicationController { - /* @ngInject */ - constructor( - $async, - $state, - clipboard, - Notifications, - LocalStorage, - KubernetesResourcePoolService, - KubernetesApplicationService, - KubernetesEventService, - KubernetesStackService, - KubernetesPodService, - KubernetesNodeService, - StackService - ) { - this.$async = $async; - this.$state = $state; - this.clipboard = clipboard; - this.Notifications = Notifications; - this.LocalStorage = LocalStorage; - this.KubernetesResourcePoolService = KubernetesResourcePoolService; - this.StackService = StackService; - - this.KubernetesApplicationService = KubernetesApplicationService; - this.KubernetesEventService = KubernetesEventService; - this.KubernetesStackService = KubernetesStackService; - this.KubernetesPodService = KubernetesPodService; - this.KubernetesNodeService = KubernetesNodeService; - - this.KubernetesApplicationDeploymentTypes = KubernetesApplicationDeploymentTypes; - this.KubernetesApplicationTypes = KubernetesApplicationTypes; - this.KubernetesDeploymentTypes = KubernetesDeploymentTypes; - - this.ApplicationDataAccessPolicies = KubernetesApplicationDataAccessPolicies; - this.KubernetesServiceTypes = KubernetesServiceTypes; - this.KubernetesPodContainerTypes = KubernetesPodContainerTypes; - - this.onInit = this.onInit.bind(this); - this.getApplication = this.getApplication.bind(this); - this.getApplicationAsync = this.getApplicationAsync.bind(this); - this.getEvents = this.getEvents.bind(this); - this.getEventsAsync = this.getEventsAsync.bind(this); - } - - selectTab(index) { - this.LocalStorage.storeActiveTab('application', index); - } - - showEditor() { - this.state.showEditorTab = true; - this.selectTab(3); - } - - isSystemNamespace() { - return KubernetesNamespaceHelper.isSystemNamespace(this.application.ResourcePool); - } - - hasEventWarnings() { - return this.state.eventWarningCount; - } - - /** - * EVENTS - */ - async getEventsAsync() { - try { - this.state.eventsLoading = true; - const events = await this.KubernetesEventService.get(this.state.params.namespace); - this.events = _.filter( - events, - (event) => - event.Involved.uid === this.application.Id || - event.Involved.uid === this.application.ServiceId || - _.find(this.application.Pods, (pod) => pod.Id === event.Involved.uid) !== undefined - ); - this.state.eventWarningCount = KubernetesEventHelper.warningCount(this.events); - } catch (err) { - this.Notifications.error('Failure', err, 'Unable to retrieve application related events'); - } finally { - this.state.eventsLoading = false; - } - } - - getEvents() { - return this.$async(this.getEventsAsync); - } - - /** - * APPLICATION - */ - async getApplicationAsync() { - try { - this.state.dataLoading = true; - const [application, nodes] = await Promise.all([ - this.KubernetesApplicationService.get(this.state.params.namespace, this.state.params.name), - this.KubernetesNodeService.get(), - ]); - this.application = application; - - this.placements = computePlacements(nodes, this.application); - this.state.placementWarning = _.find(this.placements, { AcceptsApplication: true }) ? false : true; - - if (application.StackId) { - const file = await this.StackService.getStackFile(application.StackId); - this.stackFileContent = file; - } - } catch (err) { - this.Notifications.error('Failure', err, 'Unable to retrieve application details'); - } finally { - this.state.dataLoading = false; - } - } - - getApplication() { - return this.$async(this.getApplicationAsync); - } - - async onInit() { - this.limitedFeature = FeatureId.K8S_ROLLING_RESTART; - - this.state = { - activeTab: 0, - currentName: this.$state.$current.name, - showEditorTab: false, - DisplayedPanel: 'pods', - eventsLoading: true, - dataLoading: true, - viewReady: false, - params: { - namespace: this.$transition$.params().namespace, - name: this.$transition$.params().name, - }, - appType: this.KubernetesDeploymentTypes.APPLICATION_FORM, - eventWarningCount: 0, - placementWarning: false, - expandedNote: false, - publicUrl: this.endpoint.PublicURL, - }; - - this.state.activeTab = this.LocalStorage.getActiveTab('application'); - - this.formValues = { - Note: '', - SelectedRevision: undefined, - }; - - await this.getApplication(); - await this.getEvents(); - this.state.viewReady = true; - } - - $onInit() { - return this.$async(this.onInit); - } - - $onDestroy() { - if (this.state.currentName !== this.$state.$current.name) { - this.LocalStorage.storeActiveTab('application', 0); - } - } -} - -export default KubernetesApplicationController; -angular.module('portainer.kubernetes').controller('KubernetesApplicationController', KubernetesApplicationController); diff --git a/app/portainer/react/components/index.ts b/app/portainer/react/components/index.ts index 45644ebce..490d0b486 100644 --- a/app/portainer/react/components/index.ts +++ b/app/portainer/react/components/index.ts @@ -121,7 +121,10 @@ export const ngModule = angular r2a(FallbackImage, ['src', 'fallbackIcon', 'alt', 'size', 'className']) ) .component('prIcon', r2a(Icon, ['className', 'icon', 'mode', 'size', 'spin'])) - .component('reactQueryDevTools', r2a(ReactQueryDevtoolsWrapper, [])) + .component( + 'reactQueryDevTools', + r2a(withReactQuery(ReactQueryDevtoolsWrapper), []) + ) .component( 'dashboardItem', r2a(DashboardItem, [ diff --git a/app/react/components/Badge/Badge.stories.tsx b/app/react/components/Badge/Badge.stories.tsx index cc21d4aed..60824c333 100644 --- a/app/react/components/Badge/Badge.stories.tsx +++ b/app/react/components/Badge/Badge.stories.tsx @@ -1,6 +1,6 @@ import { Meta } from '@storybook/react'; -import { Badge, Props } from './Badge'; +import { Badge, BadgeType, Props } from './Badge'; export default { component: Badge, @@ -17,11 +17,15 @@ export default { // : JSX.IntrinsicAttributes & PropsWithChildren function Template({ type = 'success' }: Props) { - const message = { + const message: Record = { success: 'success badge', danger: 'danger badge', warn: 'warn badge', info: 'info badge', + successSecondary: 'successSecondary badge', + dangerSecondary: 'dangerSecondary badge', + warnSecondary: 'warnSecondary badge', + infoSecondary: 'infoSecondary badge', }; return {message[type]}; } diff --git a/app/react/components/Badge/Badge.tsx b/app/react/components/Badge/Badge.tsx index f8a851232..a37268ae3 100644 --- a/app/react/components/Badge/Badge.tsx +++ b/app/react/components/Badge/Badge.tsx @@ -1,7 +1,60 @@ import clsx from 'clsx'; import { PropsWithChildren } from 'react'; -export type BadgeType = 'success' | 'danger' | 'warn' | 'info'; +export type BadgeType = + | 'success' + | 'danger' + | 'warn' + | 'info' + | 'successSecondary' + | 'dangerSecondary' + | 'warnSecondary' + | 'infoSecondary'; + +// the classes are typed in full because tailwind doesn't render the interpolated classes +const typeClasses: Record = { + success: clsx( + `text-success-9 bg-success-2`, + `th-dark:text-success-3 th-dark:bg-success-10`, + `th-highcontrast:text-success-3 th-highcontrast:bg-success-10` + ), + warn: clsx( + `text-warning-9 bg-warning-2`, + `th-dark:text-warning-3 th-dark:bg-warning-10`, + `th-highcontrast:text-warning-3 th-highcontrast:bg-warning-10` + ), + danger: clsx( + `text-error-9 bg-error-2`, + `th-dark:text-error-3 th-dark:bg-error-10`, + `th-highcontrast:text-error-3 th-highcontrast:bg-error-10` + ), + info: clsx( + `text-blue-9 bg-blue-2`, + `th-dark:text-blue-3 th-dark:bg-blue-10`, + `th-highcontrast:text-blue-3 th-highcontrast:bg-blue-10` + ), + // the secondary classes are a bit darker in light mode and a bit lighter in dark mode + successSecondary: clsx( + `text-success-9 bg-success-3`, + `th-dark:text-success-3 th-dark:bg-success-9`, + `th-highcontrast:text-success-3 th-highcontrast:bg-success-9` + ), + warnSecondary: clsx( + `text-warning-9 bg-warning-3`, + `th-dark:text-warning-3 th-dark:bg-warning-9`, + `th-highcontrast:text-warning-3 th-highcontrast:bg-warning-9` + ), + dangerSecondary: clsx( + `text-error-9 bg-error-3`, + `th-dark:text-error-3 th-dark:bg-error-9`, + `th-highcontrast:text-error-3 th-highcontrast:bg-error-9` + ), + infoSecondary: clsx( + `text-blue-9 bg-blue-3`, + `th-dark:text-blue-3 th-dark:bg-blue-9`, + `th-highcontrast:text-blue-3 th-highcontrast:bg-blue-9` + ), +}; export interface Props { type?: BadgeType; @@ -10,50 +63,17 @@ export interface Props { // this component is used in tables and lists in portainer. It looks like this: // https://www.figma.com/file/g5TUMngrblkXM7NHSyQsD1/New-UI?node-id=76%3A2 -export function Badge({ type, className, children }: PropsWithChildren) { +export function Badge({ + type = 'info', + className, + children, +}: PropsWithChildren) { const baseClasses = 'flex w-fit items-center !text-xs font-medium rounded-full px-2 py-0.5'; - const typeClasses = getClasses(type); return ( - + {children} ); } - -// the classes are typed in full to prevent a dev server bug, where tailwind doesn't render the interpolated classes -function getClasses(type: BadgeType | undefined) { - switch (type) { - case 'success': - return clsx( - `text-success-9 bg-success-2`, - `th-dark:text-success-3 th-dark:bg-success-10`, - `th-highcontrast:text-success-3 th-highcontrast:bg-success-10` - ); - case 'warn': - return clsx( - `text-warning-9 bg-warning-2`, - `th-dark:text-warning-3 th-dark:bg-warning-10`, - `th-highcontrast:text-warning-3 th-highcontrast:bg-warning-10` - ); - case 'danger': - return clsx( - `text-error-9 bg-error-2`, - `th-dark:text-error-3 th-dark:bg-error-10`, - `th-highcontrast:text-error-3 th-highcontrast:bg-error-10` - ); - case 'info': - return clsx( - `text-blue-9 bg-blue-2`, - `th-dark:text-blue-3 th-dark:bg-blue-10`, - `th-highcontrast:text-blue-3 th-highcontrast:bg-blue-10` - ); - default: - return clsx( - `text-blue-9 bg-blue-2`, - `th-dark:text-blue-3 th-dark:bg-blue-10`, - `th-highcontrast:text-blue-3 th-highcontrast:bg-blue-10` - ); - } -} diff --git a/app/react/components/CodeEditor.tsx b/app/react/components/CodeEditor.tsx index 03f17ed62..e9f4e0cf8 100644 --- a/app/react/components/CodeEditor.tsx +++ b/app/react/components/CodeEditor.tsx @@ -90,6 +90,7 @@ export function CodeEditor({ = { + xs: 'text-xs', + sm: 'text-sm', + md: 'text-md', }; export function InlineLoader({ children, className, + size = 'sm', }: PropsWithChildren) { return (
{children} diff --git a/app/react/components/Note/Note.tsx b/app/react/components/Note/Note.tsx new file mode 100644 index 000000000..e0998b658 --- /dev/null +++ b/app/react/components/Note/Note.tsx @@ -0,0 +1,89 @@ +import { useEffect, useState } from 'react'; +import clsx from 'clsx'; +import { ChevronUp, ChevronRight, Edit } from 'lucide-react'; + +import { Button } from '@@/buttons'; +import { FormError } from '@@/form-components/FormError'; + +export type Props = { + onChange: (value: string) => void; + defaultIsOpen?: boolean; + value?: string; + labelClass?: string; + inputClass?: string; + isRequired?: boolean; + minLength?: number; + isExpandable?: boolean; +}; + +export function Note({ + onChange, + defaultIsOpen, + value, + labelClass = 'col-sm-12 mb-2', + inputClass = 'col-sm-12', + isRequired, + minLength, + isExpandable, +}: Props) { + const [isNoteOpen, setIsNoteOpen] = useState(defaultIsOpen || isRequired); + + useEffect(() => { + setIsNoteOpen(defaultIsOpen || isRequired); + }, [defaultIsOpen, isRequired]); + + let error = ''; + if (isRequired && minLength && (!value || value.length < minLength)) { + error = `You have entered ${value ? value.length : 0} of the ${minLength} ${ + minLength === 1 ? 'character' : 'characters' + } minimum required.`; + } + + return ( +
+
+ {isExpandable && ( + + )} + {!isExpandable && ( + + Note + + )} +
+ + {isNoteOpen && ( +
+