From ac52327f9dcdb8513036f8d6b61dd04bc04565e2 Mon Sep 17 00:00:00 2001 From: Justin Bambrick <55550194+jbambrick@users.noreply.github.com> Date: Wed, 13 Sep 2023 12:49:05 -0700 Subject: [PATCH] feat(coscrad-frontend): update video index and detail presentation (#458) * WIP: update video detail and create e2e test for query flow * WIP: test transcript presentation * WIP: create transcript presenter * WIP: refactors based off PR suggestions * WIP: add unique keys to media player * update stale snaphshots * Update media player source keys and update video test data * WIP Test video index filtering * update video index Cypress test * update video detail Cypress test * remove stale comment --------- Co-authored-by: Aaron Plahn --- .../fetchManyViewModels.e2e.spec.ts.snap | 80 ++++- .../fetchViewModelById.e2e.spec.ts.snap | 40 ++- .../resourceDescriptions.e2e.spec.ts.snap | 158 ++++++++- .../app/domain-modules/audio-visual.module.ts | 6 + .../common/entities/multilingual-text.ts | 4 +- .../commands/build-video-test-command-fsas.ts | 57 ++- .../audio-visual/video.view-model.ts | 21 +- .../videos/video.detail.query-flow.e2e.cy.ts | 295 ++++++++++++++++ .../videos/video.index.query-flow.e2e.cy.ts | 328 ++++++++++++++++++ .../src/support/commands.ts | 17 +- .../notes/shared/find-original-text-item.ts | 4 +- .../utils/render-cell-for-single-language.tsx | 7 + .../video-detail.full-view.presenter.tsx | 11 +- .../video-detail.thumbnail.presenter.tsx | 7 +- .../videos/video-index.presenter.tsx | 66 +++- .../transcripts/transcript-presenter.tsx | 50 +++ .../multilingual-text-presenter.tsx | 11 - .../presenters/optional.tsx | 7 + .../filter-table-data.ts | 18 +- .../multilingual-text-item.interface.ts | 2 +- .../audio-item/multilingual-text.interface.ts | 4 +- .../audio-item/video.view-model.interface.ts | 6 +- libs/media-player/src/lib/audio-player.tsx | 4 +- libs/media-player/src/lib/video-player.tsx | 16 +- 24 files changed, 1148 insertions(+), 71 deletions(-) create mode 100644 apps/coscrad-frontend-e2e/src/e2e/resources/videos/video.detail.query-flow.e2e.cy.ts create mode 100644 apps/coscrad-frontend-e2e/src/e2e/resources/videos/video.index.query-flow.e2e.cy.ts create mode 100644 apps/coscrad-frontend/src/components/resources/utils/render-cell-for-single-language.tsx create mode 100644 apps/coscrad-frontend/src/components/transcripts/transcript-presenter.tsx diff --git a/apps/api/src/app/controllers/__tests__/__snapshots__/fetchManyViewModels.e2e.spec.ts.snap b/apps/api/src/app/controllers/__tests__/__snapshots__/fetchManyViewModels.e2e.spec.ts.snap index ed1eb41c6..4e4edfd0b 100644 --- a/apps/api/src/app/controllers/__tests__/__snapshots__/fetchManyViewModels.e2e.spec.ts.snap +++ b/apps/api/src/app/controllers/__tests__/__snapshots__/fetchManyViewModels.e2e.spec.ts.snap @@ -2952,8 +2952,44 @@ exports[`When fetching multiple resources GET /resources/videos when all of the ], }, "tags": [], - "text": "[12000] [DM] This is how. {en} (role: original) [15550] -[18300] [DM] It is done {en} (role: original) [19240]", + "transcript": { + "items": [ + { + "inPointMilliseconds": 12000, + "outPointMilliseconds": 15550, + "speakerInitials": "DM", + "text": { + "items": [ + { + "languageCode": "en", + "role": "original", + "text": "This is how.", + }, + ], + }, + }, + { + "inPointMilliseconds": 18300, + "outPointMilliseconds": 19240, + "speakerInitials": "DM", + "text": { + "items": [ + { + "languageCode": "en", + "role": "original", + "text": "It is done", + }, + ], + }, + }, + ], + "participants": [ + { + "initials": "DM", + "name": "Dee Monstrator", + }, + ], + }, "videoUrl": "https://coscrad.org/wp-content/uploads/2023/05/Rexy-and-The-Egg-_3D-Dinosaur-Animation_-_-3D-Animation-_-Maya-3D.mp4", }, ], @@ -2979,8 +3015,44 @@ exports[`When fetching multiple resources GET /resources/videos when some of the ], }, "tags": [], - "text": "[12000] [DM] This is how. {en} (role: original) [15550] -[18300] [DM] It is done {en} (role: original) [19240]", + "transcript": { + "items": [ + { + "inPointMilliseconds": 12000, + "outPointMilliseconds": 15550, + "speakerInitials": "DM", + "text": { + "items": [ + { + "languageCode": "en", + "role": "original", + "text": "This is how.", + }, + ], + }, + }, + { + "inPointMilliseconds": 18300, + "outPointMilliseconds": 19240, + "speakerInitials": "DM", + "text": { + "items": [ + { + "languageCode": "en", + "role": "original", + "text": "It is done", + }, + ], + }, + }, + ], + "participants": [ + { + "initials": "DM", + "name": "Dee Monstrator", + }, + ], + }, "videoUrl": "https://coscrad.org/wp-content/uploads/2023/05/Rexy-and-The-Egg-_3D-Dinosaur-Animation_-_-3D-Animation-_-Maya-3D.mp4", }, ], diff --git a/apps/api/src/app/controllers/__tests__/__snapshots__/fetchViewModelById.e2e.spec.ts.snap b/apps/api/src/app/controllers/__tests__/__snapshots__/fetchViewModelById.e2e.spec.ts.snap index 1037fcf44..cb698f34e 100644 --- a/apps/api/src/app/controllers/__tests__/__snapshots__/fetchViewModelById.e2e.spec.ts.snap +++ b/apps/api/src/app/controllers/__tests__/__snapshots__/fetchViewModelById.e2e.spec.ts.snap @@ -518,8 +518,44 @@ exports[`GET (fetch view models) When querying for a single View Model by ID GE ], }, "tags": [], - "text": "[12000] [DM] This is how. {en} (role: original) [15550] -[18300] [DM] It is done {en} (role: original) [19240]", + "transcript": { + "items": [ + { + "inPointMilliseconds": 12000, + "outPointMilliseconds": 15550, + "speakerInitials": "DM", + "text": { + "items": [ + { + "languageCode": "en", + "role": "original", + "text": "This is how.", + }, + ], + }, + }, + { + "inPointMilliseconds": 18300, + "outPointMilliseconds": 19240, + "speakerInitials": "DM", + "text": { + "items": [ + { + "languageCode": "en", + "role": "original", + "text": "It is done", + }, + ], + }, + }, + ], + "participants": [ + { + "initials": "DM", + "name": "Dee Monstrator", + }, + ], + }, "videoUrl": "https://coscrad.org/wp-content/uploads/2023/05/Rexy-and-The-Egg-_3D-Dinosaur-Animation_-_-3D-Animation-_-Maya-3D.mp4", } `; diff --git a/apps/api/src/app/controllers/__tests__/__snapshots__/resourceDescriptions.e2e.spec.ts.snap b/apps/api/src/app/controllers/__tests__/__snapshots__/resourceDescriptions.e2e.spec.ts.snap index c8d668ba1..52081b253 100644 --- a/apps/api/src/app/controllers/__tests__/__snapshots__/resourceDescriptions.e2e.spec.ts.snap +++ b/apps/api/src/app/controllers/__tests__/__snapshots__/resourceDescriptions.e2e.spec.ts.snap @@ -711,12 +711,162 @@ exports[`GET /resources should return the expected result 1`] = ` }, }, }, - "text": { - "coscradDataType": "NON_EMPTY_STRING", - "description": "a plain-text representation of the transcript", + "transcript": { + "complexDataType": "NESTED_TYPE", + "description": "transcript for this video", "isArray": false, "isOptional": false, - "label": "plain text", + "label": "transcript", + "schema": { + "items": { + "complexDataType": "NESTED_TYPE", + "description": "time stamps with text and speaker labels", + "isArray": true, + "isOptional": true, + "label": "items", + "schema": { + "inPointMilliseconds": { + "coscradDataType": "NON_NEGATIVE_FINITE_NUMBER", + "description": "starting time stamp (ms)", + "isArray": false, + "isOptional": false, + "label": "in point", + }, + "outPointMilliseconds": { + "coscradDataType": "NON_NEGATIVE_FINITE_NUMBER", + "description": "ending time stamp (ms)", + "isArray": false, + "isOptional": false, + "label": "out point", + }, + "speakerInitials": { + "coscradDataType": "NON_EMPTY_STRING", + "description": "the label for the current timestamped item", + "isArray": false, + "isOptional": false, + "label": "label", + }, + "text": { + "complexDataType": "NESTED_TYPE", + "description": "multi-lingual text transcription \\ translation for this item", + "isArray": false, + "isOptional": false, + "label": "text", + "schema": { + "items": { + "complexDataType": "NESTED_TYPE", + "description": "one item for each provided language", + "isArray": true, + "isOptional": false, + "label": "items", + "schema": { + "languageCode": { + "complexDataType": "ENUM", + "description": "an official identifier of the language", + "enumLabel": "Language_Code", + "enumName": "LangaugeCode", + "isArray": false, + "isOptional": false, + "label": "language code", + "labelsAndValues": [ + { + "label": "Chilcotin", + "value": "clc", + }, + { + "label": "Haida", + "value": "hai", + }, + { + "label": "English", + "value": "en", + }, + { + "label": "French", + "value": "fra", + }, + { + "label": "Chinook", + "value": "chn", + }, + { + "label": "Zapotec", + "value": "zap", + }, + { + "label": "Spanish", + "value": "spa", + }, + ], + }, + "role": { + "complexDataType": "ENUM", + "description": "role of this text in the translation process", + "enumLabel": "text item role", + "enumName": "Multilingual Text Item Role", + "isArray": false, + "isOptional": false, + "label": "text item role", + "labelsAndValues": [ + { + "label": "original", + "value": "original", + }, + { + "label": "glossed to", + "value": "glossed to", + }, + { + "label": "prompt", + "value": "prompt", + }, + { + "label": "free translation", + "value": "free translation", + }, + { + "label": "literal translation", + "value": "literal translation", + }, + ], + }, + "text": { + "coscradDataType": "NON_EMPTY_STRING", + "description": "plain text in the given language", + "isArray": false, + "isOptional": false, + "label": "text", + }, + }, + }, + }, + }, + }, + }, + "participants": { + "complexDataType": "NESTED_TYPE", + "description": "a list of participants and their initials", + "isArray": true, + "isOptional": true, + "label": "participants", + "schema": { + "initials": { + "coscradDataType": "NON_EMPTY_STRING", + "description": "the initials or text identifier for this speaker", + "isArray": false, + "isOptional": false, + "label": "speaker initials", + }, + "name": { + "coscradDataType": "NON_EMPTY_STRING", + "description": "the participant's name", + "isArray": false, + "isOptional": false, + "label": "name", + }, + }, + }, + }, }, "videoUrl": { "coscradDataType": "URL", diff --git a/apps/api/src/app/domain-modules/audio-visual.module.ts b/apps/api/src/app/domain-modules/audio-visual.module.ts index 3a6cf27b7..abacccde2 100644 --- a/apps/api/src/app/domain-modules/audio-visual.module.ts +++ b/apps/api/src/app/domain-modules/audio-visual.module.ts @@ -12,6 +12,10 @@ import { TranslateLineItem, TranslateLineItemCommandHandler, } from '../../domain/models/audio-item/commands'; +import { + ImportLineItemsToTranscript, + ImportLineItemsToTranscriptCommandHandler, +} from '../../domain/models/audio-item/commands/transcripts/import-line-items-to-transcript'; import { CreateVideo, CreateVideoCommandHandler, @@ -47,6 +51,8 @@ import { VideoController } from '../controllers/resources/video.controller'; AddParticipantToTranscriptCommandHandler, TranslateLineItem, TranslateLineItemCommandHandler, + ImportLineItemsToTranscript, + ImportLineItemsToTranscriptCommandHandler, ], }) export class AudioVisualModule {} diff --git a/apps/api/src/domain/common/entities/multilingual-text.ts b/apps/api/src/domain/common/entities/multilingual-text.ts index 26dca52f8..e468dc09d 100644 --- a/apps/api/src/domain/common/entities/multilingual-text.ts +++ b/apps/api/src/domain/common/entities/multilingual-text.ts @@ -1,6 +1,6 @@ import { IMultilingualText, - IMultlingualTextItem, + IMultilingualTextItem, LanguageCode, MultilingualTextItemRole, } from '@coscrad/api-interfaces'; @@ -43,7 +43,7 @@ export const LanguageCodeEnum = (options: TypeDecoratorOptions) => options ); -export class MultilingualTextItem extends BaseDomainModel implements IMultlingualTextItem { +export class MultilingualTextItem extends BaseDomainModel implements IMultilingualTextItem { @ExternalEnum( { labelsAndValues: Object.entries(LanguageCode).map(([label, value]) => ({ diff --git a/apps/api/src/test-data/commands/build-video-test-command-fsas.ts b/apps/api/src/test-data/commands/build-video-test-command-fsas.ts index 3329f8974..ed57526cc 100644 --- a/apps/api/src/test-data/commands/build-video-test-command-fsas.ts +++ b/apps/api/src/test-data/commands/build-video-test-command-fsas.ts @@ -1,4 +1,4 @@ -import { LanguageCode } from '@coscrad/api-interfaces'; +import { AggregateType, LanguageCode } from '@coscrad/api-interfaces'; import { CommandFSA } from '../../app/controllers/command/command-fsa/command-fsa.entity'; import buildDummyUuid from '../../domain/models/__tests__/utilities/buildDummyUuid'; import { @@ -11,10 +11,12 @@ import { ADD_LINE_ITEM_TO_TRANSCRIPT, ADD_PARTICIPANT_TO_TRANSCRIPT, CREATE_TRANSCRIPT, + IMPORT_LINE_ITEMS_TO_TRANSCRIPT, } from '../../domain/models/audio-item/commands/transcripts/constants'; +import { ImportLineItemsToTranscript } from '../../domain/models/audio-item/commands/transcripts/import-line-items-to-transcript'; import { TRANSLATE_LINE_ITEM } from '../../domain/models/audio-item/commands/transcripts/translate-line-item/constants'; -import { CreateVideo } from '../../domain/models/video'; -import { AggregateType } from '../../domain/types/AggregateType'; +import { CreateVideo, TranslateVideoName } from '../../domain/models/video'; +import { TRANSLATE_VIDEO_NAME } from '../../domain/models/video/commands/constants'; const id = buildDummyUuid(91); @@ -31,6 +33,15 @@ const createVideo: CommandFSA = { }, }; +const translateVideoName: CommandFSA = { + type: TRANSLATE_VIDEO_NAME, + payload: { + aggregateCompositeIdentifier: { id, type }, + languageCode: LanguageCode.English, + text: 'translation of video name into English', + }, +}; + const createTranscript: CommandFSA = { type: CREATE_TRANSCRIPT, payload: { @@ -70,10 +81,50 @@ const translateLineItem: CommandFSA = { }, }; +const [startTime, endTime] = [0, 1200000]; + +const audioLength = endTime - startTime; + +const numberOfTimestampsToGenerate = 10; + +const epsilon = 0.0001; + +const allTimestamps = Array(numberOfTimestampsToGenerate) + .fill(0) + .map((_, index) => [ + startTime + epsilon + (index * audioLength) / numberOfTimestampsToGenerate, + startTime + ((index + 1) * audioLength) / numberOfTimestampsToGenerate - epsilon, + ]) as [number, number][]; + +const buildDummyText = ([inPoint, outPoint]: [number, number]) => + `text for line item with time range: [${inPoint}, ${outPoint}]`; + +const buildLineItemForTimestamp = ([inPointMilliseconds, outPointMilliseconds]: [ + number, + number +]) => ({ + inPointMilliseconds, + outPointMilliseconds, + text: buildDummyText([inPointMilliseconds, outPointMilliseconds]), + languageCode: LanguageCode.Chilcotin, + speakerInitials: 'LTJ', +}); + +const importLineItemsToTranscript: CommandFSA = { + type: IMPORT_LINE_ITEMS_TO_TRANSCRIPT, + payload: { + aggregateCompositeIdentifier: { id, type }, + lineItems: allTimestamps.map(buildLineItemForTimestamp), + }, +}; + export const buildVideoTestCommandFsas = () => [ createVideo, + translateVideoName, + // Transcription Command FSAs createTranscript, addParticipantToTranscript, addLineItemToTranscript, translateLineItem, + importLineItemsToTranscript, ]; diff --git a/apps/api/src/view-models/buildViewModelForResource/viewModels/audio-visual/video.view-model.ts b/apps/api/src/view-models/buildViewModelForResource/viewModels/audio-visual/video.view-model.ts index bddec5dad..b91cda429 100644 --- a/apps/api/src/view-models/buildViewModelForResource/viewModels/audio-visual/video.view-model.ts +++ b/apps/api/src/view-models/buildViewModelForResource/viewModels/audio-visual/video.view-model.ts @@ -1,15 +1,11 @@ import { IVideoViewModel, MIMEType } from '@coscrad/api-interfaces'; -import { - ExternalEnum, - NestedDataType, - NonEmptyString, - NonNegativeFiniteNumber, - URL, -} from '@coscrad/data-types'; +import { ExternalEnum, NestedDataType, NonNegativeFiniteNumber, URL } from '@coscrad/data-types'; import { ApiProperty } from '@nestjs/swagger'; import { MultilingualText } from '../../../../domain/common/entities/multilingual-text'; +import { Transcript } from '../../../../domain/models/audio-item/entities/transcript.entity'; import { Video } from '../../../../domain/models/audio-item/entities/video.entity'; import { MediaItem } from '../../../../domain/models/media-item/entities/media-item.entity'; +import { isNullOrUndefined } from '../../../../domain/utilities/validation/is-null-or-undefined'; import { BaseViewModel } from '../base.view-model'; export class VideoViewModel extends BaseViewModel implements IVideoViewModel { @@ -61,11 +57,11 @@ export class VideoViewModel extends BaseViewModel implements IVideoViewModel { * TODO [https://www.pivotaltracker.com/story/show/184522235] Expose full * transcript in view model. */ - @NonEmptyString({ - label: 'plain text', - description: 'a plain-text representation of the transcript', + @NestedDataType(Transcript, { + label: 'transcript', + description: 'transcript for this video', }) - readonly text: string; + readonly transcript: Transcript; constructor(video: Video, allMediaItems: MediaItem[]) { super(video); @@ -74,8 +70,7 @@ export class VideoViewModel extends BaseViewModel implements IVideoViewModel { this.lengthMilliseconds = lengthMilliseconds; - // TODO Send back the full data structure for rich presentation on the client - this.text = transcript?.toString() || ''; + this.transcript = isNullOrUndefined(transcript) ? null : new Transcript(transcript.toDTO()); const { url, mimeType } = allMediaItems.find(({ id }) => id === mediaItemId); diff --git a/apps/coscrad-frontend-e2e/src/e2e/resources/videos/video.detail.query-flow.e2e.cy.ts b/apps/coscrad-frontend-e2e/src/e2e/resources/videos/video.detail.query-flow.e2e.cy.ts new file mode 100644 index 000000000..3f357ef00 --- /dev/null +++ b/apps/coscrad-frontend-e2e/src/e2e/resources/videos/video.detail.query-flow.e2e.cy.ts @@ -0,0 +1,295 @@ +import { AggregateType, LanguageCode } from '@coscrad/api-interfaces'; +import { buildDummyAggregateCompositeIdentifier, buildDummyUuid } from '../../../support/utilities'; + +const videoTitleInLanguage = `Video Title`; + +const basicVideoAggregateCompositeIdentifier = buildDummyAggregateCompositeIdentifier( + AggregateType.video, + 1 +); + +const { id: basicVideoId } = basicVideoAggregateCompositeIdentifier; + +const mediaItemCompositeIdentifier = buildDummyAggregateCompositeIdentifier( + AggregateType.mediaItem, + 21 +); + +const videoDetailRoute = `/Resources/Videos/${basicVideoId}`; + +const validUrl = + 'https://coscrad.org/wp-content/uploads/2023/05/Rexy-and-The-Egg-_3D-Dinosaur-Animation_-_-3D-Animation-_-Maya-3D.mp4'; + +describe(`the video detail page`, () => { + before(() => { + cy.clearDatabase(); + + cy.seedTestUuids(50); + + cy.seedDataWithCommand('CREATE_MEDIA_ITEM', { + aggregateCompositeIdentifier: mediaItemCompositeIdentifier, + url: validUrl, + }); + + cy.seedDataWithCommand(`PUBLISH_RESOURCE`, { + aggregateCompositeIdentifier: mediaItemCompositeIdentifier, + }); + + cy.seedDataWithCommand(`CREATE_VIDEO`, { + name: videoTitleInLanguage, + aggregateCompositeIdentifier: basicVideoAggregateCompositeIdentifier, + mediaItemId: mediaItemCompositeIdentifier.id, + }); + + cy.seedDataWithCommand(`PUBLISH_RESOURCE`, { + aggregateCompositeIdentifier: basicVideoAggregateCompositeIdentifier, + }); + }); + + beforeEach(() => { + cy.visit(videoDetailRoute); + }); + + describe(`when visiting the detail route with a bogus ID`, () => { + beforeEach(() => { + cy.visit(`/Resources/Videos/${buildDummyUuid(987)}`); + + cy.getByDataAttribute('not-found'); + }); + }); + + describe(`when the video is missing optional properties`, () => { + it(`should display the video title`, () => { + /** + * At this point, we haven't added the optional transcript or name + * translation. This is a sanity check that when all optional properties + * are omitted we don't hit some kind of null-check errors. + */ + cy.contains(videoTitleInLanguage); + }); + }); + + describe(`when the video has all properties`, () => { + const speakerInitials = 'JB'; + + const speakerName = 'Justin Bambrick'; + + const lineItems = Array(10) + .fill(null) + .map((_, index) => ({ + inPointMilliseconds: index, + outPointMilliseconds: index + 1 - 0.1, + text: `This is text for line item #${index}`, + languageCode: LanguageCode.Chilcotin, + speakerInitials, + })); + + before(() => { + cy.seedDataWithCommand(`CREATE_TRANSCRIPT`, { + aggregateCompositeIdentifier: basicVideoAggregateCompositeIdentifier, + }); + + cy.seedDataWithCommand(`ADD_PARTICIPANT_TO_TRANSCRIPT`, { + aggregateCompositeIdentifier: basicVideoAggregateCompositeIdentifier, + initials: speakerInitials, + name: speakerName, + }); + + cy.seedDataWithCommand(`ADD_PARTICIPANT_TO_TRANSCRIPT`, { + aggregateCompositeIdentifier: basicVideoAggregateCompositeIdentifier, + initials: 'AP', + name: 'Aaron Plahn', + }); + + cy.seedDataWithCommand(`IMPORT_LINE_ITEMS_TO_TRANSCRIPT`, { + aggregateCompositeIdentifier: basicVideoAggregateCompositeIdentifier, + lineItems, + }); + }); + + beforeEach(() => { + // TODO troubleshoot this + cy.spy(window.HTMLVideoElement.prototype, 'play'); + }); + + it(`should render the video title`, () => { + cy.contains(videoTitleInLanguage); + }); + + /** + * We don't need to test the functionality of the copy ID button, as this + * is done elsewhere. We merely check that it is available here. + */ + it(`should expose the copy ID button`, () => { + cy.getByDataAttribute(`copy-id`); + }); + + it(`should list the participants`, () => { + cy.contains(speakerName); + }); + + lineItems.forEach( + ({ inPointMilliseconds, outPointMilliseconds, text, speakerInitials }, index) => { + describe(`line item #${index}`, () => { + it(`should display the in point`, () => { + cy.contains(inPointMilliseconds); + }); + + it(`should display the out point`, () => { + cy.contains(outPointMilliseconds); + }); + + it(`should display the text`, () => { + cy.contains(text); + }); + + it(`should display speaker initials`, () => { + cy.contains(speakerInitials); + }); + }); + } + ); + + // TODO verify this + it.skip(`should play the video`, () => { + cy.getByDataAttribute(`video-for-${validUrl}`).click(); + // TODO spy on Audio and assert it gets played. + + expect(window.HTMLVideoElement.prototype.play).to.be.calledOnce; + }); + + describe(`the connection and notes panels`, () => { + describe(`when there are no notes or connections`, () => { + it(`should display No Notes`, () => { + cy.getByDataAttribute(`open-notes-panel-button`).click(); + + cy.contains(`No Notes`); + }); + + it(`should display No Connections`, () => { + cy.getByDataAttribute(`open-connected-resources-panel-button`).click(); + + cy.contains(`No Connections`); + }); + }); + + describe(`when there is a note`, () => { + const noteText = `This is about dinosaurs.`; + + const noteCompositeIdentifier = buildDummyAggregateCompositeIdentifier( + AggregateType.note, + 40 + ); + + describe(`with general context`, () => { + before(() => { + cy.seedDataWithCommand('CREATE_NOTE_ABOUT_RESOURCE', { + aggregateCompositeIdentifier: noteCompositeIdentifier, + resourceCompositeIdentifier: basicVideoAggregateCompositeIdentifier, + text: noteText, + languageCode: LanguageCode.English, + }); + + cy.visit(videoDetailRoute); + }); + + it(`should display the note`, () => { + cy.openPanel('notes'); + + cy.contains(noteText); + }); + + it(`should close the notes panel on demand`, () => { + cy.openPanel('notes'); + + cy.contains(noteText); + + cy.getByDataAttribute(`close-notes-panel-button`).click(); + + cy.contains(noteText).should('not.exist'); + }); + }); + }); + + describe(`when there is a connection`, () => { + const connectingNoteText = `that is why this video is connected to that term`; + + const termText = `Dinosaur`; + + const termCompositeIdentifier = { + type: AggregateType.term, + id: buildDummyUuid(3), + }; + + beforeEach(() => { + // create the connected term + cy.seedDataWithCommand(`CREATE_TERM`, { + aggregateCompositeIdentifier: termCompositeIdentifier, + text: termText, + }); + + // publish the connected term + cy.seedDataWithCommand(`PUBLISH_RESOURCE`, { + aggregateCompositeIdentifier: termCompositeIdentifier, + }); + + cy.seedDataWithCommand(`CONNECT_RESOURCES_WITH_NOTE`, { + aggregateCompositeIdentifier: { + type: AggregateType.note, + id: buildDummyUuid(4), + }, + fromMemberCompositeIdentifier: basicVideoAggregateCompositeIdentifier, + toMemberCompositeIdentifier: termCompositeIdentifier, + text: connectingNoteText, + }); + }); + + // Note that the connected resource panel does not currently display the note text + it.skip(`should display the note for the connection`, () => { + cy.getByDataAttribute(`open-connected-resources-panel-button`).click(); + + cy.contains(connectingNoteText); + + cy.contains(termText); + }); + + it(`should close the connected resources panel on demand`, () => { + cy.getByDataAttribute(`open-connected-resources-panel-button`).click(); + + cy.contains(`Connected Resources`); + + cy.getByDataAttribute(`close-connected-resources-panel-button`).click(); + + cy.contains(`Connected Resources`).should('not.be.visible'); + }); + }); + }); + }); + + // TODO test this case when we are working on the disabled button tooltip story + describe.skip(`when the videoUrl is invalid`, () => { + const compositeIdForVideoWithInvalidAudioUrl = buildDummyUuid(5); + + const bogusUrl = `https://www.coscrad.org/i-do-not-exist.mp4`; + + beforeEach(() => { + // TODO We'll need to update this when there's a proper translation flow + cy.seedDataWithCommand(`CREATE_VIDEO`, { + title: `my video URL is bogus`, + aggregateCompositeIdentifier: compositeIdForVideoWithInvalidAudioUrl, + audioURL: bogusUrl, + }); + + cy.seedDataWithCommand(`PUBLISH_RESOURCE`, { + aggregateCompositeIdentifier: compositeIdForVideoWithInvalidAudioUrl, + }); + }); + + it(`should fail gracefully`, () => { + cy.getByDataAttribute(`video-for-${bogusUrl}`).click(); + + // Ideally, we want to display the disabled icon once we find out that the URL doesn't work + cy.getByDataAttribute('error').should('not.exist'); + }); + }); +}); diff --git a/apps/coscrad-frontend-e2e/src/e2e/resources/videos/video.index.query-flow.e2e.cy.ts b/apps/coscrad-frontend-e2e/src/e2e/resources/videos/video.index.query-flow.e2e.cy.ts new file mode 100644 index 000000000..4ba5881ae --- /dev/null +++ b/apps/coscrad-frontend-e2e/src/e2e/resources/videos/video.index.query-flow.e2e.cy.ts @@ -0,0 +1,328 @@ +import { AggregateType, LanguageCode, MIMEType } from '@coscrad/api-interfaces'; +import { buildDummyAggregateCompositeIdentifier } from '../../../support/utilities'; + +const commonSearchTerms = 'video name'; + +const textUniqueToFirstVideoName = `Dinosaur`; + +const specialCharInFirstVideoName = 'ŝ'; + +const firstVideoName = `${textUniqueToFirstVideoName} ${commonSearchTerms} in Chilcotin ${specialCharInFirstVideoName}`; + +const textUniqueToEnglishTranslationOfFirstVideoName = `to English`; + +const englishTranslationOfVideoName = `Translation of the ${commonSearchTerms} ${textUniqueToEnglishTranslationOfFirstVideoName}`; + +const aggregateCompositeIdentifier = buildDummyAggregateCompositeIdentifier(AggregateType.video, 1); + +const mediaItemCompositeIdentifier = buildDummyAggregateCompositeIdentifier( + AggregateType.mediaItem, + 2 +); + +const validUrl = + 'https://coscrad.org/wp-content/uploads/2023/05/Rexy-and-The-Egg-_3D-Dinosaur-Animation_-_-3D-Animation-_-Maya-3D.mp4'; + +const secondMediaItemCompositeIdentifier = buildDummyAggregateCompositeIdentifier( + AggregateType.mediaItem, + 3 +); + +const compositeIdentifierOfVideoWithNoNameTranslationIntoEnglish = + buildDummyAggregateCompositeIdentifier(AggregateType.video, 4); + +const secondVideoName = `I only have a ${commonSearchTerms} in Chilcotin`; + +const thirdMediaItemCompositeIdentifier = buildDummyAggregateCompositeIdentifier( + AggregateType.mediaItem, + 5 +); + +const compositeIdentifierOfVideoWithNameInEnglishOnly = buildDummyAggregateCompositeIdentifier( + AggregateType.video, + 6 +); + +const thirdVideoName = `I only have a ${commonSearchTerms} in English`; + +// TODO Can we inject the config some how so that the `defaultLanguageCode` is set by this test? +describe(`Video Index-to-detail Query Flow`, () => { + before(() => { + cy.clearDatabase(); + + cy.seedTestUuids(100); + + cy.executeCommandStreamByName('users:create-admin'); + + cy.seedDataWithCommand(`CREATE_MEDIA_ITEM`, { + aggregateCompositeIdentifier: mediaItemCompositeIdentifier, + titleEnglish: 'media item for the video', + mimeType: MIMEType.mp4, + url: validUrl, + }); + + cy.seedDataWithCommand(`PUBLISH_RESOURCE`, { + aggregateCompositeIdentifier: mediaItemCompositeIdentifier, + }); + + cy.seedDataWithCommand(`CREATE_VIDEO`, { + aggregateCompositeIdentifier, + name: firstVideoName, + languageCodeForName: LanguageCode.Chilcotin, + mediaItemId: mediaItemCompositeIdentifier.id, + lengthMilliseconds: 720000, + }); + + cy.seedDataWithCommand(`PUBLISH_RESOURCE`, { + aggregateCompositeIdentifier, + }); + + cy.seedDataWithCommand('TRANSLATE_VIDEO_NAME', { + aggregateCompositeIdentifier, + languageCode: LanguageCode.English, + text: englishTranslationOfVideoName, + }); + + // Create a video with a name only in Chilcotin + cy.seedDataWithCommand(`CREATE_MEDIA_ITEM`, { + aggregateCompositeIdentifier: secondMediaItemCompositeIdentifier, + titleEnglish: 'media item for the second video', + mimeType: MIMEType.mp4, + url: validUrl, + }); + + cy.seedDataWithCommand(`PUBLISH_RESOURCE`, { + aggregateCompositeIdentifier: secondMediaItemCompositeIdentifier, + }); + + cy.seedDataWithCommand(`CREATE_VIDEO`, { + aggregateCompositeIdentifier: + compositeIdentifierOfVideoWithNoNameTranslationIntoEnglish, + name: secondVideoName, + languageCodeForName: LanguageCode.Chilcotin, + mediaItemId: mediaItemCompositeIdentifier.id, + lengthMilliseconds: 720000, + }); + + cy.seedDataWithCommand(`PUBLISH_RESOURCE`, { + aggregateCompositeIdentifier: + compositeIdentifierOfVideoWithNoNameTranslationIntoEnglish, + }); + + // Create a third video with a name only in English + cy.seedDataWithCommand(`CREATE_MEDIA_ITEM`, { + aggregateCompositeIdentifier: thirdMediaItemCompositeIdentifier, + titleEnglish: 'media item for the third video', + mimeType: MIMEType.mp4, + url: validUrl, + }); + + cy.seedDataWithCommand(`PUBLISH_RESOURCE`, { + aggregateCompositeIdentifier: thirdMediaItemCompositeIdentifier, + }); + + cy.seedDataWithCommand(`CREATE_VIDEO`, { + aggregateCompositeIdentifier: compositeIdentifierOfVideoWithNameInEnglishOnly, + name: thirdVideoName, + // This video is only named in English + languageCodeForName: LanguageCode.English, + mediaItemId: thirdMediaItemCompositeIdentifier.id, + lengthMilliseconds: 720000, + }); + + cy.seedDataWithCommand(`PUBLISH_RESOURCE`, { + aggregateCompositeIdentifier: compositeIdentifierOfVideoWithNameInEnglishOnly, + }); + + // TODO We should also test when a video has no name in `defaultLanguageCode` or `English` + }); + + describe(`the index page`, () => { + beforeEach(() => { + cy.visit('/Resources/Videos'); + }); + + it(`should display the label "Videos"`, () => { + cy.contains(`Videos`); + }); + + describe(`when there is a single video`, () => { + describe(`when the video has a title in both languages`, () => { + it(`should display the title in both languages`, () => { + cy.contains(firstVideoName); + + cy.contains(englishTranslationOfVideoName); + }); + }); + }); + + describe(`when there is a second video`, () => { + it(`should display the name of the second video`, () => { + cy.contains(secondVideoName); + }); + }); + + describe(`the third video`, () => { + it(`should be displayed in a row`, () => { + cy.contains(thirdVideoName); + }); + }); + + describe(`the search filter`, () => { + /** + * Note that "ALL" is the label for the select menu item, but + * `allProperties` is the value "behind the scenes". + */ + describe(`when the search scope is "ALL"`, () => { + const searchScope = `allProperties`; + + describe(`when the search matches all terms`, () => { + it(`should return all videos`, () => { + cy.filterTable(searchScope, commonSearchTerms); + + cy.contains(firstVideoName); + + cy.contains(secondVideoName); + + cy.contains(thirdVideoName); + }); + }); + + describe(`when the search matches none of the terms`, () => { + const searchTextThatMatchesNoRows = 'zqrst'; + + it(`should return the expected result`, () => { + cy.filterTable(searchScope, searchTextThatMatchesNoRows); + + cy.contains(firstVideoName).should('not.exist'); + + cy.contains(secondVideoName).should('not.exist'); + + cy.contains(thirdVideoName).should('not.exist'); + }); + }); + + describe(`when the search matches only one of the terms`, () => { + it(`should return the expected result`, () => { + cy.filterTable(searchScope, textUniqueToFirstVideoName); + + cy.contains(firstVideoName); + + cy.contains(secondVideoName).should('not.exist'); + + cy.contains(thirdVideoName).should('not.exist'); + }); + }); + + describe(`when using the simulated keyboard`, () => { + describe(`when searching for the special character: ${specialCharInFirstVideoName}`, () => { + it(`should find the video with ${specialCharInFirstVideoName} in its name`, () => { + cy.filterTable(searchScope, specialCharInFirstVideoName); + }); + }); + }); + }); + + describe(`when the search scope is name`, () => { + const searchScope = 'name'; + + describe(`when the filter matches all of the terms with Chilcotin names`, () => { + it(`should return both results`, () => { + cy.filterTable(searchScope, commonSearchTerms); + + cy.contains(firstVideoName); + + cy.contains(secondVideoName); + + // This term has no name in Chilcotin + cy.contains(thirdVideoName).should('not.exist'); + }); + }); + + describe(`when the search matches none of the terms`, () => { + const searchTextThatMatchesNoRows = 'zqrst'; + + it(`should return the expected result`, () => { + cy.filterTable(searchScope, searchTextThatMatchesNoRows); + + cy.contains(firstVideoName).should('not.exist'); + + cy.contains(secondVideoName).should('not.exist'); + + cy.contains(thirdVideoName).should('not.exist'); + }); + }); + + describe(`when the search matches only one of the terms`, () => { + it(`should return the expected result`, () => { + cy.filterTable(searchScope, textUniqueToFirstVideoName); + + cy.contains(firstVideoName); + + cy.contains(secondVideoName).should('not.exist'); + + cy.contains(thirdVideoName).should('not.exist'); + }); + }); + }); + + describe(`when the search scope is name (English)`, () => { + const searchScope = 'nameEnglish'; + + describe(`when the search text is empty`, () => { + it(`should return all videos`, () => { + cy.filterTable(searchScope, ''); + + cy.contains(firstVideoName); + + cy.contains(secondVideoName); + + cy.contains(thirdVideoName); + }); + }); + + describe(`when the filter matches all of the terms with an English name`, () => { + it(`should return both results`, () => { + cy.filterTable(searchScope, commonSearchTerms); + + cy.contains(firstVideoName); + + cy.contains(secondVideoName).should('not.exist'); + + cy.contains(thirdVideoName); + }); + }); + + describe(`when the search matches none of the terms`, () => { + const searchTextThatMatchesNoRows = 'zqrst'; + + it(`should return the expected result`, () => { + cy.filterTable(searchScope, searchTextThatMatchesNoRows); + + cy.contains(firstVideoName).should('not.exist'); + + cy.contains(secondVideoName).should('not.exist'); + + cy.contains(thirdVideoName).should('not.exist'); + }); + }); + + describe(`when the search matches only one of the terms`, () => { + it(`should return the expected result`, () => { + cy.filterTable(searchScope, textUniqueToEnglishTranslationOfFirstVideoName); + + /** + * note that the first video has a translation into + * English, whereas the second does not. + **/ + cy.contains(firstVideoName); + + cy.contains(secondVideoName).should('not.exist'); + + cy.contains(thirdVideoName).should('not.exist'); + }); + }); + }); + }); + }); +}); diff --git a/apps/coscrad-frontend-e2e/src/support/commands.ts b/apps/coscrad-frontend-e2e/src/support/commands.ts index 8948c11b6..9f44fc837 100644 --- a/apps/coscrad-frontend-e2e/src/support/commands.ts +++ b/apps/coscrad-frontend-e2e/src/support/commands.ts @@ -35,6 +35,8 @@ declare namespace Cypress { getCommandFormSubmissionButton(): Chainable; getLoading(): Chainable; + + filterTable(searchScope: string, searchText: string): void; } } @@ -167,7 +169,7 @@ Cypress.Commands.add( )}"`; cy.exec(command).then((_result) => { - if (command.includes(`CREATE_TRAN`)) + if (command.includes(`FOOBARBAZ`)) /* eslint-disable-next-line */ debugger; }); @@ -205,3 +207,16 @@ Cypress.Commands.add(`openPanel`, (panelType: 'notes' | 'connections') => { throw new Error(`Failed to open panel of unknown type: ${panelType}`); }); + +Cypress.Commands.add(`filterTable`, (searchScope: string, searchText: string) => { + cy.getByDataAttribute('select_index_search_scope') + .click() + .get(`[data-value="${searchScope}"]`) + .click(); + + /** + * cy.type(...) requires a non-empty string, but we want to accommodate + * leaving the search field empty in this abstraction. + */ + if (searchText?.length > 0) cy.getByDataAttribute(`index_search_bar`).click().type(searchText); +}); diff --git a/apps/coscrad-frontend/src/components/notes/shared/find-original-text-item.ts b/apps/coscrad-frontend/src/components/notes/shared/find-original-text-item.ts index 2863b8fae..5711ecba2 100644 --- a/apps/coscrad-frontend/src/components/notes/shared/find-original-text-item.ts +++ b/apps/coscrad-frontend/src/components/notes/shared/find-original-text-item.ts @@ -1,11 +1,11 @@ import { IMultilingualText, - IMultlingualTextItem, + IMultilingualTextItem, MultilingualTextItemRole, } from '@coscrad/api-interfaces'; export const findOriginalTextItem = ({ items, -}: IMultilingualText): Pick => { +}: IMultilingualText): Pick => { return items.find(({ role }) => role === MultilingualTextItemRole.original); }; diff --git a/apps/coscrad-frontend/src/components/resources/utils/render-cell-for-single-language.tsx b/apps/coscrad-frontend/src/components/resources/utils/render-cell-for-single-language.tsx new file mode 100644 index 000000000..424eabf10 --- /dev/null +++ b/apps/coscrad-frontend/src/components/resources/utils/render-cell-for-single-language.tsx @@ -0,0 +1,7 @@ +import { IMultilingualTextItem } from '@coscrad/api-interfaces'; +import { Typography } from '@mui/material'; + +export const renderMultilingualTextItem = (textItem: IMultilingualTextItem) => ( + // TODO Add language tooltip + {textItem.text} +); diff --git a/apps/coscrad-frontend/src/components/resources/videos/video-detail.full-view.presenter.tsx b/apps/coscrad-frontend/src/components/resources/videos/video-detail.full-view.presenter.tsx index 5fe3a153d..568ce707b 100644 --- a/apps/coscrad-frontend/src/components/resources/videos/video-detail.full-view.presenter.tsx +++ b/apps/coscrad-frontend/src/components/resources/videos/video-detail.full-view.presenter.tsx @@ -3,24 +3,25 @@ import { IVideoViewModel, ResourceType, } from '@coscrad/api-interfaces'; +import { VideoPlayer } from '@coscrad/media-player'; import { SinglePropertyPresenter } from '../../../utils/generic-components'; import { ResourceDetailFullViewPresenter } from '../../../utils/generic-components/presenters/detail-views'; +import { TranscriptPresenter } from '../../transcripts/transcript-presenter'; import { convertMillisecondsToSeconds } from '../utils/math'; export const VideoDetailFullViewPresenter = ({ lengthMilliseconds, - text: plainText, + transcript, name, id, + videoUrl, }: ICategorizableDetailQueryResult): JSX.Element => ( - {/* TODO[https://www.pivotaltracker.com/story/show/184530937] Support video playback via the media player lib */} - {/* TODO[https://www.pivotaltracker.com/story/show/184666073] Create a transcript presenter */} -

Transcript:

-

{plainText}

+ +
); diff --git a/apps/coscrad-frontend/src/components/resources/videos/video-detail.thumbnail.presenter.tsx b/apps/coscrad-frontend/src/components/resources/videos/video-detail.thumbnail.presenter.tsx index 1184cf0b0..b789d2e68 100644 --- a/apps/coscrad-frontend/src/components/resources/videos/video-detail.thumbnail.presenter.tsx +++ b/apps/coscrad-frontend/src/components/resources/videos/video-detail.thumbnail.presenter.tsx @@ -3,6 +3,7 @@ import { IVideoViewModel, ResourceType, } from '@coscrad/api-interfaces'; +import { VideoPlayer } from '@coscrad/media-player'; import { Typography } from '@mui/material'; import { SinglePropertyPresenter } from '../../../utils/generic-components'; import { ResourceDetailThumbnailPresenter } from '../../../utils/generic-components/presenters/detail-views'; @@ -11,8 +12,9 @@ import { convertMillisecondsToSeconds } from '../utils/math'; export const VideoDetailThumbnailPresenter = ({ id, lengthMilliseconds, - text: plainText, + transcript: text, name, + videoUrl, }: ICategorizableDetailQueryResult): JSX.Element => { return ( @@ -20,8 +22,9 @@ export const VideoDetailThumbnailPresenter = ({ display="Duration" value={convertMillisecondsToSeconds(lengthMilliseconds)} /> + Transcript: - {plainText} + {JSON.stringify(text)} ); }; diff --git a/apps/coscrad-frontend/src/components/resources/videos/video-index.presenter.tsx b/apps/coscrad-frontend/src/components/resources/videos/video-index.presenter.tsx index 86c63c560..dbadb515d 100644 --- a/apps/coscrad-frontend/src/components/resources/videos/video-index.presenter.tsx +++ b/apps/coscrad-frontend/src/components/resources/videos/video-index.presenter.tsx @@ -1,40 +1,90 @@ -import { AggregateType, IVideoViewModel } from '@coscrad/api-interfaces'; +import { + AggregateType, + IMultilingualText, + IMultilingualTextItem, + IVideoViewModel, + LanguageCode, +} from '@coscrad/api-interfaces'; +import { isNullOrUndefined } from '@coscrad/validation-constraints'; import { useContext } from 'react'; import { ConfigurableContentContext } from '../../../configurable-front-matter/configurable-content-provider'; import { VideoIndexState } from '../../../store/slices/resources/video'; import { HeadingLabel, IndexTable } from '../../../utils/generic-components/presenters/tables'; +import { + Matchers, + doesTextIncludeCaseInsensitive, +} from '../../../utils/generic-components/presenters/tables/generic-index-table-presenter/filter-table-data'; import { CellRenderersDefinition } from '../../../utils/generic-components/presenters/tables/generic-index-table-presenter/types/cell-renderers-definition'; import { renderAggregateIdCell } from '../utils/render-aggregate-id-cell'; +import { renderMultilingualTextItem } from '../utils/render-cell-for-single-language'; import { renderMediaLengthInSeconds } from '../utils/render-media-length-in-seconds-cell'; -import { renderMultilingualTextCell } from '../utils/render-multilingual-text-cell'; + +const inLanguage = (languageCodeToFind: LanguageCode, multilingualText: IMultilingualText) => + multilingualText.items.find(({ languageCode }) => languageCode === languageCodeToFind); + +type VideoTableRow = Omit & { + name: IMultilingualTextItem; + nameEnglish: IMultilingualTextItem; +}; + +const createTableRowBuilder = + (defaultLanguageCode: LanguageCode) => + (video: IVideoViewModel): VideoTableRow => ({ + ...video, + name: inLanguage(defaultLanguageCode, video.name), + nameEnglish: inLanguage(LanguageCode.English, video.name), + }); export const VideoIndexPresenter = ({ entities: videos }: VideoIndexState): JSX.Element => { const { defaultLanguageCode } = useContext(ConfigurableContentContext); - const headingLabels: HeadingLabel[] = [ + const buildTableRow = createTableRowBuilder(defaultLanguageCode); + + const headingLabels: HeadingLabel[] = [ { propertyKey: 'id', headingLabel: 'Link' }, { propertyKey: 'lengthMilliseconds', headingLabel: 'Video Length', }, { propertyKey: 'name', headingLabel: 'Name' }, + { propertyKey: 'nameEnglish', headingLabel: 'Name (English)' }, ]; - const cellRenderersDefinition: CellRenderersDefinition = { + const cellRenderersDefinition: CellRenderersDefinition = { id: renderAggregateIdCell, - lengthMilliseconds: ({ lengthMilliseconds }: IVideoViewModel) => + lengthMilliseconds: ({ lengthMilliseconds }) => renderMediaLengthInSeconds(lengthMilliseconds), - name: ({ name }: IVideoViewModel) => renderMultilingualTextCell(name, defaultLanguageCode), + name: ({ name }) => (isNullOrUndefined(name) ? null : renderMultilingualTextItem(name)), + nameEnglish: ({ nameEnglish }) => + isNullOrUndefined(nameEnglish) ? null : renderMultilingualTextItem(nameEnglish), + }; + + // We may want to bring in the full MultilingualText class to the front-end and put this behaviour on a method instead + const doesSearchTextMatchTextItemCaseInsensitive = ( + textItem: IMultilingualTextItem, + searchText: string + ) => { + if (isNullOrUndefined(textItem)) return false; + + const { text } = textItem; + + return doesTextIncludeCaseInsensitive(text, searchText); + }; + + const matchers: Matchers = { + name: doesSearchTextMatchTextItemCaseInsensitive, + nameEnglish: doesSearchTextMatchTextItemCaseInsensitive, }; return ( ); }; diff --git a/apps/coscrad-frontend/src/components/transcripts/transcript-presenter.tsx b/apps/coscrad-frontend/src/components/transcripts/transcript-presenter.tsx new file mode 100644 index 000000000..109d643a0 --- /dev/null +++ b/apps/coscrad-frontend/src/components/transcripts/transcript-presenter.tsx @@ -0,0 +1,50 @@ +import { ITranscriptItem, ITranscriptParticipant } from '@coscrad/api-interfaces'; +import { isNullOrUndefined } from '@coscrad/validation-constraints'; +import { SubtitlesRounded as SubtitlesRoundedIcon } from '@mui/icons-material'; +import { Box, Card, CardContent, CardHeader, Divider, Typography } from '@mui/material'; + +export const TranscriptPresenter = ({ transcript }): JSX.Element => { + return ( + + } + title={ + + Transcript + + } + /> + + {isNullOrUndefined(transcript) + ? null + : transcript.participants.map((item: ITranscriptParticipant) => ( + + + Participants:  + + {item.name} ({item.initials}) + + ))} + + + + {isNullOrUndefined(transcript) + ? null + : transcript.items.map((item: ITranscriptItem) => ( + + + {item.speakerInitials} [{item.inPointMilliseconds}- + {item.outPointMilliseconds} + ]:  + + + {item.text.items.map((item) => ( + "{item.text}" + ))} + + + ))} + + + ); +}; diff --git a/apps/coscrad-frontend/src/utils/generic-components/presenters/multilingual-text-presenter.tsx b/apps/coscrad-frontend/src/utils/generic-components/presenters/multilingual-text-presenter.tsx index 633e0fcfc..b577cc070 100644 --- a/apps/coscrad-frontend/src/utils/generic-components/presenters/multilingual-text-presenter.tsx +++ b/apps/coscrad-frontend/src/utils/generic-components/presenters/multilingual-text-presenter.tsx @@ -44,17 +44,6 @@ export const MultilingualTextPresenter = ({ )} - {isNullOrUndefined(textItemWithDefaultLanguage) ? null : ( - - - - - - )} {translations.map(({ languageCode, text, role }) => ( = { [K in keyof T]?: (value: T[K], searchTerm: string) => boolean; }; +// TODO Unit test this and break this out into a lib +export const doesTextIncludeCaseInsensitive = (textToSearch: string, textToFind: string): boolean => + textToSearch.toLowerCase().includes(textToFind.toLowerCase()); + +const defaultStringify = (value: unknown): string => { + if (isNull(value)) return ''; + + if (isUndefined(value)) return ''; + + return String(value); +}; + // default to a case-insensitive search -const defaultMatcher = (value: unknown, searchTerm: string): boolean => - String(value).toLowerCase().includes(searchTerm.toLowerCase()); +export const defaultMatcher = (value: unknown, searchTerm: string): boolean => + doesTextIncludeCaseInsensitive(defaultStringify(value), searchTerm); export const filterTableData = ( tableData: T[], diff --git a/libs/api-interfaces/src/lib/aggregate-views/view-models/resources/audio-item/multilingual-text-item.interface.ts b/libs/api-interfaces/src/lib/aggregate-views/view-models/resources/audio-item/multilingual-text-item.interface.ts index b9779ccea..c8b3bf51f 100644 --- a/libs/api-interfaces/src/lib/aggregate-views/view-models/resources/audio-item/multilingual-text-item.interface.ts +++ b/libs/api-interfaces/src/lib/aggregate-views/view-models/resources/audio-item/multilingual-text-item.interface.ts @@ -1,7 +1,7 @@ import { LanguageCode } from '../../multilingual-text'; import { MultilingualTextItemRole } from './multilingual-text-item-role.enum'; -export interface IMultlingualTextItem { +export interface IMultilingualTextItem { languageCode: LanguageCode; text: string; diff --git a/libs/api-interfaces/src/lib/aggregate-views/view-models/resources/audio-item/multilingual-text.interface.ts b/libs/api-interfaces/src/lib/aggregate-views/view-models/resources/audio-item/multilingual-text.interface.ts index 83bd186e0..19e57a2d4 100644 --- a/libs/api-interfaces/src/lib/aggregate-views/view-models/resources/audio-item/multilingual-text.interface.ts +++ b/libs/api-interfaces/src/lib/aggregate-views/view-models/resources/audio-item/multilingual-text.interface.ts @@ -1,5 +1,5 @@ -import { IMultlingualTextItem } from './multilingual-text-item.interface'; +import { IMultilingualTextItem } from './multilingual-text-item.interface'; export interface IMultilingualText { - items: IMultlingualTextItem[]; + items: IMultilingualTextItem[]; } diff --git a/libs/api-interfaces/src/lib/aggregate-views/view-models/resources/audio-item/video.view-model.interface.ts b/libs/api-interfaces/src/lib/aggregate-views/view-models/resources/audio-item/video.view-model.interface.ts index af1d9ec54..ca0520057 100644 --- a/libs/api-interfaces/src/lib/aggregate-views/view-models/resources/audio-item/video.view-model.interface.ts +++ b/libs/api-interfaces/src/lib/aggregate-views/view-models/resources/audio-item/video.view-model.interface.ts @@ -1,6 +1,7 @@ import { IBaseViewModel } from '../../base.view-model.interface'; import { MIMEType } from '../media-items'; import { IMultilingualText } from './multilingual-text.interface'; +import { ITranscript } from './transcript.interface'; export interface IVideoViewModel extends IBaseViewModel { name: IMultilingualText; @@ -11,8 +12,5 @@ export interface IVideoViewModel extends IBaseViewModel { lengthMilliseconds: number; - /** - * TODO Make this an ITranscript - */ - text: string; + transcript: ITranscript; } diff --git a/libs/media-player/src/lib/audio-player.tsx b/libs/media-player/src/lib/audio-player.tsx index 216f8e7e8..21747dbb2 100644 --- a/libs/media-player/src/lib/audio-player.tsx +++ b/libs/media-player/src/lib/audio-player.tsx @@ -25,11 +25,11 @@ export const AudioPlayer = ({ audioUrl, mimeType }: AudioPlayerProps) => { return ( {isAudioMIMEType(mimeType) ? ( - + ) : ( <> {Object.values(AudioMIMEType).map((mimeType) => ( - + ))} )} diff --git a/libs/media-player/src/lib/video-player.tsx b/libs/media-player/src/lib/video-player.tsx index 55dfcdb07..657d42772 100644 --- a/libs/media-player/src/lib/video-player.tsx +++ b/libs/media-player/src/lib/video-player.tsx @@ -23,12 +23,24 @@ export const VideoPlayer = ({ videoUrl, mimeType }: VideoPlayerProps) => { return ( {isVideoMIMEType(mimeType) ? ( - + ) : ( <> {/* Fallbacks for each media type */} {Object.values(VideoMIMEType).map((mimeType) => ( - + ))} )}