Commit 393a2aca authored by Robert Knight's avatar Robert Knight

Add focus modes for CFI and page ranges

These will be used in EPUB and PDF-based books respectively in LMS assignments
configured to focus on a specific chapter or page range.
parent ddc96b77
...@@ -25,9 +25,15 @@ type FilterStatusMessageProps = { ...@@ -25,9 +25,15 @@ type FilterStatusMessageProps = {
/** Plural unit of the items being shown */ /** Plural unit of the items being shown */
entityPlural: string; entityPlural: string;
/** Range of content currently focused (if not a page range). */
focusContentRange?: string | null;
/** Display name for the user currently focused, if any */ /** Display name for the user currently focused, if any */
focusDisplayName?: string | null; focusDisplayName?: string | null;
/** Page range that is currently focused, if any */
focusPageRange?: string | null;
/** /**
* The number of items that match the current filter(s). When focusing on a * The number of items that match the current filter(s). When focusing on a
* user, this value includes annotations and replies. * user, this value includes annotations and replies.
...@@ -44,9 +50,18 @@ function FilterStatusMessage({ ...@@ -44,9 +50,18 @@ function FilterStatusMessage({
additionalCount, additionalCount,
entitySingular, entitySingular,
entityPlural, entityPlural,
focusContentRange,
focusDisplayName, focusDisplayName,
focusPageRange,
resultCount, resultCount,
}: FilterStatusMessageProps) { }: FilterStatusMessageProps) {
let contentLabel;
if (focusContentRange) {
contentLabel = <span> in {focusContentRange}</span>;
} else if (focusPageRange) {
contentLabel = <span> in pages {focusPageRange}</span>;
}
return ( return (
<> <>
{resultCount > 0 && <span>Showing </span>} {resultCount > 0 && <span>Showing </span>}
...@@ -63,6 +78,7 @@ function FilterStatusMessage({ ...@@ -63,6 +78,7 @@ function FilterStatusMessage({
</span> </span>
</span> </span>
)} )}
{contentLabel}
{additionalCount > 0 && ( {additionalCount > 0 && (
<span className="whitespace-nowrap italic text-color-text-light"> <span className="whitespace-nowrap italic text-color-text-light">
{' '} {' '}
...@@ -172,9 +188,16 @@ export default function FilterAnnotationsStatus() { ...@@ -172,9 +188,16 @@ export default function FilterAnnotationsStatus() {
if (forcedVisibleCount > 0) { if (forcedVisibleCount > 0) {
return 'Reset filters'; return 'Reset filters';
} }
return focusState.active
? 'Show all' if (focusState.active) {
: `Show only ${focusState.displayName}`; return 'Show all';
} else if (focusState.displayName) {
return `Show only ${focusState.displayName}`;
} else if (focusState.configured) {
// Generic label for button to re-enable focus mode, if we don't have
// a more specific one.
return 'Reset filter';
}
} }
return 'Clear search'; return 'Clear search';
}, [ }, [
...@@ -185,6 +208,8 @@ export default function FilterAnnotationsStatus() { ...@@ -185,6 +208,8 @@ export default function FilterAnnotationsStatus() {
forcedVisibleCount, forcedVisibleCount,
]); ]);
const showFocusHint = filterMode !== 'selection' && focusState.active;
return ( return (
<div <div
// This container element needs to be present at all times but // This container element needs to be present at all times but
...@@ -212,11 +237,13 @@ export default function FilterAnnotationsStatus() { ...@@ -212,11 +237,13 @@ export default function FilterAnnotationsStatus() {
additionalCount={additionalCount} additionalCount={additionalCount}
entitySingular="annotation" entitySingular="annotation"
entityPlural="annotations" entityPlural="annotations"
focusContentRange={
showFocusHint ? focusState.contentRange : null
}
focusDisplayName={ focusDisplayName={
filterMode !== 'selection' && focusState.active showFocusHint ? focusState.displayName : null
? focusState.displayName
: ''
} }
focusPageRange={showFocusHint ? focusState.pageRange : null}
resultCount={resultCount} resultCount={resultCount}
/> />
)} )}
......
...@@ -257,4 +257,40 @@ describe('FilterAnnotationsStatus', () => { ...@@ -257,4 +257,40 @@ describe('FilterAnnotationsStatus', () => {
}); });
}); });
}); });
it('shows focused page range', () => {
fakeStore.focusState.returns({
active: true,
configured: true,
pageRange: '5-10',
});
fakeThreadUtil.countVisible.returns(7);
assertFilterText(createComponent(), 'Showing 7 annotations in pages 5-10');
});
it('shows focused content range', () => {
fakeStore.focusState.returns({
active: true,
configured: true,
contentRange: 'Chapter 2',
});
fakeThreadUtil.countVisible.returns(3);
assertFilterText(createComponent(), 'Showing 3 annotations in Chapter 2');
});
[{ pageRange: '5-10' }, { contentRange: 'Chapter 2' }].forEach(focusState => {
it('shows button to reset focus mode if content focus is configured but inactive', () => {
fakeStore.focusState.returns({
active: false,
configured: true,
...focusState,
});
fakeThreadUtil.countVisible.returns(7);
assertButton(createComponent(), {
text: 'Reset filter',
icon: false,
callback: fakeStore.toggleFocusMode,
});
});
});
}); });
...@@ -15,14 +15,30 @@ export type FilterOption = { ...@@ -15,14 +15,30 @@ export type FilterOption = {
/** /**
* Valid/recognized filters * Valid/recognized filters
*/ */
export type FilterKey = 'user'; export type FilterKey = 'cfi' | 'page' | 'user';
export type Filters = Partial<Record<FilterKey, FilterOption | undefined>>; export type Filters = Partial<Record<FilterKey, FilterOption | undefined>>;
type FocusState = { /**
* Summary of the state of focus filters.
*/
export type FocusState = {
active: boolean; active: boolean;
configured: boolean; configured: boolean;
displayName: string;
/** Display name of the user that is focused. */
displayName?: string;
/** Page range that is focused. */
pageRange?: string;
/**
* Description of content this is focused.
*
* This is used in ebooks if the content is e.g. a chapter rather than page
* range.
*/
contentRange?: string;
}; };
/** /**
...@@ -62,37 +78,63 @@ function initialState(settings: SidebarSettings): State { ...@@ -62,37 +78,63 @@ function initialState(settings: SidebarSettings): State {
} }
/** /**
* Given the provided focusConfig: is it a valid configuration for focus? * Return true if a focus filter configuration is valid.
* At this time, a `user` filter is required.
*/ */
function isValidFocusConfig(focusConfig: FocusConfig): boolean { function isValidFocusConfig(focusConfig: FocusConfig): boolean {
return !!(focusConfig.user?.username || focusConfig.user?.userid); if (focusConfig.user) {
return Boolean(focusConfig.user.username || focusConfig.user.userid);
}
if (focusConfig.cfi?.range || focusConfig.pages) {
return true;
}
return false;
} }
/** /**
* Compose an object of keyed `FilterOption`s from the given `focusConfig`. * Compose an object of keyed `FilterOption`s from the given `focusConfig`.
* At present, this will create a `user` `FilterOption` if the config is valid.
*/ */
function focusFiltersFromConfig(focusConfig: FocusConfig): Filters { function focusFiltersFromConfig(focusConfig: FocusConfig): Filters {
const user = focusConfig.user; if (!isValidFocusConfig(focusConfig)) {
if (!user || !isValidFocusConfig(focusConfig)) {
return {}; return {};
} }
const filters: Filters = {};
const user = focusConfig.user;
if (user) {
const userFilterValue = user.username || user.userid || ''; const userFilterValue = user.username || user.userid || '';
return { filters.user = {
user: {
value: userFilterValue, value: userFilterValue,
display: user.displayName || userFilterValue, display: user.displayName || userFilterValue,
},
}; };
}
if (focusConfig.pages) {
filters.page = {
value: focusConfig.pages,
display: focusConfig.pages,
};
}
if (focusConfig.cfi) {
filters.cfi = {
value: focusConfig.cfi.range,
display: focusConfig.cfi.label,
};
}
return filters;
} }
const reducers = { const reducers = {
CHANGE_FOCUS_MODE_USER(state: State, action: { user: FocusUserInfo }) { CHANGE_FOCUS_MODE_USER(state: State, action: { user: FocusUserInfo }) {
const { user } = focusFiltersFromConfig({ user: action.user });
return { return {
focusActive: isValidFocusConfig({ user: action.user }), focusActive: isValidFocusConfig({ user: action.user }),
focusFilters: focusFiltersFromConfig({ user: action.user }), focusFilters: {
...state.focusFilters,
user,
},
}; };
}, },
...@@ -188,8 +230,13 @@ const focusState = createSelector( ...@@ -188,8 +230,13 @@ const focusState = createSelector(
(focusActive, focusFilters): FocusState => { (focusActive, focusFilters): FocusState => {
return { return {
active: focusActive, active: focusActive,
configured: !!focusFilters?.user, // Check for filter with non-empty value.
displayName: focusFilters?.user?.display || '', configured: Object.values(focusFilters ?? {}).some(
val => typeof val !== 'undefined',
),
displayName: focusFilters?.user?.display ?? '',
contentRange: focusFilters?.cfi?.display,
pageRange: focusFilters?.page?.display,
}; };
}, },
); );
......
...@@ -10,6 +10,19 @@ describe('sidebar/store/modules/filters', () => { ...@@ -10,6 +10,19 @@ describe('sidebar/store/modules/filters', () => {
return store.getState().filters; return store.getState().filters;
}; };
// Values for the `focus` settings key which turn on filters when the client
// starts.
const userFocusConfig = {
user: { username: 'somebody', displayName: 'Ding Bat' },
};
const pageFocusConfig = { pages: '5-10' };
const cfiFocusConfig = {
cfi: {
range: '/2-/4',
label: 'Chapter 1',
},
};
beforeEach(() => { beforeEach(() => {
store = createStore([filtersModule, selectionModule], fakeSettings); store = createStore([filtersModule, selectionModule], fakeSettings);
}); });
...@@ -181,6 +194,22 @@ describe('sidebar/store/modules/filters', () => { ...@@ -181,6 +194,22 @@ describe('sidebar/store/modules/filters', () => {
assert.equal(focusState.displayName, 'Pantomime Nutball'); assert.equal(focusState.displayName, 'Pantomime Nutball');
}); });
it('returns page focus info', () => {
store = createStore([filtersModule], [{ focus: pageFocusConfig }]);
const focusState = store.focusState();
assert.isTrue(focusState.active);
assert.isTrue(focusState.configured);
assert.equal(focusState.pageRange, pageFocusConfig.pages);
});
it('returns CFI focus info', () => {
store = createStore([filtersModule], [{ focus: cfiFocusConfig }]);
const focusState = store.focusState();
assert.isTrue(focusState.active);
assert.isTrue(focusState.configured);
assert.equal(focusState.contentRange, cfiFocusConfig.cfi.label);
});
it('returns empty focus values when no focus is configured or set', () => { it('returns empty focus values when no focus is configured or set', () => {
const focusState = store.focusState(); const focusState = store.focusState();
assert.isFalse(focusState.active); assert.isFalse(focusState.active);
...@@ -284,22 +313,44 @@ describe('sidebar/store/modules/filters', () => { ...@@ -284,22 +313,44 @@ describe('sidebar/store/modules/filters', () => {
}); });
describe('getFocusFilters', () => { describe('getFocusFilters', () => {
[
{
focusConfig: userFocusConfig,
filterKey: 'user',
filterValue: {
value: 'somebody',
display: 'Ding Bat',
},
},
{
focusConfig: pageFocusConfig,
filterKey: 'page',
filterValue: {
value: '5-10',
display: '5-10',
},
},
{
focusConfig: cfiFocusConfig,
filterKey: 'cfi',
filterValue: {
value: '/2-/4',
display: 'Chapter 1',
},
},
].forEach(({ focusConfig, filterKey, filterValue }) => {
it('returns any set focus filters', () => { it('returns any set focus filters', () => {
store = createStore( store = createStore(
[filtersModule], [filtersModule],
[ [
{ {
focus: { focus: focusConfig,
user: { username: 'somebody', displayName: 'Ding Bat' },
},
}, },
], ],
); );
const focusFilters = store.getFocusFilters(); const focusFilters = store.getFocusFilters();
assert.exists(focusFilters.user); assert.exists(focusFilters[filterKey]);
assert.deepEqual(focusFilters.user, { assert.deepEqual(focusFilters[filterKey], filterValue);
value: 'somebody',
display: 'Ding Bat',
}); });
}); });
}); });
...@@ -311,14 +362,20 @@ describe('sidebar/store/modules/filters', () => { ...@@ -311,14 +362,20 @@ describe('sidebar/store/modules/filters', () => {
assert.isTrue(store.hasAppliedFilter()); assert.isTrue(store.hasAppliedFilter());
}); });
it('returns true if user-focused mode is active', () => { [userFocusConfig, pageFocusConfig, cfiFocusConfig].forEach(
focusConfig => {
it('returns true if focused mode is active', () => {
store = createStore( store = createStore(
[filtersModule], [filtersModule],
[{ focus: { user: { username: 'somebody' } } }], [{ focus: { ...focusConfig } }],
); );
assert.isTrue(store.hasAppliedFilter()); assert.isTrue(store.hasAppliedFilter());
store.toggleFocusMode(false);
assert.isFalse(store.hasAppliedFilter());
}); });
},
);
it('returns true if there is an applied filter', () => { it('returns true if there is an applied filter', () => {
store.setFilter('anyWhichWay', { value: 'nope', display: 'Fatigue' }); store.setFilter('anyWhichWay', { value: 'nope', display: 'Fatigue' });
...@@ -335,16 +392,6 @@ describe('sidebar/store/modules/filters', () => { ...@@ -335,16 +392,6 @@ describe('sidebar/store/modules/filters', () => {
assert.isTrue(store.hasAppliedFilter()); assert.isTrue(store.hasAppliedFilter());
}); });
it('returns false if user-focused mode is configured but inactive', () => {
store = createStore(
[filtersModule],
[{ focus: { user: { username: 'somebody' } } }],
);
store.toggleFocusMode(false);
assert.isFalse(store.hasAppliedFilter());
});
}); });
}); });
}); });
...@@ -82,9 +82,33 @@ export type ReportAnnotationActivityConfig = { ...@@ -82,9 +82,33 @@ export type ReportAnnotationActivityConfig = {
}; };
/** /**
* Structure of focus-mode config, provided in settings (app config) * Configure the client to focus on a specific subset of annotations.
*
* This hides annotations which do not match the filter when the client starts,
* and shows an option to toggle the filters on or off.
*
* This is used in the LMS for example when a teacher is grading a specific
* student's annotations or in an assignment where students are being directed
* to annotate a specific chapter.
*/ */
export type FocusConfig = { export type FocusConfig = {
/** Specify a range of content in an ebook as a CFI range. */
cfi?: {
/**
* CFI range specified as `[startCFI]-[endCFI]`. The range is exclusive of
* the end point.
*/
range: string;
/** Descriptive label for this CFI range. */
label: string;
};
/**
* Page range in the form `[start]-[end]`. The range is inclusive of the
* end page.
*/
pages?: string;
user?: FocusUserInfo; user?: FocusUserInfo;
}; };
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment