From f10a37986e02084647e4ab764c65d712bb49d0f7 Mon Sep 17 00:00:00 2001 From: Ray Lee <ray.lee@lyrasis.org> Date: Mon, 19 Sep 2022 22:21:22 -0400 Subject: [PATCH 1/3] Show status of auto updating lists in the custom list editor. --- src/components/CustomListEditor.tsx | 7 + src/components/CustomListEntriesEditor.tsx | 264 ++++++++++-------- src/components/CustomListSearch.tsx | 6 +- src/components/CustomLists.tsx | 8 + .../__tests__/CustomListSearch-test.tsx | 5 + src/reducers/customListEditor.ts | 203 ++++++++------ src/stylesheets/custom_list_editor.scss | 2 + .../custom_list_entries_editor.scss | 5 + 8 files changed, 302 insertions(+), 198 deletions(-) diff --git a/src/components/CustomListEditor.tsx b/src/components/CustomListEditor.tsx index 7698be5e5..255f9a6d1 100644 --- a/src/components/CustomListEditor.tsx +++ b/src/components/CustomListEditor.tsx @@ -23,6 +23,7 @@ import TextWithEditMode from "./TextWithEditMode"; import ShareIcon from "./icons/ShareIcon"; type CustomListEditorProps = { + autoUpdateStatus?: string; collections?: AdminCollectionData[]; entries?: CustomListEditorEntriesData; entryPoints?: string[]; @@ -32,6 +33,7 @@ type CustomListEditorProps = { isFetchingMoreSearchResults: boolean; isLoaded?: boolean; isModified?: boolean; + isSearchModified?: boolean; isOwner?: boolean; isShared?: boolean; isSharePending?: boolean; @@ -72,6 +74,7 @@ type CustomListEditorProps = { }; export default function CustomListEditor({ + autoUpdateStatus, collections, entries, entryPoints, @@ -81,6 +84,7 @@ export default function CustomListEditor({ isFetchingMoreSearchResults, isLoaded, isModified, + isSearchModified, isOwner, isShared, isSharePending, @@ -254,6 +258,7 @@ export default function CustomListEditor({ <CustomListSearch autoUpdate={properties.autoUpdate} isOwner={isOwner} + listId={listId} searchParams={searchParams} updateAutoUpdate={(value) => updateProperty?.("autoUpdate", value)} updateSearchParam={updateSearchParam} @@ -272,8 +277,10 @@ export default function CustomListEditor({ </section> <CustomListEntriesEditor + autoUpdateStatus={autoUpdateStatus} autoUpdate={properties.autoUpdate} isOwner={isOwner} + isSearchModified={isSearchModified} searchResults={searchResults} entries={entries.current} loadMoreSearchResults={loadMoreSearchResults} diff --git a/src/components/CustomListEntriesEditor.tsx b/src/components/CustomListEntriesEditor.tsx index 1e5941b6b..c96491407 100644 --- a/src/components/CustomListEntriesEditor.tsx +++ b/src/components/CustomListEntriesEditor.tsx @@ -15,12 +15,14 @@ import LoadButton from "./LoadButton"; export interface CustomListEntriesEditorProps { autoUpdate?: boolean; + autoUpdateStatus?: string; entries?: Entry[]; entryCount?: number; isFetchingMoreCustomListEntries: boolean; isFetchingSearchResults: boolean; isFetchingMoreSearchResults: boolean; isOwner?: boolean; + isSearchModified?: boolean; listId?: string | number; opdsFeedUrl?: string; searchResults?: CollectionData; @@ -55,12 +57,14 @@ const renderCatalogLink = (book, opdsFeedUrl) => { const CustomListEntriesEditor = ({ autoUpdate, - entries, - entryCount, + autoUpdateStatus, + entries = [], + entryCount = 0, isFetchingMoreCustomListEntries, isFetchingSearchResults, isFetchingMoreSearchResults, isOwner, + isSearchModified, listId, opdsFeedUrl, searchResults, @@ -134,7 +138,7 @@ const CustomListEntriesEditor = ({ const readOnly = !isOwner || autoUpdate; - let searchResultList = null; + let searchResultList: JSX.Element | null = null; if (isOwner) { searchResultList = ( @@ -210,7 +214,7 @@ const CustomListEntriesEditor = ({ <div className="title">{book.title}</div> <div className="authors"> - {book.authors.join(", ")} + {book.authors?.join(", ")} </div> </div> @@ -256,125 +260,163 @@ const CustomListEntriesEditor = ({ ); } - let entryList = null; - - if (!autoUpdate) { - const visibleEntryCount = entries.length; - const startNum = visibleEntryCount > 0 ? 1 : 0; - const endNum = visibleEntryCount; - const booksText = entryCount === 1 ? "book" : "books"; - - const entryListDisplay = - entryCount > 0 - ? `Displaying ${startNum} - ${endNum} of ${entryCount} ${booksText}` - : "No books in this list"; - - entryList = ( - <div className="custom-list-entries"> - <div className="droppable-header"> - <h4>List Entries: {entryListDisplay}</h4> + const visibleEntryCount = entries.length; + const startNum = visibleEntryCount > 0 ? 1 : 0; + const endNum = visibleEntryCount; + const booksText = entryCount === 1 ? "book" : "books"; + + const entryListDisplay = + entryCount > 0 + ? `Displaying ${startNum} - ${endNum} of ${entryCount} ${booksText}` + : "No books in this list"; + + let autoUpdateStatusName = null; + let autoUpdateStatusDescription = null; + + if (!listId) { + autoUpdateStatusName = "New"; + autoUpdateStatusDescription = + "The system will begin to populate this list using the configured search criteria when it is saved, and will fully populate the list during the first scheduled update that occurs after it has been saved."; + } else if (isSearchModified) { + autoUpdateStatusName = "Search criteria modified"; + autoUpdateStatusDescription = + "There are unsaved changes to the search criteria for this list. The system will repopulate the list using the new search criteria during the first scheduled update that occurs after it has been saved."; + } else { + if (autoUpdateStatus === "init") { + autoUpdateStatusName = "Initializing"; + autoUpdateStatusDescription = + "This list was created recently. The system has partially populated the list using the configured search criteria, and will fully populate the list during the next scheduled update."; + } else if (autoUpdateStatus === "repopulate") { + autoUpdateStatusName = "Repopulating"; + autoUpdateStatusDescription = + "The search criteria for this list were changed recently, but the entries have not yet been updated. The system will repopulate the list using the current search criteria during the next scheduled update."; + } else if (autoUpdateStatus === "updated") { + autoUpdateStatusName = "Updated"; + autoUpdateStatusDescription = + "This list was fully populated during the last scheduled update, using the configured search criteria and the titles that were available at the time. Titles that have been acquired since the last update will be added to the list during the next scheduled update."; + } else if (!autoUpdateStatus) { + autoUpdateStatusName = "Changing to automatic"; + autoUpdateStatusDescription = + "This list was populated manually, and is being changed to be updated automatically. The system will repopulate the list using the configured search criteria during the first scheduled update that occurs after it has been saved."; + } else { + autoUpdateStatusName = autoUpdateStatus; + } + } - {!readOnly && entries?.length > 0 && ( - <div> - <span>Remove all currently visible items from list:</span> + const entryList = ( + <div className="custom-list-entries"> + <div className="droppable-header"> + <h4>List Entries: {entryListDisplay}</h4> - <Button - className="danger delete-all-button top-align" - callback={deleteAllEntries} - content={ - <span> - Delete - <TrashIcon /> - </span> - } - /> + {autoUpdate && ( + <> + <div className="auto-update-status-name"> + Status: {autoUpdateStatusName} </div> - )} - </div> + <aside className="auto-update-status-desc"> + {autoUpdateStatusDescription} + </aside> + </> + )} - {!readOnly && <p>Drag search results here to add them to the list.</p>} - - <Droppable - droppableId="custom-list-entries" - isDropDisabled={readOnly || draggingFrom !== "search-results"} - > - {(provided, snapshot) => ( - <ul - ref={provided.innerRef} - id="custom-list-entries-droppable" - className={ - snapshot.isDraggingOver - ? " droppable dragging-over" - : "droppable" + {!readOnly && entries?.length > 0 && ( + <div> + <span>Remove all currently visible items from list:</span> + + <Button + className="danger delete-all-button top-align" + callback={deleteAllEntries} + content={ + <span> + Delete + <TrashIcon /> + </span> } - > - {entries?.map((book) => ( - <Draggable - key={book.id} - draggableId={book.id} - isDragDisabled={readOnly} - > - {(provided, snapshot) => ( - <li> - <div - className={ - "custom-list-entry" + - (snapshot.isDragging ? " dragging" : "") - } - ref={provided.innerRef} - style={provided.draggableStyle} - {...provided.dragHandleProps} - > - {!readOnly && <GrabIcon />} - - <div> - <div className="title">{book.title}</div> - - <div className="authors"> - {book.authors.join(", ")} - </div> - </div> + /> + </div> + )} + </div> + + {!readOnly && <p>Drag search results here to add them to the list.</p>} + + <Droppable + droppableId="custom-list-entries" + isDropDisabled={readOnly || draggingFrom !== "search-results"} + > + {(provided, snapshot) => ( + <ul + ref={provided.innerRef} + id="custom-list-entries-droppable" + className={ + snapshot.isDraggingOver ? " droppable dragging-over" : "droppable" + } + > + {entries?.map((book) => ( + <Draggable + key={book.id} + draggableId={book.id} + isDragDisabled={readOnly} + > + {(provided, snapshot) => ( + <li> + <div + className={ + "custom-list-entry" + + (snapshot.isDragging ? " dragging" : "") + } + ref={provided.innerRef} + style={provided.draggableStyle} + {...provided.dragHandleProps} + > + {!readOnly && <GrabIcon />} - {getMediumSVG(getMedium(book))} - - <div className="links"> - {renderCatalogLink(book, opdsFeedUrl)} - - {!readOnly && ( - <Button - className="small right-align" - callback={() => deleteEntry?.(book.id)} - content={ - <span> - Remove from list - <TrashIcon /> - </span> - } - /> - )} + <div> + <div className="title">{book.title}</div> + + <div className="authors"> + {book.authors?.join(", ")} </div> </div> - {provided.placeholder} - </li> - )} - </Draggable> - ))} + {getMediumSVG(getMedium(book))} - {provided.placeholder} - </ul> - )} - </Droppable> + <div className="links"> + {renderCatalogLink(book, opdsFeedUrl)} - {loadMoreEntries && ( - <LoadButton - isFetching={isFetchingMoreCustomListEntries} - loadMore={loadMoreEntries} - /> + {!readOnly && ( + <Button + className="small right-align" + callback={() => deleteEntry?.(book.id)} + content={ + <span> + Remove from list + <TrashIcon /> + </span> + } + /> + )} + </div> + </div> + + {provided.placeholder} + </li> + )} + </Draggable> + ))} + + {provided.placeholder} + </ul> )} - </div> - ); - } + </Droppable> + + {loadMoreEntries && ( + <LoadButton + isFetching={isFetchingMoreCustomListEntries} + loadMore={loadMoreEntries} + /> + )} + </div> + ); return ( <DragDropContext onDragStart={handleDragStart} onDragEnd={handleDragEnd}> diff --git a/src/components/CustomListSearch.tsx b/src/components/CustomListSearch.tsx index be1b293c6..9cb3f5ce3 100644 --- a/src/components/CustomListSearch.tsx +++ b/src/components/CustomListSearch.tsx @@ -12,6 +12,7 @@ export interface CustomListSearchProps { isOwner?: boolean; languages: LanguagesData; library: LibraryData; + listId: string; searchParams: CustomListEditorSearchParams; showAutoUpdate?: boolean; startingTitle?: string; @@ -44,6 +45,7 @@ const CustomListSearch = ({ entryPoints, isOwner, library, + listId, searchParams, showAutoUpdate, startingTitle, @@ -170,7 +172,7 @@ const CustomListSearch = ({ <div className="auto-update-selection"> <div> <EditableInput - disabled={readOnly} + disabled={readOnly || !!listId} type="radio" name="auto-update" checked={autoUpdate} @@ -188,7 +190,7 @@ const CustomListSearch = ({ <div> <EditableInput - disabled={readOnly} + disabled={readOnly || !!listId} type="radio" name="auto-update" checked={!autoUpdate} diff --git a/src/components/CustomLists.tsx b/src/components/CustomLists.tsx index fa9ac83ec..b4a8f075e 100644 --- a/src/components/CustomLists.tsx +++ b/src/components/CustomLists.tsx @@ -31,6 +31,7 @@ import ErrorMessage from "./ErrorMessage"; import CustomListsSidebar from "./CustomListsSidebar"; export interface CustomListsStateProps { + customListEditorAutoUpdateStatus?: string; customListEditorProperties?: CustomListEditorProperties; customListEditorSearchParams?: CustomListEditorSearchParams; customListEditorEntries?: CustomListEditorEntriesData; @@ -40,6 +41,7 @@ export interface CustomListsStateProps { customListEditorIsSharePending?: boolean; customListEditorIsValid?: boolean; customListEditorIsModified?: boolean; + customListEditorIsSearchModified?: boolean; customListEditorIsAutoUpdateEnabled?: boolean; lists: CustomListData[]; listDetails?: CollectionData; @@ -182,12 +184,14 @@ export class CustomLists extends React.Component< renderEditor(): JSX.Element { const editorProps = { collections: this.collectionsForLibrary(), + autoUpdateStatus: this.props.customListEditorAutoUpdateStatus, isAutoUpdateEnabled: this.props.customListEditorIsAutoUpdateEnabled, properties: this.props.customListEditorProperties, searchParams: this.props.customListEditorSearchParams, isLoaded: this.props.customListEditorIsLoaded, isValid: this.props.customListEditorIsValid, isModified: this.props.customListEditorIsModified, + isSearchModified: this.props.customListEditorIsSearchModified, isOwner: this.props.customListEditorIsOwner, isShared: this.props.customListEditorIsShared, isSharePending: this.props.customListEditorIsSharePending, @@ -437,6 +441,8 @@ function mapStateToProps(state, ownProps) { customListEditorSearchParams: state.editor.customListEditor.searchParams.current, customListEditorEntries: state.editor.customListEditor.entries, + customListEditorAutoUpdateStatus: + state.editor.customListEditor.autoUpdateStatus, customListEditorIsLoaded: state.editor.customListEditor.isLoaded, customListEditorIsOwner: state.editor.customListEditor.isOwner, customListEditorIsShared: state.editor.customListEditor.isShared, @@ -444,6 +450,8 @@ function mapStateToProps(state, ownProps) { state.editor.customListEditor.isSharePending, customListEditorIsValid: state.editor.customListEditor.isValid, customListEditorIsModified: state.editor.customListEditor.isModified, + customListEditorIsSearchModified: + state.editor.customListEditor.isSearchModified, customListEditorIsAutoUpdateEnabled: state.editor.customListEditor.isAutoUpdateEnabled, lists: diff --git a/src/components/__tests__/CustomListSearch-test.tsx b/src/components/__tests__/CustomListSearch-test.tsx index f62e0af0f..8d3d4de6a 100644 --- a/src/components/__tests__/CustomListSearch-test.tsx +++ b/src/components/__tests__/CustomListSearch-test.tsx @@ -57,6 +57,7 @@ describe("CustomListSearch", () => { isOwner={true} languages={languages} library={library} + listId="123" search={search} searchParams={searchParams} updateAutoUpdate={updateAutoUpdate} @@ -221,6 +222,7 @@ describe("CustomListSearch", () => { entryPoints={entryPoints} languages={languages} library={library} + listId="123" search={search} searchParams={searchParams} startingTitle="test" @@ -260,6 +262,7 @@ describe("CustomListSearch", () => { isOwner={true} languages={languages} library={library} + listId="123" search={search} searchParams={searchParams} showAutoUpdate={true} @@ -300,6 +303,7 @@ describe("CustomListSearch", () => { isOwner={false} languages={languages} library={library} + listId="123" search={search} searchParams={searchParams} showAutoUpdate={true} @@ -329,6 +333,7 @@ describe("CustomListSearch", () => { entryPoints={entryPoints} languages={languages} library={library} + listId="123" search={search} searchParams={searchParams} showAutoUpdate={true} diff --git a/src/reducers/customListEditor.ts b/src/reducers/customListEditor.ts index 50cc4567c..e8597f42c 100644 --- a/src/reducers/customListEditor.ts +++ b/src/reducers/customListEditor.ts @@ -544,6 +544,11 @@ export interface CustomListEditorState { */ id: number; + /** + * The update status of the list, if it is auto updating. + */ + autoUpdateStatus: string; + /** * Flag indicating if the auto updating lists feature is enabled. */ @@ -600,6 +605,8 @@ export interface CustomListEditorState { */ isModified: boolean; + isSearchModified: boolean; + /** * An error message, if an error has occurred. */ @@ -634,6 +641,7 @@ const initialSearchParams = { */ export const initialState: CustomListEditorState = { id: null, + autoUpdateStatus: "", isAutoUpdateEnabled: false, isLoaded: false, isOwner: true, @@ -657,6 +665,7 @@ export const initialState: CustomListEditorState = { }, isValid: false, isModified: false, + isSearchModified: false, error: null, }; @@ -703,6 +712,15 @@ const isSearchModified = (state: CustomListEditorState): boolean => { return baselineSearchUrl !== currentSearchUrl; }; +const checkSearchModified = ( + state: CustomListEditorState +): CustomListEditorState => { + return produce(state, (draftState) => { + draftState.isSearchModified = isSearchModified(draftState); + console.log("search modified: " + draftState.isSearchModified); + }); +}; + /** * Determines if a custom list editor contains data has been modified since it was last saved, * given its state. @@ -720,7 +738,7 @@ const isModified = (state: CustomListEditorState): boolean => { Object.keys(removed).length > 0 || baseline.name !== current.name || baseline.autoUpdate !== current.autoUpdate || - (current.autoUpdate && isSearchModified(state)) || + (current.autoUpdate && state.isSearchModified) || baseline.collections.length !== current.collections.length || !baseline.collections.every((id) => current.collections.includes(id)) ); @@ -805,6 +823,11 @@ const initialStateForList = ( draftState.isOwner = customList.is_owner; draftState.isShared = customList.is_shared; + draftState.autoUpdateStatus = + isAutoUpdateEnabled && !!customList.auto_update + ? customList.auto_update_status + : ""; + const initialProperties = { name: customList.name || "", collections: customList.collections.map((collection) => collection.id), @@ -1042,9 +1065,11 @@ const handleUpdateCustomListEditorSearchParam = validatedHandler( (state: CustomListEditorState, action): CustomListEditorState => { const { name, value } = action; - return produce(state, (draftState) => { - draftState.searchParams.current[name] = value; - }); + return checkSearchModified( + produce(state, (draftState) => { + draftState.searchParams.current[name] = value; + }) + ); } ); @@ -1279,28 +1304,30 @@ const getDefaultBooleanOperator = (builderName: string) => { */ const handleAddCustomListEditorAdvSearchQuery = validatedHandler( (state: CustomListEditorState, action): CustomListEditorState => { - return produce(state, (draftState) => { - const { builderName, query } = action; - const builder = draftState.searchParams.current.advanced[builderName]; - const { query: currentQuery, selectedQueryId } = builder; + return checkSearchModified( + produce(state, (draftState) => { + const { builderName, query } = action; + const builder = draftState.searchParams.current.advanced[builderName]; + const { query: currentQuery, selectedQueryId } = builder; - const newQuery = { - ...query, - id: newQueryId(), - }; + const newQuery = { + ...query, + id: newQueryId(), + }; - if (!currentQuery) { - builder.query = newQuery; - builder.selectedQueryId = newQuery.id; - } else { - builder.query = addDescendantQuery( - currentQuery, - selectedQueryId || currentQuery.id, - newQuery, - getDefaultBooleanOperator(builderName) - ); - } - }); + if (!currentQuery) { + builder.query = newQuery; + builder.selectedQueryId = newQuery.id; + } else { + builder.query = addDescendantQuery( + currentQuery, + selectedQueryId || currentQuery.id, + newQuery, + getDefaultBooleanOperator(builderName) + ); + } + }) + ); } ); @@ -1317,25 +1344,27 @@ const handleAddCustomListEditorAdvSearchQuery = validatedHandler( */ const handleUpdateCustomListEditorAdvSearchQueryBoolean = validatedHandler( (state: CustomListEditorState, action): CustomListEditorState => { - return produce(state, (draftState) => { - const { builderName, id, bool } = action; - const builder = draftState.searchParams.current.advanced[builderName]; - const { query: currentQuery } = builder; - const targetQuery = findDescendantQuery(currentQuery, id); - - if ( - targetQuery && - (targetQuery.and || targetQuery.or) && - !targetQuery[bool] - ) { - const oppositeBool = bool === "and" ? "or" : "and"; - const children = targetQuery[oppositeBool]; - - delete targetQuery[oppositeBool]; - - targetQuery[bool] = children; - } - }); + return checkSearchModified( + produce(state, (draftState) => { + const { builderName, id, bool } = action; + const builder = draftState.searchParams.current.advanced[builderName]; + const { query: currentQuery } = builder; + const targetQuery = findDescendantQuery(currentQuery, id); + + if ( + targetQuery && + (targetQuery.and || targetQuery.or) && + !targetQuery[bool] + ) { + const oppositeBool = bool === "and" ? "or" : "and"; + const children = targetQuery[oppositeBool]; + + delete targetQuery[oppositeBool]; + + targetQuery[bool] = children; + } + }) + ); } ); @@ -1361,29 +1390,31 @@ const handleUpdateCustomListEditorAdvSearchQueryBoolean = validatedHandler( */ const handleMoveCustomListEditorAdvSearchQuery = validatedHandler( (state: CustomListEditorState, action): CustomListEditorState => { - return produce(state, (draftState) => { - const { builderName, id, targetId } = action; - const builder = draftState.searchParams.current.advanced[builderName]; - const { query: currentQuery } = builder; - const query = findDescendantQuery(currentQuery, id); - - const newQuery = { - ...query, - id: newQueryId(), - }; + return checkSearchModified( + produce(state, (draftState) => { + const { builderName, id, targetId } = action; + const builder = draftState.searchParams.current.advanced[builderName]; + const { query: currentQuery } = builder; + const query = findDescendantQuery(currentQuery, id); + + const newQuery = { + ...query, + id: newQueryId(), + }; - const afterAddQuery = addDescendantQuery( - currentQuery, - targetId, - newQuery, - getDefaultBooleanOperator(builderName) - ); + const afterAddQuery = addDescendantQuery( + currentQuery, + targetId, + newQuery, + getDefaultBooleanOperator(builderName) + ); - const afterRemoveQuery = removeDescendantQuery(afterAddQuery, id); + const afterRemoveQuery = removeDescendantQuery(afterAddQuery, id); - builder.query = afterRemoveQuery; - builder.selectedQueryId = targetId; - }); + builder.query = afterRemoveQuery; + builder.selectedQueryId = targetId; + }) + ); } ); @@ -1399,35 +1430,37 @@ const handleMoveCustomListEditorAdvSearchQuery = validatedHandler( */ const handleRemoveCustomListEditorAdvSearchQuery = validatedHandler( (state: CustomListEditorState, action): CustomListEditorState => { - return produce(state, (draftState) => { - const { builderName, id } = action; - const builder = draftState.searchParams.current.advanced[builderName]; - const { query: currentQuery } = builder; + return checkSearchModified( + produce(state, (draftState) => { + const { builderName, id } = action; + const builder = draftState.searchParams.current.advanced[builderName]; + const { query: currentQuery } = builder; - const afterRemoveQuery = removeDescendantQuery(currentQuery, id); + const afterRemoveQuery = removeDescendantQuery(currentQuery, id); - if (afterRemoveQuery !== currentQuery) { - builder.query = afterRemoveQuery; + if (afterRemoveQuery !== currentQuery) { + builder.query = afterRemoveQuery; - // It is possible that removeDescendantQuery removed more than just the one query; for - // example, if the removed query was the child of an and/or query, and removing it left only - // one other child query, then the remaining child would have been lifted out of the parent, - // and the parent would have been deleted as well. For this reason, we can't assume that the - // parent of the removed query still exists; we have to find the nearest ancestor of the - // removed query that remains in the tree, to make that the new selected query. + // It is possible that removeDescendantQuery removed more than just the one query; for + // example, if the removed query was the child of an and/or query, and removing it left only + // one other child query, then the remaining child would have been lifted out of the parent, + // and the parent would have been deleted as well. For this reason, we can't assume that the + // parent of the removed query still exists; we have to find the nearest ancestor of the + // removed query that remains in the tree, to make that the new selected query. - const path = findDescendantQueryPath(currentQuery, id); + const path = findDescendantQueryPath(currentQuery, id); - path.pop(); - path.reverse(); + path.pop(); + path.reverse(); - const ancestorId = path.find((id) => - findDescendantQuery(afterRemoveQuery, id) - ); + const ancestorId = path.find((id) => + findDescendantQuery(afterRemoveQuery, id) + ); - builder.selectedQueryId = ancestorId; - } - }); + builder.selectedQueryId = ancestorId; + } + }) + ); } ); diff --git a/src/stylesheets/custom_list_editor.scss b/src/stylesheets/custom_list_editor.scss index 6ee24bb58..71f706914 100644 --- a/src/stylesheets/custom_list_editor.scss +++ b/src/stylesheets/custom_list_editor.scss @@ -105,6 +105,7 @@ aside { font-style: italic; + font-size: .9em; } } } @@ -220,6 +221,7 @@ aside { font-style: italic; + font-size: .9em; } } } diff --git a/src/stylesheets/custom_list_entries_editor.scss b/src/stylesheets/custom_list_entries_editor.scss index e98397e43..f250cc000 100644 --- a/src/stylesheets/custom_list_entries_editor.scss +++ b/src/stylesheets/custom_list_entries_editor.scss @@ -19,6 +19,11 @@ display: inline-block; } + .auto-update-status-desc { + font-style: italic; + font-size: .9em; + } + button { float: right; From 51237707e199c19fb8d42ef3e2c7aa534de938c4 Mon Sep 17 00:00:00 2001 From: Ray Lee <ray.lee@lyrasis.org> Date: Tue, 20 Sep 2022 14:27:49 -0400 Subject: [PATCH 2/3] Add/update tests. --- .../CustomListEntriesEditor-test.tsx | 114 +++++++++++++++--- .../__tests__/CustomListSearch-test.tsx | 33 ++++- .../__tests__/customListEditor-test.ts | 30 ++++- src/reducers/customListEditor.ts | 16 ++- 4 files changed, 167 insertions(+), 26 deletions(-) diff --git a/src/components/__tests__/CustomListEntriesEditor-test.tsx b/src/components/__tests__/CustomListEntriesEditor-test.tsx index f10cee342..5f0002ef5 100644 --- a/src/components/__tests__/CustomListEntriesEditor-test.tsx +++ b/src/components/__tests__/CustomListEntriesEditor-test.tsx @@ -355,10 +355,10 @@ describe("CustomListEntriesEditor", () => { "http://schema.org/EBook"; }); - it("does not render list entries when autoUpdate is true", () => { + it("renders list entries", () => { const wrapper = mount( <CustomListEntriesEditor - autoUpdate={true} + autoUpdate={false} entries={entriesData} isOwner={true} loadMoreSearchResults={loadMoreSearchResults} @@ -373,13 +373,32 @@ describe("CustomListEntriesEditor", () => { const entriesContainer = wrapper.find(".custom-list-entries"); - expect(entriesContainer.length).to.equal(0); + expect(entriesContainer.length).to.equal(1); + + const droppable = entriesContainer.find(Droppable); + + expect(droppable.length).to.equal(1); + + const entries = droppable.find(Draggable); + + expect(entries.length).to.equal(2); + + expect(entries.at(0).text()).to.contain("entry A"); + expect(entries.at(0).text()).to.contain("author A"); + expect(entries.at(1).text()).to.contain("entry B"); + expect(entries.at(1).text()).to.contain("author B1, author B2"); + + const display = wrapper.find(".custom-list-entries h4"); + + expect(display.text()).to.equal( + "List Entries: Displaying 1 - 2 of 2 books" + ); }); - it("renders list entries when autoUpdate is false", () => { + it("makes list entries read only if autoUpdate is true", () => { const wrapper = mount( <CustomListEntriesEditor - autoUpdate={false} + autoUpdate={true} entries={entriesData} isOwner={true} loadMoreSearchResults={loadMoreSearchResults} @@ -393,27 +412,86 @@ describe("CustomListEntriesEditor", () => { ); const entriesContainer = wrapper.find(".custom-list-entries"); + const removeAllButton = entriesContainer.find(".droppable-header button"); - expect(entriesContainer.length).to.equal(1); + expect(removeAllButton.length).to.equal(0); - const droppable = entriesContainer.find(Droppable); + const removeEntryButtons = entriesContainer.find( + ".custom-list-entry button" + ); - expect(droppable.length).to.equal(1); + expect(removeEntryButtons.length).to.equal(0); + }); - const entries = droppable.find(Draggable); + it("renders an auto update status if autoUpdate is true", () => { + const wrapper = mount( + <CustomListEntriesEditor + autoUpdate={true} + autoUpdateStatus="" + entries={entriesData} + isOwner={true} + loadMoreSearchResults={loadMoreSearchResults} + loadMoreEntries={loadMoreEntries} + isFetchingSearchResults={false} + isFetchingMoreSearchResults={false} + isFetchingMoreCustomListEntries={false} + entryCount={2} + />, + { context: fullContext, childContextTypes } + ); - expect(entries.length).to.equal(2); + let status; - expect(entries.at(0).text()).to.contain("entry A"); - expect(entries.at(0).text()).to.contain("author A"); - expect(entries.at(1).text()).to.contain("entry B"); - expect(entries.at(1).text()).to.contain("author B1, author B2"); + status = wrapper.find(".custom-list-entries .auto-update-status-name"); + expect(status.text()).to.equal("Status: New"); - const display = wrapper.find(".custom-list-entries h4"); + wrapper.setProps({ isSearchModified: true }); - expect(display.text()).to.equal( - "List Entries: Displaying 1 - 2 of 2 books" - ); + status = wrapper.find(".custom-list-entries .auto-update-status-name"); + expect(status.text()).to.equal("Status: New"); + + wrapper.setProps({ + listId: "123", + isSearchModified: false, + autoUpdateStatus: "init", + }); + + status = wrapper.find(".custom-list-entries .auto-update-status-name"); + expect(status.text()).to.equal("Status: Initializing"); + + wrapper.setProps({ isSearchModified: true }); + + status = wrapper.find(".custom-list-entries .auto-update-status-name"); + expect(status.text()).to.equal("Status: Search criteria modified"); + + wrapper.setProps({ isSearchModified: false, autoUpdateStatus: "updated" }); + + status = wrapper.find(".custom-list-entries .auto-update-status-name"); + expect(status.text()).to.equal("Status: Updated"); + + wrapper.setProps({ isSearchModified: true }); + + status = wrapper.find(".custom-list-entries .auto-update-status-name"); + expect(status.text()).to.equal("Status: Search criteria modified"); + + wrapper.setProps({ + isSearchModified: false, + autoUpdateStatus: "repopulate", + }); + + status = wrapper.find(".custom-list-entries .auto-update-status-name"); + expect(status.text()).to.equal("Status: Repopulating"); + + wrapper.setProps({ isSearchModified: true }); + + status = wrapper.find(".custom-list-entries .auto-update-status-name"); + expect(status.text()).to.equal("Status: Search criteria modified"); + + wrapper.setProps({ autoUpdate: false }); + + status = wrapper.find(".custom-list-entries .auto-update-status-name"); + + expect(status.length).to.equal(0); }); it("renders a link to view each entry", () => { diff --git a/src/components/__tests__/CustomListSearch-test.tsx b/src/components/__tests__/CustomListSearch-test.tsx index 8d3d4de6a..7551ecccb 100644 --- a/src/components/__tests__/CustomListSearch-test.tsx +++ b/src/components/__tests__/CustomListSearch-test.tsx @@ -262,7 +262,7 @@ describe("CustomListSearch", () => { isOwner={true} languages={languages} library={library} - listId="123" + listId={null} search={search} searchParams={searchParams} showAutoUpdate={true} @@ -297,6 +297,37 @@ describe("CustomListSearch", () => { }); it("disables the radio buttons for auto update when isOwner is false", () => { + wrapper = mount( + <CustomListSearch + entryPoints={entryPoints} + isOwner={false} + languages={languages} + library={library} + listId={null} + search={search} + searchParams={searchParams} + showAutoUpdate={true} + updateAutoUpdate={updateAutoUpdate} + updateSearchParam={updateSearchParam} + /> + ); + + const autoUpdateOptions = wrapper + .find(".auto-update") + .find(".form-group"); + + const autoUpdateOn = autoUpdateOptions.at(0); + const autoUpdateOnRadio = autoUpdateOn.find("input"); + + expect(autoUpdateOnRadio.props().disabled).to.be.true; + + const autoUpdateOff = autoUpdateOptions.at(1); + const autoUpdateOffRadio = autoUpdateOff.find("input"); + + expect(autoUpdateOffRadio.props().disabled).to.be.true; + }); + + it("disables the radio buttons for auto update when listId is not null", () => { wrapper = mount( <CustomListSearch entryPoints={entryPoints} diff --git a/src/reducers/__tests__/customListEditor-test.ts b/src/reducers/__tests__/customListEditor-test.ts index 27309087f..a8430ac08 100644 --- a/src/reducers/__tests__/customListEditor-test.ts +++ b/src/reducers/__tests__/customListEditor-test.ts @@ -783,13 +783,14 @@ describe("custom list editor reducer", () => { expect(nextState.searchParams.current.language).to.equal("eng"); }); - it("updates isValid and isModified", () => { + it("updates isValid, isModified, and isSearchModified", () => { const nextState = reducer(state, { type: ActionCreator.UPDATE_CUSTOM_LIST_EDITOR_SEARCH_PARAM, name: "entryPoint", value: "Book", }); + expect(nextState.isSearchModified).to.equal(true); expect(nextState.isModified).to.equal(true); expect(nextState.isValid).to.equal(false); }); @@ -1136,7 +1137,7 @@ describe("custom list editor reducer", () => { ).to.equal("92"); }); - it("updates isValid and isModified", () => { + it("updates isValid, isModified, and isSearchModified", () => { const state = { ...initialState, properties: { @@ -1155,6 +1156,7 @@ describe("custom list editor reducer", () => { query: valueQuery, }); + expect(nextState.isSearchModified).to.equal(true); expect(nextState.isModified).to.equal(true); expect(nextState.isValid).to.equal(true); }); @@ -1256,7 +1258,7 @@ describe("custom list editor reducer", () => { expect(nextState).to.deep.equal(state); }); - it("updates isValid and isModified", () => { + it("updates isValid, isModified, and isSearchModified", () => { const namedState = { ...state, properties: { @@ -1276,6 +1278,7 @@ describe("custom list editor reducer", () => { bool: "or", }); + expect(nextState.isSearchModified).to.equal(true); expect(nextState.isModified).to.equal(true); expect(nextState.isValid).to.equal(true); }); @@ -1391,7 +1394,7 @@ describe("custom list editor reducer", () => { }); }); - it("updates isValid and isModified", () => { + it("updates isValid, isModified, isSearchModified", () => { const namedState = { ...state, properties: { @@ -1411,6 +1414,7 @@ describe("custom list editor reducer", () => { targetId: "95", }); + expect(nextState.isSearchModified).to.equal(true); expect(nextState.isModified).to.equal(true); expect(nextState.isValid).to.equal(true); }); @@ -1536,7 +1540,7 @@ describe("custom list editor reducer", () => { ).to.equal(undefined); }); - it("updates isValid and isModified", () => { + it("updates isValid, isModified, and isSearchModified", () => { const state = { ...initialState, properties: { @@ -1548,6 +1552,21 @@ describe("custom list editor reducer", () => { }, searchParams: { ...initialState.searchParams, + baseline: { + ...initialState.searchParams.baseline, + advanced: { + ...initialState.searchParams.baseline.advanced, + include: { + ...initialState.searchParams.baseline.advanced.include, + query: { + id: "92", + key: "title", + value: "bar", + }, + selectedQueryId: "92", + }, + }, + }, current: { ...initialState.searchParams.current, advanced: { @@ -1574,6 +1593,7 @@ describe("custom list editor reducer", () => { id: "92", }); + expect(nextState.isSearchModified).to.equal(true); expect(nextState.isModified).to.equal(true); expect(nextState.isValid).to.equal(false); }); diff --git a/src/reducers/customListEditor.ts b/src/reducers/customListEditor.ts index e8597f42c..7e93e20c1 100644 --- a/src/reducers/customListEditor.ts +++ b/src/reducers/customListEditor.ts @@ -605,6 +605,10 @@ export interface CustomListEditorState { */ isModified: boolean; + /** + * The modified state of the search parameters; true if the search has been changed since the + * last save, false otherwise. + */ isSearchModified: boolean; /** @@ -712,12 +716,19 @@ const isSearchModified = (state: CustomListEditorState): boolean => { return baselineSearchUrl !== currentSearchUrl; }; +/** + * Checks if a the search parameters in a custom list editor state have been modified, and stores + * the result in the state. + * + * @param state The custom list editor state + * @returns A new custom list editor state, with the isSearchModified property updated with the + * result. All other properties of the returned state are identical to the input state. + */ const checkSearchModified = ( state: CustomListEditorState ): CustomListEditorState => { return produce(state, (draftState) => { draftState.isSearchModified = isSearchModified(draftState); - console.log("search modified: " + draftState.isSearchModified); }); }; @@ -745,7 +756,8 @@ const isModified = (state: CustomListEditorState): boolean => { }; /** - * Validates the data in a custom list editor state, and checks if the data has been modified. + * Validates the data in a custom list editor state, and checks if the data has been modified, and + * stores the results in the state. * * @param state The custom list editor state * @returns A new custom list editor state, with the isValid and isModified properties updated From 9a9e329e2d22aba393608ce892fe81356968d066 Mon Sep 17 00:00:00 2001 From: Ray Lee <ray.lee@lyrasis.org> Date: Mon, 3 Oct 2022 17:16:39 -0400 Subject: [PATCH 3/3] Tweak status descriptions. --- src/components/CustomListEntriesEditor.tsx | 12 ++++++------ src/reducers/customListEditor.ts | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/components/CustomListEntriesEditor.tsx b/src/components/CustomListEntriesEditor.tsx index c96491407..5ab6d555e 100644 --- a/src/components/CustomListEntriesEditor.tsx +++ b/src/components/CustomListEntriesEditor.tsx @@ -276,28 +276,28 @@ const CustomListEntriesEditor = ({ if (!listId) { autoUpdateStatusName = "New"; autoUpdateStatusDescription = - "The system will begin to populate this list using the configured search criteria when it is saved, and will fully populate the list during the first scheduled update that occurs after it has been saved."; + "This is a new list. Once the initial search criteria have been saved, the system will begin to populate its entries; however, the list might not be fully populated until the next scheduled update."; } else if (isSearchModified) { autoUpdateStatusName = "Search criteria modified"; autoUpdateStatusDescription = - "There are unsaved changes to the search criteria for this list. The system will repopulate the list using the new search criteria during the first scheduled update that occurs after it has been saved."; + "There are unsaved changes to the search criteria for this list. Once the changes have been saved, the new search criteria will be used to repopulate the list during the next scheduled update."; } else { if (autoUpdateStatus === "init") { autoUpdateStatusName = "Initializing"; autoUpdateStatusDescription = - "This list was created recently. The system has partially populated the list using the configured search criteria, and will fully populate the list during the next scheduled update."; + "This list was created recently. The system has partially populated the list using the configured search criteria and will fully populate the list during the next scheduled update."; } else if (autoUpdateStatus === "repopulate") { autoUpdateStatusName = "Repopulating"; autoUpdateStatusDescription = - "The search criteria for this list were changed recently, but the entries have not yet been updated. The system will repopulate the list using the current search criteria during the next scheduled update."; + "The search criteria for this list were changed recently, but the entries have not yet been updated. The new search criteria will be used to repopulate the list during the next scheduled update."; } else if (autoUpdateStatus === "updated") { autoUpdateStatusName = "Updated"; autoUpdateStatusDescription = - "This list was fully populated during the last scheduled update, using the configured search criteria and the titles that were available at the time. Titles that have been acquired since the last update will be added to the list during the next scheduled update."; + "This list was fully populated during the last scheduled update, using the configured search criteria and the titles that were available at the time. New titles matching the criteria will be added to the list during the next scheduled update."; } else if (!autoUpdateStatus) { autoUpdateStatusName = "Changing to automatic"; autoUpdateStatusDescription = - "This list was populated manually, and is being changed to be updated automatically. The system will repopulate the list using the configured search criteria during the first scheduled update that occurs after it has been saved."; + "This list was populated manually, but is being changed to be updated automatically. The configured search criteria will be used to repopulate the list during the next scheduled update."; } else { autoUpdateStatusName = autoUpdateStatus; } diff --git a/src/reducers/customListEditor.ts b/src/reducers/customListEditor.ts index 7e93e20c1..ef20bb98f 100644 --- a/src/reducers/customListEditor.ts +++ b/src/reducers/customListEditor.ts @@ -717,7 +717,7 @@ const isSearchModified = (state: CustomListEditorState): boolean => { }; /** - * Checks if a the search parameters in a custom list editor state have been modified, and stores + * Checks if the search parameters in a custom list editor state have been modified and stores * the result in the state. * * @param state The custom list editor state