Skip to content

Commit 67ad042

Browse files
authored
Merge pull request #172 from performant-software/feature/udcsl67_citations
UDCSL #67 - Citations
2 parents 20d8d0f + 2b7a5b1 commit 67ad042

34 files changed

+2547
-663
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ node_modules/
55
packages/*/types
66

77
storybook-static/
8+
yarn.error.log

packages/controlled-vocabulary/package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@performant-software/controlled-vocabulary",
3-
"version": "1.0.1",
3+
"version": "1.0.2",
44
"description": "A package of components to allow user to configure dropdown elements. Use with the \"controlled_vocabulary\" gem.",
55
"license": "MIT",
66
"main": "./build/index.js",
@@ -12,8 +12,8 @@
1212
"build": "webpack --mode production && flow-copy-source -v src types"
1313
},
1414
"dependencies": {
15-
"@performant-software/semantic-components": "^1.0.1",
16-
"@performant-software/shared-components": "^1.0.1",
15+
"@performant-software/semantic-components": "^1.0.2",
16+
"@performant-software/shared-components": "^1.0.2",
1717
"i18next": "^21.9.2",
1818
"semantic-ui-react": "^2.1.2",
1919
"underscore": "^1.13.2"

packages/semantic-ui/package.json

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@performant-software/semantic-components",
3-
"version": "1.0.1",
3+
"version": "1.0.2",
44
"description": "A package of shared components based on the Semantic UI Framework.",
55
"license": "MIT",
66
"main": "./build/index.js",
@@ -12,9 +12,10 @@
1212
"build": "webpack --mode production && flow-copy-source -v src types"
1313
},
1414
"dependencies": {
15-
"@performant-software/shared-components": "^1.0.1",
15+
"@performant-software/shared-components": "^1.0.2",
1616
"@react-google-maps/api": "^2.8.1",
1717
"axios": "^0.26.1",
18+
"citeproc": "^2.4.62",
1819
"i18next": "^19.4.4",
1920
"react-calendar": "^3.3.0",
2021
"react-color": "^2.18.1",
@@ -26,7 +27,9 @@
2627
"react-uuid": "^1.0.2",
2728
"semantic-ui-less": "^2.4.1",
2829
"semantic-ui-react": "^2.1.2",
29-
"underscore": "^1.13.2"
30+
"underscore": "^1.13.2",
31+
"zotero-api-client": "^0.40.0",
32+
"zotero-translation-client": "^5.0.1"
3033
},
3134
"peerDependencies": {
3235
"react": ">= 16.13.1 < 18.0.0",
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
.bibliography-list .sort-selector.ui.basic.buttons {
2+
margin-left: 5px;
3+
}
Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
1+
// @flow
2+
3+
import React, {
4+
useCallback,
5+
useEffect,
6+
useMemo,
7+
useState,
8+
type ComponentType
9+
} from 'react';
10+
import uuid from 'react-uuid';
11+
import {
12+
Button,
13+
Grid,
14+
List,
15+
Message
16+
} from 'semantic-ui-react';
17+
import _ from 'underscore';
18+
import BibliographyModal from './BibliographyModal';
19+
import BibliographySearchInput from './BibliographySearchInput';
20+
import Citation from './Citation';
21+
import i18n from '../i18n/i18n';
22+
import SortSelector from './SortSelector';
23+
import { SORT_DESCENDING } from '../constants/Sort';
24+
import StyleSelector from './StyleSelector';
25+
import Toaster from './Toaster';
26+
import useList from './List';
27+
import ZoteroTranslateContext from '../context/ZoteroTranslateContext';
28+
import './BibliographyList.css';
29+
30+
type Item = {
31+
id?: number,
32+
uid?: string,
33+
data: any
34+
};
35+
36+
type ComponentProps = {
37+
items: Array<Item>,
38+
locale: string,
39+
style: ?{
40+
name: string,
41+
xml: string
42+
}
43+
};
44+
45+
const DEFAULT_ITEM_TYPE = 'book';
46+
const LOCALE_URL = 'https://raw.githubusercontent.com/citation-style-language/locales/master/locales-en-US.xml';
47+
48+
const Sort = {
49+
author: 'author',
50+
date: 'date',
51+
title: 'title'
52+
};
53+
54+
const SortOptions = [{
55+
key: Sort.title,
56+
value: Sort.title,
57+
text: i18n.t('BibliographyList.sort.title')
58+
}, {
59+
key: Sort.author,
60+
value: Sort.author,
61+
text: i18n.t('BibliographyList.sort.author')
62+
}, {
63+
key: Sort.date,
64+
value: Sort.date,
65+
text: i18n.t('BibliographyList.sort.date')
66+
}];
67+
68+
const BibliographyListComponent: ComponentType<any> = useList((props: ComponentProps) => (
69+
<List
70+
divided
71+
relaxed='very'
72+
>
73+
{ _.map(props.items, (item, index) => (
74+
<List.Item
75+
as={Grid}
76+
columns={2}
77+
key={index}
78+
padded
79+
>
80+
<List.Content
81+
as={Grid.Column}
82+
textAlign='left'
83+
verticalAlign='middle'
84+
width={14}
85+
>
86+
<Citation
87+
item={item}
88+
locale={props.locale}
89+
style={props.style && props.style.xml}
90+
/>
91+
</List.Content>
92+
<List.Content
93+
as={Grid.Column}
94+
textAlign='right'
95+
verticalAlign='middle'
96+
width={2}
97+
>
98+
{ _.map(props.actions, (action, actionIndex) => (
99+
<Button
100+
aria-label={action.name}
101+
basic
102+
icon={action.icon}
103+
key={actionIndex}
104+
onClick={action.onClick.bind(this, item)}
105+
/>
106+
))}
107+
</List.Content>
108+
</List.Item>
109+
))}
110+
</List>
111+
));
112+
113+
type Props = {
114+
items: Array<Item>,
115+
onDelete: (item: Item) => Promise<any>,
116+
onSave: (item: Item) => Promise<any>,
117+
translateUrl: string
118+
};
119+
120+
const BibliographyList = (props: Props) => {
121+
const [locale, setLocale] = useState();
122+
const [showToaster, setShowToaster] = useState(false);
123+
const [style, setStyle] = useState();
124+
const [sort, setSort] = useState({});
125+
126+
/**
127+
* Converts the passed item into an object with "id", "uid", and "data" keys.
128+
*
129+
* @type {function(*): {uid: string, data: *, id: *}}
130+
*/
131+
const createItem = useCallback((item) => {
132+
const { id } = item || {};
133+
const { uid = uuid() } = item || {};
134+
135+
// Build the data object
136+
const data = _.omit(item, 'id', 'uid', 'data');
137+
138+
// Remove any empty creators
139+
_.extend(data, {
140+
creators: _.reject(item.creators, (creator) => !(creator.name || creator.firstName || creator.lastName))
141+
});
142+
143+
return { id, uid, data };
144+
}, []);
145+
146+
/**
147+
* Returns the sort property value for the passed item based on the selected sort.
148+
*
149+
* @type {function(*, *): *}
150+
*/
151+
const getSortProperty = useCallback((item, index) => {
152+
let value;
153+
154+
if (sort === Sort.title) {
155+
value = item.title;
156+
} else if (sort === Sort.author) {
157+
const author = _.first(item.creators);
158+
value = author?.name || `${author?.lastName}, ${author?.firstName}`;
159+
} else if (sort === Sort.date) {
160+
value = item.date;
161+
} else {
162+
value = index;
163+
}
164+
165+
return value;
166+
}, []);
167+
168+
/**
169+
* Sets the items to display in the list. This function will filter out any items marked for delete,
170+
* expand the "data" property into the main object, and sort the collection according the user's selection.
171+
*/
172+
const items = useMemo(() => {
173+
// Filter the list to exclude items marked for removal and transform the items
174+
let newItems = _.chain(props.items)
175+
.filter((item) => !item._destroy)
176+
.map((item) => ({ ..._.omit(item, 'data'), ...item.data }))
177+
.value();
178+
179+
// Sort the list according to the selected sort property
180+
newItems = _.sortBy(newItems, getSortProperty);
181+
182+
// If sorting in descending order, reverse the list
183+
if (sort.direction === SORT_DESCENDING) {
184+
newItems = newItems.reverse();
185+
}
186+
187+
return newItems;
188+
}, [getSortProperty, sort, props.items]);
189+
190+
/**
191+
* Renders the style selector and sort components.
192+
*
193+
* @type {unknown}
194+
*/
195+
const renderListHeader = useCallback(() => (
196+
<>
197+
<StyleSelector
198+
onChange={(name, xml) => setStyle({ name, xml })}
199+
value={style && style.name}
200+
/>
201+
<SortSelector
202+
direction={sort.direction}
203+
onChange={(value) => setSort(value)}
204+
options={SortOptions}
205+
text={sort.text}
206+
value={sort.value}
207+
/>
208+
</>
209+
), [sort, style]);
210+
211+
/**
212+
* Deletes the passed item.
213+
*
214+
* @type {function(*): Promise<unknown>}
215+
*/
216+
const onDelete = useCallback((item) => Promise.resolve(
217+
props.onDelete(createItem(item))
218+
), [createItem, props.onDelete]);
219+
220+
/**
221+
* Saves the passed item.
222+
*
223+
* @type {function(*): Promise<unknown>}
224+
*/
225+
const onSave = useCallback((item) => Promise.resolve(
226+
props.onSave(createItem(item))
227+
), [createItem, props.onSave]);
228+
229+
/**
230+
* Loads the locale XML.
231+
*/
232+
useEffect(() => {
233+
fetch(LOCALE_URL)
234+
.then((resp) => resp.text())
235+
.then((text) => setLocale(text));
236+
}, []);
237+
238+
return (
239+
<ZoteroTranslateContext.Provider
240+
value={{ translateUrl: props.translateUrl }}
241+
>
242+
<div>
243+
<BibliographySearchInput
244+
onError={() => setShowToaster(true)}
245+
onFind={(results) => _.map(results, onSave)}
246+
/>
247+
<BibliographyListComponent
248+
{...props}
249+
actions={[{
250+
name: 'edit'
251+
}, {
252+
name: 'delete'
253+
}]}
254+
className='bibliography-list'
255+
items={items}
256+
locale={locale}
257+
modal={{
258+
component: BibliographyModal,
259+
props: {
260+
defaults: {
261+
itemType: DEFAULT_ITEM_TYPE
262+
}
263+
}
264+
}}
265+
onDelete={onDelete}
266+
onSave={onSave}
267+
renderListHeader={renderListHeader}
268+
style={style}
269+
/>
270+
{ showToaster && (
271+
<Toaster
272+
onDismiss={() => setShowToaster(false)}
273+
timeout={2500}
274+
type={Toaster.MessageTypes.warning}
275+
>
276+
<Message.Header
277+
content={i18n.t('Common.messages.noResults')}
278+
/>
279+
</Toaster>
280+
)}
281+
</div>
282+
</ZoteroTranslateContext.Provider>
283+
);
284+
};
285+
286+
export default BibliographyList;

0 commit comments

Comments
 (0)