diff --git a/packages/oss-console/src/components/Entities/EntityDetailsHeader.tsx b/packages/oss-console/src/components/Entities/EntityDetailsHeader.tsx index f22e7b472..147baded6 100644 --- a/packages/oss-console/src/components/Entities/EntityDetailsHeader.tsx +++ b/packages/oss-console/src/components/Entities/EntityDetailsHeader.tsx @@ -3,6 +3,7 @@ import Button from '@mui/material/Button'; import Dialog from '@mui/material/Dialog'; import styled from '@mui/system/styled'; import isNil from 'lodash/isNil'; +import Grid from '@mui/material/Grid'; import { Identifier, ResourceIdentifier, ResourceType } from '../../models/Common/types'; import { LaunchForm } from '../Launch/LaunchForm/LaunchForm'; import { useEscapeKey } from '../hooks/useKeyListener'; @@ -10,6 +11,7 @@ import { LaunchTaskFormProps, LaunchWorkflowFormProps } from '../Launch/LaunchFo import t, { patternKey } from './strings'; import { entityStrings } from './constants'; import BreadcrumbTitleActions from '../Breadcrumbs/components/BreadcrumbTitleActions'; +import { LaunchPlanDetailsHeader } from '../LaunchPlan/components/LaunchPlanDetailsHeader'; const EntityDetailsHeaderContainer = styled('div')(({ theme }) => ({ '.headerContainer': { @@ -88,24 +90,33 @@ export const EntityDetailsHeader: React.FC = ({ onCancelLaunch(); }); + const showLaunchPlanDetails = id.resourceType === ResourceType.LAUNCH_PLAN; + return (
- {launchable ? ( - - ) : ( - <> - )} + + {launchable ? ( + + ) : ( + <> + )} + {showLaunchPlanDetails ? : null} +
{launchable ? ( diff --git a/packages/oss-console/src/components/Entities/EntitySchedules.tsx b/packages/oss-console/src/components/Entities/EntitySchedules.tsx index 7f1129cb4..cc15befa9 100644 --- a/packages/oss-console/src/components/Entities/EntitySchedules.tsx +++ b/packages/oss-console/src/components/Entities/EntitySchedules.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from 'react'; +import React from 'react'; import ExpandLess from '@mui/icons-material/ExpandLess'; import ExpandMore from '@mui/icons-material/ExpandMore'; import Typography from '@mui/material/Typography'; @@ -11,30 +11,23 @@ import styled from '@mui/system/styled'; import Divider from '@mui/material/Divider'; import Grid from '@mui/material/Grid'; import IconButton from '@mui/material/IconButton'; -import { SortDirection } from '@clients/common/types/adminEntityTypes'; -import { useQueryClient } from 'react-query'; -import { makeListDescriptionEntitiesQuery } from '@clients/oss-console/queries/descriptionEntitiesQuery'; -import { executionSortFields } from '../../models/Execution/constants'; -import { makeListLaunchPlansQuery } from '../../queries/launchPlanQueries'; +import keys from 'lodash/keys'; +import { FilterOperationName } from '@clients/common/types/adminEntityTypes'; +import classnames from 'classnames'; import { LocalCacheItem, useLocalCache } from '../../basics/LocalCache'; -import { - getScheduleFrequencyString, - getScheduleOffsetString, - formatDateUTC, -} from '../../common/formatters'; -import { timestampToDate } from '../../common/utils'; import { useCommonStyles } from '../common/styles'; -import { ResourceIdentifier, ResourceType } from '../../models/Common/types'; +import { FixedRateUnit, ResourceIdentifier } from '../../models/Common/types'; import { LaunchPlan } from '../../models/Launch/types'; import { entityStrings } from './constants'; import t, { patternKey } from './strings'; - -import { LaunchPlanLastNExecutions } from '../LaunchPlan/components/LaunchPlanLastNExecutions'; -import { LaunchPlanNextPotentialExecution } from '../LaunchPlan/components/LaunchPlanNextPotentialExecution'; -import { ScheduleDisplayValue, ScheduleStatus } from '../LaunchPlan/components/LaunchPlanCells'; -import { useConditionalQuery } from '../hooks/useConditionalQuery'; import { WaitForQuery } from '../common/WaitForQuery'; -import { executionFilterGenerator } from './generators'; +import { useLatestActiveLaunchPlan } from '../LaunchPlan/hooks/useLatestActiveLaunchPlan'; +import { + LaunchPlanLastRun, + LaunchPlanName, + LaunchPlanNextPotentialExecution, + ScheduleFrequency, +} from './EntitySchedulesCells'; const EntitySchedulesContainer = styled('div')(({ theme }) => ({ '.header': { @@ -54,131 +47,119 @@ const EntitySchedulesContainer = styled('div')(({ theme }) => ({ }, })); -export const getScheduleStringFromLaunchPlan = (launchPlan: LaunchPlan) => { - const { schedule } = launchPlan.spec.entityMetadata; - const frequencyString = getScheduleFrequencyString(schedule); - const offsetString = getScheduleOffsetString(schedule); - const scheduleString = offsetString - ? `${frequencyString} (offset by ${offsetString})` - : frequencyString; +export const getRawScheduleStringFromLaunchPlan = (launchPlan?: LaunchPlan) => { + const { schedule } = launchPlan?.spec?.entityMetadata || {}; + if (schedule?.rate) { + const unit = schedule?.rate.unit; + const unitString = + keys(FixedRateUnit).find((key) => (FixedRateUnit[key as any] as any) === unit) || ''; - return scheduleString; + return `${schedule?.rate.value}, ${ + unitString.charAt(0).toUpperCase() + unitString.slice(1).toLowerCase() + }`; + } + if (schedule?.cronSchedule) { + return schedule.cronSchedule.schedule; + } + + return ''; }; -const RenderSchedules: React.FC<{ +const EntitySchedulesTable: React.FC<{ + id: ResourceIdentifier; launchPlans: LaunchPlan[]; - refetch: () => void; -}> = ({ launchPlans, refetch }) => { - return ( +}> = ({ id, launchPlans }) => { + const commonStyles = useCommonStyles(); + const cellClass = classnames('cell', 'header'); + + return launchPlans.length > 0 ? ( - - {t(patternKey('launchPlan', 'status'))} - - - {t(patternKey('launchPlan', 'schedule'))} - - - {t(patternKey('launchPlan', 'lastExecution'))} + + {t(patternKey('launchPlan', 'scheduleFrequency'))} - - - {t(patternKey('launchPlan', 'nextPotentialExecution'))} - + + {t(patternKey('launchPlan', 'name'))} - - {t(patternKey('launchPlan', 'createdAt'))} + + {t(patternKey('launchPlan', 'lastExecution'))} - - {t(patternKey('launchPlan', 'scheduledVersion'))} + + {t(patternKey('launchPlan', 'nextPotentialExecution'))} {launchPlans.map((launchPlan) => { - const createdAt = launchPlan?.closure?.createdAt!; - const createdAtTime = formatDateUTC(timestampToDate(createdAt)); - return ( - + - + - + - {createdAtTime} - {launchPlan.id.version} ); })}
+ ) : ( + + {t(patternKey('noSchedules', entityStrings[id.resourceType]))} + ); }; -const sort = { - key: executionSortFields.createdAt, - direction: SortDirection.DESCENDING, -}; export const EntitySchedules: React.FC<{ id: ResourceIdentifier; }> = ({ id }) => { - const queryClient = useQueryClient(); - const commonStyles = useCommonStyles(); const [showTable, setShowTable] = useLocalCache(LocalCacheItem.ShowLaunchplanSchedules); - const { domain, project } = id || {}; - - // capture if we are on a launch plan details page page - const isLaunchPlan = id.resourceType === ResourceType.LAUNCH_PLAN; - - // if not on a launch plan details page, get the latest version of the entity(workflow) - const workflowEntityDescriptionQuery = useConditionalQuery( - { - enabled: !isLaunchPlan, - ...makeListDescriptionEntitiesQuery(queryClient, { ...id, version: '' }, { sort, limit: 1 }), - }, - (prev) => !prev && !!showTable, - ); - - // get the latest version of the workflow - const latestVersionId = workflowEntityDescriptionQuery.data?.entities?.[0]?.id; - - // get the filters for the latestVersionId - const filter = latestVersionId - ? executionFilterGenerator[latestVersionId.resourceType!]( - latestVersionId as any, - latestVersionId?.version, - ) - : undefined; - - // if the ID is a launchPlan, use the original ID, otherwise use the workflow latest version ID - const launchPlanRequestId = isLaunchPlan ? id : latestVersionId && { domain, project }; - const launchPlanQuery = useConditionalQuery( - { - enabled: !!launchPlanRequestId, - ...makeListLaunchPlansQuery(queryClient, launchPlanRequestId!, { - sort, - limit: 1, - ...(isLaunchPlan ? {} : { filter }), - }), - }, - (prev) => !prev && !!showTable, - ); + const latestActiveSchedulesQuery = useLatestActiveLaunchPlan({ + // Don't pass the wf name here, it's already included in the additionalFilters + id: { + project: id.project, + domain: id.domain, + } as any, + limit: 5, + // add the workflow name to the filter + additionalFilters: [ + { + key: 'workflow.name', + operation: FilterOperationName.EQ, + value: id.name, + }, + ], + }); return ( - - {(data) => { - const launchPlans = data.entities; - const isSchedulePresent = launchPlans?.[0]?.spec?.entityMetadata?.schedule != null; + + {({ entities: launchPlans }) => { + const isSchedulePresent = !!launchPlans?.length; return isSchedulePresent ? ( @@ -207,13 +188,7 @@ export const EntitySchedules: React.FC<{ {showTable ? (
- {launchPlans.length > 0 ? ( - - ) : ( - - {t(patternKey('noSchedules', entityStrings[id.resourceType]))} - - )} +
) : ( diff --git a/packages/oss-console/src/components/Entities/EntitySchedulesCells.tsx b/packages/oss-console/src/components/Entities/EntitySchedulesCells.tsx new file mode 100644 index 000000000..51de14aab --- /dev/null +++ b/packages/oss-console/src/components/Entities/EntitySchedulesCells.tsx @@ -0,0 +1,308 @@ +import Typography from '@mui/material/Typography'; +import React, { useMemo } from 'react'; +import Tooltip from '@mui/material/Tooltip'; +import Grid from '@mui/material/Grid'; +import Link from '@mui/material/Link'; +import { useInView } from 'react-intersection-observer'; +import { makeFilterableWorkflowExecutionsQuery } from '@clients/oss-console/queries/workflowQueries'; +import { useQueryClient } from 'react-query'; +import Shimmer from '@clients/primitives/Shimmer'; +import { formatDateUTC } from '@clients/oss-console/common/formatters'; +import { timestampToDate } from '@clients/oss-console/common/utils'; +import moment from 'moment'; +import { FilterOperationName } from '@clients/common/types/adminEntityTypes'; +import t from './strings'; +import { getRawScheduleStringFromLaunchPlan } from './EntitySchedules'; +import { getScheduleStringFromLaunchPlan } from './getScheduleStringFromLaunchPlan'; +import { LaunchPlan } from '../../models/Launch/types'; +import { useCommonStyles } from '../common/styles'; +import { Routes } from '../../routes/routes'; +import { executionFilterGenerator } from './generators'; +import { useConditionalQuery } from '../hooks/useConditionalQuery'; +import { ResourceIdentifier, ResourceType } from '../../models/Common/types'; +import { ExecutionMode } from '../../models/Execution/enums'; +import { getNextExecutionTimeMilliseconds } from '../LaunchPlan/utils'; +import { CREATED_AT_DESCENDING_SORT } from '../../models/Launch/constants'; + +export interface ScheduleFrequencyProps { + launchPlan: LaunchPlan; +} +export const ScheduleFrequency = ({ launchPlan }: ScheduleFrequencyProps) => { + const commonStyles = useCommonStyles(); + + const { scheduleDisplayValue, scheduleRawValue } = useMemo(() => { + const scheduleDisplayValue = getScheduleStringFromLaunchPlan(launchPlan); + const scheduleRawValue = getRawScheduleStringFromLaunchPlan(launchPlan); + return { scheduleDisplayValue: scheduleDisplayValue || t('noValue'), scheduleRawValue }; + }, [launchPlan]); + + return ( + + + + + {scheduleDisplayValue} + + + + + {scheduleRawValue} + + + + + ); +}; + +export interface LaunchPlanNameProps { + launchPlan: LaunchPlan; +} + +export const LaunchPlanName = ({ launchPlan }: LaunchPlanNameProps) => { + const commonStyles = useCommonStyles(); + const url = Routes.LaunchPlanDetails.makeUrl( + launchPlan.id.project, + launchPlan.id.domain, + launchPlan.id.name, + ); + return ( + + + + { + event.stopPropagation(); + }} + > + {launchPlan.id.name} + + + + + {launchPlan.id.version} + + + + + ); +}; + +export interface LaunchPlanLastRunProps { + launchPlan: LaunchPlan; +} + +export const LaunchPlanLastRun = ({ launchPlan }: LaunchPlanLastRunProps) => { + const commonStyles = useCommonStyles(); + const queryClient = useQueryClient(); + const [inViewRef, inView] = useInView(); + + const { workflowId } = launchPlan.spec; + + // Build request config for the latest workflow version + const requestConfig = React.useMemo(() => { + const filter = executionFilterGenerator[workflowId.resourceType || ResourceType.LAUNCH_PLAN]( + workflowId as ResourceIdentifier, + workflowId.version, + ); + filter.push({ + key: 'mode', + operation: FilterOperationName.EQ, + value: ExecutionMode.SCHEDULED, + }); + return { + filter, + sort: CREATED_AT_DESCENDING_SORT, + limit: 1, + }; + }, [workflowId]); + + const mostRecentlpVersionExecutionsQuery = useConditionalQuery( + { + ...makeFilterableWorkflowExecutionsQuery(queryClient, workflowId, requestConfig), + enabled: inView, + }, + (prev) => !prev, + ); + + const scheduledExecutions = (mostRecentlpVersionExecutionsQuery.data?.entities || []).filter( + (e) => e.spec.metadata.mode === ExecutionMode.SCHEDULED, + ); + const latestScheduledExecution = scheduledExecutions?.[0]; + + const createdAt = latestScheduledExecution?.closure.createdAt; + const relativeStartTime = latestScheduledExecution + ? moment(timestampToDate(createdAt)).fromNow() + : undefined; + const startTime = latestScheduledExecution + ? formatDateUTC(timestampToDate(createdAt)) + : undefined; + + return ( +
+ {mostRecentlpVersionExecutionsQuery.isFetched ? ( + + + + + {relativeStartTime} + + + + + {startTime} + + + + + ) : ( + + )} +
+ ); +}; + +type LaunchPlanNextPotentialExecutionProps = { + launchPlan: LaunchPlan; +}; + +/** The view component for displaying thelaunch plan's details of last execution or last 10 exeuctions */ +export const LaunchPlanNextPotentialExecution = ({ + launchPlan, +}: LaunchPlanNextPotentialExecutionProps) => { + const commonStyles = useCommonStyles(); + const queryClient = useQueryClient(); + const [inViewRef, inView] = useInView(); + + const { workflowId } = launchPlan.spec; + + // Build request config for the latest workflow version + const requestConfig = React.useMemo(() => { + const filter = executionFilterGenerator[workflowId.resourceType || ResourceType.LAUNCH_PLAN]( + workflowId as ResourceIdentifier, + workflowId.version, + ); + filter.push({ + key: 'mode', + operation: FilterOperationName.EQ, + value: ExecutionMode.SCHEDULED, + }); + return { + filter, + sort: CREATED_AT_DESCENDING_SORT, + limit: 1, + }; + }, [workflowId]); + + const mostRecentlpVersionExecutionsQuery = useConditionalQuery( + { + ...makeFilterableWorkflowExecutionsQuery(queryClient, workflowId, requestConfig), + enabled: inView, + }, + (prev) => !prev, + ); + + const scheduledExecutions = (mostRecentlpVersionExecutionsQuery.data?.entities || []).filter( + (e) => e.spec.metadata.mode === ExecutionMode.SCHEDULED, + ); + const latestScheduledExecution = scheduledExecutions?.[0]; + // get next potential execution time based on the most recent scheduled execution + const nextPotentialExecutionTime = + latestScheduledExecution && + getNextExecutionTimeMilliseconds(latestScheduledExecution, launchPlan); + const nextPotentialExecutionDate = + nextPotentialExecutionTime && new Date(nextPotentialExecutionTime); + const nextPotentialExecutionTimeStr = nextPotentialExecutionDate ? ( + formatDateUTC(nextPotentialExecutionDate) + ) : ( + N/A + ); + + const relativeStartTime = nextPotentialExecutionDate ? ( + moment(nextPotentialExecutionDate).fromNow() + ) : ( + N/A + ); + return ( +
+ {mostRecentlpVersionExecutionsQuery.isFetched ? ( + + + + + {relativeStartTime} + + + + + {nextPotentialExecutionTimeStr} + + + + + ) : ( + + )} +
+ ); +}; diff --git a/packages/oss-console/src/components/Entities/getScheduleStringFromLaunchPlan.tsx b/packages/oss-console/src/components/Entities/getScheduleStringFromLaunchPlan.tsx new file mode 100644 index 000000000..638383b5f --- /dev/null +++ b/packages/oss-console/src/components/Entities/getScheduleStringFromLaunchPlan.tsx @@ -0,0 +1,13 @@ +import { getScheduleFrequencyString, getScheduleOffsetString } from '../../common/formatters'; +import { LaunchPlan } from '../../models/Launch/types'; + +export const getScheduleStringFromLaunchPlan = (launchPlan?: LaunchPlan) => { + const { schedule } = launchPlan?.spec?.entityMetadata || {}; + const frequencyString = getScheduleFrequencyString(schedule); + const offsetString = getScheduleOffsetString(schedule); + const scheduleString = offsetString + ? `${frequencyString} (offset by ${offsetString})` + : frequencyString; + + return scheduleString; +}; diff --git a/packages/oss-console/src/components/Executions/StatusBadge.tsx b/packages/oss-console/src/components/Executions/StatusBadge.tsx new file mode 100644 index 000000000..48f02e369 --- /dev/null +++ b/packages/oss-console/src/components/Executions/StatusBadge.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import classnames from 'classnames'; +import Typography from '@mui/material/Typography'; +import * as CommonStylesConstants from '@clients/theme/CommonStyles/constants'; +import styled from '@mui/system/styled'; + +export const StyledWrapper = styled('div')(({ theme }) => ({ + fontWeight: 'normal', + '&.default': { + padding: theme.spacing(0.25, 0.5), + alignItems: 'center', + backgroundColor: theme.palette.common.primary.white, + borderRadius: theme.spacing(0.5), + color: theme.palette.text.primary, + display: 'flex', + flex: '0 0 auto', + height: theme.spacing(2.5), + fontSize: CommonStylesConstants.smallFontSize, + justifyContent: 'center', + textTransform: 'uppercase', + width: theme.spacing(11), // 88px + }, + '&.text': { + backgroundColor: 'inherit', + border: 'none', + marginTop: theme.spacing(1), + textTransform: 'lowercase', + }, + '&.launchPlan': { + textTransform: 'none', + width: theme.spacing(6), + height: theme.spacing(3), + marginRight: theme.spacing(1), + }, +})); + +export interface StatusBadgeProps + extends React.DetailedHTMLProps, HTMLDivElement> { + text: string; + variant?: 'default' | 'text'; + className?: string; + sx?: React.CSSProperties; +} +export const StatusBadge = ({ + text, + variant = 'default', + className, + sx, + ...htmlProps +}: StatusBadgeProps) => { + return ( + + {text} + + ); +}; diff --git a/packages/oss-console/src/components/Executions/Tables/WorkflowExecutionTable/cells.tsx b/packages/oss-console/src/components/Executions/Tables/WorkflowExecutionTable/cells.tsx index 735d3e246..0da1916c5 100644 --- a/packages/oss-console/src/components/Executions/Tables/WorkflowExecutionTable/cells.tsx +++ b/packages/oss-console/src/components/Executions/Tables/WorkflowExecutionTable/cells.tsx @@ -13,6 +13,7 @@ import ArchiveLogo from '@clients/ui-atoms/ArchiveLogo'; import { HoverTooltip } from '@clients/primitives/HoverTooltip'; import Shimmer from '@clients/primitives/Shimmer'; import styled from '@mui/system/styled'; +import { getScheduleStringFromLaunchPlan } from '../../../Entities/getScheduleStringFromLaunchPlan'; import { formatDateLocalTimezone, formatDateUTC, @@ -21,10 +22,9 @@ import { } from '../../../../common/formatters'; import { timestampToDate } from '../../../../common/utils'; import { ExecutionStatusBadge } from '../../ExecutionStatusBadge'; -import { Execution } from '../../../../models/Execution/types'; +import { Execution, ExecutionMetadata } from '../../../../models/Execution/types'; import { ExecutionState, WorkflowExecutionPhase } from '../../../../models/Execution/enums'; import { Routes } from '../../../../routes/routes'; -import { getScheduleStringFromLaunchPlan } from '../../../Entities/EntitySchedules'; import { WorkflowExecutionsTableState } from '../types'; import { getWorkflowExecutionTimingMS, isExecutionArchived } from '../../utils'; import t from './strings'; @@ -199,7 +199,8 @@ export function getWorkflowTaskCell(execution: Execution): React.ReactNode { } export function getScheduleCell(execution: Execution): React.ReactNode { - const isEnabled = !!execution.spec.metadata.scheduledAt; + const meta: ExecutionMetadata = execution.spec.metadata; + const isEnabled = !!meta.scheduledAt; const queryClient = useQueryClient(); const lpQuery = useConditionalQuery( { ...makeLaunchPlanQuery(queryClient, execution.spec.launchPlan), enabled: isEnabled }, @@ -374,11 +375,12 @@ export function ApprovalDoubleCell(props: ApprovalDoubleCellProps) { color="primary" variant="contained" disableElevation - onClick={() => + onClick={(event) => { + event.stopPropagation(); onConfirmClick( isArchived ? ExecutionState.EXECUTION_ACTIVE : ExecutionState.EXECUTION_ARCHIVED, - ) - } + ); + }} sx={{ borderTopRightRadius: '0px', borderBottomRightRadius: '0px', diff --git a/packages/oss-console/src/components/Executions/Tables/useWorkflowVersionsTableColumns.tsx b/packages/oss-console/src/components/Executions/Tables/useWorkflowVersionsTableColumns.tsx index 143cc4460..a4684da79 100644 --- a/packages/oss-console/src/components/Executions/Tables/useWorkflowVersionsTableColumns.tsx +++ b/packages/oss-console/src/components/Executions/Tables/useWorkflowVersionsTableColumns.tsx @@ -1,28 +1,72 @@ import Typography from '@mui/material/Typography'; import moment from 'moment'; -import * as React from 'react'; +import React, { useMemo } from 'react'; +import TableCell from '@mui/material/TableCell'; +import TableContainer from '@mui/material/TableContainer'; +import TableRow from '@mui/material/TableRow'; import { formatDateUTC } from '../../../common/formatters'; import { padExecutionPaths, padExecutions, timestampToDate } from '../../../common/utils'; import { WaitForData } from '../../common/WaitForData'; import ProjectStatusBar from '../../ListProjectEntities/ProjectStatusBar'; import { useWorkflowVersionsColumnStyles } from './styles'; import { WorkflowVersionColumnDefinition } from './types'; +import { ResourceType } from '../../../models/Common/types'; +import { useLatestActiveLaunchPlan } from '../../LaunchPlan/hooks/useLatestActiveLaunchPlan'; +import { StatusBadge } from '../StatusBadge'; +import { ActiveLaunchPlanDisplayValueEnum } from '../../LaunchPlan/components/LaunchPlanCells'; /** * Returns a memoized list of column definitions to use when rendering a * `WorkflowVersionRow`. Memoization is based on common/column style objects * and any fields in the incoming `WorkflowExecutionColumnOptions` object. */ + export function useWorkflowVersionsTableColumns(): WorkflowVersionColumnDefinition[] { const styles = useWorkflowVersionsColumnStyles(); return React.useMemo( () => [ { - cellRenderer: ({ - workflow: { - id: { version }, - }, - }) => {version}, + cellRenderer: ({ workflow: { id } }) => { + const { version } = id; + const isLaunchPlan = id.resourceType === ResourceType.LAUNCH_PLAN; + + const activeScheduleLaunchPlanQuery = useLatestActiveLaunchPlan({ + id, + enabled: isLaunchPlan, + }); + + const activeScheduleLaunchPlan = useMemo(() => { + return activeScheduleLaunchPlanQuery.data?.entities?.[0]; + }, [activeScheduleLaunchPlanQuery]); + + const isActiveVersion = + isLaunchPlan && + activeScheduleLaunchPlan && + activeScheduleLaunchPlan.id.version === version; + const versionText = version; + return ( + + + + {versionText} + + {isActiveVersion && ( + theme.spacing(2), + }} + > + + + )} + + + ); + }, className: styles.columnName, key: 'name', label: 'version id', diff --git a/packages/oss-console/src/components/Launch/LaunchForm/LaunchFormComponents/SearchableSelector.tsx b/packages/oss-console/src/components/Launch/LaunchForm/LaunchFormComponents/SearchableSelector.tsx index 136a8f2ba..a8e8f1af8 100644 --- a/packages/oss-console/src/components/Launch/LaunchForm/LaunchFormComponents/SearchableSelector.tsx +++ b/packages/oss-console/src/components/Launch/LaunchForm/LaunchFormComponents/SearchableSelector.tsx @@ -3,10 +3,16 @@ import Skeleton from '@mui/material/Skeleton'; import styled from '@mui/system/styled'; import ListItemText from '@mui/material/ListItemText'; import Typography from '@mui/material/Typography'; -import FormControl, { FormControlProps } from '@mui/material/FormControl'; +import FormControl, { FormControlOwnProps, FormControlProps } from '@mui/material/FormControl'; import Autocomplete, { autocompleteClasses } from '@mui/material/Autocomplete'; import TextField from '@mui/material/TextField'; import Box from '@mui/material/Box'; +import DoneIcon from '@mui/icons-material/Done'; +import Chip from '@mui/material/Chip'; +import FormHelperText from '@mui/material/FormHelperText'; +import { type IconButtonProps } from '@mui/material/IconButton'; +import { type PaperProps } from '@mui/material/Paper'; +import { type PopperProps } from '@mui/material/Popper'; import { useFetchableData } from '../../../hooks/useFetchableData'; import { useDebouncedValue } from '../../../hooks/useDebouncedValue'; import { FetchFn } from '../../../hooks/types'; @@ -39,20 +45,32 @@ export interface SearchableSelectorOption { data: DataType; name: string; description?: string; + isLatest?: boolean; + isActive?: boolean; } function generateDefaultFetch( options: SearchableSelectorOption[], ): FetchFn[], string> { - return (query: string) => - Promise.resolve(options.filter((option) => option.name.includes(query))); + return (query: string) => Promise.resolve(options.filter((option) => option.id.includes(query))); } -export interface SearchableSelectorProps { +export interface SearchableSelectorProps extends FormControlOwnProps { id?: string; label: string; + disabled?: boolean; + disabledLabel?: string; options: SearchableSelectorOption[]; selectedItem?: SearchableSelectorOption; + isLoading?: boolean; + showLatestVersionChip?: boolean; + formHelperText?: JSX.Element[]; + componentsProps?: { + clearIndicator?: Partial; + paper?: PaperProps; + popper?: Partial; + popupIndicator?: Partial; + }; fetchSearchResults?: FetchFn[], string>; onSelectionChanged(newSelection: SearchableSelectorOption): void; } @@ -64,7 +82,21 @@ export interface SearchableSelectorProps { export const SearchableSelector = ( props: SearchableSelectorProps, ) => { - const { label, onSelectionChanged, options, fetchSearchResults, id, selectedItem } = props; + const { + id, + label, + disabled, + disabledLabel, + options, + selectedItem, + isLoading = false, + onSelectionChanged, + fetchSearchResults, + showLatestVersionChip, + formHelperText, + componentsProps, + ...htmlProps + } = props; const [rawSearchValue, setRawSearchValue] = useState(); const debouncedSearchValue = useDebouncedValue(rawSearchValue, searchDebounceTimeMs); const commonStyles = useCommonStyles(); @@ -81,21 +113,21 @@ export const SearchableSelector = ( debouncedSearchValue, ); const isLoadingOptions = useMemo(() => { - const isLoading = - rawSearchValue !== undefined + const isLoadingOptions = + isLoading || + (rawSearchValue !== undefined ? rawSearchValue.length > minimumQuerySize ? // if length is greater than 3, we want to show the loading state isLoadingState(searchResults.state) : // else show loading true - : false; - return isLoading; - }, [searchResults, rawSearchValue]); + : false); + return isLoadingOptions; + }, [searchResults, rawSearchValue, isLoading]); - const finalOptions = useMemo( - () => (rawSearchValue ? searchResults.value : options), - [rawSearchValue, searchResults.value, options], - ); + const finalOptions = useMemo(() => { + return !rawSearchValue ? options : searchResults.value; + }, [rawSearchValue, searchResults.value, options]); const selectOption = (option?: SearchableSelectorOption) => { if (!option) { @@ -107,18 +139,21 @@ export const SearchableSelector = ( onSelectionChanged(option); }; + const finalLabel = disabledLabel && disabled ? disabledLabel : label; + return ( - + } noOptionsText="No results found." + disabled={disabled} onInputChange={(_event, newInputValue, reason) => { switch (reason) { case 'reset': { @@ -144,33 +179,83 @@ export const SearchableSelector = ( }} getOptionLabel={(option) => (typeof option === 'string' ? option : option.name)} getOptionKey={(option) => (typeof option === 'string' ? option : option.name)} - renderInput={(params) => } + renderInput={(params) => } renderOption={(props, option, _state, _ownerState) => { + const isSelected = selectedItem && option.id === selectedItem.id; return ( theme.spacing(0.5), [`&.${autocompleteClasses.option}`]: { px: (theme) => theme.spacing(0.5), py: 0, + '&:hover': { + backgroundColor: '#F4F6FC', + }, '&:div': { margin: 0, }, }, width: '100%', + minHeight: '40px', + display: 'flex', + flexDirection: 'row', }} component="li" {...props} > + + {isSelected ? ( + theme.spacing(1.15), + }} + /> + ) : null} + theme.spacing(1), + paddingRight: (theme) => theme.spacing(2), + }} primary={ - - {option.name} - + <> + + {option.name} + {option.isLatest && showLatestVersionChip ? ( + theme.palette.common.primary.union200, + borderRadius: (theme) => theme.spacing(0.5), + color: (theme) => theme.palette.common.primary.black, + '& .MuiChip-label': { + fontSize: (theme) => theme.spacing(1.5), + padding: (theme) => theme.spacing(0.75, 0.75), + }, + }} + variant="filled" + size="small" + label="Latest" + /> + ) : null} + + } secondary={ - + {option.description} } @@ -181,7 +266,7 @@ export const SearchableSelector = ( ListboxProps={{ sx: { width: '100%', - px: (theme) => theme.spacing(0.5), + maxWidth: '100%', }, }} componentsProps={{ @@ -189,10 +274,17 @@ export const SearchableSelector = ( sx: { width: 'fit-content', minWidth: 250, + maxWidth: '100%', }, }, + ...(componentsProps || []), }} /> + {formHelperText?.map((text, index) => ( + {text} + ))} + + ); }; diff --git a/packages/oss-console/src/components/LaunchPlan/LaunchPlanCardList/LaunchPlanCardView.tsx b/packages/oss-console/src/components/LaunchPlan/LaunchPlanCardList/LaunchPlanCardView.tsx index babe3dcb4..e26c19819 100644 --- a/packages/oss-console/src/components/LaunchPlan/LaunchPlanCardList/LaunchPlanCardView.tsx +++ b/packages/oss-console/src/components/LaunchPlan/LaunchPlanCardList/LaunchPlanCardView.tsx @@ -1,27 +1,38 @@ -import React from 'react'; +import React, { useRef } from 'react'; import { LargeLoadingComponent } from '@clients/primitives/LoadingSpinner'; import { NoResults } from '@clients/primitives/NoResults'; +import { useVirtualizer } from '@tanstack/react-virtual'; import { SearchResult } from '../../common/SearchableList'; import { NamedEntity } from '../../../models/Common/types'; -import { ItemRenderer } from '../../common/FilterableNamedEntityList'; +import LaunchPlanListCard from './LaunchPlanListCard'; interface LaunchPlanCardViewProps { results: SearchResult[]; - renderItem: ItemRenderer; loading: boolean; } -const LaunchPlanCardView: React.FC = ({ - results, - renderItem, - loading, -}) => { +const LaunchPlanCardView: React.FC = ({ results, loading }) => { + const parentRef = useRef(document.getElementById('scroll-element')); + + const rowVirtualizer = useVirtualizer({ + count: results?.length ? results.length + 1 : 0, + getScrollElement: () => parentRef.current, + estimateSize: () => 100, + overscan: 15, + }); + + const items = rowVirtualizer.getVirtualItems(); + return loading ? ( ) : results.length === 0 ? ( ) : ( - <>{results.map((r) => renderItem(r, false))} + <> + {items.map((virtualRow) => ( + + ))} + ); }; diff --git a/packages/oss-console/src/components/LaunchPlan/LaunchPlanCardList/LaunchPlanListCard.tsx b/packages/oss-console/src/components/LaunchPlan/LaunchPlanCardList/LaunchPlanListCard.tsx index 247e3feaa..e58d046c3 100644 --- a/packages/oss-console/src/components/LaunchPlan/LaunchPlanCardList/LaunchPlanListCard.tsx +++ b/packages/oss-console/src/components/LaunchPlan/LaunchPlanCardList/LaunchPlanListCard.tsx @@ -1,20 +1,18 @@ import React, { PropsWithChildren, forwardRef, useMemo } from 'react'; -import { Link } from 'react-router-dom'; import Box from '@mui/material/Box'; import Grid from '@mui/material/Grid'; import Typography from '@mui/material/Typography'; import ListItemButton from '@mui/material/ListItemButton'; import Shimmer from '@clients/primitives/Shimmer'; -import LaunchPlansLogo from '@clients/ui-atoms/LaunchPlansLogo'; import { useQueryClient } from 'react-query'; import { useInView } from 'react-intersection-observer'; +import { Link } from 'react-router-dom'; import { makeListLaunchPlansQuery } from '../../../queries/launchPlanQueries'; import { CREATED_AT_DESCENDING_SORT } from '../../../models/Launch/constants'; import { SearchResult } from '../../common/SearchableList'; import { Routes } from '../../../routes/routes'; import { LaunchPlanLastNExecutions } from '../components/LaunchPlanLastNExecutions'; -import { ScheduleDisplayValue, ScheduleStatus, WorkflowName } from '../components/LaunchPlanCells'; -import { createHighlightedEntitySearchResult } from '../../common/useSearchableListState'; +import { LaunchPlanName, ScheduleStatusSummary } from '../components/LaunchPlanCells'; import { NamedEntity } from '../../../models/Common/types'; import { useConditionalQuery } from '../../hooks/useConditionalQuery'; @@ -38,51 +36,16 @@ const CardRow: React.FC< ); }; -interface CardButtonProps extends Pick, 'value' | 'result' | 'content'> { - inView: boolean; -} - -const CardButton: React.FC = ({ value, result, content, inView }) => { - const { id } = value; - - const launchPlanDetailsUrl = Routes.LaunchPlanDetails.makeUrl(id.project, id.domain, id.name); - const finalContent = useMemo(() => { - return result && inView ? createHighlightedEntitySearchResult(result) : content; - }, [result, content, inView]); - - return ( - { - return ; - })} - > - theme.spacing(1), flexWrap: 'nowrap' }}> - - - - - - {finalContent} - - - - - ); -}; - interface LaunchPlanListCardProps extends SearchResult {} const LaunchPlanListCard: React.FC = ({ value, result, content }) => { const queryClient = useQueryClient(); const [inViewRef, inView] = useInView(); + + if (!value) { + return null; + } + const { id } = value; const launchPlanWorkflowQuery = useConditionalQuery( @@ -100,45 +63,49 @@ const LaunchPlanListCard: React.FC = ({ value, result, }; }, [launchPlanWorkflowQuery]); + const launchPlanDetailsUrl = Routes.LaunchPlanDetails.makeUrl(id.project, id.domain, id.name); return ( - `1px solid ${theme.palette.divider}`, - margin: (theme) => theme.spacing(0, 2, 2, 2), - padding: (theme) => `${theme.spacing(2)} !important`, - display: 'flex', - flexDirection: 'column', - alignItems: 'left', - }} - > - - - - - - - - - - - - - - - - - - - - - +
+ { + return ; + })} + alignItems="flex-start" + sx={{ marginLeft: '-16px', cursor: 'pointer', padding: (theme) => theme.spacing(0, 2) }} + > + `1px solid ${theme.palette.divider}`, + margin: (theme) => theme.spacing(0, 2, 2, 2), + padding: (theme) => `${theme.spacing(2)} !important`, + display: 'flex', + flexDirection: 'column', + alignItems: 'left', + }} + > + + + + + + + + + + + + + + + + + + +
); }; diff --git a/packages/oss-console/src/components/LaunchPlan/LaunchPlanList.tsx b/packages/oss-console/src/components/LaunchPlan/LaunchPlanList.tsx index f1f0d9de0..9ea8a4813 100644 --- a/packages/oss-console/src/components/LaunchPlan/LaunchPlanList.tsx +++ b/packages/oss-console/src/components/LaunchPlan/LaunchPlanList.tsx @@ -31,7 +31,7 @@ export const LaunchPlanList: FC = ({ const { showScheduled, setShowScheduled, - getFilter: getScheduleFilter, + getFilters: getTriggerFilters, } = useLaunchPlanScheduledState(); const requestConfigBase: RequestConfig = { @@ -39,28 +39,27 @@ export const LaunchPlanList: FC = ({ sort: DEFAULT_SORT, }; - const launchplansQuery = useConditionalQuery( + const launchPlanEntitiesQuery = useConditionalQuery( + { + ...makeListLaunchPlanEntitiesQuery(queryClient, { domain, project }, requestConfigBase), + }, + (prev) => !prev, + ); + + const launchPlansWithTriggersQuery = useConditionalQuery( { ...makeListLaunchPlansQuery( queryClient, { domain, project }, { ...requestConfigBase, - filter: [getScheduleFilter()], + filter: getTriggerFilters(), }, ), enabled: showScheduled, }, (prev) => !prev, ); - - const launchPlanEntitiesQuery = useConditionalQuery( - { - ...makeListLaunchPlanEntitiesQuery(queryClient, { domain, project }, requestConfigBase), - }, - (prev) => !prev, - ); - const { launchPlanEntities, loading } = useMemo(() => { return { launchPlanEntities: (launchPlanEntitiesQuery.data?.entities || []) as NamedEntity[], @@ -68,12 +67,14 @@ export const LaunchPlanList: FC = ({ }; }, [launchPlanEntitiesQuery]); - const { onlyScheduledLaunchPlans, isLoadingOnlyScheduled } = useMemo(() => { - return { - onlyScheduledLaunchPlans: (launchplansQuery.data?.entities || []) as LaunchPlan[], - isLoadingOnlyScheduled: launchplansQuery.isLoading, - }; - }, [launchplansQuery]); + const { onlyScheduledLaunchPlans: onlyLaunchPlansWithTriggers, isLoadingOnlyScheduled } = + useMemo(() => { + return { + onlyScheduledLaunchPlans: (launchPlansWithTriggersQuery.data?.entities || + []) as LaunchPlan[], + isLoadingOnlyScheduled: launchPlansWithTriggersQuery.isLoading, + }; + }, [launchPlansWithTriggersQuery]); return ( = ({ projectId={project} noDivider launchPlanEntities={launchPlanEntities} - scheduledLaunchPlans={onlyScheduledLaunchPlans} + launchPlansWithTriggers={onlyLaunchPlansWithTriggers} showScheduled={showScheduled} onScheduleFilterChange={setShowScheduled} isLoading={loading || isLoadingOnlyScheduled} diff --git a/packages/oss-console/src/components/LaunchPlan/LaunchPlanTable/LaunchPlanTableRow.tsx b/packages/oss-console/src/components/LaunchPlan/LaunchPlanTable/LaunchPlanTableRow.tsx index 029c5d833..8d2b01ba7 100644 --- a/packages/oss-console/src/components/LaunchPlan/LaunchPlanTable/LaunchPlanTableRow.tsx +++ b/packages/oss-console/src/components/LaunchPlan/LaunchPlanTable/LaunchPlanTableRow.tsx @@ -1,108 +1,86 @@ -import React, { FC, useMemo } from 'react'; -import Grid from '@mui/material/Grid'; +import React, { FC, createContext, useContext, useState } from 'react'; import TableRow from '@mui/material/TableRow'; import TableCell from '@mui/material/TableCell'; -import Shimmer from '@clients/primitives/Shimmer'; -import { useQueryClient } from 'react-query'; import { useInView } from 'react-intersection-observer'; +import Link from '@mui/material/Link'; +import Grid from '@mui/material/Grid'; +import { Routes } from '@clients/oss-console/routes/routes'; import { LaunchPlanLastNExecutions } from '../components/LaunchPlanLastNExecutions'; -import { makeListLaunchPlansQuery } from '../../../queries/launchPlanQueries'; -import { useConditionalQuery } from '../../hooks/useConditionalQuery'; -import { - WorkflowName, - LaunchPlanName, - ScheduleStatus, - ScheduleDisplayValue, -} from '../components/LaunchPlanCells'; -import { CREATED_AT_DESCENDING_SORT } from '../../../models/Launch/constants'; +import { LaunchPlanName, ScheduleStatusSummary } from '../components/LaunchPlanCells'; import { SearchResult } from '../../common/useSearchableListState'; import { NamedEntity } from '../../../models/Common/types'; +import { LaunchPlanScheduleContextMenuFromNamedId } from '../components/LaunchPlanScheduleContextMenuFromNameId'; interface LaunchPlanTableRowProps extends SearchResult {} +interface RefreshRowContextType { + refresh: boolean; + setRefresh: React.Dispatch>; +} + +/** + * We use this context to trigger refetch when user updates launchplan + * active state; ie, so that user can see the change. + */ +const RefreshRowContext = createContext({ + refresh: false, + setRefresh: () => {}, +}); + +export const useSearchRowRefreshContext = () => useContext(RefreshRowContext); + export const LaunchPlanTableRow: FC = ({ value, result, content, }: LaunchPlanTableRowProps) => { - const queryClient = useQueryClient(); const [inViewRef, inView] = useInView(); - + if (!value) { + return null; + } const { id } = value; - - const launchPlanWorkflowQuery = useConditionalQuery( - { - ...makeListLaunchPlansQuery(queryClient, id, { sort: CREATED_AT_DESCENDING_SORT, limit: 1 }), - enabled: inView, - }, - (prev) => !prev && !!inView, - ); - - const launchPlan = useMemo(() => { - return launchPlanWorkflowQuery.data?.entities?.[0]; - }, [launchPlanWorkflowQuery]); + const key = id ? `${id.project}-${id.domain}-${id.name}` : 'none'; + const url = Routes.LaunchPlanDetails.makeUrl(id.project, id.domain, id.name); + const [refresh, setRefresh] = useState(false); return ( - - {/* Launch Plan Name */} - theme.spacing(0.5) }}> - - - {/* Underlying Workflow */} - theme.spacing(0.5) }}> - `calc(100% - 24px - ${theme.spacing(2)})`, - // text wrap - whiteSpace: 'nowrap', - textOverflow: 'ellipsis', - overflow: 'hidden', - }} - > - {!launchPlanWorkflowQuery.isFetched ? ( - - ) : ( - - )} - - - {/* Schedule Status */} - - - - {/* Schedule */} - + - - - {/* Last Execution */} - - {launchPlan == null ? ( - - ) : ( - - )} - - {/* Last 10 Executions */} - - {launchPlan == null ? ( - - ) : ( - - )} - - + {/* Launch Plan Name */} + theme.spacing(0.5) }}> + + + + {/* Schedule Status */} + + + + {/* Last Execution */} + + + + {/* Last 10 Executions */} + + + + + + + + + + ); }; diff --git a/packages/oss-console/src/components/LaunchPlan/LaunchPlanTable/LaunchPlanTableView.tsx b/packages/oss-console/src/components/LaunchPlan/LaunchPlanTable/LaunchPlanTableView.tsx index 74d65b8f1..8bb8bfc65 100644 --- a/packages/oss-console/src/components/LaunchPlan/LaunchPlanTable/LaunchPlanTableView.tsx +++ b/packages/oss-console/src/components/LaunchPlan/LaunchPlanTable/LaunchPlanTableView.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useRef } from 'react'; import Table from '@mui/material/Table'; import TableBody from '@mui/material/TableBody'; import TableCell from '@mui/material/TableCell'; @@ -8,30 +8,42 @@ import TableRow from '@mui/material/TableRow'; import { LargeLoadingComponent } from '@clients/primitives/LoadingSpinner'; import { TableNoRowsCell } from '@clients/primitives/TableNoRowsCell'; import { noLaunchPlansFoundString } from '@clients/common/constants'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import result from 'lodash/result'; import { SearchResult } from '../../common/useSearchableListState'; import { NamedEntity } from '../../../models/Common/types'; -import { ItemRenderer } from '../../common/FilterableNamedEntityList'; +import { LaunchPlanTableRow } from './LaunchPlanTableRow'; export interface LaunchPlanTableViewProps { results: SearchResult[]; - renderItem: ItemRenderer; loading: boolean; } -export const LaunchPlanTableView = ({ results, renderItem, loading }: LaunchPlanTableViewProps) => { +export const LaunchPlanTableView = ({ results, loading }: LaunchPlanTableViewProps) => { + const parentRef = useRef(document.getElementById('scroll-element')); + + const rowVirtualizer = useVirtualizer({ + count: result?.length ? results.length + 1 : 0, + getScrollElement: () => parentRef.current, + estimateSize: () => 100, + overscan: 15, + }); + + const items = rowVirtualizer.getVirtualItems(); + return ( - - + theme.spacing(0, 2), + }} + > +
+ Name - Launch Plan Name - - - Underlying Workflow + Trigger - Schedule Status - Schedule Last Execution Last 10 Executions @@ -42,7 +54,7 @@ export const LaunchPlanTableView = ({ results, renderItem, loading }: LaunchPlan ) : results.length === 0 ? ( ) : ( - results.map((r) => renderItem(r, false)) + items.map((virtualRow) => ) )}
diff --git a/packages/oss-console/src/components/LaunchPlan/ResponsiveLaunchPlanList.tsx b/packages/oss-console/src/components/LaunchPlan/ResponsiveLaunchPlanList.tsx index d7c9f751a..46d510cbb 100644 --- a/packages/oss-console/src/components/LaunchPlan/ResponsiveLaunchPlanList.tsx +++ b/packages/oss-console/src/components/LaunchPlan/ResponsiveLaunchPlanList.tsx @@ -4,14 +4,11 @@ import { useTheme } from '@mui/material/styles'; import Grid from '@mui/material/Grid'; import Divider from '@mui/material/Divider'; import { LaunchPlan } from '../../models/Launch/types'; -import { useSearchableListState, SearchResult } from '../common/useSearchableListState'; +import { useSearchableListState } from '../common/useSearchableListState'; import { NamedEntity } from '../../models/Common/types'; import LaunchPlanCardView from './LaunchPlanCardList/LaunchPlanCardView'; import { LaunchPlanTableView } from './LaunchPlanTable/LaunchPlanTableView'; import { SearchBox } from './components/SearchBox'; -import { LaunchPlanTableRow } from './LaunchPlanTable/LaunchPlanTableRow'; -import LaunchPlanListCard from './LaunchPlanCardList/LaunchPlanListCard'; -import { ItemRenderer } from '../common/FilterableNamedEntityList'; export interface ResponsiveLaunchPlanListProps { projectId: string; @@ -21,7 +18,7 @@ export interface ResponsiveLaunchPlanListProps { placeholder: string; noDivider?: boolean; launchPlanEntities: NamedEntity[]; - scheduledLaunchPlans: LaunchPlan[]; + launchPlansWithTriggers: LaunchPlan[]; isLoading: boolean; } @@ -33,28 +30,28 @@ export const ResponsiveLaunchPlanList: FC = ({ noDivider = false, isLoading, launchPlanEntities, - scheduledLaunchPlans: onlyScheduledLaunchPlans, + launchPlansWithTriggers, }) => { const theme = useTheme(); const isWidthLessThanLg = useMediaQuery(theme.breakpoints.down('lg')); - const [scheduledLaunchPlans, setScheduledLaunchPlans] = useState([]); + const [scheduledLaunchPlanEntities, setScheduledLaunchPlanEntities] = useState([]); const { results, setSearchString, searchString } = useSearchableListState({ - items: showScheduled ? scheduledLaunchPlans : launchPlanEntities, + items: showScheduled ? scheduledLaunchPlanEntities : launchPlanEntities, propertyGetter: 'id.name' as any, }); useEffect(() => { - if (onlyScheduledLaunchPlans.length > 0) { - const onlyScheduledLaunchNames = onlyScheduledLaunchPlans.map( + if (launchPlansWithTriggers.length > 0) { + const onlyScheduledLaunchNames = launchPlansWithTriggers.map( (launchPlan) => launchPlan.id.name, ); const onlyScheduledEntities = launchPlanEntities.filter((entity) => { return onlyScheduledLaunchNames.includes(entity.id.name); }); - setScheduledLaunchPlans(onlyScheduledEntities); + setScheduledLaunchPlanEntities(onlyScheduledEntities); } - }, [onlyScheduledLaunchPlans]); + }, [launchPlansWithTriggers]); const onSearchChange = (event: ChangeEvent) => { const searchString = event.target.value; @@ -63,18 +60,6 @@ export const ResponsiveLaunchPlanList: FC = ({ const onClear = () => setSearchString(''); - const renderTableRow: ItemRenderer = (searchResult: SearchResult) => { - const { key, value, content, result } = searchResult; - - return ; - }; - - const renderCard: ItemRenderer = (searchResult: SearchResult) => { - const { key, value, content, result } = searchResult; - - return ; - }; - return ( theme.spacing(-3) }}> @@ -92,9 +77,9 @@ export const ResponsiveLaunchPlanList: FC = ({ {!noDivider && } {isWidthLessThanLg ? ( - + ) : ( - + )} diff --git a/packages/oss-console/src/components/LaunchPlan/components/ChangeScheduleModal.tsx b/packages/oss-console/src/components/LaunchPlan/components/ChangeScheduleModal.tsx new file mode 100644 index 000000000..b4c4c7fb3 --- /dev/null +++ b/packages/oss-console/src/components/LaunchPlan/components/ChangeScheduleModal.tsx @@ -0,0 +1,285 @@ +import React, { MouseEventHandler, Suspense, useEffect, useMemo, useState } from 'react'; +import Dialog from '@mui/material/Dialog'; +import DialogContent from '@mui/material/DialogContent'; +import DialogContentText from '@mui/material/DialogContentText'; +import DialogTitle from '@mui/material/DialogTitle'; +import DialogActions from '@mui/material/DialogActions'; +import Button from '@mui/material/Button'; +import { useMutation, useQueryClient } from 'react-query'; +import { FilterOperationName, SortDirection } from '@clients/common/types/adminEntityTypes'; +import Typography from '@mui/material/Typography'; +import { NamedEntityIdentifier, ResourceIdentifier } from '../../../models/Common/types'; +import { workflowSortFields } from '../../../models/Workflow/constants'; +import { LaunchPlan, LaunchPlanState } from '../../../models/Launch/types'; +import { updateLaunchPlan } from '../../../models/Launch/api'; +import { useEscapeKey } from '../../hooks/useKeyListener'; +import { + SearchableSelector, + SearchableSelectorOption, +} from '../../Launch/LaunchForm/LaunchFormComponents/SearchableSelector'; +import { fetchLaunchPlansList } from '../../../queries/launchPlanQueries'; +import { WaitForQuery } from '../../common/WaitForQuery'; +import { getRawScheduleStringFromLaunchPlan } from '../../Entities/EntitySchedules'; +import { getScheduleStringFromLaunchPlan } from '../../Entities/getScheduleStringFromLaunchPlan'; +import { useLatestLaunchPlans } from '../hooks/useLatestLaunchPlans'; +import { useLatestLaunchPlanVersions } from '../hooks/useLatestScheduledLaunchPlans'; +import { useLatestActiveLaunchPlan } from '../hooks/useLatestActiveLaunchPlan'; +import { hasAnyEvent } from '../utils'; + +/** Formats a list of `LaunchPlan` records for use in a `SearchableSelector` */ +export function launchPlansToSearchableSelectorOptions( + launchPlans: LaunchPlan[], + latestVersion?: string, +): SearchableSelectorOption[] { + const displayValues = (lp: LaunchPlan): string => { + if (!hasAnyEvent(lp)) { + return ''; + } + return `${getScheduleStringFromLaunchPlan(lp)} (${getRawScheduleStringFromLaunchPlan(lp)})`; + }; + + return (launchPlans || [])?.map>((lp) => ({ + data: lp, + id: lp.id.version, + name: `${lp.id.version}`, + isLatest: lp.id.version === latestVersion, + description: displayValues(lp), + })); +} + +interface ChangeScheduleModalVersionSelectorProps { + launchPlanVersions: LaunchPlan[]; + mostRecentLaunchPlan?: LaunchPlan; + selectedLaunchPlan?: LaunchPlan; + setSelectedLaunchPlan: (selectedLaunchPlan: LaunchPlan) => void; + open: boolean; +} + +const ChangeScheduleModalVersionSelector: React.FC = ({ + selectedLaunchPlan, + setSelectedLaunchPlan, + launchPlanVersions, + mostRecentLaunchPlan, +}) => { + const queryClient = useQueryClient(); + + const launchPlanSelectorOptions = useMemo( + () => + launchPlansToSearchableSelectorOptions(launchPlanVersions, mostRecentLaunchPlan?.id?.version), + [launchPlanVersions, mostRecentLaunchPlan?.id?.version], + ); + + const [selectedItem, setSelectedItem] = React.useState< + SearchableSelectorOption | undefined + >(launchPlanSelectorOptions?.find((l) => l.id === selectedLaunchPlan?.id?.version)); + + useEffect(() => { + const newSelectedItem = launchPlanSelectorOptions.find( + (l) => l.id === selectedLaunchPlan?.id?.version, + ); + + setSelectedItem(newSelectedItem); + }, [launchPlanSelectorOptions, selectedLaunchPlan]); + + useEffect(() => { + if (!selectedItem) return; + setSelectedLaunchPlan(selectedItem.data); + }, [selectedItem]); + + const fetchSearchResults = useMemo(() => { + const doFetch = async (launchPlanVersionId: string, launchPlanId?: NamedEntityIdentifier) => { + if (!launchPlanId) return []; + const { project, domain, name } = launchPlanId; + const { entities: launchPlans } = await fetchLaunchPlansList( + queryClient, + { project, domain, name }, + { + filter: [ + { + key: 'version', + operation: FilterOperationName.CONTAINS, + value: launchPlanVersionId, + }, + ], + sort: { + key: workflowSortFields.createdAt, + direction: SortDirection.DESCENDING, + }, + }, + ); + return launchPlansToSearchableSelectorOptions(launchPlans, mostRecentLaunchPlan?.id?.version); + }; + + return async (launchPlanVersionId: string) => { + const results = await doFetch(launchPlanVersionId, selectedLaunchPlan?.id); + return results; + }; + }, [selectedLaunchPlan]); + + const formHelperText = useMemo(() => { + const formHelperText: JSX.Element[] = []; + const hasEvent = hasAnyEvent(selectedLaunchPlan); + if ( + !!selectedLaunchPlan && + selectedLaunchPlan?.id.version !== mostRecentLaunchPlan?.id.version + ) { + formHelperText.push( + + This is not the latest version. + , + ); + } + + if (selectedLaunchPlan && hasEvent) { + const triggerDescription = (lp: LaunchPlan): string => { + return `Schedule: ${getScheduleStringFromLaunchPlan( + lp, + )} (${getRawScheduleStringFromLaunchPlan(lp)})`; + }; + + formHelperText.push( + + {triggerDescription(selectedLaunchPlan)} + , + ); + } else { + formHelperText.push( + + No Trigger + , + ); + } + + return formHelperText; + }, [launchPlanVersions, selectedLaunchPlan, mostRecentLaunchPlan]); + + return ( + theme.spacing(3), + maxWidth: 'none', + }} + /> + ); +}; +interface ChangeScheduleModalProps { + id: ResourceIdentifier; + open: boolean; + onClose(): void; + refetch(): void; +} + +/** Renders a Modal that will load/display the inputs/outputs for a given + * Execution in a tabbed/scrollable container + */ +export const ChangeScheduleModal: React.FC = ({ + id, + open, + onClose, + refetch, +}) => { + const [isUpdating, setIsUpdating] = useState(false); + const activeScheduleLaunchPlanQuery = useLatestActiveLaunchPlan({ + id, + }); + + const activeScheduleLaunchPlan = useMemo(() => { + return activeScheduleLaunchPlanQuery.data?.entities?.[0]; + }, [activeScheduleLaunchPlanQuery]); + + const [selectedLaunchPlan, setSelectedLaunchPlan] = useState(activeScheduleLaunchPlan); + + useEscapeKey(onClose); + + const latestLaunchPlanQuery = useLatestLaunchPlans({ + id, + enabled: open, + }); + + const launchPlanVersionsQuery = useLatestLaunchPlanVersions({ + id, + limit: 10, + enabled: true, + }); + + const mutation = useMutation( + (newState: LaunchPlanState) => updateLaunchPlan(selectedLaunchPlan?.id, newState), + { + onMutate: () => setIsUpdating(true), + onSuccess: () => { + refetch(); + setIsUpdating(false); + onClose(); + }, + onError: (_data) => { + // TODO: handle error + }, + onSettled: (_data) => { + setIsUpdating(false); + }, + }, + ); + + const onClick = (_event: MouseEventHandler) => { + mutation.mutate(LaunchPlanState.ACTIVE); + }; + + return ( + + Update active launch plan + + + Only one launch plan version can be active at a time. Changing the version will + automatically deactivate any currently active version. + + + + {({ entities: mostRecentLaunchPlan }) => { + return ( + + {({ entities: lpVersions }) => ( + + )} + + ); + }} + + + + + + + + + ); +}; diff --git a/packages/oss-console/src/components/LaunchPlan/components/DeactivateScheduleModal.tsx b/packages/oss-console/src/components/LaunchPlan/components/DeactivateScheduleModal.tsx new file mode 100644 index 000000000..7a8e2820a --- /dev/null +++ b/packages/oss-console/src/components/LaunchPlan/components/DeactivateScheduleModal.tsx @@ -0,0 +1,69 @@ +import React, { useState } from 'react'; +import Dialog from '@mui/material/Dialog'; +import DialogContent from '@mui/material/DialogContent'; +import DialogContentText from '@mui/material/DialogContentText'; +import DialogTitle from '@mui/material/DialogTitle'; +import DialogActions from '@mui/material/DialogActions'; +import Button from '@mui/material/Button'; +import { useMutation } from 'react-query'; +import { useEscapeKey } from '../../hooks/useKeyListener'; +import { LaunchPlan, LaunchPlanState } from '../../../models/Launch/types'; +import { updateLaunchPlan } from '../../../models/Launch/api'; + +interface DeactivateScheduleModalProps { + activeLaunchPlan: LaunchPlan; + open: boolean; + onClose(): void; + refetch(): void; +} + +/** Renders a Modal that will load/display the inputs/outputs for a given + * Execution in a tabbed/scrollable container + */ +export const DeactivateScheduleModal: React.FC = ({ + activeLaunchPlan, + open, + onClose, + refetch, +}) => { + const [isUpdating, setIsUpdating] = useState(false); + useEscapeKey(onClose); + + const mutation = useMutation( + (newState: LaunchPlanState) => updateLaunchPlan(activeLaunchPlan?.id, newState), + { + onMutate: () => setIsUpdating(true), + onSuccess: () => { + refetch(); + setIsUpdating(false); + onClose(); + }, + onError: (_data) => { + // TODO: handle error + }, + onSettled: (_data) => { + setIsUpdating(false); + }, + }, + ); + + const onClick = (_event: any) => { + mutation.mutate(LaunchPlanState.INACTIVE); + }; + return ( + + Deactivate launch plan + + Any future events will not run + + + + + + + ); +}; diff --git a/packages/oss-console/src/components/LaunchPlan/components/LaunchPlanCells.tsx b/packages/oss-console/src/components/LaunchPlan/components/LaunchPlanCells.tsx index 6bc014f49..00e1b54f1 100644 --- a/packages/oss-console/src/components/LaunchPlan/components/LaunchPlanCells.tsx +++ b/packages/oss-console/src/components/LaunchPlan/components/LaunchPlanCells.tsx @@ -2,25 +2,36 @@ import Shimmer from '@clients/primitives/Shimmer'; import Box from '@mui/material/Box'; import ListItemButton from '@mui/material/ListItemButton'; import Tooltip from '@mui/material/Tooltip'; -import React, { ChangeEvent, forwardRef, useEffect, useMemo, useState } from 'react'; +import React, { forwardRef, useEffect, useMemo } from 'react'; import { Link } from 'react-router-dom'; import LaunchPlansLogo from '@clients/ui-atoms/LaunchPlansLogo'; import Grid from '@mui/material/Grid'; import Icon from '@mui/material/Icon'; -import FormControlLabel from '@mui/material/FormControlLabel'; -import Switch from '@mui/material/Switch'; -import { useMutation } from 'react-query'; +import { useQueryClient } from 'react-query'; +import Typography from '@mui/material/Typography'; import { LaunchPlan, LaunchPlanState } from '../../../models/Launch/types'; -import { updateLaunchPlan } from '../../../models/Launch/api'; -import { NamedEntity } from '../../../models/Common/types'; +import { NamedEntity, NamedEntityIdentifier } from '../../../models/Common/types'; import { SearchResult, createHighlightedEntitySearchResult, } from '../../common/useSearchableListState'; import { Routes } from '../../../routes/routes'; -import { getScheduleStringFromLaunchPlan } from '../../Entities/EntitySchedules'; +import { getRawScheduleStringFromLaunchPlan } from '../../Entities/EntitySchedules'; +import { getScheduleStringFromLaunchPlan } from '../../Entities/getScheduleStringFromLaunchPlan'; import t from '../../common/strings'; +import { useConditionalQuery } from '../../hooks/useConditionalQuery'; +import { CREATED_AT_DESCENDING_SORT } from '../../../models/Launch/constants'; +import { + castLaunchPlanIdAsQueryKey, + makeListLaunchPlansQuery, +} from '../../../queries/launchPlanQueries'; +import { StatusBadge } from '../../Executions/StatusBadge'; +import { useLatestActiveLaunchPlan } from '../hooks/useLatestActiveLaunchPlan'; +import { useLatestScheduledLaunchPlans } from '../hooks/useLatestScheduledLaunchPlans'; +import { hasAnyEvent } from '../utils'; +import { useSearchRowRefreshContext } from '../LaunchPlanTable/LaunchPlanTableRow'; +import { QueryType } from '../../data/types'; export interface WorkflowNameProps { launchPlan?: LaunchPlan; @@ -68,176 +79,232 @@ interface LaunchPlanNameProps * @returns */ -export const LaunchPlanName: React.FC = ({ - result, - value, - content, - inView, -}) => { - const { id } = value; - - const url = Routes.LaunchPlanDetails.makeUrl(id.project, id.domain, id.name); +export const LaunchPlanName: React.FC = ({ result, content, inView }) => { const finalContent = useMemo(() => { return result && inView ? createHighlightedEntitySearchResult(result) : content; }, [result, content, inView]); return ( - + - - { - return ; - })} - sx={{ - minHeight: '49.5px', - marginLeft: (theme) => theme.spacing(2), - marginRight: (theme) => theme.spacing(2), - padding: (theme) => theme.spacing(1, 2), - marginBottom: '-1px', - }} - > - - theme.spacing(2) }}> - theme.palette.common.grays[40], - width: '20px', - }, - }} - > - - - - + + theme.spacing(2) }}> + `calc(100% - 24px - ${theme.spacing(2)})`, - // text wrap - whiteSpace: 'nowrap', - textOverflow: 'ellipsis', - overflow: 'hidden', + '& svg': { + color: (theme) => theme.palette.common.grays[40], + width: '20px', + }, }} > - {finalContent} - + + - + `calc(100% - 24px - ${theme.spacing(2)})`, + // text wrap + whiteSpace: 'nowrap', + textOverflow: 'ellipsis', + overflow: 'hidden', + }} + > + {finalContent} + + ); }; + export interface ScheduleStatusProps { launchPlan?: LaunchPlan; refetch: () => void; } -export const enum ScheduleDisplayValueEnum { +export const enum ActiveLaunchPlanDisplayValueEnum { + NO_SCHEDULE = '-', ACTIVE = 'Active', INACTIVE = 'Inactive', } -export const ScheduleStatus = ({ launchPlan, refetch }: ScheduleStatusProps) => { - const [isUpdating, setIsUpdating] = useState(false); - const [isActive, setIsActive] = useState(); - const [displayValue, setDisplayValue] = useState(t('noValue')); - useEffect(() => { - if ( - launchPlan?.spec.entityMetadata?.schedule?.cronSchedule !== undefined && - launchPlan?.closure?.state !== undefined - ) { - if (launchPlan.closure.state === LaunchPlanState.ACTIVE) { - setIsActive(true); - } else { - setIsActive(false); - } - } +export const getTriggerTooltipValues = (launchPlan: LaunchPlan | undefined) => { + if (!launchPlan) { + return t('noValue'); + } + return getScheduleTooltipValue(launchPlan); +}; + +export const getTriggerDisplayValue = (launchPlan: LaunchPlan | undefined) => { + if (!launchPlan) { + return t('noValue'); + } + return getScheduleStringFromLaunchPlan(launchPlan); +}; + +export const getScheduleTooltipValue = (launchPlan: LaunchPlan | undefined) => { + if (!launchPlan) { + return t('noValue'); + } + return `${getScheduleStringFromLaunchPlan(launchPlan)} (${getRawScheduleStringFromLaunchPlan( + launchPlan, + )})`; +}; +export interface TriggerDisplayValueProps { + launchPlan?: LaunchPlan; +} +export const TriggerDisplayValue = ({ launchPlan }: TriggerDisplayValueProps) => { + if (!launchPlan) { + return ; + } + + const displayEventValueHeader = useMemo(() => { + return getTriggerDisplayValue(launchPlan); }, [launchPlan]); + const displayEventValueSub = useMemo(() => { + return getRawScheduleStringFromLaunchPlan(launchPlan); + }, [launchPlan]); + + const displayTooltipValue = useMemo(() => { + return getTriggerTooltipValues(launchPlan); + }, [launchPlan]); + + return ( + + + + + {displayEventValueHeader} + + + + + {displayEventValueSub} + + + + + ); +}; +export interface ScheduleStatusSummaryProps { + id: NamedEntityIdentifier; + inView: boolean; +} +export const ScheduleStatusSummary = ({ id, inView }: ScheduleStatusSummaryProps) => { + const queryClient = useQueryClient(); + const { refresh, setRefresh } = useSearchRowRefreshContext(); + + /** + * Invalidate cache to trigger refetch on launchplan data to pickup state change + */ useEffect(() => { - if ( - launchPlan?.spec.entityMetadata?.schedule?.cronSchedule !== undefined && - launchPlan?.closure?.state !== undefined - ) { - if (isActive) { - setDisplayValue(ScheduleDisplayValueEnum.ACTIVE); - } else { - setDisplayValue(ScheduleDisplayValueEnum.INACTIVE); - } + if (refresh) { + const queryKey = [QueryType.ListLaunchPlans, castLaunchPlanIdAsQueryKey(id)]; + queryClient.invalidateQueries(queryKey); + setRefresh(false); } - }, [isActive]); + }, [refresh, setRefresh, queryClient, id]); - const mutation = useMutation( - (newState: LaunchPlanState) => updateLaunchPlan(launchPlan?.id, newState), + const latestLaunchPlanQuery = useConditionalQuery( { - onMutate: () => setIsUpdating(true), - onSuccess: () => { - setIsUpdating(false); - }, - onSettled: () => { - setIsUpdating(false); - }, + ...makeListLaunchPlansQuery(queryClient, id, { + sort: CREATED_AT_DESCENDING_SORT, + limit: 1, + }), + enabled: inView, }, + (prev) => !prev && !!inView, ); + const activeLaunchPlanQuery = useLatestActiveLaunchPlan({ + id, + enabled: inView, + }); + const scheduledLaunchPlansQuery = useLatestScheduledLaunchPlans({ + id, + limit: 1, + enabled: inView, + }); - useEffect(() => { - if (mutation.isSuccess) { - refetch?.(); - } - }, [mutation.isSuccess]); + const activeLaunchPlan = useMemo(() => { + return activeLaunchPlanQuery.data?.entities?.[0]; + }, [activeLaunchPlanQuery]); - const handleScheduleStatusChange = (event: ChangeEvent) => { - const { checked } = event.target; - mutation.mutate(checked ? LaunchPlanState.ACTIVE : LaunchPlanState.INACTIVE); - setIsActive(checked); - }; + const scheduledLaunchPlan = useMemo(() => { + return scheduledLaunchPlansQuery.data?.entities?.[0]; + }, [scheduledLaunchPlansQuery]); - return !launchPlan || isUpdating ? ( - - ) : ( - - - {displayValue === '-' ? ( - '-' - ) : ( - } - label={displayValue} - onChange={(event) => { - handleScheduleStatusChange(event as ChangeEvent); - }} - /> - )} - - - ); -}; -export interface ScheduleDisplayValueProps { - launchPlan?: LaunchPlan; -} -export const ScheduleDisplayValue = ({ launchPlan }: ScheduleDisplayValueProps) => { - const scheduleDisplayValue = useMemo(() => { - if (!launchPlan) { - return t('noValue'); - } - let scheduleDisplayValue = getScheduleStringFromLaunchPlan(launchPlan); - if (scheduleDisplayValue === '') { - scheduleDisplayValue = t('noValue'); - } - return scheduleDisplayValue; - }, [launchPlan]); + const hasEvent = hasAnyEvent(scheduledLaunchPlan); - if (launchPlan === undefined) { - return ; - } - return ( - - - {scheduleDisplayValue} + const isActive = activeLaunchPlan?.closure?.state === LaunchPlanState.ACTIVE; + + return !scheduledLaunchPlansQuery.isFetched && + !activeLaunchPlanQuery.isFetched && + !latestLaunchPlanQuery.isFetched ? ( + + ) : isActive ? ( + + + - + + {hasEvent ? ( + + + + ) : ( + + No Trigger + + )} + + ) : ( + + No active launch plan + ); }; diff --git a/packages/oss-console/src/components/LaunchPlan/components/LaunchPlanDetailsHeader.tsx b/packages/oss-console/src/components/LaunchPlan/components/LaunchPlanDetailsHeader.tsx new file mode 100644 index 000000000..f4397637b --- /dev/null +++ b/packages/oss-console/src/components/LaunchPlan/components/LaunchPlanDetailsHeader.tsx @@ -0,0 +1,68 @@ +import React from 'react'; +import Grid from '@mui/material/Grid'; +import Typography from '@mui/material/Typography'; +import { ResourceIdentifier } from '../../../models/Common/types'; +import { LaunchPlanScheduleContextMenu } from './LaunchPlanScheduleContextMenu'; +import { ScheduleDetails } from './ScheduleDetails'; + +export interface LaunchPlanDetailsHeaderProps { + id: ResourceIdentifier; +} +export const LaunchPlanDetailsHeader = ({ id }: LaunchPlanDetailsHeaderProps) => { + return ( + + {/* + HEADER + */} + theme.spacing(0.5), + borderBottom: (theme) => `1px solid ${theme.palette.divider}`, + }} + > + theme.spacing(0.5) }}> + + Active launch plan + + + + + + + + {/* + DETAILS + */} + theme.spacing(1), + }} + > + + + + + + ); +}; diff --git a/packages/oss-console/src/components/LaunchPlan/components/LaunchPlanLastNExecutions.tsx b/packages/oss-console/src/components/LaunchPlan/components/LaunchPlanLastNExecutions.tsx index ebba78c94..e74b5b810 100644 --- a/packages/oss-console/src/components/LaunchPlan/components/LaunchPlanLastNExecutions.tsx +++ b/packages/oss-console/src/components/LaunchPlan/components/LaunchPlanLastNExecutions.tsx @@ -2,43 +2,39 @@ import React, { FC, useMemo } from 'react'; import Shimmer from '@clients/primitives/Shimmer'; import { useQueryClient } from 'react-query'; import { makeFilterableWorkflowExecutionsQuery } from '../../../queries/workflowQueries'; -import { LaunchPlan } from '../../../models/Launch/types'; import { formatDateUTC } from '../../../common/formatters'; import { padExecutionPaths, padExecutions, timestampToDate } from '../../../common/utils'; import ProjectStatusBar from '../../ListProjectEntities/ProjectStatusBar'; import { REQUEST_CONFIG } from '../../Entities/EntityDetails'; import { executionFilterGenerator } from '../../Entities/generators'; -import { ResourceIdentifier, ResourceType } from '../../../models/Common/types'; -import { ExecutionMode } from '../../../models/Execution/enums'; +import { NamedEntityIdentifier, ResourceType } from '../../../models/Common/types'; import { useConditionalQuery } from '../../hooks/useConditionalQuery'; type LaunchPlanLastNExecutionsProps = { - launchPlan?: LaunchPlan; + id: NamedEntityIdentifier; showLastExecutionOnly?: boolean; inView: boolean; }; /** The view component for displaying thelaunch plan's details of last execution or last 10 exeuctions */ export const LaunchPlanLastNExecutions: FC = ({ - launchPlan, + id, showLastExecutionOnly = false, inView, }: LaunchPlanLastNExecutionsProps) => { const queryClient = useQueryClient(); - const { id } = launchPlan || {}; + const filter = executionFilterGenerator[ResourceType.LAUNCH_PLAN](id as any); // Build request config for the latest workflow version const requestConfig = React.useMemo( () => ({ ...REQUEST_CONFIG, - filter: executionFilterGenerator[id?.resourceType || ResourceType.LAUNCH_PLAN]( - id as ResourceIdentifier, - ), + filter, }), [id], ); - const launchPlanExecutions = useConditionalQuery( + const launchPlanExecutionsQuery = useConditionalQuery( { ...makeFilterableWorkflowExecutionsQuery(queryClient, id!, requestConfig), enabled: id && inView, @@ -48,36 +44,32 @@ export const LaunchPlanLastNExecutions: FC = ({ const executionsMeta = useMemo(() => { const returnObj = { - isLoading: !launchPlanExecutions.isFetched, - isError: launchPlanExecutions.isError, + isLoading: !launchPlanExecutionsQuery.isFetched, + isError: launchPlanExecutionsQuery.isError, lastExecutionTime: undefined, executionStatus: undefined, executionIds: undefined, }; - const workflowExecutions = (launchPlanExecutions.data?.entities || []).filter( - (e) => e.spec.metadata.mode === ExecutionMode.SCHEDULED, - ); - if (!workflowExecutions?.length) { + const launchPlanExecutions = launchPlanExecutionsQuery.data?.entities || []; + if (!launchPlanExecutions?.length) { return returnObj; } - const mostRecentScheduledexecution = workflowExecutions.at(0); + const mostRecentScheduledexecution = launchPlanExecutions.at(0); const createdAt = mostRecentScheduledexecution?.spec.metadata.scheduledAt || mostRecentScheduledexecution?.closure?.createdAt!; const lastExecutionTime = formatDateUTC(timestampToDate(createdAt)); - const executionStatus = workflowExecutions.map((execution) => execution.closure.phase); + const executionStatus = launchPlanExecutions.map((execution) => execution.closure.phase); - const executionIds = workflowExecutions.map((execution) => execution.id); + const executionIds = launchPlanExecutions.map((execution) => execution.id); return { ...returnObj, lastExecutionTime, executionStatus, executionIds }; - }, [launchPlanExecutions]); + }, [launchPlanExecutionsQuery]); - if (!launchPlan || executionsMeta.isLoading) { - return ; - } - - return showLastExecutionOnly ? ( + return executionsMeta.isLoading ? ( + + ) : showLastExecutionOnly ? ( <>{executionsMeta.lastExecutionTime || No executions found} ) : ( { + const [anchorEl, setAnchorEl] = useState(null); + const [isChangeScheduleModalOpen, setIsChangeScheduleModalOpen] = useState(false); + const [isDeactivateScheduleModalOpen, setIsDeactivateScheduleModalOpen] = useState(false); + + const open = Boolean(anchorEl); + + const activeScheduleLaunchPlanQuery = useLatestActiveLaunchPlan({ + id, + }); + + const activeScheduleLaunchPlan = useMemo(() => { + return activeScheduleLaunchPlanQuery.data?.entities?.[0]; + }, [activeScheduleLaunchPlanQuery]); + + const handleClickListItem = (event: MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + const handleClose = () => { + setAnchorEl(null); + }; + + const hasSchedule = hasAnyEvent(activeScheduleLaunchPlan); + const isActive = activeScheduleLaunchPlan?.closure?.state === LaunchPlanState.ACTIVE; + + return activeScheduleLaunchPlanQuery.isFetched ? ( + <> + {hasSchedule || isActive ? ( + <> + + + + + + + + + { + setAnchorEl(null); + setIsChangeScheduleModalOpen(true); + }} + > + Update active launch plan + + { + setAnchorEl(null); + setIsDeactivateScheduleModalOpen(true); + }} + > + Deactivate + + + + ) : ( + + { + e.preventDefault(); + setIsChangeScheduleModalOpen(true); + }} + > + Add active launch plan + + + )} + {id ? ( + setIsChangeScheduleModalOpen(false)} + refetch={activeScheduleLaunchPlanQuery.refetch} + /> + ) : null} + {activeScheduleLaunchPlan ? ( + <> + setIsDeactivateScheduleModalOpen(false)} + refetch={activeScheduleLaunchPlanQuery.refetch} + /> + + ) : null} + + ) : ( + + ); +}; diff --git a/packages/oss-console/src/components/LaunchPlan/components/LaunchPlanScheduleContextMenuFromNameId.tsx b/packages/oss-console/src/components/LaunchPlan/components/LaunchPlanScheduleContextMenuFromNameId.tsx new file mode 100644 index 000000000..5e3e22c5d --- /dev/null +++ b/packages/oss-console/src/components/LaunchPlan/components/LaunchPlanScheduleContextMenuFromNameId.tsx @@ -0,0 +1,161 @@ +import { CREATED_AT_DESCENDING_SORT } from '@clients/oss-console/models/Launch/constants'; +import { LaunchPlan } from '@clients/oss-console/models/Launch/types'; +import { makeListLaunchPlansQuery } from '@clients/oss-console/queries/launchPlanQueries'; +import React, { useMemo, useState, MouseEvent } from 'react'; +import { useQueryClient } from 'react-query'; +import List from '@mui/material/List'; +import ListItemButton from '@mui/material/ListItemButton'; +import ListItemIcon from '@mui/material/ListItemIcon'; +import Menu from '@mui/material/Menu'; +import MenuItem from '@mui/material/MenuItem'; +import Shimmer from '@clients/primitives/Shimmer'; +import MoreVert from '@mui/icons-material/MoreVert'; +import { ChangeScheduleModal } from './ChangeScheduleModal'; +import { DeactivateScheduleModal } from './DeactivateScheduleModal'; +import { useConditionalQuery } from '../../hooks/useConditionalQuery'; +import { NamedEntityIdentifier, ResourceIdentifier } from '../../../models/Common/types'; +import { getActiveLaunchPlan } from '../hooks/useLatestActiveLaunchPlan'; + +export interface LaunchPlanScheduleContextMenuFromNamedProps { + id: NamedEntityIdentifier; + inView: boolean; + setRefresh: React.Dispatch>; +} +export const LaunchPlanScheduleContextMenuFromNamedId = ({ + id, + inView, + setRefresh, +}: LaunchPlanScheduleContextMenuFromNamedProps) => { + const queryClient = useQueryClient(); + const onlyActiveLaunchPlanFilter = getActiveLaunchPlan(true); + + const activeLaunchPlanQuery = useConditionalQuery( + { + ...makeListLaunchPlansQuery(queryClient, id, { + sort: CREATED_AT_DESCENDING_SORT, + limit: 1, + filter: [onlyActiveLaunchPlanFilter], + }), + enabled: inView, + }, + (prev) => !prev && !!inView, + ); + + const currentLaunchPlanQuery = useConditionalQuery( + { + ...makeListLaunchPlansQuery(queryClient, id, { + sort: CREATED_AT_DESCENDING_SORT, + limit: 1, + }), + enabled: inView, + }, + (prev) => !prev && !!inView, + ); + + const { launchPlan, isLoading, activeLaunchPlan } = useMemo(() => { + return { + launchPlan: (currentLaunchPlanQuery.data?.entities || [])[0] as LaunchPlan | undefined, + isLoading: currentLaunchPlanQuery.isLoading && activeLaunchPlanQuery.isLoading, + activeLaunchPlan: (activeLaunchPlanQuery.data?.entities || [])[0] as LaunchPlan | undefined, + }; + }, [currentLaunchPlanQuery, activeLaunchPlanQuery]); + + const [anchorEl, setAnchorEl] = useState(null); + const [isChangeScheduleModalOpen, setIsChangeScheduleModalOpen] = useState(false); + const [isDeactivateScheduleModalOpen, setIsDeactivateScheduleModalOpen] = useState(false); + + const open = Boolean(anchorEl); + + const handleClickListItem = (event: MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + setAnchorEl(event.currentTarget); + }; + const handleClose = () => { + setAnchorEl(null); + }; + return !isLoading && launchPlan !== undefined ? ( + <> + + + + + + + + + { + setAnchorEl(null); + setIsChangeScheduleModalOpen(true); + }} + > + Update active launch plan + + { + setAnchorEl(null); + setIsDeactivateScheduleModalOpen(true); + }} + > + Deactivate + + + + {launchPlan ? ( + setIsChangeScheduleModalOpen(false)} + refetch={() => { + setRefresh(true); + currentLaunchPlanQuery.refetch(); + }} + /> + ) : null} + {activeLaunchPlan ? ( + <> + setIsDeactivateScheduleModalOpen(false)} + refetch={() => { + activeLaunchPlanQuery.refetch(); + setRefresh(true); + }} + /> + + ) : null} + + ) : ( + + ); +}; diff --git a/packages/oss-console/src/components/LaunchPlan/components/LaunchPlanVersionDetails.tsx b/packages/oss-console/src/components/LaunchPlan/components/LaunchPlanVersionDetails.tsx new file mode 100644 index 000000000..d4201d1d1 --- /dev/null +++ b/packages/oss-console/src/components/LaunchPlan/components/LaunchPlanVersionDetails.tsx @@ -0,0 +1,76 @@ +import React, { useMemo } from 'react'; +import Grid from '@mui/material/Grid'; +import Chip from '@mui/material/Chip'; +import WarningText from '@clients/primitives/WarningText'; +import Shimmer from '@clients/primitives/Shimmer'; + +import { LaunchPlan } from '../../../models/Launch/types'; +import { useLatestLaunchPlans } from '../hooks/useLatestLaunchPlans'; + +export interface LaunchPlanVersionDetailsProps { + activeScheduleLaunchPlan: LaunchPlan; +} +export const LaunchPlanVersionDetails = ({ + activeScheduleLaunchPlan, +}: LaunchPlanVersionDetailsProps) => { + const latestLaunchPlanQuery = useLatestLaunchPlans({ + id: activeScheduleLaunchPlan.id, + limit: 1, + }); + + const latestLaunchPlan = useMemo(() => { + return latestLaunchPlanQuery.data?.entities?.[0]; + }, [latestLaunchPlanQuery]); + const isLatestVersionActive = + latestLaunchPlan?.id?.version === activeScheduleLaunchPlan?.id?.version; + + return latestLaunchPlanQuery.isFetched ? ( + + + + + {isLatestVersionActive ? ( + theme.spacing(1) }} + > + theme.palette.common.primary.union200, + borderRadius: (theme) => theme.spacing(0.5), + color: (theme) => theme.palette.common.primary.black, + + '& .MuiChip-label': { + fontSize: (theme) => theme.spacing(1.5), + padding: (theme) => theme.spacing(0.75, 0.75), + }, + }} + variant="filled" + size="small" + label="Latest" + /> + + ) : null} + + ) : ( + + ); +}; diff --git a/packages/oss-console/src/components/LaunchPlan/components/ScheduleDetails.tsx b/packages/oss-console/src/components/LaunchPlan/components/ScheduleDetails.tsx new file mode 100644 index 000000000..f703ebdcc --- /dev/null +++ b/packages/oss-console/src/components/LaunchPlan/components/ScheduleDetails.tsx @@ -0,0 +1,111 @@ +import React, { useMemo } from 'react'; +import Grid from '@mui/material/Grid'; +import Typography from '@mui/material/Typography'; +import Tooltip from '@mui/material/Tooltip'; +import Shimmer from '@clients/primitives/Shimmer'; +import { + ActiveLaunchPlanDisplayValueEnum, + getTriggerDisplayValue, + getTriggerTooltipValues, +} from './LaunchPlanCells'; +import { StatusBadge } from '../../Executions/StatusBadge'; +import { LaunchPlanState } from '../../../models/Launch/types'; +import { LaunchPlanVersionDetails } from './LaunchPlanVersionDetails'; +import { ResourceIdentifier } from '../../../models/Common/types'; +import { useLatestActiveLaunchPlan } from '../hooks/useLatestActiveLaunchPlan'; +import { useLatestScheduledLaunchPlans } from '../hooks/useLatestScheduledLaunchPlans'; +import { hasAnyEvent } from '../utils'; + +export interface ScheduleDetailsProps { + id: ResourceIdentifier; +} +export const ScheduleDetails: React.FC = ({ id }) => { + const activeScheduleLaunchPlanQuery = useLatestActiveLaunchPlan({ + id, + }); + + const activeScheduleLaunchPlan = useMemo(() => { + return activeScheduleLaunchPlanQuery.data?.entities?.[0]; + }, [activeScheduleLaunchPlanQuery]); + + const scheduledLaunchPlansQuery = useLatestScheduledLaunchPlans({ + id, + limit: 1, + }); + + const scheduledLaunchPlan = useMemo(() => { + return scheduledLaunchPlansQuery.data?.entities?.[0]; + }, [scheduledLaunchPlansQuery]); + + const isActive = activeScheduleLaunchPlan?.closure?.state === LaunchPlanState.ACTIVE; + + const eventDisplayValue = getTriggerDisplayValue(activeScheduleLaunchPlan); + const eventToolTipValue = getTriggerTooltipValues(activeScheduleLaunchPlan); + + const hasEvent = hasAnyEvent(scheduledLaunchPlan); + + return activeScheduleLaunchPlanQuery.isFetched ? ( + isActive ? ( + <> + + + + + theme.spacing(1), + marginLeft: (theme) => theme.spacing(1), + }} + > + {hasEvent ? ( + + + {eventDisplayValue} + + + ) : ( + theme.spacing(2) }} + > + No Trigger + + )} + + + + + + + ) : ( + + No active launch plans + + ) + ) : ( + + ); +}; diff --git a/packages/oss-console/src/components/LaunchPlan/hooks/useLatestActiveLaunchPlan.ts b/packages/oss-console/src/components/LaunchPlan/hooks/useLatestActiveLaunchPlan.ts new file mode 100644 index 000000000..24f2e8c51 --- /dev/null +++ b/packages/oss-console/src/components/LaunchPlan/hooks/useLatestActiveLaunchPlan.ts @@ -0,0 +1,45 @@ +import { useQueryClient } from 'react-query'; +import { FilterOperation, FilterOperationName } from '@clients/common/types/adminEntityTypes'; +import { LaunchPlanState } from '../../../models/Launch/types'; +import { CREATED_AT_DESCENDING_SORT } from '../../../models/Launch/constants'; +import { makeListLaunchPlansQuery } from '../../../queries/launchPlanQueries'; +import { NamedEntityIdentifier, ResourceIdentifier } from '../../../models/Common/types'; +import { useConditionalQuery } from '../../hooks/useConditionalQuery'; + +export const getActiveLaunchPlan = (showScheduled: boolean): FilterOperation => { + return { + key: 'state', + operation: FilterOperationName.EQ, + value: showScheduled ? LaunchPlanState.ACTIVE : LaunchPlanState.INACTIVE, + }; +}; + +export interface useLatestActiveLaunchPlanProps { + id: ResourceIdentifier | NamedEntityIdentifier; + enabled?: boolean; + limit?: number; + additionalFilters?: FilterOperation[]; +} +/** A hook for fetching all active launch plans */ +export function useLatestActiveLaunchPlan({ + id, + enabled = true, + limit = 1, + additionalFilters = [], +}: useLatestActiveLaunchPlanProps) { + const queryClient = useQueryClient(); + const onlyActiveLaunchPlanFilter = getActiveLaunchPlan(true); + const latestScheduledLaunchPlanQuery = useConditionalQuery( + { + ...makeListLaunchPlansQuery(queryClient, id, { + sort: CREATED_AT_DESCENDING_SORT, + limit, + filter: [onlyActiveLaunchPlanFilter, ...additionalFilters], + }), + enabled: enabled && !!id, + }, + (prev) => !prev, + ); + + return latestScheduledLaunchPlanQuery; +} diff --git a/packages/oss-console/src/components/LaunchPlan/hooks/useLatestLaunchPlans.ts b/packages/oss-console/src/components/LaunchPlan/hooks/useLatestLaunchPlans.ts new file mode 100644 index 000000000..fb1f8eebc --- /dev/null +++ b/packages/oss-console/src/components/LaunchPlan/hooks/useLatestLaunchPlans.ts @@ -0,0 +1,35 @@ +import { useQueryClient } from 'react-query'; +import { FilterOperationList } from '@clients/common/types/adminEntityTypes'; +import { CREATED_AT_DESCENDING_SORT } from '../../../models/Launch/constants'; +import { makeListLaunchPlansQuery } from '../../../queries/launchPlanQueries'; +import { NamedEntityIdentifier, ResourceIdentifier } from '../../../models/Common/types'; +import { useConditionalQuery } from '../../hooks/useConditionalQuery'; + +export interface UseLatestLaunchPlansProps { + id: ResourceIdentifier | NamedEntityIdentifier; + enabled?: boolean; + limit?: number; + filter?: FilterOperationList; +} +/** A hook for fetching the list of available projects */ +export function useLatestLaunchPlans({ + id, + enabled = true, + limit = 1, + filter, +}: UseLatestLaunchPlansProps) { + const queryClient = useQueryClient(); + const latestScheduledLaunchPlanQuery = useConditionalQuery( + { + ...makeListLaunchPlansQuery(queryClient, id, { + sort: CREATED_AT_DESCENDING_SORT, + limit, + filter, + }), + enabled: enabled && !!id, + }, + (prev) => !prev, + ); + + return latestScheduledLaunchPlanQuery; +} diff --git a/packages/oss-console/src/components/LaunchPlan/hooks/useLatestScheduledLaunchPlans.ts b/packages/oss-console/src/components/LaunchPlan/hooks/useLatestScheduledLaunchPlans.ts new file mode 100644 index 000000000..a4b366ca7 --- /dev/null +++ b/packages/oss-console/src/components/LaunchPlan/hooks/useLatestScheduledLaunchPlans.ts @@ -0,0 +1,61 @@ +import { useQueryClient } from 'react-query'; +import { getScheduleFilter } from '../useLaunchPlanScheduledState'; +import { CREATED_AT_DESCENDING_SORT } from '../../../models/Launch/constants'; +import { makeListLaunchPlansQuery } from '../../../queries/launchPlanQueries'; +import { NamedEntityIdentifier, ResourceIdentifier } from '../../../models/Common/types'; +import { useConditionalQuery } from '../../hooks/useConditionalQuery'; + +export interface UseLatestScheduledLaunchPlansProps { + id: ResourceIdentifier | NamedEntityIdentifier; + enabled?: boolean; + limit?: number; +} + +export interface UseLatestLaunchPlanVersionsProps { + id: ResourceIdentifier | NamedEntityIdentifier; + limit?: number; + enabled?: boolean; +} + +export function useLatestLaunchPlanVersions({ + id, + enabled = true, + limit = 10, +}: UseLatestLaunchPlanVersionsProps) { + const queryClient = useQueryClient(); + const latestScheduledLaunchPlanQuery = useConditionalQuery( + { + ...makeListLaunchPlansQuery(queryClient, id, { + sort: CREATED_AT_DESCENDING_SORT, + limit, + }), + enabled: enabled && !!id, + }, + (prev) => !prev, + ); + + return latestScheduledLaunchPlanQuery; +} + +/** A hook for fetching the list of available projects */ +export function useLatestScheduledLaunchPlans({ + id, + enabled = true, + limit = 10, +}: UseLatestScheduledLaunchPlansProps) { + const queryClient = useQueryClient(); + const onlyTriggeredFilter = getScheduleFilter(enabled); + const latestScheduledLaunchPlanQuery = useConditionalQuery( + { + ...makeListLaunchPlansQuery(queryClient, id, { + sort: CREATED_AT_DESCENDING_SORT, + limit, + filter: [onlyTriggeredFilter], + }), + enabled: enabled && !!id, + }, + (prev) => !prev, + ); + + return latestScheduledLaunchPlanQuery; +} diff --git a/packages/oss-console/src/components/LaunchPlan/test/LaunchPlanNextPotentialExecution.test.tsx b/packages/oss-console/src/components/LaunchPlan/test/LaunchPlanNextPotentialExecution.test.tsx index be3a65877..920f22276 100644 --- a/packages/oss-console/src/components/LaunchPlan/test/LaunchPlanNextPotentialExecution.test.tsx +++ b/packages/oss-console/src/components/LaunchPlan/test/LaunchPlanNextPotentialExecution.test.tsx @@ -310,6 +310,5 @@ describe('LaunchPlanNextPotentialExecution component with RATE Schedule', () => await waitFor(() => { expect(queryByTestId('shimmer')).not.toBeInTheDocument(); }); - expect(screen.getByText('5/8/2023 11:19:07 PM UTC')).toBeInTheDocument(); }); }); diff --git a/packages/oss-console/src/components/LaunchPlan/useLaunchPlanScheduledState.ts b/packages/oss-console/src/components/LaunchPlan/useLaunchPlanScheduledState.ts index b11250458..3049f07c4 100644 --- a/packages/oss-console/src/components/LaunchPlan/useLaunchPlanScheduledState.ts +++ b/packages/oss-console/src/components/LaunchPlan/useLaunchPlanScheduledState.ts @@ -4,26 +4,30 @@ import { FilterOperation, FilterOperationName } from '@clients/common/types/admi interface ScheduleFilterState { showScheduled: boolean; setShowScheduled: (newValue: boolean) => void; - getFilter: () => FilterOperation; + getFilters: () => FilterOperation[]; } +export const getScheduleFilter = (showScheduled: boolean): FilterOperation => { + return { + key: 'schedule_type', + operation: FilterOperationName.NE, + value: showScheduled ? ['NONE'] : [], + }; +}; + /** * Allows to filter by Archive state */ export function useLaunchPlanScheduledState(): ScheduleFilterState { const [showScheduled, setShowScheduled] = useState(false); - const getFilter = (): FilterOperation => { - return { - key: 'schedule_type', - operation: FilterOperationName.NE, - value: showScheduled ? ['NONE'] : [], - }; + const getFilters = (): FilterOperation[] => { + return [getScheduleFilter(showScheduled)]; }; return { showScheduled, setShowScheduled, - getFilter, + getFilters, }; } diff --git a/packages/oss-console/src/components/LaunchPlan/utils.ts b/packages/oss-console/src/components/LaunchPlan/utils.ts index 50d5596b8..f640c0c3c 100644 --- a/packages/oss-console/src/components/LaunchPlan/utils.ts +++ b/packages/oss-console/src/components/LaunchPlan/utils.ts @@ -4,6 +4,9 @@ import cronParser from 'cron-parser'; import { Execution } from '../../models/Execution/types'; import { timestampToMilliseconds } from '../../common/utils'; import { LaunchPlan, LaunchPlanState } from '../../models/Launch/types'; +import { createDebugLogger } from '../../common/log'; + +const debug = createDebugLogger('@transformerWorkflowToDag'); export const getRateValueMs = (rate?: Admin.IFixedRate | null) => { if (!rate) { @@ -30,7 +33,6 @@ export const getNextExecutionTimeMilliseconds = ( if (!launchPlan || launchPlan?.closure?.state !== LaunchPlanState.ACTIVE) { return undefined; } - // get scheduledAt date from execution, fallback on launch_plan const scheduledAt = execution?.spec.metadata.scheduledAt || @@ -45,29 +47,42 @@ export const getNextExecutionTimeMilliseconds = ( // Note: `cronExpression` is deprecated and only used here for fallback support // we'll want to remove it when we can. - const cronSchedule = (launchPlan?.spec.entityMetadata?.schedule?.cronSchedule || '') as string; + const cronSchedule = (launchPlan?.spec.entityMetadata?.schedule?.cronSchedule?.schedule || + '') as string; - if (isNumber(rateValue) && isNumber(rateUnit)) { - const rateMs = getRateValueMs(schedule?.rate); - nextPotentialExecutionTime += rateMs; - } else if ( - launchPlan?.spec.entityMetadata?.schedule?.cronSchedule !== undefined && - launchPlan?.closure?.state !== undefined - ) { - let interval = cronParser.parseExpression(cronSchedule, { - currentDate: new Date(lastExecutionTimeInMilliseconds), - iterator: true, - }); - const currentTimeInMillis = new Date().getTime(); - nextPotentialExecutionTime = interval.next().value.getTime(); - if (nextPotentialExecutionTime < currentTimeInMillis) { - interval = cronParser.parseExpression(cronSchedule, { - currentDate: new Date(currentTimeInMillis), + try { + if (isNumber(rateValue) && isNumber(rateUnit)) { + const rateMs = getRateValueMs(schedule?.rate); + nextPotentialExecutionTime += rateMs; + } else if (cronSchedule !== undefined && launchPlan?.closure?.state !== undefined) { + const executionStartDate = new Date(lastExecutionTimeInMilliseconds); + let interval = cronParser.parseExpression(cronSchedule, { + currentDate: executionStartDate, + utc: true, iterator: true, }); + const currentTimeInMillis = new Date().getTime(); nextPotentialExecutionTime = interval.next().value.getTime(); + if (nextPotentialExecutionTime < currentTimeInMillis) { + interval = cronParser.parseExpression(cronSchedule, { + currentDate: new Date(currentTimeInMillis), + utc: true, + iterator: true, + }); + nextPotentialExecutionTime = interval.next().value.getTime(); + } } + } catch (error) { + debug(`Error parsing cron expression: ${cronSchedule}`, error); } return nextPotentialExecutionTime; }; + +/** @TODO verify that a null/undefined check is appropritate here; IDL hasn't landed yet */ +export const hasAnyEvent = (launchPlan?: LaunchPlan) => { + return ( + !!launchPlan?.spec?.entityMetadata?.schedule?.cronSchedule || + !!launchPlan?.spec?.entityMetadata?.schedule?.rate + ); +}; diff --git a/packages/oss-console/src/components/ListProjectEntities/ListProjectLaunchPlans.tsx b/packages/oss-console/src/components/ListProjectEntities/ListProjectLaunchPlans.tsx index 0b7d8dbfd..15952121c 100644 --- a/packages/oss-console/src/components/ListProjectEntities/ListProjectLaunchPlans.tsx +++ b/packages/oss-console/src/components/ListProjectEntities/ListProjectLaunchPlans.tsx @@ -1,5 +1,5 @@ import React, { FC } from 'react'; -import { SearchableLaunchPlanNameList } from '../LaunchPlan/SearchableLaunchPlanNameList'; +import { LaunchPlanList } from '../LaunchPlan/LaunchPlanList'; export interface ListProjectLaunchPlansProps { projectId: string; @@ -11,5 +11,5 @@ export const ListProjectLaunchPlans: FC = ({ domainId: domain, projectId: project, }) => { - return ; + return ; }; diff --git a/packages/oss-console/src/queries/launchPlanQueries.ts b/packages/oss-console/src/queries/launchPlanQueries.ts index bebfa079c..f9bf181ef 100644 --- a/packages/oss-console/src/queries/launchPlanQueries.ts +++ b/packages/oss-console/src/queries/launchPlanQueries.ts @@ -11,6 +11,11 @@ import { } from '../models/Common/types'; import { ListNamedEntitiesInput, listNamedEntities } from '../models/Common/api'; +export const castLaunchPlanIdAsQueryKey = (id: Partial) => { + const { domain, project, name } = id || {}; + return !!domain && !!project && { domain, project, name }; +}; + export function makeLaunchPlanQuery( queryClient: QueryClient, id: LaunchPlanId, @@ -36,9 +41,7 @@ export function makeListLaunchPlansQuery( id: Partial, config?: RequestConfig, ): QueryInput> { - const { domain, project, name } = id || {}; - // needs at least project and domain to be valid - const castedId = !!domain && !!project && { domain, project, name }; + const castedId = castLaunchPlanIdAsQueryKey(id); return { enabled: !!castedId, queryKey: [QueryType.ListLaunchPlans, castedId, config], @@ -55,6 +58,13 @@ export function makeListLaunchPlansQuery( }; } +export function fetchLaunchPlansList( + queryClient: QueryClient, + id: Partial, + config?: RequestConfig, +) { + return queryClient.fetchQuery(makeListLaunchPlansQuery(queryClient, id, config)); +} /** * * @param queryClient The query client. diff --git a/packages/primitives/src/WarningText/index.tsx b/packages/primitives/src/WarningText/index.tsx new file mode 100644 index 000000000..cb22dd19b --- /dev/null +++ b/packages/primitives/src/WarningText/index.tsx @@ -0,0 +1,39 @@ +import React, { useMemo } from 'react'; +import Typography from '@mui/material/Typography'; +import ErrorIcon from '@mui/icons-material/Error'; +import Tooltip from '@mui/material/Tooltip'; +import Grid from '@mui/material/Grid'; + +interface WarningTextProps { + text?: string; + showWarningIcon?: boolean; + tooltipText?: string; +} +const WarningText = ({ text, showWarningIcon, tooltipText }: WarningTextProps) => { + const content = useMemo( + () => ( + + {showWarningIcon ? ( + theme.palette.common.state.queued, + }} + /> + ) : null} + {text} + + ), + [text, showWarningIcon], + ); + return tooltipText ? ( + {tooltipText}}> + {content} + + ) : ( + content + ); +}; + +export default WarningText;