Skip to content

Commit 4420539

Browse files
committed
Show/hide libraries on top-level integration pages
1 parent 92d4cc1 commit 4420539

4 files changed

Lines changed: 294 additions & 34 deletions

File tree

src/components/EditableConfigList.tsx

Lines changed: 133 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import ErrorMessage from "./ErrorMessage";
1010
import PencilIcon from "./icons/PencilIcon";
1111
import TrashIcon from "./icons/TrashIcon";
1212
import VisibleIcon from "./icons/VisibleIcon";
13+
import DisclosureIcon from "./icons/DisclosureIcon";
1314
import Admin from "../models/Admin";
1415
import * as PropTypes from "prop-types";
1516

@@ -76,11 +77,15 @@ export interface ExtraFormSectionProps<T, U> {
7677
7778
GenericEditableConfigList allows subclasses to define additional props. Subclasses of
7879
EditableConfigList cannot change the props and do not have to specify a type for them. */
80+
interface EditableConfigListState {
81+
expandedItems: Record<string, boolean>;
82+
}
83+
7984
export abstract class GenericEditableConfigList<
8085
T,
8186
U,
8287
V extends EditableConfigListProps<T>
83-
> extends React.Component<V> {
88+
> extends React.Component<V, EditableConfigListState> {
8489
context: { admin: Admin };
8590
static contextTypes = {
8691
admin: PropTypes.object.isRequired,
@@ -106,12 +111,18 @@ export abstract class GenericEditableConfigList<
106111
extraFormKey?: string;
107112
private editFormRef = React.createRef<any>();
108113

114+
state: EditableConfigListState = {
115+
expandedItems: {},
116+
};
117+
109118
constructor(props) {
110119
super(props);
111120
this.editItem = this.editItem.bind(this);
112121
this.save = this.save.bind(this);
113122
this.label = this.label.bind(this);
114123
this.renderLi = this.renderLi.bind(this);
124+
this.toggleLibraries = this.toggleLibraries.bind(this);
125+
this.toggleAllLibraries = this.toggleAllLibraries.bind(this);
115126
}
116127

117128
UNSAFE_componentWillMount() {
@@ -221,38 +232,76 @@ export abstract class GenericEditableConfigList<
221232

222233
renderLi(item, index): JSX.Element {
223234
const AdditionalContent = this.AdditionalContent || null;
235+
const libraries: Array<{ short_name: string }> | undefined =
236+
item?.libraries;
237+
const libraryCount = Array.isArray(libraries) ? libraries.length : null;
238+
const itemKey = String(item[this.identifierKey]);
239+
const isExpanded = libraryCount > 0 && !!this.state.expandedItems[itemKey];
224240

225241
return (
226242
<li key={index}>
227-
<a
228-
className="btn small edit-item"
229-
href={this.urlBase + "edit/" + item[this.identifierKey]}
230-
>
231-
{this.canEdit(item) ? (
232-
<span>
233-
Edit <PencilIcon />
234-
</span>
235-
) : (
243+
<div className="item-header">
244+
<a
245+
className="btn small edit-item"
246+
href={this.urlBase + "edit/" + item[this.identifierKey]}
247+
>
248+
{this.canEdit(item) ? (
249+
<span>
250+
Edit <PencilIcon />
251+
</span>
252+
) : (
253+
<span>
254+
View <VisibleIcon />
255+
</span>
256+
)}
257+
</a>
258+
259+
<h3>
260+
{libraryCount !== null && (
261+
<button
262+
className="library-toggle"
263+
onClick={(e) =>
264+
e.altKey
265+
? this.toggleAllLibraries()
266+
: this.toggleLibraries(itemKey)
267+
}
268+
aria-expanded={isExpanded}
269+
disabled={libraryCount === 0}
270+
>
271+
<DisclosureIcon expanded={isExpanded} />
272+
</button>
273+
)}
236274
<span>
237-
View <VisibleIcon />
275+
{this.label(item)}
276+
{libraryCount !== null && (
277+
<span className="library-count">
278+
{" "}
279+
(
280+
{libraryCount === 0
281+
? "no libraries"
282+
: libraryCount === 1
283+
? "1 library"
284+
: `${libraryCount} libraries`}
285+
)
286+
</span>
287+
)}
238288
</span>
289+
</h3>
290+
291+
{this.canDelete() && (
292+
<Button
293+
className="danger delete-item small"
294+
callback={() => this.delete(item)}
295+
content={
296+
<span>
297+
Delete
298+
<TrashIcon />
299+
</span>
300+
}
301+
/>
239302
)}
240-
</a>
241-
242-
<h3>{this.label(item)}</h3>
243-
244-
{this.canDelete() && (
245-
<Button
246-
className="danger delete-item small"
247-
callback={() => this.delete(item)}
248-
content={
249-
<span>
250-
Delete
251-
<TrashIcon />
252-
</span>
253-
}
254-
/>
255-
)}
303+
</div>
304+
{isExpanded && this.renderAssociatedLibraries(item)}
256305
{AdditionalContent && (
257306
<AdditionalContent
258307
type={this.itemTypeName}
@@ -265,6 +314,63 @@ export abstract class GenericEditableConfigList<
265314
);
266315
}
267316

317+
toggleLibraries(itemKey: string): void {
318+
this.setState((prev) => ({
319+
expandedItems: {
320+
...prev.expandedItems,
321+
[itemKey]: !prev.expandedItems[itemKey],
322+
},
323+
}));
324+
}
325+
326+
toggleAllLibraries(): void {
327+
const items: any[] = (this.props.data as any)?.[this.listDataKey] || [];
328+
const itemsWithLibraries = items.filter(
329+
(item) => Array.isArray(item.libraries) && item.libraries.length > 0
330+
);
331+
if (itemsWithLibraries.length === 0) return;
332+
333+
const anyCollapsed = itemsWithLibraries.some(
334+
(item) => !this.state.expandedItems[String(item[this.identifierKey])]
335+
);
336+
337+
const newExpandedItems: Record<string, boolean> = {};
338+
if (anyCollapsed) {
339+
for (const item of itemsWithLibraries) {
340+
newExpandedItems[String(item[this.identifierKey])] = true;
341+
}
342+
}
343+
this.setState({ expandedItems: newExpandedItems });
344+
}
345+
346+
renderAssociatedLibraries(item: any): JSX.Element {
347+
const libraries: Array<{ short_name: string }> = item.libraries;
348+
const allLibraries: Array<{
349+
short_name: string;
350+
name?: string;
351+
uuid?: string;
352+
}> = (this.props.data as any)?.allLibraries || [];
353+
354+
return (
355+
<ul className="associated-libraries">
356+
{libraries.map((lib) => {
357+
const libraryData = allLibraries.find(
358+
(l) => l.short_name === lib.short_name
359+
);
360+
const name = libraryData?.name || lib.short_name;
361+
const href = libraryData?.uuid
362+
? `/admin/web/config/libraries/edit/${libraryData.uuid}`
363+
: null;
364+
return (
365+
<li key={lib.short_name}>
366+
{href ? <a href={href}>{name}</a> : name}
367+
</li>
368+
);
369+
})}
370+
</ul>
371+
);
372+
}
373+
268374
label(item): string {
269375
return item[this.labelKey];
270376
}

src/components/__tests__/EditableConfigList-test.tsx

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -596,4 +596,81 @@ describe("EditableConfigList", () => {
596596
wrapper.setContext({ admin: librarian });
597597
expect(wrapper.instance().getAdminLevel()).to.equal(1);
598598
});
599+
600+
describe("renderAssociatedLibraries", () => {
601+
const allLibraries = [
602+
{ short_name: "nypl", name: "New York Public Library" },
603+
{ short_name: "bpl", name: "Brooklyn Public Library" },
604+
];
605+
606+
it("renders nothing when the item has no libraries property", () => {
607+
const libraries = wrapper.find(".associated-libraries");
608+
expect(libraries.length).to.equal(0);
609+
});
610+
611+
it("renders nothing when the item has an empty libraries array", () => {
612+
wrapper = mount(
613+
<ThingEditableConfigList
614+
data={{ things: [{ ...thingData, libraries: [] }] } as any}
615+
fetchData={fetchData}
616+
editItem={editItem}
617+
deleteItem={deleteItem}
618+
csrfToken="token"
619+
isFetching={false}
620+
/>,
621+
{ context: { admin: systemAdmin }, childContextTypes }
622+
);
623+
const libraries = wrapper.find(".associated-libraries");
624+
expect(libraries.length).to.equal(0);
625+
});
626+
627+
it("renders library names when allLibraries is available", () => {
628+
const thingWithLibraries = {
629+
...thingData,
630+
libraries: [{ short_name: "nypl" }, { short_name: "bpl" }],
631+
};
632+
wrapper = mount(
633+
<ThingEditableConfigList
634+
data={{ things: [thingWithLibraries], allLibraries } as any}
635+
fetchData={fetchData}
636+
editItem={editItem}
637+
deleteItem={deleteItem}
638+
csrfToken="token"
639+
isFetching={false}
640+
/>,
641+
{ context: { admin: systemAdmin }, childContextTypes }
642+
);
643+
wrapper.find(".library-toggle").simulate("click");
644+
const libraryList = wrapper.find(".associated-libraries");
645+
expect(libraryList.length).to.equal(1);
646+
const items = libraryList.find("li");
647+
expect(items.length).to.equal(2);
648+
expect(items.at(0).text()).to.equal("New York Public Library");
649+
expect(items.at(1).text()).to.equal("Brooklyn Public Library");
650+
});
651+
652+
it("falls back to short_name when allLibraries is not available", () => {
653+
const thingWithLibraries = {
654+
...thingData,
655+
libraries: [{ short_name: "nypl" }],
656+
};
657+
wrapper = mount(
658+
<ThingEditableConfigList
659+
data={{ things: [thingWithLibraries] } as any}
660+
fetchData={fetchData}
661+
editItem={editItem}
662+
deleteItem={deleteItem}
663+
csrfToken="token"
664+
isFetching={false}
665+
/>,
666+
{ context: { admin: systemAdmin }, childContextTypes }
667+
);
668+
wrapper.find(".library-toggle").simulate("click");
669+
const libraryList = wrapper.find(".associated-libraries");
670+
expect(libraryList.length).to.equal(1);
671+
const items = libraryList.find("li");
672+
expect(items.length).to.equal(1);
673+
expect(items.at(0).text()).to.equal("nypl");
674+
});
675+
});
599676
});
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import * as React from "react";
2+
3+
interface DisclosureIconProps {
4+
expanded: boolean;
5+
}
6+
7+
const DisclosureIcon = ({ expanded }: DisclosureIconProps): JSX.Element => (
8+
<svg
9+
xmlns="http://www.w3.org/2000/svg"
10+
viewBox="0 0 12 12"
11+
aria-hidden="true"
12+
className={`disclosure-icon${expanded ? " expanded" : ""}`}
13+
>
14+
<polygon points="2,1 10,6 2,11" fill="currentColor" />
15+
</svg>
16+
);
17+
18+
export default DisclosureIcon;

src/stylesheets/config_tab_container.scss

Lines changed: 66 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -55,17 +55,76 @@ $dangercolor: #D9534F;
5555
padding-left: 10px;
5656
li {
5757
display: flex;
58-
flex-direction: row;
59-
justify-content: space-between;
60-
align-items: center;
58+
flex-direction: column;
6159
margin: 20px 0px;
6260
padding-bottom: 5px;
6361
border-bottom: 1px solid $pagetextcolor;
6462

65-
h3 {
66-
margin-right: 20px;
67-
flex-grow: 1;
68-
font-size: 18px;
63+
.item-header {
64+
display: flex;
65+
flex-direction: row;
66+
justify-content: space-between;
67+
align-items: center;
68+
69+
h3 {
70+
display: flex;
71+
align-items: center;
72+
gap: 0.5em;
73+
margin: 0 20px 0 0;
74+
flex-grow: 1;
75+
font-size: 18px;
76+
77+
.library-count {
78+
font-size: 0.85em;
79+
font-weight: normal;
80+
color: $pagetextcolorlight;
81+
}
82+
83+
.library-toggle {
84+
background: none;
85+
border: none;
86+
cursor: pointer;
87+
color: $linkcolor;
88+
padding: 0;
89+
flex-shrink: 0;
90+
width: 1.1em;
91+
height: 1.1em;
92+
93+
&:hover:not(:disabled) {
94+
color: $linkhovercolor;
95+
}
96+
97+
&:disabled {
98+
visibility: hidden;
99+
}
100+
101+
.disclosure-icon {
102+
display: block;
103+
width: 100%;
104+
height: 100%;
105+
transition: transform 0.15s ease;
106+
transform: rotate(0deg);
107+
transform-origin: center center;
108+
109+
&.expanded {
110+
transform: rotate(90deg);
111+
}
112+
}
113+
}
114+
}
115+
}
116+
117+
.associated-libraries {
118+
list-style: disc;
119+
margin: 4px 0 6px 0;
120+
padding-left: 3em;
121+
122+
li {
123+
display: list-item;
124+
margin: 2px 0;
125+
padding-bottom: 0;
126+
border-bottom: none;
127+
}
69128
}
70129
}
71130
}

0 commit comments

Comments
 (0)