Skip to content
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

[FC-0036] feat: Add ContentTagsDrawer widget #654

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 117 additions & 0 deletions src/content-tags-drawer/ContentTagsCollapsible.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import React from 'react';
import {
Badge,
Collapsible,
SelectableBox,
Button,
ModalPopup,
useToggle,
} from '@edx/paragon';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
import messages from './messages';
import './ContentTagsCollapsible.scss';

import ContentTagsDropDownSelector from './ContentTagsDropDownSelector';

import ContentTagsTree from './ContentTagsTree';

/**
* Collapsible component that holds a Taxonomy along with Tags that belong to it.
* This includes both applied tags and tags that are available to select
* from a dropdown list.
* @param {Object} taxonomyAndTagsData - Object containing Taxonomy meta data along with applied tags
* @param {number} taxonomyAndTagsData.id - id of Taxonomy
* @param {string} taxonomyAndTagsData.name - name of Taxonomy
* @param {string} taxonomyAndTagsData.description - description of Taxonomy
* @param {boolean} taxonomyAndTagsData.enabled - Whether Taxonomy is enabled/disabled
* @param {boolean} taxonomyAndTagsData.allowMultiple - Whether Taxonomy allows multiple tags to be applied
* @param {boolean} taxonomyAndTagsData.allowFreeText - Whether Taxonomy allows free text tags
* @param {boolean} taxonomyAndTagsData.systemDefined - Whether Taxonomy is system defined or authored by user
* @param {boolean} taxonomyAndTagsData.visibleToAuthors - Whether Taxonomy should be visible to object authors
* @param {string[]} taxonomyAndTagsData.orgs - Array of orgs this Taxonomy belongs to
* @param {boolean} taxonomyAndTagsData.allOrgs - Whether Taxonomy belongs to all orgs
* @param {Object[]} taxonomyAndTagsData.contentTags - Array of taxonomy tags that are applied to the content
* @param {string} taxonomyAndTagsData.contentTags.value - Value of applied Tag
* @param {string} taxonomyAndTagsData.contentTags.lineage - Array of Tag's ancestors sorted (ancestor -> tag)
*/
const ContentTagsCollapsible = ({ taxonomyAndTagsData }) => {
const intl = useIntl();
const {
id, name, contentTags,
} = taxonomyAndTagsData;

const [isOpen, open, close] = useToggle(false);
const [target, setTarget] = React.useState(null);

return (
<div className="d-flex">
<Collapsible title={name} styling="card-lg" className="taxonomy-tags-collapsible">
<div key={id}>
<ContentTagsTree appliedContentTags={contentTags} />
</div>

<div className="d-flex taxonomy-tags-selector-menu">
<Button
ref={setTarget}
variant="outline-primary"
onClick={open}
>
<FormattedMessage {...messages.addTagsButtonText} />
</Button>
</div>
<ModalPopup
hasArrow
placement="bottom"
positionRef={target}
isOpen={isOpen}
onClose={close}
>
<div className="bg-white p-3 shadow">

<SelectableBox.Set
type="checkbox"
name="tags"
columns={1}
ariaLabel={intl.formatMessage(messages.taxonomyTagsAriaLabel)}
className="taxonomy-tags-selectable-box-set"
>
<ContentTagsDropDownSelector
key={`selector-${id}`}
taxonomyId={id}
level={0}
/>
</SelectableBox.Set>
</div>
</ModalPopup>

</Collapsible>
<div className="d-flex">
<Badge
variant="light"
pill
className={classNames('align-self-start', 'mt-3', {
// eslint-disable-next-line quote-props
'invisible': contentTags.length === 0,
})}
>
{contentTags.length}
</Badge>
</div>
</div>
);
};

ContentTagsCollapsible.propTypes = {
taxonomyAndTagsData: PropTypes.shape({
id: PropTypes.number,
name: PropTypes.string,
contentTags: PropTypes.arrayOf(PropTypes.shape({
value: PropTypes.string,
lineage: PropTypes.arrayOf(PropTypes.string),
})),
}).isRequired,
};

export default ContentTagsCollapsible;
24 changes: 24 additions & 0 deletions src/content-tags-drawer/ContentTagsCollapsible.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
.taxonomy-tags-collapsible {
flex: 1;
border: none !important;

.collapsible-trigger {
border: none !important;
}
}

.taxonomy-tags-selector-menu {
button {
flex: 1;
}
}

.taxonomy-tags-selector-menu + div {
width: 100%;
}

.taxonomy-tags-selectable-box-set {
grid-auto-rows: unset !important;
overflow-y: scroll;
max-height: 20rem;
}
63 changes: 63 additions & 0 deletions src/content-tags-drawer/ContentTagsCollapsible.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import React from 'react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { act, render } from '@testing-library/react';
import PropTypes from 'prop-types';

import ContentTagsCollapsible from './ContentTagsCollapsible';

jest.mock('./data/apiHooks', () => ({
useTaxonomyTagsDataResponse: jest.fn(),
useIsTaxonomyTagsDataLoaded: jest.fn(),
}));

const data = {
id: 123,
name: 'Taxonomy 1',
contentTags: [
{
value: 'Tag 1',
lineage: ['Tag 1'],
},
{
value: 'Tag 2',
lineage: ['Tag 2'],
},
],
};

const ContentTagsCollapsibleComponent = ({ taxonomyAndTagsData }) => (
<IntlProvider locale="en" messages={{}}>
<ContentTagsCollapsible taxonomyAndTagsData={taxonomyAndTagsData} />
</IntlProvider>
);

