diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8f2e6d4c90..7ba06a9d64 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,7 @@ Versioning](https://semver.org/spec/v2.0.0.html).
- standalone website:
- Integrate VOD dashboard (#2086)
- List the lives in the contents section (#2104)
+ - Create a live from the website (#2134)
- Add a License Manager widget for LTI VOD view
- Add a title to the classroom file dropzone
- Add can_edit property on a serialized video
diff --git a/src/frontend/apps/standalone_site/src/features/Contents/features/Live/components/Create/LiveCreate.spec.tsx b/src/frontend/apps/standalone_site/src/features/Contents/features/Live/components/Create/LiveCreate.spec.tsx
new file mode 100644
index 0000000000..69d3d2e2d0
--- /dev/null
+++ b/src/frontend/apps/standalone_site/src/features/Contents/features/Live/components/Create/LiveCreate.spec.tsx
@@ -0,0 +1,29 @@
+import { screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { render } from 'lib-tests';
+
+import LiveCreate from './LiveCreate';
+
+jest.mock('./LiveCreateForm', () => ({
+ __esModule: true,
+ default: () =>
My LiveCreate Form
,
+}));
+
+describe(' ', () => {
+ test('renders LiveCreate', () => {
+ render( );
+
+ const button = screen.getByRole('button', { name: /Create Live/i });
+ expect(button).toBeInTheDocument();
+ expect(
+ screen.queryByRole('heading', { name: /Create Live/i }),
+ ).not.toBeInTheDocument();
+
+ userEvent.click(button);
+
+ expect(
+ screen.getByRole('heading', { name: /Create Live/i }),
+ ).toBeInTheDocument();
+ expect(screen.getByText('My LiveCreate Form')).toBeInTheDocument();
+ });
+});
diff --git a/src/frontend/apps/standalone_site/src/features/Contents/features/Live/components/Create/LiveCreate.tsx b/src/frontend/apps/standalone_site/src/features/Contents/features/Live/components/Create/LiveCreate.tsx
new file mode 100644
index 0000000000..4190fd5284
--- /dev/null
+++ b/src/frontend/apps/standalone_site/src/features/Contents/features/Live/components/Create/LiveCreate.tsx
@@ -0,0 +1,80 @@
+import { Button, Heading, Text } from 'grommet';
+import { useResponsive } from 'lib-components';
+import { Fragment } from 'react';
+import { defineMessages, useIntl } from 'react-intl';
+import { Link, Route, Switch, useHistory } from 'react-router-dom';
+
+import { WhiteCard } from 'components/Cards';
+import { Modal } from 'components/Modal';
+import { routes } from 'routes';
+
+import LiveCreateForm from './LiveCreateForm';
+
+const messages = defineMessages({
+ LiveTitle: {
+ defaultMessage: 'Lives',
+ description: 'Lives title',
+ id: 'features.Contents.features.Live.Create.LiveTitle',
+ },
+ CreateLiveLabel: {
+ defaultMessage: 'Create Live',
+ description: 'Text heading create live.',
+ id: 'features.Contents.features.Live.Create.CreateLiveLabel',
+ },
+});
+
+const LiveCreate = () => {
+ const intl = useIntl();
+ const { breakpoint } = useResponsive();
+ const history = useHistory();
+
+ const liveRoute = routes.CONTENTS.subRoutes.LIVE;
+ const livePath = liveRoute.path;
+ const liveCreatePath = liveRoute.subRoutes?.CREATE?.path || '';
+
+ return (
+
+
+
+ {intl.formatMessage(messages.LiveTitle)}
+
+
+
+
+
+
+
+ {
+ history.push(livePath);
+ }}
+ >
+
+ {intl.formatMessage(messages.CreateLiveLabel)}
+
+
+
+
+
+
+ );
+};
+
+export default LiveCreate;
diff --git a/src/frontend/apps/standalone_site/src/features/Contents/features/Live/components/Create/LiveCreateForm.spec.tsx b/src/frontend/apps/standalone_site/src/features/Contents/features/Live/components/Create/LiveCreateForm.spec.tsx
new file mode 100644
index 0000000000..ddbc8d0aa0
--- /dev/null
+++ b/src/frontend/apps/standalone_site/src/features/Contents/features/Live/components/Create/LiveCreateForm.spec.tsx
@@ -0,0 +1,229 @@
+import { screen, fireEvent, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import fetchMock from 'fetch-mock';
+import { createMemoryHistory } from 'history';
+import { render, Deferred } from 'lib-tests';
+import { Router } from 'react-router-dom';
+
+import LiveCreateForm from './LiveCreateForm';
+
+const playlistsResponse = {
+ count: 1,
+ next: null,
+ previous: null,
+ results: [
+ { id: 'some-playlist-id', title: 'some playlist title' },
+ { id: 'an-other-playlist', title: 'an other title' },
+ ],
+};
+
+const consoleError = jest
+ .spyOn(console, 'error')
+ .mockImplementation(() => jest.fn());
+
+let deferredPlaylists: Deferred;
+describe(' ', () => {
+ beforeEach(() => {
+ deferredPlaylists = new Deferred();
+ fetchMock.get(
+ '/api/playlists/?limit=20&offset=0&ordering=-created_on&can_edit=true',
+ deferredPlaylists.promise,
+ );
+ });
+
+ afterEach(() => {
+ fetchMock.restore();
+ jest.resetAllMocks();
+ consoleError.mockClear();
+ });
+
+ test('renders LiveCreateForm', async () => {
+ render( );
+
+ deferredPlaylists.resolve(playlistsResponse);
+
+ expect(screen.getByRole('textbox', { name: /title/i })).toBeInTheDocument();
+ expect(
+ await screen.findByRole('button', {
+ name: 'Choose the playlist.; Selected: some-playlist-id',
+ }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole('textbox', { name: /description/i }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole('button', { name: /Add Live/i }),
+ ).toBeInTheDocument();
+ });
+
+ test('fields mandatory', async () => {
+ render( );
+
+ deferredPlaylists.resolve(playlistsResponse);
+
+ fireEvent.change(screen.getByRole('textbox', { name: /title/i }), {
+ target: { value: 'my title' },
+ });
+
+ userEvent.click(
+ await screen.findByRole('button', {
+ name: 'Choose the playlist.; Selected: some-playlist-id',
+ }),
+ );
+
+ userEvent.click(
+ await screen.findByRole('option', { name: 'an other title' }),
+ );
+
+ expect(
+ await screen.findByRole('button', {
+ name: 'Choose the playlist.; Selected: an-other-playlist',
+ }),
+ ).toBeInTheDocument();
+
+ await waitFor(() =>
+ expect(
+ screen.getByRole('button', { name: /Add Live/i }),
+ ).not.toBeDisabled(),
+ );
+ });
+
+ test('fields are posted correctly', async () => {
+ const history = createMemoryHistory();
+ fetchMock.post('/api/videos/', {
+ ok: true,
+ id: '1234',
+ });
+
+ fetchMock.mock(
+ '/api/videos/1234/initiate-live/',
+ {
+ ok: true,
+ id: '1234',
+ is_live: true,
+ },
+ {
+ method: 'POST',
+ },
+ );
+
+ render(
+
+
+ ,
+ );
+
+ deferredPlaylists.resolve(playlistsResponse);
+
+ fireEvent.change(screen.getByRole('textbox', { name: /title/i }), {
+ target: { value: 'my title' },
+ });
+
+ fireEvent.change(screen.getByRole('textbox', { name: /description/i }), {
+ target: { value: 'my description' },
+ });
+
+ userEvent.click(
+ await screen.findByRole('button', {
+ name: 'Choose the playlist.; Selected: some-playlist-id',
+ }),
+ );
+
+ userEvent.click(
+ await screen.findByRole('option', { name: 'an other title' }),
+ );
+
+ expect(
+ await screen.findByRole('button', {
+ name: 'Choose the playlist.; Selected: an-other-playlist',
+ }),
+ ).toBeInTheDocument();
+
+ const submit = screen.getByRole('button', { name: /Add Live/i });
+
+ await waitFor(() => expect(submit).not.toBeDisabled());
+
+ userEvent.click(submit);
+
+ await waitFor(() => {
+ expect(
+ fetchMock.lastCall('/api/videos/', {
+ method: 'POST',
+ })?.[1],
+ ).toEqual({
+ headers: { 'Content-Type': 'application/json' },
+ method: 'POST',
+ body: JSON.stringify({
+ playlist: 'an-other-playlist',
+ title: 'my title',
+ live_type: 'jitsi',
+ description: 'my description',
+ }),
+ });
+ });
+
+ await waitFor(() => {
+ expect(history.location.pathname).toBe('/my-contents/lives/1234');
+ });
+ });
+
+ test('post failed', async () => {
+ fetchMock.post('/api/videos/', 500);
+
+ render( );
+
+ deferredPlaylists.resolve(playlistsResponse);
+
+ fireEvent.change(screen.getByRole('textbox', { name: /title/i }), {
+ target: { value: 'my title' },
+ });
+
+ fireEvent.change(screen.getByRole('textbox', { name: /description/i }), {
+ target: { value: 'my description' },
+ });
+
+ userEvent.click(
+ await screen.findByRole('button', {
+ name: 'Choose the playlist.; Selected: some-playlist-id',
+ }),
+ );
+
+ userEvent.click(
+ await screen.findByRole('option', { name: 'an other title' }),
+ );
+
+ expect(
+ await screen.findByRole('button', {
+ name: 'Choose the playlist.; Selected: an-other-playlist',
+ }),
+ ).toBeInTheDocument();
+
+ const submit = screen.getByRole('button', { name: /Add Live/i });
+
+ await waitFor(() => expect(submit).not.toBeDisabled());
+
+ userEvent.click(submit);
+
+ expect(
+ await screen.findByText(
+ /Sorry, an error has occurred. Please try again./i,
+ ),
+ ).toBeInTheDocument();
+
+ expect(consoleError).toHaveBeenCalled();
+ });
+
+ test('error playlist', async () => {
+ render( );
+
+ deferredPlaylists.resolve(500);
+
+ expect(
+ await screen.findByText(
+ /Sorry, an error has occurred. Please try again./i,
+ ),
+ ).toBeInTheDocument();
+
+ expect(consoleError).toHaveBeenCalled();
+ });
+});
diff --git a/src/frontend/apps/standalone_site/src/features/Contents/features/Live/components/Create/LiveCreateForm.tsx b/src/frontend/apps/standalone_site/src/features/Contents/features/Live/components/Create/LiveCreateForm.tsx
new file mode 100644
index 0000000000..cfefe6dd3c
--- /dev/null
+++ b/src/frontend/apps/standalone_site/src/features/Contents/features/Live/components/Create/LiveCreateForm.tsx
@@ -0,0 +1,159 @@
+import { Text, TextInput, Box, TextArea } from 'grommet';
+import { Alert } from 'grommet-icons';
+import { Nullable } from 'lib-common';
+import { Form, FormField, LiveModeType, useResponsive } from 'lib-components';
+import { initiateLive, useCreateVideo } from 'lib-video';
+import { Fragment, useState } from 'react';
+import { defineMessages, useIntl } from 'react-intl';
+import { useQueryClient } from 'react-query';
+import { useHistory } from 'react-router-dom';
+
+import { ModalButton } from 'components/Modal';
+import { useSelectPlaylist } from 'features/Playlist';
+import { routes } from 'routes';
+
+const messages = defineMessages({
+ titleLabel: {
+ defaultMessage: 'Title',
+ description: 'Label for title in live creation form.',
+ id: 'features.Contents.features.Live.LiveCreateForm.titleLabel',
+ },
+ descriptionLabel: {
+ defaultMessage: 'Description',
+ description: 'Label for description in live creation form.',
+ id: 'features.Contents.features.Live.LiveCreateForm.descriptionLabel',
+ },
+ requiredField: {
+ defaultMessage: 'This field is required to create the live.',
+ description: 'Message when live field is missing.',
+ id: 'features.Contents.features.Live.LiveCreateForm.requiredField',
+ },
+ submitLabel: {
+ defaultMessage: 'Add Live',
+ description: 'Label for button submit in live creation form.',
+ id: 'features.Contents.features.Live.LiveCreateForm.submitLabel',
+ },
+ Error: {
+ defaultMessage: 'Sorry, an error has occurred. Please try again.',
+ description: 'Text when there is an error.',
+ id: 'features.Contents.features.Live.LiveCreateForm.Error',
+ },
+});
+
+type LiveCreate = {
+ playlist: string;
+ title: string;
+ description?: string;
+ live_type?: Nullable;
+};
+
+const LiveCreateForm = () => {
+ const intl = useIntl();
+ const queryClient = useQueryClient();
+ const history = useHistory();
+ const { isDesktop } = useResponsive();
+ const [isUpdatingToLive, setIsUpdatingToLive] = useState(false);
+ const livePath = routes.CONTENTS.subRoutes.LIVE.path;
+ const [live, setLive] = useState({
+ playlist: '',
+ title: '',
+ live_type: LiveModeType.JITSI,
+ });
+ const { errorPlaylist, selectPlaylist } = useSelectPlaylist((results) => {
+ setLive((value) => ({
+ ...value,
+ playlist: results[0].id,
+ }));
+ });
+ const {
+ mutate: createLive,
+ error: errorVideo,
+ isLoading: isCreating,
+ } = useCreateVideo({
+ onSuccess: async (data) => {
+ setIsUpdatingToLive(true);
+
+ await initiateLive(data, LiveModeType.JITSI);
+
+ // Force a refresh of the videos list to get with `is_live=true`
+ await queryClient.resetQueries(['videos']);
+
+ setIsUpdatingToLive(false);
+
+ history.push(`${livePath}/${data.id}`);
+ },
+ });
+
+ return (
+
+ {(errorVideo || errorPlaylist) && (
+
+
+
+ {intl.formatMessage(messages.Error)}
+
+
+ )}
+
+
+ );
+};
+
+export default LiveCreateForm;
diff --git a/src/frontend/apps/standalone_site/src/features/Contents/features/Live/components/LiveRouter.spec.tsx b/src/frontend/apps/standalone_site/src/features/Contents/features/Live/components/LiveRouter.spec.tsx
index f1329dd956..e16d2b58e6 100644
--- a/src/frontend/apps/standalone_site/src/features/Contents/features/Live/components/LiveRouter.spec.tsx
+++ b/src/frontend/apps/standalone_site/src/features/Contents/features/Live/components/LiveRouter.spec.tsx
@@ -17,13 +17,34 @@ describe(' ', () => {
render( , {
routerOptions: { history: ['/my-contents/lives'] },
});
+
+ expect(
+ screen.getByRole('button', { name: 'Create Live' }),
+ ).toBeInTheDocument();
expect(screen.getByText('My Lives Read')).toBeInTheDocument();
});
+ test('render create live', async () => {
+ render( , {
+ routerOptions: { history: ['/my-contents/lives/create'] },
+ });
+ expect(
+ await screen.findByRole('heading', {
+ name: 'Create Live',
+ level: 2,
+ }),
+ ).toBeInTheDocument();
+ expect(screen.getByText(/My Lives Read/i)).toBeInTheDocument();
+ });
+
test('render live no match', () => {
render( , {
routerOptions: { history: ['/some/bad/route'] },
});
+
+ expect(
+ screen.getByRole('button', { name: 'Create Live' }),
+ ).toBeInTheDocument();
expect(screen.getByText('My Lives Read')).toBeInTheDocument();
});
});
diff --git a/src/frontend/apps/standalone_site/src/features/Contents/features/Live/components/LiveRouter.tsx b/src/frontend/apps/standalone_site/src/features/Contents/features/Live/components/LiveRouter.tsx
index 3c943b5e87..cc856f4747 100644
--- a/src/frontend/apps/standalone_site/src/features/Contents/features/Live/components/LiveRouter.tsx
+++ b/src/frontend/apps/standalone_site/src/features/Contents/features/Live/components/LiveRouter.tsx
@@ -1,13 +1,24 @@
import { Box } from 'grommet';
import { Route, Switch } from 'react-router-dom';
+import { routes } from 'routes';
+
+import LiveCreate from './Create/LiveCreate';
import Lives from './Read/Lives';
const LiveRouter = () => {
+ const liveRoute = routes.CONTENTS.subRoutes.LIVE;
+ const liveCreatePath = liveRoute.subRoutes?.CREATE?.path || '';
+
return (
+
+
+
+
+
diff --git a/src/frontend/apps/standalone_site/src/routes/routes.tsx b/src/frontend/apps/standalone_site/src/routes/routes.tsx
index e761d68a86..7f5e356418 100644
--- a/src/frontend/apps/standalone_site/src/routes/routes.tsx
+++ b/src/frontend/apps/standalone_site/src/routes/routes.tsx
@@ -233,11 +233,6 @@ export const routes: Routes = {
),
subRoutes: {
CREATE: {
- label: (
-
- ),
path: `/my-contents/videos/create`,
},
UPDATE: {
@@ -256,6 +251,11 @@ export const routes: Routes = {
title={messages.menuContentsLivesLabel}
/>
),
+ subRoutes: {
+ CREATE: {
+ path: `/my-contents/lives/create`,
+ },
+ },
isNavStrict: true,
},
CLASSROOM: {
@@ -270,11 +270,6 @@ export const routes: Routes = {
),
subRoutes: {
CREATE: {
- label: (
-
- ),
path: `/my-contents/classroom/create`,
},
UPDATE: {
diff --git a/src/frontend/packages/lib_video/src/api/initiateLive/index.ts b/src/frontend/packages/lib_video/src/api/initiateLive/index.ts
index 1de2bc7c0e..a9913771ac 100644
--- a/src/frontend/packages/lib_video/src/api/initiateLive/index.ts
+++ b/src/frontend/packages/lib_video/src/api/initiateLive/index.ts
@@ -4,6 +4,7 @@ import {
API_ENDPOINT,
LiveModeType,
Video,
+ fetchResponseHandler,
} from 'lib-components';
/**
@@ -29,9 +30,7 @@ export const initiateLive = async (
},
);
- if (!response.ok) {
- throw new Error(`Failed to initialize a live mode for video ${video.id}.`);
- }
-
- return (await response.json()) as Video;
+ return await fetchResponseHandler(response, {
+ errorMessage: `Failed to initialize a live mode for video ${video.id}.`,
+ });
};