From 824818d8c95dfca1489963cdd3ec8ac2c866cf45 Mon Sep 17 00:00:00 2001 From: mduda Date: Thu, 21 Aug 2025 15:22:31 +0200 Subject: [PATCH] upcoming: [DPS-34039] Add actions in Streams list --- ...r-12645-upcoming-features-1754489643667.md | 5 + packages/api-v4/src/account/types.ts | 2 + packages/api-v4/src/datastream/streams.ts | 28 ++- packages/api-v4/src/datastream/types.ts | 14 +- ...r-12645-upcoming-features-1754489744402.md | 5 + .../features/DataStream/DataStreamLanding.tsx | 7 +- .../LabelValue.tsx} | 33 +-- .../src/features/DataStream/Shared/types.ts | 22 +- .../Streams/StreamActionMenu.test.tsx | 52 +++++ .../DataStream/Streams/StreamActionMenu.tsx | 46 ++++ .../StreamCreateSubmitBar.test.tsx | 38 ---- ...ationLinodeObjectStorageDetailsSummary.tsx | 26 --- .../Streams/StreamCreate/StreamCreate.tsx | 120 ----------- .../StreamCreateGeneralInfo.test.tsx | 54 ----- .../StreamFormCheckoutBar.styles.ts} | 0 .../StreamFormCheckoutBar.test.tsx} | 12 +- .../CheckoutBar/StreamFormCheckoutBar.tsx} | 8 +- .../CheckoutBar/StreamFormSubmitBar.test.tsx | 60 ++++++ .../CheckoutBar/StreamFormSubmitBar.tsx} | 25 ++- ...LinodeObjectStorageDetailsSummary.test.tsx | 29 ++- ...ationLinodeObjectStorageDetailsSummary.tsx | 34 +++ .../Delivery/StreamFormDelivery.test.tsx} | 10 +- .../Delivery/StreamFormDelivery.tsx} | 23 +- .../Streams/StreamForm/StreamCreate.tsx | 86 ++++++++ .../Streams/StreamForm/StreamEdit.test.tsx | 84 ++++++++ .../Streams/StreamForm/StreamEdit.tsx | 131 ++++++++++++ .../Streams/StreamForm/StreamForm.tsx | 52 +++++ .../StreamFormClusters.test.tsx} | 14 +- .../StreamFormClusters.tsx} | 33 ++- .../StreamFormClustersData.ts} | 2 +- .../StreamForm/StreamFormGeneralInfo.test.tsx | 101 +++++++++ .../StreamFormGeneralInfo.tsx} | 35 +-- .../streamCreateLazyRoute.ts | 2 +- .../Streams/StreamForm/streamEditLazyRoute.ts | 9 + .../{StreamCreate => StreamForm}/types.ts | 6 +- .../Streams/StreamTableRow.test.tsx | 54 +++++ .../DataStream/Streams/StreamTableRow.tsx | 25 ++- .../Streams/StreamsLanding.test.tsx | 202 ++++++++++++++---- .../DataStream/Streams/StreamsLanding.tsx | 97 ++++++++- .../features/DataStream/dataStreamUtils.ts | 40 +++- .../features/Events/factories/datastream.tsx | 16 ++ .../src/mocks/presets/crud/datastream.ts | 11 +- .../mocks/presets/crud/handlers/datastream.ts | 78 +++++++ .../manager/src/routes/datastream/index.ts | 21 +- ...r-12645-upcoming-features-1754489801598.md | 5 + .../queries/src/datastreams/datastream.ts | 43 ++++ packages/validation/src/datastream.schema.ts | 35 +-- 47 files changed, 1418 insertions(+), 417 deletions(-) create mode 100644 packages/api-v4/.changeset/pr-12645-upcoming-features-1754489643667.md create mode 100644 packages/manager/.changeset/pr-12645-upcoming-features-1754489744402.md rename packages/manager/src/features/DataStream/{Streams/StreamCreate/Delivery/DestinationDetail.tsx => Shared/LabelValue.tsx} (52%) create mode 100644 packages/manager/src/features/DataStream/Streams/StreamActionMenu.test.tsx create mode 100644 packages/manager/src/features/DataStream/Streams/StreamActionMenu.tsx delete mode 100644 packages/manager/src/features/DataStream/Streams/StreamCreate/CheckoutBar/StreamCreateSubmitBar.test.tsx delete mode 100644 packages/manager/src/features/DataStream/Streams/StreamCreate/Delivery/DestinationLinodeObjectStorageDetailsSummary.tsx delete mode 100644 packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreate.tsx delete mode 100644 packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreateGeneralInfo.test.tsx rename packages/manager/src/features/DataStream/Streams/{StreamCreate/CheckoutBar/StreamCreateCheckoutBar.styles.ts => StreamForm/CheckoutBar/StreamFormCheckoutBar.styles.ts} (100%) rename packages/manager/src/features/DataStream/Streams/{StreamCreate/CheckoutBar/StreamCreateCheckoutBar.test.tsx => StreamForm/CheckoutBar/StreamFormCheckoutBar.test.tsx} (85%) rename packages/manager/src/features/DataStream/Streams/{StreamCreate/CheckoutBar/StreamCreateCheckoutBar.tsx => StreamForm/CheckoutBar/StreamFormCheckoutBar.tsx} (86%) create mode 100644 packages/manager/src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormSubmitBar.test.tsx rename packages/manager/src/features/DataStream/Streams/{StreamCreate/CheckoutBar/StreamCreateSubmitBar.tsx => StreamForm/CheckoutBar/StreamFormSubmitBar.tsx} (62%) rename packages/manager/src/features/DataStream/Streams/{StreamCreate => StreamForm}/Delivery/DestinationLinodeObjectStorageDetailsSummary.test.tsx (78%) create mode 100644 packages/manager/src/features/DataStream/Streams/StreamForm/Delivery/DestinationLinodeObjectStorageDetailsSummary.tsx rename packages/manager/src/features/DataStream/Streams/{StreamCreate/Delivery/StreamCreateDelivery.test.tsx => StreamForm/Delivery/StreamFormDelivery.test.tsx} (96%) rename packages/manager/src/features/DataStream/Streams/{StreamCreate/Delivery/StreamCreateDelivery.tsx => StreamForm/Delivery/StreamFormDelivery.tsx} (86%) create mode 100644 packages/manager/src/features/DataStream/Streams/StreamForm/StreamCreate.tsx create mode 100644 packages/manager/src/features/DataStream/Streams/StreamForm/StreamEdit.test.tsx create mode 100644 packages/manager/src/features/DataStream/Streams/StreamForm/StreamEdit.tsx create mode 100644 packages/manager/src/features/DataStream/Streams/StreamForm/StreamForm.tsx rename packages/manager/src/features/DataStream/Streams/{StreamCreate/StreamCreateClusters.test.tsx => StreamForm/StreamFormClusters.test.tsx} (96%) rename packages/manager/src/features/DataStream/Streams/{StreamCreate/StreamCreateClusters.tsx => StreamForm/StreamFormClusters.tsx} (90%) rename packages/manager/src/features/DataStream/Streams/{StreamCreate/StreamCreateClustersData.ts => StreamForm/StreamFormClustersData.ts} (92%) create mode 100644 packages/manager/src/features/DataStream/Streams/StreamForm/StreamFormGeneralInfo.test.tsx rename packages/manager/src/features/DataStream/Streams/{StreamCreate/StreamCreateGeneralInfo.tsx => StreamForm/StreamFormGeneralInfo.tsx} (65%) rename packages/manager/src/features/DataStream/Streams/{StreamCreate => StreamForm}/streamCreateLazyRoute.ts (90%) create mode 100644 packages/manager/src/features/DataStream/Streams/StreamForm/streamEditLazyRoute.ts rename packages/manager/src/features/DataStream/Streams/{StreamCreate => StreamForm}/types.ts (71%) create mode 100644 packages/manager/src/features/DataStream/Streams/StreamTableRow.test.tsx create mode 100644 packages/queries/.changeset/pr-12645-upcoming-features-1754489801598.md diff --git a/packages/api-v4/.changeset/pr-12645-upcoming-features-1754489643667.md b/packages/api-v4/.changeset/pr-12645-upcoming-features-1754489643667.md new file mode 100644 index 00000000000..5d8062cea97 --- /dev/null +++ b/packages/api-v4/.changeset/pr-12645-upcoming-features-1754489643667.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Upcoming Features +--- + +DELETE, PUT API endpoints for Streams ([#12645](https://github.com/linode/manager/pull/12645)) diff --git a/packages/api-v4/src/account/types.ts b/packages/api-v4/src/account/types.ts index 36def13c55b..079952d2d79 100644 --- a/packages/api-v4/src/account/types.ts +++ b/packages/api-v4/src/account/types.ts @@ -470,6 +470,8 @@ export const EventActionKeys = [ 'stackscript_revise', 'stackscript_update', 'stream_create', + 'stream_delete', + 'stream_update', 'subnet_create', 'subnet_delete', 'subnet_update', diff --git a/packages/api-v4/src/datastream/streams.ts b/packages/api-v4/src/datastream/streams.ts index 2e9ef2229cd..621bafa7247 100644 --- a/packages/api-v4/src/datastream/streams.ts +++ b/packages/api-v4/src/datastream/streams.ts @@ -1,4 +1,4 @@ -import { createStreamSchema } from '@linode/validation'; +import { createStreamSchema, updateStreamSchema } from '@linode/validation'; import { BETA_API_ROOT } from '../constants'; import Request, { @@ -10,7 +10,7 @@ import Request, { } from '../request'; import type { Filter, ResourcePage as Page, Params } from '../types'; -import type { CreateStreamPayload, Stream } from './types'; +import type { CreateStreamPayload, Stream, UpdateStreamPayload } from './types'; /** * Returns all the information about a specified Stream. @@ -47,3 +47,27 @@ export const createStream = (data: CreateStreamPayload) => setURL(`${BETA_API_ROOT}/monitor/streams`), setMethod('POST'), ); + +/** + * Updates a Stream. + * + * @param streamId { number } The ID of the Stream. + * @param data { object } Options for type, status, etc. + */ +export const updateStream = (streamId: number, data: UpdateStreamPayload) => + Request( + setData(data, updateStreamSchema), + setURL(`${BETA_API_ROOT}/monitor/streams/${encodeURIComponent(streamId)}`), + setMethod('PUT'), + ); + +/** + * Deletes a Stream. + * + * @param streamId { number } The ID of the Stream. + */ +export const deleteStream = (streamId: number) => + Request<{}>( + setURL(`${BETA_API_ROOT}/monitor/streams/${encodeURIComponent(streamId)}`), + setMethod('DELETE'), + ); diff --git a/packages/api-v4/src/datastream/types.ts b/packages/api-v4/src/datastream/types.ts index 76f50d5e919..523509524e9 100644 --- a/packages/api-v4/src/datastream/types.ts +++ b/packages/api-v4/src/datastream/types.ts @@ -103,12 +103,24 @@ interface CustomHeader { export interface CreateStreamPayload { destinations: number[]; - details?: StreamDetails; + details: StreamDetails; label: string; status?: StreamStatus; type: StreamType; } +export interface UpdateStreamPayload { + destinations: number[]; + details: StreamDetails; + label: string; + status: StreamStatus; + type: StreamType; +} + +export interface UpdateStreamPayloadWithId extends UpdateStreamPayload { + id: number; +} + export interface CreateDestinationPayload { details: CustomHTTPsDetails | LinodeObjectStorageDetails; label: string; diff --git a/packages/manager/.changeset/pr-12645-upcoming-features-1754489744402.md b/packages/manager/.changeset/pr-12645-upcoming-features-1754489744402.md new file mode 100644 index 00000000000..2347c3716ba --- /dev/null +++ b/packages/manager/.changeset/pr-12645-upcoming-features-1754489744402.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +DataStreams: add actions with handlers in Streams list, add Edit Stream component ([#12645](https://github.com/linode/manager/pull/12645)) diff --git a/packages/manager/src/features/DataStream/DataStreamLanding.tsx b/packages/manager/src/features/DataStream/DataStreamLanding.tsx index 2750bfe465c..5480d990ee6 100644 --- a/packages/manager/src/features/DataStream/DataStreamLanding.tsx +++ b/packages/manager/src/features/DataStream/DataStreamLanding.tsx @@ -1,7 +1,10 @@ import * as React from 'react'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; -import { LandingHeader } from 'src/components/LandingHeader'; +import { + LandingHeader, + type LandingHeaderProps, +} from 'src/components/LandingHeader'; import { ProductInformationBanner } from 'src/components/ProductInformationBanner/ProductInformationBanner'; import { SuspenseLoader } from 'src/components/SuspenseLoader'; import { SafeTabPanel } from 'src/components/Tabs/SafeTabPanel'; @@ -23,7 +26,7 @@ const Streams = React.lazy(() => ); export const DataStreamLanding = React.memo(() => { - const landingHeaderProps = { + const landingHeaderProps: LandingHeaderProps = { breadcrumbProps: { pathname: '/datastream', }, diff --git a/packages/manager/src/features/DataStream/Streams/StreamCreate/Delivery/DestinationDetail.tsx b/packages/manager/src/features/DataStream/Shared/LabelValue.tsx similarity index 52% rename from packages/manager/src/features/DataStream/Streams/StreamCreate/Delivery/DestinationDetail.tsx rename to packages/manager/src/features/DataStream/Shared/LabelValue.tsx index 5e4fb9a05a6..ddb57224006 100644 --- a/packages/manager/src/features/DataStream/Streams/StreamCreate/Delivery/DestinationDetail.tsx +++ b/packages/manager/src/features/DataStream/Shared/LabelValue.tsx @@ -2,30 +2,37 @@ import { Box, Typography } from '@linode/ui'; import { styled, useTheme } from '@mui/material/styles'; import * as React from 'react'; -type DestinationDetailProps = { +type LabelValueProps = { + compact?: boolean; + 'data-testid'?: string; label: string; value: string; }; -export const DestinationDetail = (props: DestinationDetailProps) => { - const { label, value } = props; +export const LabelValue = (props: LabelValueProps) => { + const { compact = false, label, value, 'data-testid': dataTestId } = props; const theme = useTheme(); return ( - - {label}: - {value} + + + {label}: + + {value} ); }; -const StyledLabel = styled(Typography, { - label: 'StyledLabel', -})(({ theme }) => ({ - font: theme.font.bold, - width: 160, -})); - const StyledValue = styled(Box, { label: 'StyledValue', })(({ theme }) => ({ diff --git a/packages/manager/src/features/DataStream/Shared/types.ts b/packages/manager/src/features/DataStream/Shared/types.ts index 57d8fb68233..e0e4a08a142 100644 --- a/packages/manager/src/features/DataStream/Shared/types.ts +++ b/packages/manager/src/features/DataStream/Shared/types.ts @@ -1,18 +1,15 @@ -import { destinationType, streamStatus } from '@linode/api-v4'; +import { destinationType, streamStatus, streamType } from '@linode/api-v4'; import type { CreateDestinationPayload } from '@linode/api-v4'; -export interface DestinationTypeOption { - label: string; - value: string; -} +export type FormMode = 'create' | 'edit'; export interface LabelValueOption { label: string; value: string; } -export const destinationTypeOptions: DestinationTypeOption[] = [ +export const destinationTypeOptions: LabelValueOption[] = [ { value: destinationType.CustomHttps, label: 'Custom HTTPS', @@ -23,7 +20,18 @@ export const destinationTypeOptions: DestinationTypeOption[] = [ }, ]; -export const streamStatusOptions = [ +export const streamTypeOptions: LabelValueOption[] = [ + { + value: streamType.AuditLogs, + label: 'Audit Logs', + }, + { + value: streamType.LKEAuditLogs, + label: 'Kubernetes Audit Logs', + }, +]; + +export const streamStatusOptions: LabelValueOption[] = [ { value: streamStatus.Active, label: 'Enabled', diff --git a/packages/manager/src/features/DataStream/Streams/StreamActionMenu.test.tsx b/packages/manager/src/features/DataStream/Streams/StreamActionMenu.test.tsx new file mode 100644 index 00000000000..58f84216c9a --- /dev/null +++ b/packages/manager/src/features/DataStream/Streams/StreamActionMenu.test.tsx @@ -0,0 +1,52 @@ +import { screen } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; +import * as React from 'react'; + +import { streamFactory } from 'src/factories/datastream'; +import { StreamActionMenu } from 'src/features/DataStream/Streams/StreamActionMenu'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import type { StreamStatus } from '@linode/api-v4'; + +const fakeHandler = vi.fn(); + +describe('Stream action menu', () => { + const renderComponent = (status: StreamStatus) => { + renderWithTheme( + + ); + }; + + describe('when stream is active', () => { + it('should include proper Stream actions', async () => { + renderComponent('active'); + + const actionMenuButton = screen.queryByLabelText(/^Action menu for/)!; + + await userEvent.click(actionMenuButton); + + for (const action of ['Edit', 'Disable', 'Delete']) { + expect(screen.getByText(action)).toBeVisible(); + } + }); + }); + + describe('when stream is inactive', () => { + it('should include proper Stream actions', async () => { + renderComponent('inactive'); + + const actionMenuButton = screen.queryByLabelText(/^Action menu for/)!; + + await userEvent.click(actionMenuButton); + + for (const action of ['Edit', 'Enable', 'Delete']) { + expect(screen.getByText(action)).toBeVisible(); + } + }); + }); +}); diff --git a/packages/manager/src/features/DataStream/Streams/StreamActionMenu.tsx b/packages/manager/src/features/DataStream/Streams/StreamActionMenu.tsx new file mode 100644 index 00000000000..a5d061c54cf --- /dev/null +++ b/packages/manager/src/features/DataStream/Streams/StreamActionMenu.tsx @@ -0,0 +1,46 @@ +import { type Stream, streamStatus } from '@linode/api-v4'; +import * as React from 'react'; + +import { ActionMenu } from 'src/components/ActionMenu/ActionMenu'; + +export interface Handlers { + onDelete: (stream: Stream) => void; + onDisableOrEnable: (stream: Stream) => void; + onEdit: (stream: Stream) => void; +} + +interface StreamActionMenuProps extends Handlers { + stream: Stream; +} + +export const StreamActionMenu = (props: StreamActionMenuProps) => { + const { stream, onDelete, onDisableOrEnable, onEdit } = props; + + const menuActions = [ + { + onClick: () => { + onEdit(stream); + }, + title: 'Edit', + }, + { + onClick: () => { + onDisableOrEnable(stream); + }, + title: stream.status === streamStatus.Active ? 'Disable' : 'Enable', + }, + { + onClick: () => { + onDelete(stream); + }, + title: 'Delete', + }, + ]; + + return ( + + ); +}; diff --git a/packages/manager/src/features/DataStream/Streams/StreamCreate/CheckoutBar/StreamCreateSubmitBar.test.tsx b/packages/manager/src/features/DataStream/Streams/StreamCreate/CheckoutBar/StreamCreateSubmitBar.test.tsx deleted file mode 100644 index cc366229d01..00000000000 --- a/packages/manager/src/features/DataStream/Streams/StreamCreate/CheckoutBar/StreamCreateSubmitBar.test.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { destinationType } from '@linode/api-v4'; -import { screen } from '@testing-library/react'; -import React from 'react'; -import { describe, expect } from 'vitest'; - -import { StreamCreateSubmitBar } from 'src/features/DataStream/Streams/StreamCreate/CheckoutBar/StreamCreateSubmitBar'; -import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; - -describe('StreamCreateSubmitBar', () => { - const createStream = () => {}; - - const renderComponent = () => { - renderWithThemeAndHookFormContext({ - component: , - useFormOptions: { - defaultValues: { - destination: { type: destinationType.LinodeObjectStorage }, - }, - }, - }); - }; - - it('should render checkout bar with enabled checkout button', async () => { - renderComponent(); - const submitButton = screen.getByText('Create Stream'); - - expect(submitButton).toBeEnabled(); - }); - - it('should render Delivery summary with destination type', () => { - renderComponent(); - const deliveryTitle = screen.getByText('Delivery'); - const deliveryType = screen.getByText('Linode Object Storage'); - - expect(deliveryTitle).toBeInTheDocument(); - expect(deliveryType).toBeInTheDocument(); - }); -}); diff --git a/packages/manager/src/features/DataStream/Streams/StreamCreate/Delivery/DestinationLinodeObjectStorageDetailsSummary.tsx b/packages/manager/src/features/DataStream/Streams/StreamCreate/Delivery/DestinationLinodeObjectStorageDetailsSummary.tsx deleted file mode 100644 index 275a4dfb64c..00000000000 --- a/packages/manager/src/features/DataStream/Streams/StreamCreate/Delivery/DestinationLinodeObjectStorageDetailsSummary.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { useRegionsQuery } from '@linode/queries'; -import React from 'react'; - -import { DestinationDetail } from 'src/features/DataStream/Streams/StreamCreate/Delivery/DestinationDetail'; - -import type { LinodeObjectStorageDetails } from '@linode/api-v4'; - -export const DestinationLinodeObjectStorageDetailsSummary = ( - props: LinodeObjectStorageDetails -) => { - const { bucket_name, host, region, path } = props; - const { data: regions } = useRegionsQuery(); - - const regionValue = regions?.find(({ id }) => id === region)?.label || region; - - return ( - <> - - - - - - - - ); -}; diff --git a/packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreate.tsx b/packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreate.tsx deleted file mode 100644 index 3e689d3025a..00000000000 --- a/packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreate.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import { yupResolver } from '@hookform/resolvers/yup'; -import { destinationType, streamType } from '@linode/api-v4'; -import { useCreateStreamMutation } from '@linode/queries'; -import { omitProps, Stack } from '@linode/ui'; -import { createStreamAndDestinationFormSchema } from '@linode/validation'; -import Grid from '@mui/material/Grid'; -import { useNavigate } from '@tanstack/react-router'; -import * as React from 'react'; -import { FormProvider, useForm, useWatch } from 'react-hook-form'; - -import { DocumentTitleSegment } from 'src/components/DocumentTitle'; -import { LandingHeader } from 'src/components/LandingHeader'; -import { StreamCreateSubmitBar } from 'src/features/DataStream/Streams/StreamCreate/CheckoutBar/StreamCreateSubmitBar'; -import { StreamCreateDelivery } from 'src/features/DataStream/Streams/StreamCreate/Delivery/StreamCreateDelivery'; -import { sendCreateStreamEvent } from 'src/utilities/analytics/customEventAnalytics'; - -import { StreamCreateClusters } from './StreamCreateClusters'; -import { StreamCreateGeneralInfo } from './StreamCreateGeneralInfo'; - -import type { CreateStreamPayload } from '@linode/api-v4'; -import type { LandingHeaderProps } from 'src/components/LandingHeader'; -import type { - CreateStreamAndDestinationForm, - CreateStreamForm, -} from 'src/features/DataStream/Streams/StreamCreate/types'; - -export const StreamCreate = () => { - const { mutateAsync: createStream } = useCreateStreamMutation(); - const navigate = useNavigate(); - - const form = useForm({ - defaultValues: { - stream: { - type: streamType.AuditLogs, - details: {}, - }, - destination: { - type: destinationType.LinodeObjectStorage, - details: { - region: '', - }, - }, - }, - mode: 'onBlur', - resolver: yupResolver(createStreamAndDestinationFormSchema), - }); - - const { control, handleSubmit } = form; - const selectedStreamType = useWatch({ - control, - name: 'stream.type', - }); - - const landingHeaderProps: LandingHeaderProps = { - breadcrumbProps: { - pathname: '/datastream/streams/create', - crumbOverrides: [ - { - label: 'DataStream', - linkTo: '/datastream/streams', - position: 1, - }, - ], - }, - removeCrumbX: 2, - title: 'Create Stream', - }; - - const onSubmit = () => { - const { - stream: { label, type, destinations, details }, - } = form.getValues(); - const payload: CreateStreamForm = { - label, - type, - destinations, - details, - }; - - if (type === streamType.LKEAuditLogs && details) { - if (details.is_auto_add_all_clusters_enabled) { - payload.details = omitProps(details, ['cluster_ids']); - } else { - payload.details = omitProps(details, [ - 'is_auto_add_all_clusters_enabled', - ]); - } - } - - createStream(payload as CreateStreamPayload).then(() => { - sendCreateStreamEvent('Stream Create Page'); - navigate({ to: '/datastream/streams' }); - }); - }; - - return ( - <> - - - -
- - - - - {selectedStreamType === streamType.LKEAuditLogs && ( - - )} - - - - - - - -
-
- - ); -}; diff --git a/packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreateGeneralInfo.test.tsx b/packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreateGeneralInfo.test.tsx deleted file mode 100644 index dacb024ab50..00000000000 --- a/packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreateGeneralInfo.test.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { streamType } from '@linode/api-v4'; -import { screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import React from 'react'; - -import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; - -import { StreamCreateGeneralInfo } from './StreamCreateGeneralInfo'; - -describe('StreamCreateGeneralInfo', () => { - it('should render Name input and allow to type text', async () => { - renderWithThemeAndHookFormContext({ - component: , - }); - - // Type test value inside the input - const nameInput = screen.getByPlaceholderText('Stream name...'); - await userEvent.type(nameInput, 'Test'); - - await waitFor(() => { - expect(nameInput.getAttribute('value')).toEqual('Test'); - }); - }); - - it('should render Stream type input and allow to select different options', async () => { - renderWithThemeAndHookFormContext({ - component: , - useFormOptions: { - defaultValues: { - stream: { - type: streamType.AuditLogs, - }, - }, - }, - }); - - const streamTypesAutocomplete = screen.getByRole('combobox'); - - expect(streamTypesAutocomplete).toHaveValue('Audit Logs'); - - // Open the dropdown - await userEvent.click(streamTypesAutocomplete); - - // Select the "Kubernetes Audit Logs" option - const kubernetesAuditLogs = await screen.findByText( - 'Kubernetes Audit Logs' - ); - await userEvent.click(kubernetesAuditLogs); - - await waitFor(() => { - expect(streamTypesAutocomplete).toHaveValue('Kubernetes Audit Logs'); - }); - }); -}); diff --git a/packages/manager/src/features/DataStream/Streams/StreamCreate/CheckoutBar/StreamCreateCheckoutBar.styles.ts b/packages/manager/src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormCheckoutBar.styles.ts similarity index 100% rename from packages/manager/src/features/DataStream/Streams/StreamCreate/CheckoutBar/StreamCreateCheckoutBar.styles.ts rename to packages/manager/src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormCheckoutBar.styles.ts diff --git a/packages/manager/src/features/DataStream/Streams/StreamCreate/CheckoutBar/StreamCreateCheckoutBar.test.tsx b/packages/manager/src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormCheckoutBar.test.tsx similarity index 85% rename from packages/manager/src/features/DataStream/Streams/StreamCreate/CheckoutBar/StreamCreateCheckoutBar.test.tsx rename to packages/manager/src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormCheckoutBar.test.tsx index 4665e078caa..7eefd4a16b1 100644 --- a/packages/manager/src/features/DataStream/Streams/StreamCreate/CheckoutBar/StreamCreateCheckoutBar.test.tsx +++ b/packages/manager/src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormCheckoutBar.test.tsx @@ -5,20 +5,20 @@ import React from 'react'; import { FormProvider, useForm } from 'react-hook-form'; import { describe, expect } from 'vitest'; -import { StreamCreateCheckoutBar } from 'src/features/DataStream/Streams/StreamCreate/CheckoutBar/StreamCreateCheckoutBar'; -import { StreamCreateGeneralInfo } from 'src/features/DataStream/Streams/StreamCreate/StreamCreateGeneralInfo'; +import { StreamFormCheckoutBar } from 'src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormCheckoutBar'; +import { StreamFormGeneralInfo } from 'src/features/DataStream/Streams/StreamForm/StreamFormGeneralInfo'; import { renderWithTheme, renderWithThemeAndHookFormContext, } from 'src/utilities/testHelpers'; -describe('StreamCreateCheckoutBar', () => { +describe('StreamFormCheckoutBar', () => { const getDeliveryPriceContext = () => screen.getByText(/\/unit/i).textContent; const createStream = () => {}; const renderComponent = () => { renderWithThemeAndHookFormContext({ - component: , + component: , useFormOptions: { defaultValues: { destination: { @@ -61,8 +61,8 @@ describe('StreamCreateCheckoutBar', () => { return (
- - + +
); diff --git a/packages/manager/src/features/DataStream/Streams/StreamCreate/CheckoutBar/StreamCreateCheckoutBar.tsx b/packages/manager/src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormCheckoutBar.tsx similarity index 86% rename from packages/manager/src/features/DataStream/Streams/StreamCreate/CheckoutBar/StreamCreateCheckoutBar.tsx rename to packages/manager/src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormCheckoutBar.tsx index c48bcaa3c3e..cc4dab35826 100644 --- a/packages/manager/src/features/DataStream/Streams/StreamCreate/CheckoutBar/StreamCreateCheckoutBar.tsx +++ b/packages/manager/src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormCheckoutBar.tsx @@ -5,17 +5,17 @@ import { useFormContext, useWatch } from 'react-hook-form'; import { CheckoutBar } from 'src/components/CheckoutBar/CheckoutBar'; import { displayPrice } from 'src/components/DisplayPrice'; import { getDestinationTypeOption } from 'src/features/DataStream/dataStreamUtils'; -import { StyledHeader } from 'src/features/DataStream/Streams/StreamCreate/CheckoutBar/StreamCreateCheckoutBar.styles'; +import { StyledHeader } from 'src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormCheckoutBar.styles'; -import type { CreateStreamAndDestinationForm } from 'src/features/DataStream/Streams/StreamCreate/types'; +import type { StreamAndDestinationFormType } from 'src/features/DataStream/Streams/StreamForm/types'; export interface Props { createStream: () => void; } -export const StreamCreateCheckoutBar = (props: Props) => { +export const StreamFormCheckoutBar = (props: Props) => { const { createStream } = props; - const { control } = useFormContext(); + const { control } = useFormContext(); const destinationType = useWatch({ control, name: 'destination.type' }); const formValues = useWatch({ control, diff --git a/packages/manager/src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormSubmitBar.test.tsx b/packages/manager/src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormSubmitBar.test.tsx new file mode 100644 index 00000000000..df6554a590b --- /dev/null +++ b/packages/manager/src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormSubmitBar.test.tsx @@ -0,0 +1,60 @@ +import { destinationType, streamType } from '@linode/api-v4'; +import { screen } from '@testing-library/react'; +import React from 'react'; +import { describe, expect } from 'vitest'; + +import { StreamFormSubmitBar } from 'src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormSubmitBar'; +import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; + +import type { FormMode } from 'src/features/DataStream/Shared/types'; + +describe('StreamFormSubmitBar', () => { + const createStream = () => {}; + + const renderComponent = (mode: FormMode) => { + renderWithThemeAndHookFormContext({ + component: , + useFormOptions: { + defaultValues: { + stream: { + type: streamType.AuditLogs, + details: {}, + }, + destination: { + type: destinationType.LinodeObjectStorage, + details: { + region: '', + }, + }, + }, + }, + }); + }; + + describe('when in create mode', () => { + it('should render checkout bar with enabled Create Stream button', async () => { + renderComponent('create'); + const submitButton = screen.getByText('Create Stream'); + + expect(submitButton).toBeEnabled(); + }); + }); + + describe('when in edit mode', () => { + it('should render checkout bar with enabled Edit Stream button', async () => { + renderComponent('edit'); + const submitButton = screen.getByText('Edit Stream'); + + expect(submitButton).toBeEnabled(); + }); + }); + + it('should render Delivery summary with destination type', () => { + renderComponent('create'); + const deliveryTitle = screen.getByText('Delivery'); + const deliveryType = screen.getByText('Linode Object Storage'); + + expect(deliveryTitle).toBeInTheDocument(); + expect(deliveryType).toBeInTheDocument(); + }); +}); diff --git a/packages/manager/src/features/DataStream/Streams/StreamCreate/CheckoutBar/StreamCreateSubmitBar.tsx b/packages/manager/src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormSubmitBar.tsx similarity index 62% rename from packages/manager/src/features/DataStream/Streams/StreamCreate/CheckoutBar/StreamCreateSubmitBar.tsx rename to packages/manager/src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormSubmitBar.tsx index 9c009208eb8..74d5eb83e2f 100644 --- a/packages/manager/src/features/DataStream/Streams/StreamCreate/CheckoutBar/StreamCreateSubmitBar.tsx +++ b/packages/manager/src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormSubmitBar.tsx @@ -2,19 +2,24 @@ import { Box, Button, Divider, Paper, Stack, Typography } from '@linode/ui'; import * as React from 'react'; import { useFormContext, useWatch } from 'react-hook-form'; -import { getDestinationTypeOption } from 'src/features/DataStream/dataStreamUtils'; -import { StyledHeader } from 'src/features/DataStream/Streams/StreamCreate/CheckoutBar/StreamCreateCheckoutBar.styles'; +import { + getDestinationTypeOption, + isFormInEditMode, +} from 'src/features/DataStream/dataStreamUtils'; +import { StyledHeader } from 'src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormCheckoutBar.styles'; -import type { CreateStreamAndDestinationForm } from 'src/features/DataStream/Streams/StreamCreate/types'; +import type { FormMode } from 'src/features/DataStream/Shared/types'; +import type { StreamAndDestinationFormType } from 'src/features/DataStream/Streams/StreamForm/types'; -type StreamCreateSidebarProps = { - createStream: () => void; +type StreamFormSubmitBarProps = { + mode: FormMode; + onSubmit: () => void; }; -export const StreamCreateSubmitBar = (props: StreamCreateSidebarProps) => { - const { createStream } = props; +export const StreamFormSubmitBar = (props: StreamFormSubmitBarProps) => { + const { onSubmit, mode } = props; - const { control } = useFormContext(); + const { control } = useFormContext(); const destinationType = useWatch({ control, name: 'destination.type' }); return ( @@ -32,7 +37,7 @@ export const StreamCreateSubmitBar = (props: StreamCreateSidebarProps) => { diff --git a/packages/manager/src/features/DataStream/Streams/StreamCreate/Delivery/DestinationLinodeObjectStorageDetailsSummary.test.tsx b/packages/manager/src/features/DataStream/Streams/StreamForm/Delivery/DestinationLinodeObjectStorageDetailsSummary.test.tsx similarity index 78% rename from packages/manager/src/features/DataStream/Streams/StreamCreate/Delivery/DestinationLinodeObjectStorageDetailsSummary.test.tsx rename to packages/manager/src/features/DataStream/Streams/StreamForm/Delivery/DestinationLinodeObjectStorageDetailsSummary.test.tsx index cff5103b0ed..b66fe12a369 100644 --- a/packages/manager/src/features/DataStream/Streams/StreamCreate/Delivery/DestinationLinodeObjectStorageDetailsSummary.test.tsx +++ b/packages/manager/src/features/DataStream/Streams/StreamForm/Delivery/DestinationLinodeObjectStorageDetailsSummary.test.tsx @@ -1,5 +1,5 @@ import { regionFactory } from '@linode/utilities'; -import { screen, waitFor, within } from '@testing-library/react'; +import { screen, waitFor } from '@testing-library/react'; import React from 'react'; import { makeResourcePage } from 'src/mocks/serverHandlers'; @@ -33,26 +33,23 @@ describe('DestinationLinodeObjectStorageDetailsSummary', () => { ); + // Host: expect(screen.getByText('test host')).toBeVisible(); - + // Bucket: expect(screen.getByText('test bucket')).toBeVisible(); - + // Log Path: expect(screen.getByText('test/path')).toBeVisible(); - + // Region: await waitFor(() => { expect(screen.getByText('US, Chicago, IL')).toBeVisible(); }); - - expect( - within(screen.getByText('Access Key ID:').closest('div')!).getByText( - '*****************' - ) - ).toBeInTheDocument(); - - expect( - within(screen.getByText('Secret Access Key:').closest('div')!).getByText( - '*****************' - ) - ).toBeInTheDocument(); + // Access Key ID: + expect(screen.getByTestId('access-key-id')).toHaveTextContent( + '*****************' + ); + // Secret Access Key: + expect(screen.getByTestId('secret-access-key')).toHaveTextContent( + '*****************' + ); }); }); diff --git a/packages/manager/src/features/DataStream/Streams/StreamForm/Delivery/DestinationLinodeObjectStorageDetailsSummary.tsx b/packages/manager/src/features/DataStream/Streams/StreamForm/Delivery/DestinationLinodeObjectStorageDetailsSummary.tsx new file mode 100644 index 00000000000..f031a4997ef --- /dev/null +++ b/packages/manager/src/features/DataStream/Streams/StreamForm/Delivery/DestinationLinodeObjectStorageDetailsSummary.tsx @@ -0,0 +1,34 @@ +import { useRegionsQuery } from '@linode/queries'; +import React from 'react'; + +import { LabelValue } from 'src/features/DataStream/Shared/LabelValue'; + +import type { LinodeObjectStorageDetails } from '@linode/api-v4'; + +export const DestinationLinodeObjectStorageDetailsSummary = ( + props: LinodeObjectStorageDetails +) => { + const { bucket_name, host, region, path } = props; + const { data: regions } = useRegionsQuery(); + + const regionValue = regions?.find(({ id }) => id === region)?.label || region; + + return ( + <> + + + + + + + + ); +}; diff --git a/packages/manager/src/features/DataStream/Streams/StreamCreate/Delivery/StreamCreateDelivery.test.tsx b/packages/manager/src/features/DataStream/Streams/StreamForm/Delivery/StreamFormDelivery.test.tsx similarity index 96% rename from packages/manager/src/features/DataStream/Streams/StreamCreate/Delivery/StreamCreateDelivery.test.tsx rename to packages/manager/src/features/DataStream/Streams/StreamForm/Delivery/StreamFormDelivery.test.tsx index 5e58470ad1a..71e0ed84dca 100644 --- a/packages/manager/src/features/DataStream/Streams/StreamCreate/Delivery/StreamCreateDelivery.test.tsx +++ b/packages/manager/src/features/DataStream/Streams/StreamForm/Delivery/StreamFormDelivery.test.tsx @@ -9,13 +9,13 @@ import { makeResourcePage } from 'src/mocks/serverHandlers'; import { http, HttpResponse, server } from 'src/mocks/testServer'; import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; -import { StreamCreateDelivery } from './StreamCreateDelivery'; +import { StreamFormDelivery } from './StreamFormDelivery'; const loadingTestId = 'circle-progress'; const mockDestinations = destinationFactory.buildList(5); -describe('StreamCreateDelivery', () => { +describe('StreamFormDelivery', () => { beforeEach(async () => { server.use( http.get('*/monitor/streams/destinations', () => { @@ -26,7 +26,7 @@ describe('StreamCreateDelivery', () => { it('should render disabled Destination Type input with proper selection', async () => { renderWithThemeAndHookFormContext({ - component: , + component: , useFormOptions: { defaultValues: { destination: { @@ -50,7 +50,7 @@ describe('StreamCreateDelivery', () => { it('should render Destination Name input and allow to select an existing option', async () => { renderWithThemeAndHookFormContext({ - component: , + component: , useFormOptions: { defaultValues: { destination: { @@ -81,7 +81,7 @@ describe('StreamCreateDelivery', () => { const renderComponentAndAddNewDestinationName = async () => { renderWithThemeAndHookFormContext({ - component: , + component: , useFormOptions: { defaultValues: { destination: { diff --git a/packages/manager/src/features/DataStream/Streams/StreamCreate/Delivery/StreamCreateDelivery.tsx b/packages/manager/src/features/DataStream/Streams/StreamForm/Delivery/StreamFormDelivery.tsx similarity index 86% rename from packages/manager/src/features/DataStream/Streams/StreamCreate/Delivery/StreamCreateDelivery.tsx rename to packages/manager/src/features/DataStream/Streams/StreamForm/Delivery/StreamFormDelivery.tsx index 223a7bb8fb7..c3d24570a90 100644 --- a/packages/manager/src/features/DataStream/Streams/StreamCreate/Delivery/StreamCreateDelivery.tsx +++ b/packages/manager/src/features/DataStream/Streams/StreamForm/Delivery/StreamFormDelivery.tsx @@ -16,13 +16,13 @@ import { Controller, useFormContext, useWatch } from 'react-hook-form'; import { getDestinationTypeOption } from 'src/features/DataStream/dataStreamUtils'; import { DestinationLinodeObjectStorageDetailsForm } from 'src/features/DataStream/Shared/DestinationLinodeObjectStorageDetailsForm'; import { destinationTypeOptions } from 'src/features/DataStream/Shared/types'; -import { DestinationLinodeObjectStorageDetailsSummary } from 'src/features/DataStream/Streams/StreamCreate/Delivery/DestinationLinodeObjectStorageDetailsSummary'; +import { DestinationLinodeObjectStorageDetailsSummary } from 'src/features/DataStream/Streams/StreamForm/Delivery/DestinationLinodeObjectStorageDetailsSummary'; import type { DestinationType, LinodeObjectStorageDetails, } from '@linode/api-v4'; -import type { CreateStreamAndDestinationForm } from 'src/features/DataStream/Streams/StreamCreate/types'; +import type { StreamAndDestinationFormType } from 'src/features/DataStream/Streams/StreamForm/types'; type DestinationName = { create?: boolean; @@ -40,15 +40,12 @@ const controlPaths = { region: 'destination.details.region', }; -export const StreamCreateDelivery = () => { +export const StreamFormDelivery = () => { const theme = useTheme(); - const { control, setValue } = - useFormContext(); + const { control, setValue } = useFormContext(); const [showDestinationForm, setShowDestinationForm] = React.useState(false); - const [showExistingDestination, setShowExistingDestination] = - React.useState(false); const { data: destinations, isLoading, error } = useAllDestinationsQuery(); const destinationNameOptions: DestinationName[] = (destinations || []).map( @@ -118,16 +115,12 @@ export const StreamCreateDelivery = () => { label="Destination Name" onBlur={field.onBlur} onChange={(_, newValue) => { - const selectedExistingDestination = !!( - newValue?.label && newValue?.id + setValue( + 'stream.destinations', + newValue?.id ? [newValue?.id] : [] ); - if (selectedExistingDestination) { - setValue('stream.destinations', [newValue?.id as number]); - } field.onChange(newValue?.label || newValue); - setValue('stream.destinations', [newValue?.id as number]); setShowDestinationForm(!!newValue?.create); - setShowExistingDestination(selectedExistingDestination); }} options={destinationNameOptions.filter( ({ type }) => type === selectedDestinationType @@ -158,7 +151,7 @@ export const StreamCreateDelivery = () => { controlPaths={controlPaths} /> )} - {showExistingDestination && ( + {!!selectedDestinations?.length && ( id === selectedDestinations[0] diff --git a/packages/manager/src/features/DataStream/Streams/StreamForm/StreamCreate.tsx b/packages/manager/src/features/DataStream/Streams/StreamForm/StreamCreate.tsx new file mode 100644 index 00000000000..c5fa4ee0007 --- /dev/null +++ b/packages/manager/src/features/DataStream/Streams/StreamForm/StreamCreate.tsx @@ -0,0 +1,86 @@ +import { yupResolver } from '@hookform/resolvers/yup'; +import { destinationType, streamType } from '@linode/api-v4'; +import { useCreateStreamMutation } from '@linode/queries'; +import { streamAndDestinationFormSchema } from '@linode/validation'; +import { useNavigate } from '@tanstack/react-router'; +import * as React from 'react'; +import { FormProvider, useForm } from 'react-hook-form'; + +import { DocumentTitleSegment } from 'src/components/DocumentTitle'; +import { + LandingHeader, + type LandingHeaderProps, +} from 'src/components/LandingHeader'; +import { getStreamPayloadDetails } from 'src/features/DataStream/dataStreamUtils'; +import { StreamForm } from 'src/features/DataStream/Streams/StreamForm/StreamForm'; +import { sendCreateStreamEvent } from 'src/utilities/analytics/customEventAnalytics'; + +import type { CreateStreamPayload } from '@linode/api-v4'; +import type { + StreamAndDestinationFormType, + StreamFormType, +} from 'src/features/DataStream/Streams/StreamForm/types'; + +export const StreamCreate = () => { + const { mutateAsync: createStream } = useCreateStreamMutation(); + const navigate = useNavigate(); + + const form = useForm({ + defaultValues: { + stream: { + type: streamType.AuditLogs, + details: {}, + }, + destination: { + type: destinationType.LinodeObjectStorage, + details: { + region: '', + }, + }, + }, + mode: 'onBlur', + resolver: yupResolver(streamAndDestinationFormSchema), + }); + + const landingHeaderProps: LandingHeaderProps = { + breadcrumbProps: { + pathname: '/datastream/streams/create', + crumbOverrides: [ + { + label: 'DataStream', + linkTo: '/datastream/streams', + position: 1, + }, + ], + }, + removeCrumbX: 2, + title: 'Create Stream', + }; + + const onSubmit = () => { + const { + stream: { label, type, destinations, details }, + } = form.getValues(); + const payload: StreamFormType = { + label, + type, + destinations, + details: getStreamPayloadDetails(type, details), + }; + + createStream(payload as CreateStreamPayload).then(() => { + sendCreateStreamEvent('Stream Create Page'); + navigate({ to: '/datastream/streams' }); + }); + }; + + return ( + <> + + + + + + + ); +}; diff --git a/packages/manager/src/features/DataStream/Streams/StreamForm/StreamEdit.test.tsx b/packages/manager/src/features/DataStream/Streams/StreamForm/StreamEdit.test.tsx new file mode 100644 index 00000000000..41699fa8205 --- /dev/null +++ b/packages/manager/src/features/DataStream/Streams/StreamForm/StreamEdit.test.tsx @@ -0,0 +1,84 @@ +import { + screen, + waitFor, + waitForElementToBeRemoved, +} from '@testing-library/react'; +import React from 'react'; +import { describe } from 'vitest'; + +import { destinationFactory, streamFactory } from 'src/factories/datastream'; +import { StreamEdit } from 'src/features/DataStream/Streams/StreamForm/StreamEdit'; +import { makeResourcePage } from 'src/mocks/serverHandlers'; +import { http, HttpResponse, server } from 'src/mocks/testServer'; +import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; + +const loadingTestId = 'circle-progress'; +const streamId = 123; +const mockDestinations = [destinationFactory.build({ id: 1 })]; +const mockStream = streamFactory.build({ + id: streamId, + label: `Data Stream ${streamId}`, + destinations: mockDestinations, +}); + +vi.mock('@tanstack/react-router', async () => { + const actual = await vi.importActual('@tanstack/react-router'); + return { + ...actual, + useParams: vi.fn().mockReturnValue({ streamId: 123 }), + }; +}); + +describe('StreamEdit', () => { + const assertInputHasValue = (inputLabel: string, inputValue: string) => { + expect(screen.getByLabelText(inputLabel)).toHaveValue(inputValue); + }; + + it('should render edited stream when stream fetched properly', async () => { + server.use( + http.get(`*/monitor/streams/${streamId}`, () => { + return HttpResponse.json(mockStream); + }), + http.get('*/monitor/streams/destinations', () => { + return HttpResponse.json(makeResourcePage(mockDestinations)); + }) + ); + + renderWithThemeAndHookFormContext({ + component: , + }); + + const loadingElement = screen.queryByTestId(loadingTestId); + if (loadingElement) { + await waitForElementToBeRemoved(loadingElement); + } + + await waitFor(() => { + assertInputHasValue('Name', 'Data Stream 123'); + }); + assertInputHasValue('Stream Type', 'Audit Logs'); + await waitFor(() => { + assertInputHasValue('Destination Type', 'Linode Object Storage'); + }); + assertInputHasValue('Destination Name', 'Destination 1'); + + // Host: + expect(screen.getByText('3000')).toBeVisible(); + // Bucket: + expect(screen.getByText('Bucket Name')).toBeVisible(); + // Region: + await waitFor(() => { + expect(screen.getByText('US, Chicago, IL')).toBeVisible(); + }); + // Access Key ID: + expect(screen.getByTestId('access-key-id')).toHaveTextContent( + '*****************' + ); + // Secret Access Key: + expect(screen.getByTestId('secret-access-key')).toHaveTextContent( + '*****************' + ); + // Log Path: + expect(screen.getByText('file')).toBeVisible(); + }); +}); diff --git a/packages/manager/src/features/DataStream/Streams/StreamForm/StreamEdit.tsx b/packages/manager/src/features/DataStream/Streams/StreamForm/StreamEdit.tsx new file mode 100644 index 00000000000..9a3f6bf846e --- /dev/null +++ b/packages/manager/src/features/DataStream/Streams/StreamForm/StreamEdit.tsx @@ -0,0 +1,131 @@ +import { yupResolver } from '@hookform/resolvers/yup'; +import { destinationType, streamType } from '@linode/api-v4'; +import { useStreamQuery, useUpdateStreamMutation } from '@linode/queries'; +import { Box, CircleProgress, ErrorState } from '@linode/ui'; +import { streamAndDestinationFormSchema } from '@linode/validation'; +import { useNavigate, useParams } from '@tanstack/react-router'; +import * as React from 'react'; +import { useEffect } from 'react'; +import { FormProvider, useForm } from 'react-hook-form'; + +import { DocumentTitleSegment } from 'src/components/DocumentTitle'; +import { + LandingHeader, + type LandingHeaderProps, +} from 'src/components/LandingHeader'; +import { getStreamPayloadDetails } from 'src/features/DataStream/dataStreamUtils'; +import { StreamForm } from 'src/features/DataStream/Streams/StreamForm/StreamForm'; + +import type { UpdateStreamPayloadWithId } from '@linode/api-v4'; +import type { StreamAndDestinationFormType } from 'src/features/DataStream/Streams/StreamForm/types'; + +export const StreamEdit = () => { + const navigate = useNavigate(); + const { streamId } = useParams({ + from: '/datastream/streams/$streamId/edit', + }); + const { mutateAsync: updateStream } = useUpdateStreamMutation(); + const { data: stream, isLoading, error } = useStreamQuery(Number(streamId)); + + const form = useForm({ + defaultValues: { + stream: { + type: streamType.AuditLogs, + details: {}, + }, + destination: { + type: destinationType.LinodeObjectStorage, + details: { + region: '', + }, + }, + }, + mode: 'onBlur', + resolver: yupResolver(streamAndDestinationFormSchema), + }); + + useEffect(() => { + if (stream) { + const details = + Object.keys(stream.details).length > 0 + ? { + is_auto_add_all_clusters_enabled: false, + cluster_ids: [], + ...stream.details, + } + : {}; + + form.reset({ + stream: { + ...stream, + details, + destinations: stream.destinations.map(({ id }) => id), + }, + destination: stream.destinations?.[0], + }); + } + }, [stream, form]); + + const landingHeaderProps: LandingHeaderProps = { + breadcrumbProps: { + pathname: '/datastream/streams/edit', + crumbOverrides: [ + { + label: 'DataStream', + linkTo: '/datastream/streams', + position: 1, + }, + ], + }, + removeCrumbX: 2, + title: 'Edit Stream', + }; + + const onSubmit = () => { + const { + stream: { label, type, destinations, details }, + } = form.getValues(); + + // TODO: DPS-33120 create destination call if new destination created + + const payload: UpdateStreamPayloadWithId = { + id: stream!.id, + label, + type: stream!.type, + status: stream!.status, + destinations: destinations as number[], // TODO: remove type assertion after DPS-33120 + details: getStreamPayloadDetails(type, details), + }; + + updateStream(payload).then(() => { + navigate({ to: '/datastream/streams' }); + }); + }; + + return ( + <> + + + {isLoading && ( + + + + )} + {error && ( + + )} + {!isLoading && !error && ( + + + + )} + + ); +}; diff --git a/packages/manager/src/features/DataStream/Streams/StreamForm/StreamForm.tsx b/packages/manager/src/features/DataStream/Streams/StreamForm/StreamForm.tsx new file mode 100644 index 00000000000..9ec617576de --- /dev/null +++ b/packages/manager/src/features/DataStream/Streams/StreamForm/StreamForm.tsx @@ -0,0 +1,52 @@ +import { streamType } from '@linode/api-v4'; +import { Stack } from '@linode/ui'; +import Grid from '@mui/material/Grid'; +import * as React from 'react'; +import type { SubmitHandler } from 'react-hook-form'; +import { useFormContext, useWatch } from 'react-hook-form'; + +import { StreamFormSubmitBar } from 'src/features/DataStream/Streams/StreamForm/CheckoutBar/StreamFormSubmitBar'; +import { StreamFormDelivery } from 'src/features/DataStream/Streams/StreamForm/Delivery/StreamFormDelivery'; + +import { StreamFormClusters } from './StreamFormClusters'; +import { StreamFormGeneralInfo } from './StreamFormGeneralInfo'; + +import type { FormMode } from 'src/features/DataStream/Shared/types'; +import type { StreamAndDestinationFormType } from 'src/features/DataStream/Streams/StreamForm/types'; + +type StreamFormProps = { + mode: FormMode; + onSubmit: SubmitHandler; + streamId?: string; +}; + +export const StreamForm = (props: StreamFormProps) => { + const { mode, onSubmit, streamId } = props; + + const { control, handleSubmit } = + useFormContext(); + + const selectedStreamType = useWatch({ + control, + name: 'stream.type', + }); + + return ( +
+ + + + + {selectedStreamType === streamType.LKEAuditLogs && ( + + )} + + + + + + + +
+ ); +}; diff --git a/packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreateClusters.test.tsx b/packages/manager/src/features/DataStream/Streams/StreamForm/StreamFormClusters.test.tsx similarity index 96% rename from packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreateClusters.test.tsx rename to packages/manager/src/features/DataStream/Streams/StreamForm/StreamFormClusters.test.tsx index 9bb723fba9e..6ed13dd08f6 100644 --- a/packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreateClusters.test.tsx +++ b/packages/manager/src/features/DataStream/Streams/StreamForm/StreamFormClusters.test.tsx @@ -5,15 +5,18 @@ import { describe, expect, it } from 'vitest'; import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; -import { StreamCreateClusters } from './StreamCreateClusters'; +import { StreamFormClusters } from './StreamFormClusters'; const renderComponentWithoutSelectedClusters = () => { renderWithThemeAndHookFormContext({ - component: , + component: , useFormOptions: { defaultValues: { stream: { - details: {}, + details: { + cluster_ids: [], + is_auto_add_all_clusters_enabled: false, + }, }, }, }, @@ -49,7 +52,7 @@ const expectCheckboxStateToBe = ( } }; -describe('StreamCreateClusters', () => { +describe('StreamFormClusters', () => { it('should render all clusters in table', async () => { renderComponentWithoutSelectedClusters(); @@ -159,12 +162,13 @@ describe('StreamCreateClusters', () => { describe('when form has already selected clusters', () => { it('should render table with properly selected clusters', async () => { renderWithThemeAndHookFormContext({ - component: , + component: , useFormOptions: { defaultValues: { stream: { details: { cluster_ids: [3], + is_auto_add_all_clusters_enabled: false, }, }, }, diff --git a/packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreateClusters.tsx b/packages/manager/src/features/DataStream/Streams/StreamForm/StreamFormClusters.tsx similarity index 90% rename from packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreateClusters.tsx rename to packages/manager/src/features/DataStream/Streams/StreamForm/StreamFormClusters.tsx index af894c9a200..b04eb9c9753 100644 --- a/packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreateClusters.tsx +++ b/packages/manager/src/features/DataStream/Streams/StreamForm/StreamFormClusters.tsx @@ -1,5 +1,5 @@ import { Box, Checkbox, Notice, Paper, Typography } from '@linode/ui'; -import { usePrevious } from '@linode/utilities'; +import { isNotNullOrUndefined, usePrevious } from '@linode/utilities'; import React, { useEffect, useState } from 'react'; import type { ControllerRenderProps } from 'react-hook-form'; import { useWatch } from 'react-hook-form'; @@ -14,9 +14,9 @@ import { TableHead } from 'src/components/TableHead'; import { TableRow } from 'src/components/TableRow'; import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; import { TableSortCell } from 'src/components/TableSortCell'; -import { clusters } from 'src/features/DataStream/Streams/StreamCreate/StreamCreateClustersData'; +import { clusters } from 'src/features/DataStream/Streams/StreamForm/StreamFormClustersData'; -import type { CreateStreamAndDestinationForm } from 'src/features/DataStream/Streams/StreamCreate/types'; +import type { StreamAndDestinationFormType } from 'src/features/DataStream/Streams/StreamForm/types'; // TODO: remove type after fetching the clusters will be done export type Cluster = { @@ -28,9 +28,9 @@ export type Cluster = { type OrderByKeys = 'label' | 'logGeneration' | 'region'; -export const StreamCreateClusters = () => { +export const StreamFormClusters = () => { const { control, setValue, formState } = - useFormContext(); + useFormContext(); const [order, setOrder] = useState<'asc' | 'desc'>('asc'); const [orderBy, setOrderBy] = useState('label'); @@ -40,16 +40,31 @@ export const StreamCreateClusters = () => { .filter(({ logGeneration }) => logGeneration) .map(({ id }) => id); - const isAutoAddAllClustersEnabled = useWatch({ + const [isAutoAddAllClustersEnabled, clusterIds] = useWatch({ control, - name: 'stream.details.is_auto_add_all_clusters_enabled', + name: [ + 'stream.details.is_auto_add_all_clusters_enabled', + 'stream.details.cluster_ids', + ], }); const previousIsAutoAddAllClustersEnabled = usePrevious( isAutoAddAllClustersEnabled ); useEffect(() => { - if (isAutoAddAllClustersEnabled !== previousIsAutoAddAllClustersEnabled) { + setValue( + 'stream.details.cluster_ids', + isAutoAddAllClustersEnabled + ? idsWithLogGenerationEnabled + : clusterIds || [] + ); + }, []); + + useEffect(() => { + if ( + isNotNullOrUndefined(previousIsAutoAddAllClustersEnabled) && + isAutoAddAllClustersEnabled !== previousIsAutoAddAllClustersEnabled + ) { setValue( 'stream.details.cluster_ids', isAutoAddAllClustersEnabled ? idsWithLogGenerationEnabled : [] @@ -73,7 +88,7 @@ export const StreamCreateClusters = () => { const getTableContent = ( field: ControllerRenderProps< - CreateStreamAndDestinationForm, + StreamAndDestinationFormType, 'stream.details.cluster_ids' > ) => { diff --git a/packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreateClustersData.ts b/packages/manager/src/features/DataStream/Streams/StreamForm/StreamFormClustersData.ts similarity index 92% rename from packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreateClustersData.ts rename to packages/manager/src/features/DataStream/Streams/StreamForm/StreamFormClustersData.ts index baaf487c7dd..246a5b189e6 100644 --- a/packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreateClustersData.ts +++ b/packages/manager/src/features/DataStream/Streams/StreamForm/StreamFormClustersData.ts @@ -1,4 +1,4 @@ -import type { Cluster } from 'src/features/DataStream/Streams/StreamCreate/StreamCreateClusters'; +import type { Cluster } from 'src/features/DataStream/Streams/StreamForm/StreamFormClusters'; export const clusters: Cluster[] = [ { diff --git a/packages/manager/src/features/DataStream/Streams/StreamForm/StreamFormGeneralInfo.test.tsx b/packages/manager/src/features/DataStream/Streams/StreamForm/StreamFormGeneralInfo.test.tsx new file mode 100644 index 00000000000..aeea11d63e9 --- /dev/null +++ b/packages/manager/src/features/DataStream/Streams/StreamForm/StreamFormGeneralInfo.test.tsx @@ -0,0 +1,101 @@ +import { streamType } from '@linode/api-v4'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { describe, expect } from 'vitest'; + +import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; + +import { StreamFormGeneralInfo } from './StreamFormGeneralInfo'; + +describe('StreamFormGeneralInfo', () => { + describe('when in create mode', () => { + it('should render Name input and allow to type text', async () => { + renderWithThemeAndHookFormContext({ + component: , + }); + + // Type test value inside the input + const nameInput = screen.getByPlaceholderText('Stream name...'); + await userEvent.type(nameInput, 'Test'); + + await waitFor(() => { + expect(nameInput.getAttribute('value')).toEqual('Test'); + }); + }); + + it('should render Stream type input and allow to select different options', async () => { + renderWithThemeAndHookFormContext({ + component: , + useFormOptions: { + defaultValues: { + stream: { + type: streamType.AuditLogs, + }, + }, + }, + }); + + const streamTypesAutocomplete = screen.getByRole('combobox'); + + expect(streamTypesAutocomplete).toHaveValue('Audit Logs'); + + // Open the dropdown + await userEvent.click(streamTypesAutocomplete); + + // Select the "Kubernetes Audit Logs" option + const kubernetesAuditLogs = await screen.findByText( + 'Kubernetes Audit Logs' + ); + await userEvent.click(kubernetesAuditLogs); + + await waitFor(() => { + expect(streamTypesAutocomplete).toHaveValue('Kubernetes Audit Logs'); + }); + }); + }); + + describe('when in edit mode and with streamId prop', () => { + const streamId = '123'; + it('should render ID', () => { + renderWithThemeAndHookFormContext({ + component: , + }); + + // ID: + expect(screen.getByText(streamId)).toBeVisible(); + }); + + it('should render Name input and allow to type text', async () => { + renderWithThemeAndHookFormContext({ + component: , + }); + + // Type test value inside the input + const nameInput = screen.getByPlaceholderText('Stream name...'); + await userEvent.type(nameInput, 'Test'); + + await waitFor(() => { + expect(nameInput.getAttribute('value')).toEqual('Test'); + }); + }); + + it('should render disabled Stream type input', async () => { + renderWithThemeAndHookFormContext({ + component: , + useFormOptions: { + defaultValues: { + stream: { + type: streamType.AuditLogs, + }, + }, + }, + }); + + const streamTypesAutocomplete = screen.getByRole('combobox'); + + expect(streamTypesAutocomplete).toBeDisabled(); + expect(streamTypesAutocomplete).toHaveValue('Audit Logs'); + }); + }); +}); diff --git a/packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreateGeneralInfo.tsx b/packages/manager/src/features/DataStream/Streams/StreamForm/StreamFormGeneralInfo.tsx similarity index 65% rename from packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreateGeneralInfo.tsx rename to packages/manager/src/features/DataStream/Streams/StreamForm/StreamFormGeneralInfo.tsx index e73ec41fe91..8ade80467c4 100644 --- a/packages/manager/src/features/DataStream/Streams/StreamCreate/StreamCreateGeneralInfo.tsx +++ b/packages/manager/src/features/DataStream/Streams/StreamForm/StreamFormGeneralInfo.tsx @@ -3,22 +3,25 @@ import { Autocomplete, Paper, TextField, Typography } from '@linode/ui'; import React from 'react'; import { Controller, useFormContext } from 'react-hook-form'; -import { type CreateStreamAndDestinationForm } from './types'; +import { + getStreamTypeOption, + isFormInEditMode, +} from 'src/features/DataStream/dataStreamUtils'; +import { LabelValue } from 'src/features/DataStream/Shared/LabelValue'; +import { streamTypeOptions } from 'src/features/DataStream/Shared/types'; -export const StreamCreateGeneralInfo = () => { - const { control, setValue } = - useFormContext(); +import type { StreamAndDestinationFormType } from './types'; +import type { FormMode } from 'src/features/DataStream/Shared/types'; - const streamTypeOptions = [ - { - value: streamType.AuditLogs, - label: 'Audit Logs', - }, - { - value: streamType.LKEAuditLogs, - label: 'Kubernetes Audit Logs', - }, - ]; +type StreamFormGeneralInfoProps = { + mode: FormMode; + streamId?: string; +}; + +export const StreamFormGeneralInfo = (props: StreamFormGeneralInfoProps) => { + const { mode, streamId } = props; + + const { control, setValue } = useFormContext(); const updateStreamDetails = (value: string) => { if (value === streamType.LKEAuditLogs) { @@ -31,6 +34,7 @@ export const StreamCreateGeneralInfo = () => { return ( General Information + {streamId && } { render={({ field, fieldState }) => ( { updateStreamDetails(value); }} options={streamTypeOptions} - value={streamTypeOptions.find(({ value }) => value === field.value)} + value={getStreamTypeOption(field.value)} /> )} rules={{ required: true }} diff --git a/packages/manager/src/features/DataStream/Streams/StreamCreate/streamCreateLazyRoute.ts b/packages/manager/src/features/DataStream/Streams/StreamForm/streamCreateLazyRoute.ts similarity index 90% rename from packages/manager/src/features/DataStream/Streams/StreamCreate/streamCreateLazyRoute.ts rename to packages/manager/src/features/DataStream/Streams/StreamForm/streamCreateLazyRoute.ts index 0ec5b500ee8..eda795aea08 100644 --- a/packages/manager/src/features/DataStream/Streams/StreamCreate/streamCreateLazyRoute.ts +++ b/packages/manager/src/features/DataStream/Streams/StreamForm/streamCreateLazyRoute.ts @@ -1,6 +1,6 @@ import { createLazyRoute } from '@tanstack/react-router'; -import { StreamCreate } from 'src/features/DataStream/Streams/StreamCreate/StreamCreate'; +import { StreamCreate } from 'src/features/DataStream/Streams/StreamForm/StreamCreate'; export const streamCreateLazyRoute = createLazyRoute( '/datastream/streams/create' diff --git a/packages/manager/src/features/DataStream/Streams/StreamForm/streamEditLazyRoute.ts b/packages/manager/src/features/DataStream/Streams/StreamForm/streamEditLazyRoute.ts new file mode 100644 index 00000000000..a9ad91972cb --- /dev/null +++ b/packages/manager/src/features/DataStream/Streams/StreamForm/streamEditLazyRoute.ts @@ -0,0 +1,9 @@ +import { createLazyRoute } from '@tanstack/react-router'; + +import { StreamEdit } from 'src/features/DataStream/Streams/StreamForm/StreamEdit'; + +export const streamEditLazyRoute = createLazyRoute( + '/datastream/streams/$streamId/edit' +)({ + component: StreamEdit, +}); diff --git a/packages/manager/src/features/DataStream/Streams/StreamCreate/types.ts b/packages/manager/src/features/DataStream/Streams/StreamForm/types.ts similarity index 71% rename from packages/manager/src/features/DataStream/Streams/StreamCreate/types.ts rename to packages/manager/src/features/DataStream/Streams/StreamForm/types.ts index c89de4c8bbf..ae29faa91ec 100644 --- a/packages/manager/src/features/DataStream/Streams/StreamCreate/types.ts +++ b/packages/manager/src/features/DataStream/Streams/StreamForm/types.ts @@ -1,12 +1,12 @@ import type { CreateStreamPayload } from '@linode/api-v4'; import type { CreateDestinationForm } from 'src/features/DataStream/Shared/types'; -export interface CreateStreamForm +export interface StreamFormType extends Omit { destinations: (number | undefined)[]; } -export interface CreateStreamAndDestinationForm { +export interface StreamAndDestinationFormType { destination: CreateDestinationForm; - stream: CreateStreamForm; + stream: StreamFormType; } diff --git a/packages/manager/src/features/DataStream/Streams/StreamTableRow.test.tsx b/packages/manager/src/features/DataStream/Streams/StreamTableRow.test.tsx new file mode 100644 index 00000000000..025fa1636d6 --- /dev/null +++ b/packages/manager/src/features/DataStream/Streams/StreamTableRow.test.tsx @@ -0,0 +1,54 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { describe, expect } from 'vitest'; + +import { streamFactory } from 'src/factories/datastream'; +import { StreamTableRow } from 'src/features/DataStream/Streams/StreamTableRow'; +import { + mockMatchMedia, + renderWithTheme, + wrapWithTableBody, +} from 'src/utilities/testHelpers'; + +const fakeHandler = vi.fn(); + +describe('StreamTableRow', () => { + const stream = { ...streamFactory.build(), id: 1 }; + + it('should render a stream row', async () => { + mockMatchMedia(); + renderWithTheme( + wrapWithTableBody( + + ) + ); + + // Name: + screen.getByText('Data Stream 1'); + // Stream Type: + screen.getByText('Audit Logs'); + // Status: + screen.getByText('Enabled'); + // Destination Type: + screen.getByText('Linode Object Storage'); + // ID: + screen.getByText('1'); + // Creation Time: + screen.getByText(/2025-07-30/); + + const actionMenu = screen.getByLabelText( + `Action menu for Stream ${stream.label}` + ); + await userEvent.click(actionMenu); + + expect(screen.getByText('Edit')).toBeVisible(); + expect(screen.getByText('Disable')).toBeVisible(); + expect(screen.getByText('Delete')).toBeVisible(); + }); +}); diff --git a/packages/manager/src/features/DataStream/Streams/StreamTableRow.tsx b/packages/manager/src/features/DataStream/Streams/StreamTableRow.tsx index 44d010c6f59..4a5c0f0cacb 100644 --- a/packages/manager/src/features/DataStream/Streams/StreamTableRow.tsx +++ b/packages/manager/src/features/DataStream/Streams/StreamTableRow.tsx @@ -5,30 +5,49 @@ import { DateTimeDisplay } from 'src/components/DateTimeDisplay'; import { StatusIcon } from 'src/components/StatusIcon/StatusIcon'; import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; +import { + getDestinationTypeOption, + getStreamTypeOption, +} from 'src/features/DataStream/dataStreamUtils'; +import { StreamActionMenu } from 'src/features/DataStream/Streams/StreamActionMenu'; +import type { Handlers as StreamHandlers } from './StreamActionMenu'; import type { Stream, StreamStatus } from '@linode/api-v4'; -interface StreamTableRowProps { +interface StreamTableRowProps extends StreamHandlers { stream: Stream; } export const StreamTableRow = React.memo((props: StreamTableRowProps) => { - const { stream } = props; + const { stream, onDelete, onDisableOrEnable, onEdit } = props; return ( {stream.label} + {getStreamTypeOption(stream.type)?.label} {humanizeStreamStatus(stream.status)} {stream.id} - {stream.destinations[0].label} + + {getDestinationTypeOption(stream.destinations[0]?.type)?.label} + + + + + + ); }); diff --git a/packages/manager/src/features/DataStream/Streams/StreamsLanding.test.tsx b/packages/manager/src/features/DataStream/Streams/StreamsLanding.test.tsx index c10cb9f70d3..58b69d54200 100644 --- a/packages/manager/src/features/DataStream/Streams/StreamsLanding.test.tsx +++ b/packages/manager/src/features/DataStream/Streams/StreamsLanding.test.tsx @@ -1,56 +1,94 @@ -import { waitForElementToBeRemoved, within } from '@testing-library/react'; +import { + screen, + waitForElementToBeRemoved, + within, +} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import * as React from 'react'; -import { expect } from 'vitest'; +import { beforeEach, describe, expect } from 'vitest'; import { streamFactory } from 'src/factories/datastream'; import { StreamsLanding } from 'src/features/DataStream/Streams/StreamsLanding'; import { makeResourcePage } from 'src/mocks/serverHandlers'; import { http, HttpResponse, server } from 'src/mocks/testServer'; -import { renderWithTheme } from 'src/utilities/testHelpers'; +import { mockMatchMedia, renderWithTheme } from 'src/utilities/testHelpers'; const loadingTestId = 'circle-progress'; -describe('Streams Landing Table', () => { - it('should render streams landing tab header and table with items PaginationFooter', async () => { - server.use( - http.get('*/monitor/streams', () => { - return HttpResponse.json(makeResourcePage(streamFactory.buildList(30))); - }) - ); +const queryMocks = vi.hoisted(() => ({ + useNavigate: vi.fn(() => vi.fn()), + useUpdateStreamMutation: vi.fn().mockReturnValue({ + mutateAsync: vi.fn(), + }), + useDeleteStreamMutation: vi.fn().mockReturnValue({ + mutateAsync: vi.fn(), + }), +})); - const { - getByText, - queryByTestId, - getAllByTestId, - getByPlaceholderText, - getByLabelText, - getByRole, - } = renderWithTheme(, { +vi.mock('@tanstack/react-router', async () => { + const actual = await vi.importActual('@tanstack/react-router'); + return { + ...actual, + useNavigate: queryMocks.useNavigate, + }; +}); + +vi.mock('@linode/queries', async () => { + const actual = await vi.importActual('@linode/queries'); + return { + ...actual, + useUpdateStreamMutation: queryMocks.useUpdateStreamMutation, + useDeleteStreamMutation: queryMocks.useDeleteStreamMutation, + }; +}); + +const stream = streamFactory.build({ id: 1 }); +const streams = [stream, ...streamFactory.buildList(30)]; + +describe('Streams Landing Table', () => { + const renderComponentAndWaitForLoadingComplete = async () => { + renderWithTheme(, { initialRoute: '/datastream/streams', }); - const loadingElement = queryByTestId(loadingTestId); + const loadingElement = screen.queryByTestId(loadingTestId); if (loadingElement) { await waitForElementToBeRemoved(loadingElement); } + }; + + beforeEach(() => { + mockMatchMedia(); + }); + + it('should render streams landing tab header and table with items PaginationFooter', async () => { + server.use( + http.get('*/monitor/streams', () => { + return HttpResponse.json(makeResourcePage(streams)); + }) + ); + + await renderComponentAndWaitForLoadingComplete(); // search text input - getByPlaceholderText('Search for a Stream'); + screen.getByPlaceholderText('Search for a Stream'); // select - getByLabelText('Status'); + screen.getByLabelText('Status'); // button - getByText('Create Stream'); + screen.getByText('Create Stream'); // Table column headers - getByText('Name'); - within(getByRole('table')).getByText('Status'); - getByText('ID'); - getByText('Destination Type'); + screen.getByText('Name'); + screen.getByText('Stream Type'); + within(screen.getByRole('table')).getByText('Status'); + screen.getByText('ID'); + screen.getByText('Destination Type'); + screen.getByText('Creation Time'); // PaginationFooter - const paginationFooterSelectPageSizeInput = getAllByTestId( + const paginationFooterSelectPageSizeInput = screen.getAllByTestId( 'textfield-input' )[2] as HTMLInputElement; expect(paginationFooterSelectPageSizeInput.value).toBe('Show 25'); @@ -63,17 +101,109 @@ describe('Streams Landing Table', () => { }) ); - const { getByText, queryByTestId } = renderWithTheme(, { - initialRoute: '/datastream/streams', - }); - - const loadingElement = queryByTestId(loadingTestId); - if (loadingElement) { - await waitForElementToBeRemoved(loadingElement); - } + await renderComponentAndWaitForLoadingComplete(); - getByText((text) => + screen.getByText((text) => text.includes('Create a data stream and configure delivery of cloud logs') ); }); + + const clickOnActionMenu = async () => { + const actionMenu = screen.getByLabelText( + `Action menu for Stream ${stream.label}` + ); + await userEvent.click(actionMenu); + }; + + const clickOnActionMenuItem = async (itemText: string) => { + await userEvent.click(screen.getByText(itemText)); + }; + + describe('given action menu', () => { + beforeEach(() => { + server.use( + http.get('*/monitor/streams', () => { + return HttpResponse.json(makeResourcePage(streams)); + }) + ); + }); + + describe('when Edit clicked', () => { + it('should navigate to edit page', async () => { + const mockNavigate = vi.fn(); + queryMocks.useNavigate.mockReturnValue(mockNavigate); + + await renderComponentAndWaitForLoadingComplete(); + + await clickOnActionMenu(); + await clickOnActionMenuItem('Edit'); + + expect(mockNavigate).toHaveBeenCalledWith({ + to: '/datastream/streams/1/edit', + }); + }); + }); + + describe('when Disable clicked', () => { + it('should update stream with proper parameters', async () => { + const mockUpdateStreamMutation = vi.fn().mockResolvedValue({}); + queryMocks.useUpdateStreamMutation.mockReturnValue({ + mutateAsync: mockUpdateStreamMutation, + }); + + await renderComponentAndWaitForLoadingComplete(); + await clickOnActionMenu(); + await clickOnActionMenuItem('Disable'); + + expect(mockUpdateStreamMutation).toHaveBeenCalledWith({ + id: 1, + status: 'inactive', + label: 'Data Stream 1', + destinations: [123], + details: {}, + type: 'audit_logs', + }); + }); + }); + + describe('when Enabled clicked', () => { + it('should update stream with proper parameters', async () => { + const mockUpdateStreamMutation = vi.fn().mockResolvedValue({}); + queryMocks.useUpdateStreamMutation.mockReturnValue({ + mutateAsync: mockUpdateStreamMutation, + }); + + stream.status = 'inactive'; + await renderComponentAndWaitForLoadingComplete(); + await clickOnActionMenu(); + await clickOnActionMenuItem('Enable'); + + expect(mockUpdateStreamMutation).toHaveBeenCalledWith({ + id: 1, + status: 'active', + label: 'Data Stream 1', + destinations: [123], + details: {}, + type: 'audit_logs', + }); + }); + }); + + describe('when Delete clicked', () => { + it('should delete stream', async () => { + const mockDeleteStreamMutation = vi.fn().mockResolvedValue({}); + queryMocks.useDeleteStreamMutation.mockReturnValue({ + mutateAsync: mockDeleteStreamMutation, + }); + + await renderComponentAndWaitForLoadingComplete(); + await clickOnActionMenu(); + await clickOnActionMenuItem('Delete'); + + expect(mockDeleteStreamMutation).toHaveBeenCalledWith({ + id: 1, + }); + }); + }); + }); }); diff --git a/packages/manager/src/features/DataStream/Streams/StreamsLanding.tsx b/packages/manager/src/features/DataStream/Streams/StreamsLanding.tsx index 39089819cd8..6d131412af3 100644 --- a/packages/manager/src/features/DataStream/Streams/StreamsLanding.tsx +++ b/packages/manager/src/features/DataStream/Streams/StreamsLanding.tsx @@ -1,8 +1,14 @@ -import { useStreamsQuery } from '@linode/queries'; +import { streamStatus } from '@linode/api-v4'; +import { + useDeleteStreamMutation, + useStreamsQuery, + useUpdateStreamMutation, +} from '@linode/queries'; import { CircleProgress, ErrorState, Hidden } from '@linode/ui'; import { TableBody, TableCell, TableHead, TableRow } from '@mui/material'; import Table from '@mui/material/Table'; import { useNavigate, useSearch } from '@tanstack/react-router'; +import { enqueueSnackbar } from 'notistack'; import * as React from 'react'; import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter'; @@ -18,9 +24,14 @@ import { StreamsLandingEmptyState } from 'src/features/DataStream/Streams/Stream import { StreamTableRow } from 'src/features/DataStream/Streams/StreamTableRow'; import { useOrderV2 } from 'src/hooks/useOrderV2'; import { usePaginationV2 } from 'src/hooks/usePaginationV2'; +import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; + +import type { Handlers as StreamHandlers } from './StreamActionMenu'; +import type { Stream } from '@linode/api-v4'; export const StreamsLanding = () => { const navigate = useNavigate(); + const streamsUrl = '/datastream/streams'; const search = useSearch({ from: '/datastream/streams', @@ -42,6 +53,9 @@ export const StreamsLanding = () => { preferenceKey: `streams-order`, }); + const { mutateAsync: updateStream } = useUpdateStreamMutation(); + const { mutateAsync: deleteStream } = useDeleteStreamMutation(); + const filter = { ['+order']: order, ['+order_by']: orderBy, @@ -106,6 +120,78 @@ export const StreamsLanding = () => { return ; } + const handleEdit = ({ id }: Stream) => { + navigate({ to: `/datastream/streams/${id}/edit` }); + }; + + const handleDelete = ({ id, label }: Stream) => { + deleteStream({ + id, + }) + .then(() => { + return enqueueSnackbar(`Stream ${label} deleted successfully`, { + variant: 'success', + }); + }) + .catch((error) => { + return enqueueSnackbar( + getAPIErrorOrDefault( + error, + `There was an issue deleting your stream` + )[0].reason, + { + variant: 'error', + } + ); + }); + }; + + const handleDisableOrEnable = ({ + id, + destinations, + details, + label, + type, + status, + }: Stream) => { + updateStream({ + id, + destinations: destinations.map(({ id: destinationId }) => destinationId), + details, + label, + type, + status: + status === streamStatus.Active + ? streamStatus.Inactive + : streamStatus.Active, + }) + .then(() => { + return enqueueSnackbar( + `Stream ${label} ${status === streamStatus.Active ? 'disabled' : 'enabled'}`, + { + variant: 'success', + } + ); + }) + .catch((error) => { + return enqueueSnackbar( + getAPIErrorOrDefault( + error, + `There was an issue ${status === streamStatus.Active ? 'disabling' : 'enabling'} your stream` + )[0].reason, + { + variant: 'error', + } + ); + }); + }; + + const handlers: StreamHandlers = { + onDisableOrEnable: handleDisableOrEnable, + onEdit: handleEdit, + onDelete: handleDelete, + }; + return ( <> { direction={order} handleClick={handleOrderChange} label="label" + sx={{ width: '30%' }} > Name + Stream Type { > ID - Destination Type + Destination Type + + { Creation Time + {streams?.data.map((stream) => ( - + ))} diff --git a/packages/manager/src/features/DataStream/dataStreamUtils.ts b/packages/manager/src/features/DataStream/dataStreamUtils.ts index 7caef631238..50111ba9186 100644 --- a/packages/manager/src/features/DataStream/dataStreamUtils.ts +++ b/packages/manager/src/features/DataStream/dataStreamUtils.ts @@ -1,8 +1,42 @@ -import { destinationTypeOptions } from 'src/features/DataStream/Shared/types'; +import { isEmpty, streamType } from '@linode/api-v4'; +import { omitProps } from '@linode/ui'; -import type { DestinationTypeOption } from 'src/features/DataStream/Shared/types'; +import { + destinationTypeOptions, + streamTypeOptions, +} from 'src/features/DataStream/Shared/types'; + +import type { StreamDetails, StreamType } from '@linode/api-v4'; +import type { + FormMode, + LabelValueOption, +} from 'src/features/DataStream/Shared/types'; export const getDestinationTypeOption = ( destinationTypeValue: string -): DestinationTypeOption | undefined => +): LabelValueOption | undefined => destinationTypeOptions.find(({ value }) => value === destinationTypeValue); + +export const getStreamTypeOption = ( + streamTypeValue: string +): LabelValueOption | undefined => + streamTypeOptions.find(({ value }) => value === streamTypeValue); + +export const isFormInEditMode = (mode: FormMode) => mode === 'edit'; + +export const getStreamPayloadDetails = ( + type: StreamType, + details: StreamDetails +): StreamDetails => { + let payloadDetails: StreamDetails = {}; + + if (!isEmpty(details) && type === streamType.LKEAuditLogs) { + if (details.is_auto_add_all_clusters_enabled) { + payloadDetails = omitProps(details, ['cluster_ids']); + } else { + payloadDetails = omitProps(details, ['is_auto_add_all_clusters_enabled']); + } + } + + return payloadDetails; +}; diff --git a/packages/manager/src/features/Events/factories/datastream.tsx b/packages/manager/src/features/Events/factories/datastream.tsx index 26f01242c16..ddcffb1fb59 100644 --- a/packages/manager/src/features/Events/factories/datastream.tsx +++ b/packages/manager/src/features/Events/factories/datastream.tsx @@ -13,6 +13,22 @@ export const stream: PartialEventMap<'stream'> = { ), }, + stream_delete: { + notification: (e) => ( + <> + Stream has been{' '} + deleted. + + ), + }, + stream_update: { + notification: (e) => ( + <> + Stream has been{' '} + updated. + + ), + }, }; export const destination: PartialEventMap<'destination'> = { diff --git a/packages/manager/src/mocks/presets/crud/datastream.ts b/packages/manager/src/mocks/presets/crud/datastream.ts index 8820ded8dd7..8fdde956d14 100644 --- a/packages/manager/src/mocks/presets/crud/datastream.ts +++ b/packages/manager/src/mocks/presets/crud/datastream.ts @@ -1,15 +1,24 @@ import { createDestinations, createStreams, + deleteStream, getDestinations, getStreams, + updateStream, } from 'src/mocks/presets/crud/handlers/datastream'; import type { MockPresetCrud } from 'src/mocks/types'; export const datastreamCrudPreset: MockPresetCrud = { group: { id: 'DataStream' }, - handlers: [getStreams, createStreams, getDestinations, createDestinations], + handlers: [ + getStreams, + createStreams, + deleteStream, + updateStream, + getDestinations, + createDestinations, + ], id: 'datastream:crud', label: 'Data Stream CRUD', }; diff --git a/packages/manager/src/mocks/presets/crud/handlers/datastream.ts b/packages/manager/src/mocks/presets/crud/handlers/datastream.ts index cf49a542407..52c24054c3b 100644 --- a/packages/manager/src/mocks/presets/crud/handlers/datastream.ts +++ b/packages/manager/src/mocks/presets/crud/handlers/datastream.ts @@ -92,6 +92,84 @@ export const createStreams = (mockState: MockState) => [ ), ]; +export const updateStream = (mockState: MockState) => [ + http.put( + '*/v4beta/monitor/streams/:id', + async ({ + params, + request, + }): Promise> => { + const id = Number(params.id); + const stream = await mswDB.get('streams', id); + + if (!stream) { + return makeNotFoundResponse(); + } + + const destinations = await mswDB.getAll('destinations'); + const payload = await request.clone().json(); + const updatedStream = { + ...stream, + ...payload, + destinations: payload['destinations'].map((destinationId: number) => + destinations?.find(({ id }) => id === destinationId) + ), + updated: DateTime.now().toISO(), + }; + + await mswDB.update('streams', id, updatedStream, mockState); + + queueEvents({ + event: { + action: 'stream_update', + entity: { + id: stream.id, + label: stream.label, + type: 'stream', + url: `/v4beta/monitor/streams/${stream.id}`, + }, + }, + mockState, + sequence: [{ status: 'notification' }], + }); + + return makeResponse(updatedStream); + } + ), +]; + +export const deleteStream = (mockState: MockState) => [ + http.delete( + '*/v4beta/monitor/streams/:id', + async ({ params }): Promise> => { + const id = Number(params.id); + const stream = await mswDB.get('streams', id); + + if (!stream) { + return makeNotFoundResponse(); + } + + await mswDB.delete('streams', id, mockState); + + queueEvents({ + event: { + action: 'stream_delete', + entity: { + id: stream.id, + label: stream.label, + type: 'domain', + url: `/v4beta/monitor/streams/${stream.id}`, + }, + }, + mockState, + sequence: [{ status: 'notification' }], + }); + + return makeResponse({}); + } + ), +]; + export const getDestinations = () => [ http.get( '*/v4beta/monitor/streams/destinations', diff --git a/packages/manager/src/routes/datastream/index.ts b/packages/manager/src/routes/datastream/index.ts index 7f421ed2fdc..a8205708a23 100644 --- a/packages/manager/src/routes/datastream/index.ts +++ b/packages/manager/src/routes/datastream/index.ts @@ -43,10 +43,27 @@ const streamsCreateRoute = createRoute({ path: 'streams/create', }).lazy(() => import( - 'src/features/DataStream/Streams/StreamCreate/streamCreateLazyRoute' + 'src/features/DataStream/Streams/StreamForm/streamCreateLazyRoute' ).then((m) => m.streamCreateLazyRoute) ); +const streamsEditRoute = createRoute({ + getParentRoute: () => dataStreamRoute, + params: { + parse: ({ streamId }: { streamId: string }) => ({ + streamId: Number(streamId), + }), + stringify: ({ streamId }: { streamId: number }) => ({ + streamId: String(streamId), + }), + }, + path: 'streams/$streamId/edit', +}).lazy(() => + import('src/features/DataStream/Streams/StreamForm/streamEditLazyRoute').then( + (m) => m.streamEditLazyRoute + ) +); + export interface DestinationSearchParams extends TableSearchParams { label?: string; } @@ -72,6 +89,6 @@ const destinationsCreateRoute = createRoute({ export const dataStreamRouteTree = dataStreamRoute.addChildren([ dataStreamLandingRoute, - streamsRoute.addChildren([streamsCreateRoute]), + streamsRoute.addChildren([streamsCreateRoute, streamsEditRoute]), destinationsRoute.addChildren([destinationsCreateRoute]), ]); diff --git a/packages/queries/.changeset/pr-12645-upcoming-features-1754489801598.md b/packages/queries/.changeset/pr-12645-upcoming-features-1754489801598.md new file mode 100644 index 00000000000..0f003c00343 --- /dev/null +++ b/packages/queries/.changeset/pr-12645-upcoming-features-1754489801598.md @@ -0,0 +1,5 @@ +--- +"@linode/queries": Upcoming Features +--- + +Add queries for Streams DELETE, PUT API endpoints ([#12645](https://github.com/linode/manager/pull/12645)) diff --git a/packages/queries/src/datastreams/datastream.ts b/packages/queries/src/datastreams/datastream.ts index 902c2be377d..4ab748b38d5 100644 --- a/packages/queries/src/datastreams/datastream.ts +++ b/packages/queries/src/datastreams/datastream.ts @@ -1,10 +1,12 @@ import { createDestination, createStream, + deleteStream, getDestination, getDestinations, getStream, getStreams, + updateStream, } from '@linode/api-v4'; import { profileQueries } from '@linode/queries'; import { getAll } from '@linode/utilities'; @@ -20,6 +22,7 @@ import type { Params, ResourcePage, Stream, + UpdateStreamPayloadWithId, } from '@linode/api-v4'; export const getAllDataStreams = ( @@ -83,6 +86,9 @@ export const useStreamsQuery = (params: Params = {}, filter: Filter = {}) => ...datastreamQueries.streams._ctx.paginated(params, filter), }); +export const useStreamQuery = (id: number) => + useQuery({ ...datastreamQueries.stream(id) }); + export const useCreateStreamMutation = () => { const queryClient = useQueryClient(); return useMutation({ @@ -107,6 +113,43 @@ export const useCreateStreamMutation = () => { }); }; +export const useUpdateStreamMutation = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, ...data }) => updateStream(id, data), + onSuccess(stream) { + // Invalidate paginated lists + queryClient.invalidateQueries({ + queryKey: datastreamQueries.streams.queryKey, + }); + + // Update stream in cache + queryClient.setQueryData( + datastreamQueries.stream(stream.id).queryKey, + stream, + ); + }, + }); +}; + +export const useDeleteStreamMutation = () => { + const queryClient = useQueryClient(); + return useMutation<{}, APIError[], { id: number }>({ + mutationFn: ({ id }) => deleteStream(id), + onSuccess(_, { id }) { + // Invalidate paginated lists + queryClient.invalidateQueries({ + queryKey: datastreamQueries.streams.queryKey, + }); + + // Remove stream from the cache + queryClient.removeQueries({ + queryKey: datastreamQueries.stream(id).queryKey, + }); + }, + }); +}; + export const useAllDestinationsQuery = ( params: Params = {}, filter: Filter = {}, diff --git a/packages/validation/src/datastream.schema.ts b/packages/validation/src/datastream.schema.ts index 14d21b97088..a7242c86388 100644 --- a/packages/validation/src/datastream.schema.ts +++ b/packages/validation/src/datastream.schema.ts @@ -125,34 +125,37 @@ const streamSchemaBase = object({ .max(maxLength, maxLengthMessage) .required('Stream name is required.'), status: mixed<'active' | 'inactive'>().oneOf(['active', 'inactive']), + type: string() + .oneOf(['audit_logs', 'lke_audit_logs']) + .required('Stream type is required.'), destinations: array().of(number().defined()).ensure().min(1).required(), - details: mixed | object>().when( - 'type', - { + details: mixed | object>() + .when('type', { is: 'lke_audit_logs', then: () => streamDetailsSchema.required(), otherwise: detailsShouldBeEmpty, - }, - ), + }) + .required(), }); -export const createStreamSchema = streamSchemaBase.shape({ - type: string() - .oneOf(['audit_logs', 'lke_audit_logs']) - .required('Stream type is required.'), +export const createStreamSchema = streamSchemaBase; + +export const updateStreamSchema = streamSchemaBase.shape({ + status: mixed<'active' | 'inactive'>() + .oneOf(['active', 'inactive']) + .required(), }); -export const createStreamAndDestinationFormSchema = object({ - stream: createStreamSchema.shape({ +export const streamAndDestinationFormSchema = object({ + stream: streamSchemaBase.shape({ destinations: array().of(number()).ensure().min(1).required(), - details: mixed | object>().when( - 'type', - { + details: mixed | object>() + .when('type', { is: 'lke_audit_logs', then: () => streamDetailsBase.required(), otherwise: detailsShouldBeEmpty, - }, - ), + }) + .required(), }), destination: createDestinationSchema.defined().when('stream.destinations', { is: (value: never[]) => value?.length === 1 && value[0] === undefined,