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 = {
/** Plural unit of the items being shown */
entityPlural: string;
/** Range of content currently focused (if not a page range). */
focusContentRange?: string | null;
/** Display name for the user currently focused, if any */
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
* user, this value includes annotations and replies.
......@@ -44,9 +50,18 @@ function FilterStatusMessage({
additionalCount,
entitySingular,
entityPlural,
focusContentRange,
focusDisplayName,
focusPageRange,
resultCount,
}: FilterStatusMessageProps) {
let contentLabel;
if (focusContentRange) {
contentLabel = <span> in {focusContentRange}</span>;
} else if (focusPageRange) {
contentLabel = <span> in pages {focusPageRange}</span>;
}
return (
<>
{resultCount > 0 && <span>Showing </span>}
......@@ -63,6 +78,7 @@ function FilterStatusMessage({
</span>
</span>
)}
{contentLabel}
{additionalCount > 0 && (
<span className="whitespace-nowrap italic text-color-text-light">
{' '}
......@@ -172,9 +188,16 @@ export default function FilterAnnotationsStatus() {
if (forcedVisibleCount > 0) {
return 'Reset filters';
}
return focusState.active
? 'Show all'
: `Show only ${focusState.displayName}`;
if (focusState.active) {
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';
}, [
......@@ -185,6 +208,8 @@ export default function FilterAnnotationsStatus() {
forcedVisibleCount,
]);
const showFocusHint = filterMode !== 'selection' && focusState.active;
return (
<div
// This container element needs to be present at all times but
......@@ -212,11 +237,13 @@ export default function FilterAnnotationsStatus() {
additionalCount={additionalCount}
entitySingular="annotation"
entityPlural="annotations"
focusContentRange={
showFocusHint ? focusState.contentRange : null
}
focusDisplayName={
filterMode !== 'selection' && focusState.active
? focusState.displayName
: ''
showFocusHint ? focusState.displayName : null
}
focusPageRange={showFocusHint ? focusState.pageRange : null}
resultCount={resultCount}
/>
)}
......
......@@ -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 = {
/**
* Valid/recognized filters
*/
export type FilterKey = 'user';
export type FilterKey = 'cfi' | 'page' | 'user';
export type Filters = Partial<Record<FilterKey, FilterOption | undefined>>;
type FocusState = {
/**
* Summary of the state of focus filters.
*/
export type FocusState = {
active: 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 {
}
/**
* Given the provided focusConfig: is it a valid configuration for focus?
* At this time, a `user` filter is required.
* Return true if a focus filter configuration is valid.
*/
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`.
* At present, this will create a `user` `FilterOption` if the config is valid.
*/
function focusFiltersFromConfig(focusConfig: FocusConfig): Filters {
const user = focusConfig.user;
if (!user || !isValidFocusConfig(focusConfig)) {
if (!isValidFocusConfig(focusConfig)) {
return {};
}
const userFilterValue = user.username || user.userid || '';
return {
user: {
const filters: Filters = {};
const user = focusConfig.user;
if (user) {
const userFilterValue = user.username || user.userid || '';
filters.user = {
value: 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 = {
CHANGE_FOCUS_MODE_USER(state: State, action: { user: FocusUserInfo }) {
const { user } = focusFiltersFromConfig({ user: action.user });
return {
focusActive: isValidFocusConfig({ user: action.user }),
focusFilters: focusFiltersFromConfig({ user: action.user }),
focusFilters: {
...state.focusFilters,
user,
},
};
},
......@@ -188,8 +230,13 @@ const focusState = createSelector(
(focusActive, focusFilters): FocusState => {
return {
active: focusActive,
configured: !!focusFilters?.user,
displayName: focusFilters?.user?.display || '',
// Check for filter with non-empty value.
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', () => {
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(() => {
store = createStore([filtersModule, selectionModule], fakeSettings);
});
......@@ -181,6 +194,22 @@ describe('sidebar/store/modules/filters', () => {
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', () => {
const focusState = store.focusState();
assert.isFalse(focusState.active);
......@@ -284,22 +313,44 @@ describe('sidebar/store/modules/filters', () => {
});
describe('getFocusFilters', () => {
it('returns any set focus filters', () => {
store = createStore(
[filtersModule],
[
{
focus: {
user: { username: 'somebody', displayName: 'Ding Bat' },
[
{
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', () => {
store = createStore(
[filtersModule],
[
{
focus: focusConfig,
},
},
],
);
const focusFilters = store.getFocusFilters();
assert.exists(focusFilters.user);
assert.deepEqual(focusFilters.user, {
value: 'somebody',
display: 'Ding Bat',
],
);
const focusFilters = store.getFocusFilters();
assert.exists(focusFilters[filterKey]);
assert.deepEqual(focusFilters[filterKey], filterValue);
});
});
});
......@@ -311,14 +362,20 @@ describe('sidebar/store/modules/filters', () => {
assert.isTrue(store.hasAppliedFilter());
});
it('returns true if user-focused mode is active', () => {
store = createStore(
[filtersModule],
[{ focus: { user: { username: 'somebody' } } }],
);
assert.isTrue(store.hasAppliedFilter());
});
[userFocusConfig, pageFocusConfig, cfiFocusConfig].forEach(
focusConfig => {
it('returns true if focused mode is active', () => {
store = createStore(
[filtersModule],
[{ focus: { ...focusConfig } }],
);
assert.isTrue(store.hasAppliedFilter());
store.toggleFocusMode(false);
assert.isFalse(store.hasAppliedFilter());
});
},
);
it('returns true if there is an applied filter', () => {
store.setFilter('anyWhichWay', { value: 'nope', display: 'Fatigue' });
......@@ -335,16 +392,6 @@ describe('sidebar/store/modules/filters', () => {
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 = {
};
/**
* 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 = {
/** 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;
};
......
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