ContentTagsCollapsibleComponent.propTypes = {
taxonomyAndTagsData: PropTypes.shape({
id: PropTypes.number,
name: PropTypes.string,
contentTags: PropTypes.arrayOf(PropTypes.shape({
value: PropTypes.string,
lineage: PropTypes.arrayOf(PropTypes.string),
})),
}).isRequired,
};

describe('<ContentTagsCollapsible />', () => {
it('should render taxonomy tags data along content tags number badge', async () => {
await act(async () => {
const { container, getByText } = render(<ContentTagsCollapsibleComponent taxonomyAndTagsData={data} />);
expect(getByText('Taxonomy 1')).toBeInTheDocument();
expect(container.getElementsByClassName('badge').length).toBe(1);
expect(getByText('2')).toBeInTheDocument();
});
});

it('should render taxonomy tags data without tags number badge', async () => {
data.contentTags = [];
await act(async () => {
const { container, getByText } = render(<ContentTagsCollapsibleComponent taxonomyAndTagsData={data} />);
expect(getByText('Taxonomy 1')).toBeInTheDocument();
expect(container.getElementsByClassName('invisible').length).toBe(1);
});
});
});
127 changes: 127 additions & 0 deletions src/content-tags-drawer/ContentTagsDrawer.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import React, { useMemo, useEffect } from 'react';
import {
Container,
CloseButton,
Spinner,
} from '@edx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useParams } from 'react-router-dom';
import messages from './messages';
import ContentTagsCollapsible from './ContentTagsCollapsible';
import { extractOrgFromContentId } from './utils';
import {
useContentTaxonomyTagsDataResponse,
useIsContentTaxonomyTagsDataLoaded,
useContentDataResponse,
useIsContentDataLoaded,
} from './data/apiHooks';
import { useTaxonomyListDataResponse, useIsTaxonomyListDataLoaded } from '../taxonomy/data/apiHooks';
import Loading from '../generic/Loading';

const ContentTagsDrawer = () => {
const intl = useIntl();
const { contentId } = useParams();

const org = extractOrgFromContentId(contentId);

const useContentData = () => {
const contentData = useContentDataResponse(contentId);
const isContentDataLoaded = useIsContentDataLoaded(contentId);
return { contentData, isContentDataLoaded };
};

const useContentTaxonomyTagsData = () => {
const contentTaxonomyTagsData = useContentTaxonomyTagsDataResponse(contentId);
const isContentTaxonomyTagsLoaded = useIsContentTaxonomyTagsDataLoaded(contentId);
return { contentTaxonomyTagsData, isContentTaxonomyTagsLoaded };
};

const useTaxonomyListData = () => {
const taxonomyListData = useTaxonomyListDataResponse(org);
const isTaxonomyListLoaded = useIsTaxonomyListDataLoaded(org);
return { taxonomyListData, isTaxonomyListLoaded };
};

const { contentData, isContentDataLoaded } = useContentData();
const { contentTaxonomyTagsData, isContentTaxonomyTagsLoaded } = useContentTaxonomyTagsData();
const { taxonomyListData, isTaxonomyListLoaded } = useTaxonomyListData();

const closeContentTagsDrawer = () => {
// "*" allows communication with any origin
window.parent.postMessage('closeManageTagsDrawer', '*');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't there is any set standard for this, but a few other places this is used it's a string like: 'lmshtmlfragment.resize' and 'tools.toggleNotes'.

Copy link
Member Author

@yusuf-musleh yusuf-musleh Nov 17, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Making this change would require also making changes to the edx-platform. I think so it doesn't get blocked by another review cycle there, we can address it in a later PR.

};

useEffect(() => {
const handleEsc = (event) => {
/* Close drawer when ESC-key is pressed and selectable dropdown box not open */
const selectableBoxOpen = document.querySelector('[data-selectable-box="taxonomy-tags"]');
if (event.key === 'Escape' && !selectableBoxOpen) {
closeContentTagsDrawer();
}
};
document.addEventListener('keydown', handleEsc);

return () => {
document.removeEventListener('keydown', handleEsc);
};
}, []);

const taxonomies = useMemo(() => {
if (taxonomyListData && contentTaxonomyTagsData) {
// Initialize list of content tags in taxonomies to populate
const taxonomiesList = taxonomyListData.results.map((taxonomy) => {
// eslint-disable-next-line no-param-reassign
taxonomy.contentTags = [];
return taxonomy;
});

const contentTaxonomies = contentTaxonomyTagsData.taxonomies;

// eslint-disable-next-line array-callback-return
contentTaxonomies.map((contentTaxonomyTags) => {
const contentTaxonomy = taxonomiesList.find((taxonomy) => taxonomy.id === contentTaxonomyTags.taxonomyId);
if (contentTaxonomy) {
contentTaxonomy.contentTags = contentTaxonomyTags.tags;
}
});

return taxonomiesList;
}
return [];
}, [taxonomyListData, contentTaxonomyTagsData]);

return (

<div className="mt-1">
<Container size="xl">
<CloseButton onClick={() => closeContentTagsDrawer()} data-testid="drawer-close-button" />
<span>{intl.formatMessage(messages.headerSubtitle)}</span>
{ isContentDataLoaded
? <h3>{ contentData.displayName }</h3>
: (
<div className="d-flex justify-content-center align-items-center flex-column">
<Spinner
animation="border"
size="xl"
screenReaderText={intl.formatMessage(messages.loadingMessage)}
/>
</div>
)}

<hr />

{ isTaxonomyListLoaded && isContentTaxonomyTagsLoaded
? taxonomies.map((data) => (
<div key={`taxonomy-tags-collapsible-${data.id}`}>
<ContentTagsCollapsible taxonomyAndTagsData={data} />
<hr />
</div>
))
: <Loading />}

</Container>
</div>
);
};

export default ContentTagsDrawer;
Loading