-
Notifications
You must be signed in to change notification settings - Fork 76
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: improve collection sidebar #1320
Merged
ChrisChV
merged 22 commits into
openedx:master
from
open-craft:rpenido/fal-3791-collecion-page-sidebar
Sep 28, 2024
Merged
Changes from 10 commits
Commits
Show all changes
22 commits
Select commit
Hold shift + click to select a range
f9fa28e
feat: improve collection sidebar
rpenido 4efe06d
refactor: remove SearchContext from sidebar
rpenido d23a8a5
test: change block types
rpenido 5c52971
test: refactor getBlock test
rpenido 3d37dee
fix: typo on test
rpenido 7786528
feat: add comments to splice blockTypesArray code
rpenido 9690499
test: improve CollectionInfoHeader tests
rpenido 58499a5
fix: lint
rpenido 7cbb250
test: fix search api call count
rpenido 89f0e08
fix: lint
rpenido 0a0530f
test: add new test for block types hook
rpenido 9bf728c
fix: clean unused facet
rpenido 22d967b
test: add test to library collection card click
rpenido 8c10dfe
fix: json format
rpenido 4a02120
fix: hide Open button if collection home is already open
rpenido 9b4dd10
Merge branch 'master' into rpenido/fal-3791-collecion-page-sidebar
rpenido cd3b912
chore: trigger CI
rpenido c370a7a
refactor: stop using meilisearch to get a single collection
rpenido 261d2a9
test: more coverage
rpenido e795a2b
refactor: refactor library collection page
rpenido ce6c7bf
docs: add docstring
rpenido 766e4c8
style: Update src/library-authoring/common/context.tsx with a nit
ChrisChV File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
168 changes: 168 additions & 0 deletions
168
src/library-authoring/collections/CollectionDetails.test.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,168 @@ | ||
import type MockAdapter from 'axios-mock-adapter'; | ||
import fetchMock from 'fetch-mock-jest'; | ||
|
||
import { mockContentSearchConfig, mockGetBlockTypes } from '../../search-manager/data/api.mock'; | ||
import { type CollectionHit, formatSearchHit } from '../../search-manager/data/api'; | ||
import { | ||
initializeMocks, | ||
fireEvent, | ||
render, | ||
screen, | ||
waitFor, | ||
within, | ||
} from '../../testUtils'; | ||
import mockResult from '../__mocks__/collection-search.json'; | ||
import * as api from '../data/api'; | ||
import { mockContentLibrary } from '../data/api.mocks'; | ||
import CollectionDetails from './CollectionDetails'; | ||
|
||
let axiosMock: MockAdapter; | ||
let mockShowToast: (message: string) => void; | ||
|
||
mockContentSearchConfig.applyMock(); | ||
mockGetBlockTypes.applyMock(); | ||
|
||
const library = mockContentLibrary.libraryData; | ||
const collectionHit = formatSearchHit(mockResult.results[2].hits[0]) as CollectionHit; | ||
|
||
describe('<CollectionDetails />', () => { | ||
beforeEach(() => { | ||
const mocks = initializeMocks(); | ||
axiosMock = mocks.axiosMock; | ||
mockShowToast = mocks.mockShowToast; | ||
}); | ||
|
||
afterEach(() => { | ||
jest.clearAllMocks(); | ||
axiosMock.restore(); | ||
fetchMock.mockReset(); | ||
}); | ||
|
||
it('should render Collection Details', async () => { | ||
render(<CollectionDetails library={library} collection={collectionHit} />); | ||
|
||
// Collection Description | ||
expect(screen.getByText('Description / Card Preview Text')).toBeInTheDocument(); | ||
const { description } = collectionHit; | ||
expect(screen.getByText(description)).toBeInTheDocument(); | ||
|
||
// Collection History | ||
expect(screen.getByText('Collection History')).toBeInTheDocument(); | ||
// Modified date | ||
expect(screen.getByText('September 20, 2024')).toBeInTheDocument(); | ||
// Created date | ||
expect(screen.getByText('September 19, 2024')).toBeInTheDocument(); | ||
}); | ||
|
||
it('should allow modifying the description', async () => { | ||
render(<CollectionDetails library={library} collection={collectionHit} />); | ||
|
||
const { | ||
description: originalDescription, | ||
blockId, | ||
contextKey, | ||
} = collectionHit; | ||
|
||
expect(screen.getByText(originalDescription)).toBeInTheDocument(); | ||
|
||
const url = api.getLibraryCollectionApiUrl(contextKey, blockId); | ||
axiosMock.onPatch(url).reply(200); | ||
|
||
const textArea = screen.getByRole('textbox'); | ||
|
||
// Change the description to the same value | ||
fireEvent.focus(textArea); | ||
fireEvent.change(textArea, { target: { value: originalDescription } }); | ||
fireEvent.blur(textArea); | ||
|
||
await waitFor(() => { | ||
expect(axiosMock.history.patch).toHaveLength(0); | ||
expect(mockShowToast).not.toHaveBeenCalled(); | ||
}); | ||
|
||
// Change the description to a new value | ||
fireEvent.focus(textArea); | ||
fireEvent.change(textArea, { target: { value: 'New description' } }); | ||
fireEvent.blur(textArea); | ||
|
||
await waitFor(() => { | ||
expect(axiosMock.history.patch).toHaveLength(1); | ||
expect(axiosMock.history.patch[0].url).toEqual(url); | ||
expect(axiosMock.history.patch[0].data).toEqual(JSON.stringify({ description: 'New description' })); | ||
expect(mockShowToast).toHaveBeenCalledWith('Collection updated successfully.'); | ||
}); | ||
}); | ||
|
||
it('should show error while modifing the description', async () => { | ||
render(<CollectionDetails library={library} collection={collectionHit} />); | ||
|
||
const { | ||
description: originalDescription, | ||
blockId, | ||
contextKey, | ||
} = collectionHit; | ||
|
||
expect(screen.getByText(originalDescription)).toBeInTheDocument(); | ||
|
||
const url = api.getLibraryCollectionApiUrl(contextKey, blockId); | ||
axiosMock.onPatch(url).reply(500); | ||
|
||
const textArea = screen.getByRole('textbox'); | ||
|
||
// Change the description to a new value | ||
fireEvent.focus(textArea); | ||
fireEvent.change(textArea, { target: { value: 'New description' } }); | ||
fireEvent.blur(textArea); | ||
|
||
await waitFor(() => { | ||
expect(axiosMock.history.patch).toHaveLength(1); | ||
expect(axiosMock.history.patch[0].url).toEqual(url); | ||
expect(axiosMock.history.patch[0].data).toEqual(JSON.stringify({ description: 'New description' })); | ||
expect(mockShowToast).toHaveBeenCalledWith('Failed to update collection.'); | ||
}); | ||
}); | ||
|
||
it('should render Collection stats', async () => { | ||
mockGetBlockTypes('someBlocks'); | ||
render(<CollectionDetails library={library} collection={collectionHit} />); | ||
|
||
expect(screen.getByText('Collection Stats')).toBeInTheDocument(); | ||
expect(await screen.findByText('Total')).toBeInTheDocument(); | ||
|
||
[ | ||
{ blockType: 'Total', count: 3 }, | ||
{ blockType: 'Text', count: 2 }, | ||
{ blockType: 'Problem', count: 1 }, | ||
].forEach(({ blockType, count }) => { | ||
const blockCount = screen.getByText(blockType).closest('div') as HTMLDivElement; | ||
expect(within(blockCount).getByText(count.toString())).toBeInTheDocument(); | ||
}); | ||
}); | ||
|
||
it('should render Collection stats for empty collection', async () => { | ||
mockGetBlockTypes('noBlocks'); | ||
render(<CollectionDetails library={library} collection={collectionHit} />); | ||
|
||
expect(screen.getByText('Collection Stats')).toBeInTheDocument(); | ||
expect(await screen.findByText('This collection is currently empty.')).toBeInTheDocument(); | ||
}); | ||
|
||
it('should render Collection stats for big collection', async () => { | ||
mockGetBlockTypes('moreBlocks'); | ||
render(<CollectionDetails library={library} collection={collectionHit} />); | ||
|
||
expect(screen.getByText('Collection Stats')).toBeInTheDocument(); | ||
expect(await screen.findByText('36')).toBeInTheDocument(); | ||
|
||
[ | ||
{ blockType: 'Total', count: 36 }, | ||
{ blockType: 'Video', count: 8 }, | ||
{ blockType: 'Problem', count: 7 }, | ||
{ blockType: 'Text', count: 6 }, | ||
{ blockType: 'Other', count: 15 }, | ||
].forEach(({ blockType, count }) => { | ||
const blockCount = screen.getByText(blockType).closest('div') as HTMLDivElement; | ||
expect(within(blockCount).getByText(count.toString())).toBeInTheDocument(); | ||
}); | ||
}); | ||
}); |
170 changes: 170 additions & 0 deletions
170
src/library-authoring/collections/CollectionDetails.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,170 @@ | ||
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; | ||
import { Icon, Stack } from '@openedx/paragon'; | ||
import { useContext, useState } from 'react'; | ||
import classNames from 'classnames'; | ||
|
||
import { getItemIcon } from '../../generic/block-type-utils'; | ||
import { ToastContext } from '../../generic/toast-context'; | ||
import { BlockTypeLabel, type CollectionHit, useGetBlockTypes } from '../../search-manager'; | ||
import type { ContentLibrary } from '../data/api'; | ||
import { useUpdateCollection } from '../data/apiHooks'; | ||
import HistoryWidget from '../generic/history-widget'; | ||
import messages from './messages'; | ||
|
||
interface BlockCountProps { | ||
count: number, | ||
blockType?: string, | ||
label: React.ReactNode, | ||
className?: string, | ||
} | ||
|
||
const BlockCount = ({ | ||
count, | ||
blockType, | ||
label, | ||
className, | ||
}: BlockCountProps) => { | ||
const icon = blockType && getItemIcon(blockType); | ||
return ( | ||
<Stack className={classNames('text-center', className)}> | ||
<span className="text-muted">{label}</span> | ||
<Stack direction="horizontal" gap={1} className="justify-content-center"> | ||
{icon && <Icon src={icon} size="lg" />} | ||
<span>{count}</span> | ||
</Stack> | ||
</Stack> | ||
); | ||
}; | ||
|
||
interface CollectionStatsWidgetProps { | ||
collection: CollectionHit, | ||
} | ||
|
||
const CollectionStatsWidget = ({ collection }: CollectionStatsWidgetProps) => { | ||
const { data: blockTypes } = useGetBlockTypes([ | ||
`context_key = "${collection.contextKey}"`, | ||
`collections.key = "${collection.blockId}"`, | ||
]); | ||
|
||
if (!blockTypes) { | ||
return null; | ||
} | ||
|
||
const blockTypesArray = Object.entries(blockTypes) | ||
.map(([blockType, count]) => ({ blockType, count })) | ||
.sort((a, b) => b.count - a.count); | ||
|
||
const totalBlocksCount = blockTypesArray.reduce((acc, { count }) => acc + count, 0); | ||
// Show the top 3 block type counts individually, and splice the remaining block types together under "Other". | ||
const numBlockTypesShown = 3; | ||
const otherBlocks = blockTypesArray.splice(numBlockTypesShown); | ||
const otherBlocksCount = otherBlocks.reduce((acc, { count }) => acc + count, 0); | ||
|
||
if (totalBlocksCount === 0) { | ||
return ( | ||
<div | ||
className="text-center text-muted align-content-center" | ||
style={{ | ||
height: '72px', // same height as the BlockCount component | ||
}} | ||
> | ||
<FormattedMessage {...messages.detailsTabStatsNoComponents} /> | ||
</div> | ||
); | ||
} | ||
|
||
return ( | ||
<Stack direction="horizontal" className="p-2 justify-content-between" gap={2}> | ||
<BlockCount | ||
label={<FormattedMessage {...messages.detailsTabStatsTotalComponents} />} | ||
count={totalBlocksCount} | ||
className="border-right" | ||
/> | ||
{blockTypesArray.map(({ blockType, count }) => ( | ||
<BlockCount | ||
key={blockType} | ||
label={<BlockTypeLabel type={blockType} />} | ||
blockType={blockType} | ||
count={count} | ||
/> | ||
))} | ||
{otherBlocks.length > 0 && ( | ||
<BlockCount | ||
label={<FormattedMessage {...messages.detailsTabStatsOtherComponents} />} | ||
count={otherBlocksCount} | ||
/> | ||
)} | ||
</Stack> | ||
); | ||
}; | ||
|
||
interface CollectionDetailsProps { | ||
library: ContentLibrary, | ||
collection: CollectionHit, | ||
} | ||
|
||
const CollectionDetails = ({ library, collection }: CollectionDetailsProps) => { | ||
const intl = useIntl(); | ||
const { showToast } = useContext(ToastContext); | ||
|
||
const [description, setDescription] = useState(collection.description); | ||
|
||
const updateMutation = useUpdateCollection(collection.contextKey, collection.blockId); | ||
|
||
// istanbul ignore if: this should never happen | ||
if (!collection) { | ||
throw new Error('A collection must be provided to CollectionDetails'); | ||
} | ||
|
||
const onSubmit = (e: React.FocusEvent<HTMLTextAreaElement>) => { | ||
const newDescription = e.target.value; | ||
if (newDescription === collection.description) { | ||
return; | ||
} | ||
updateMutation.mutateAsync({ | ||
description: newDescription, | ||
}).then(() => { | ||
showToast(intl.formatMessage(messages.updateCollectionSuccessMsg)); | ||
}).catch(() => { | ||
showToast(intl.formatMessage(messages.updateCollectionErrorMsg)); | ||
}); | ||
}; | ||
|
||
return ( | ||
<Stack | ||
gap={3} | ||
> | ||
<div> | ||
<h3 className="h5"> | ||
{intl.formatMessage(messages.detailsTabDescriptionTitle)} | ||
</h3> | ||
{library.canEditLibrary ? ( | ||
<textarea | ||
className="form-control" | ||
value={description} | ||
onChange={(e) => setDescription(e.target.value)} | ||
onBlur={onSubmit} | ||
/> | ||
) : collection.description} | ||
</div> | ||
<div> | ||
<h3 className="h5"> | ||
{intl.formatMessage(messages.detailsTabStatsTitle)} | ||
</h3> | ||
<CollectionStatsWidget collection={collection} /> | ||
</div> | ||
<hr className="w-100" /> | ||
<div> | ||
<h3 className="h5"> | ||
{intl.formatMessage(messages.detailsTabHistoryTitle)} | ||
</h3> | ||
<HistoryWidget | ||
created={collection.created ? new Date(collection.created * 1000) : null} | ||
modified={collection.modified ? new Date(collection.modified * 1000) : null} | ||
/> | ||
</div> | ||
</Stack> | ||
); | ||
}; | ||
|
||
export default CollectionDetails; |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It would be really cool if these
BlockTypeLabel
messages supported plurals. cf https://formatjs.io/docs/react-intl/components/#formattedpluralThere was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We also have one endpoint (mock here) that returns this same data (but not regionalized). I think we should also deduplicate this.
Could we handle this in a future task?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yep, this can totally be in a separate task -- you might have to push back on UI/UX review of this task, because it's a "nice to have" on this label, and not that simple to implement.
But that endpoint you referenced returns the allowed block types for a given content library, which I suspect is an old use case for libraries v2, since we create libraries that support all block types (with lib.type=='COMPLEX' by default), and there's nowhere in the frontend to change that. I personally don't think we should be using a REST API to tell us what different block types are called -- the app translations are enough.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Eventually we can update
BlockTypeLabel
so that it can load and cache the translated block names for random third-party XBlocks, but it definitely shouldn't bother using a REST API for the "standard" block types. But let's not worry about that in the coming weeks.Exactly. That endpoint should be deprecated and is not the right one to use if it's not localized.