Skip to content

Commit

Permalink
feat: Implement Content Tags Drawer
Browse files Browse the repository at this point in the history
This implements a side drawer widget for content taxonomy tags.
It includes displaying the object's tags, along with their
lineage (ancestor tags) data. It also implements the listing the
available taxonomy tags (including nesting ones) to select from
to apply to this unit.

Note: The editing of tags (adding/removing) will be added in a future
PR.

* feat: Add initial UnitTaxonomyTagsDrawer widget
* feat: Add fetching unit taxonomy tags from backend
* feat: Add fetching/group tags with taxonomies
* feat: Add fetch Unit data and display name
* feat: Add Taxonomy Tags dropdown selector
* feat: Add TagBubble for tag styling
* chore: Add distinct keys to elements + remove logs
* feat: Add close drawer with ESC- keypress
* feat: Make dropdown selectors keyboard accessible
* chore: Fix issues causing validation to fail
* test: Add coverage tests for UnitTaxonomyDrawer
* feat: Incorporate tags lineage data from API
* refactor: Remove/replace deprecated injectIntl
* test: Remove redux store related code + fix warnings
* feat: Use <Loading /> instead of loading string
* docs: Add docs string to TaxonomyTagsCollapsible
* feat: Use <Spinner/> to allow mutiple loading to show
* feat: Rename UnitTaxonomyTagDrawer -> ContentTagsDrawer
* feat: Add ContentTagsTree component to render Tags
* feat: Only fetch tags when dropdowns are opened
* refactor: Simply dropdown close/open states
* feat: Use built in class styles instead of custom
* feat: Replace hardcoded values with scss variables
* refactor: follow existing structure for reactQuery/APIs
* feat: Change tag bubble outline color
* feat: Add TagOutlineIcon for implicit tags
* feat: Make aria label internationalized
* feat: Replace custom styles with builtin classes
* fix: Fix bug with closing drawer
* refactor: Simplify content tags fetching code
* refactor: Simplify getTaxonomyListApiUrl
  • Loading branch information
yusuf-musleh authored and arbrandes committed Nov 20, 2023
1 parent 9b053de commit 375006d
Show file tree
Hide file tree
Showing 32 changed files with 1,686 additions and 15 deletions.
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', '*');
};

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

0 comments on commit 375006d

Please sign in to comment.