Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ Helpers in `tests/jest/testUtils/`:

## Code Style

- Prettier: double quotes, semicolons, trailing commas (es5), 80 char width
- Prettier (double quotes, semicolons, trailing commas (es5), printWidth 80) and ESLint are authoritative for formatting and style. The husky pre-commit hook auto-formats staged files with Prettier, and CI runs ESLint (`npm run lint:js`), so do not flag or hand-verify formatting. printWidth is a soft target, not a hard limit; lines Prettier leaves longer than 80 (e.g., interface `extends` clauses) are fine, and there is no ESLint max-len rule.
- ESLint (flat config, `eslint.config.mjs`) with `jsx-a11y/strict` — runs on the whole tree in CI via `npm run lint:js` (`eslint . --max-warnings 0`, so warnings fail the build) and on staged files via the husky pre-commit hook. `npm run lint` runs ESLint (`lint:js`) followed by sass-lint.
- `@typescript-eslint/no-explicit-any` is disabled (any is allowed)
- Prefer template literals over string concatenation for building strings with variables
Expand Down
14 changes: 10 additions & 4 deletions src/components/CatalogServices.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ import EditableConfigList, {
} from "./EditableConfigList";
import { connect } from "react-redux";
import ActionCreator from "../actions";
import {
fetchLibrariesIfNeeded,
settledAllLibraries,
} from "../utils/allLibraries";
import { CatalogServicesData, CatalogServiceData } from "../interfaces";
import ServiceEditForm from "./ServiceEditForm";

Expand All @@ -17,6 +21,7 @@ export class CatalogServices extends EditableConfigList<
> {
EditForm = ServiceEditForm;
listDataKey = "catalog_services";
usesLibraryList = true;
itemTypeName = "catalog service";
urlBase = "/admin/web/config/catalogServices/";
identifierKey = "id";
Expand All @@ -37,9 +42,7 @@ function mapStateToProps(state) {
{},
(state.editor.catalogServices && state.editor.catalogServices.data) || {}
);
if (state.editor.libraries && state.editor.libraries.data) {
data.allLibraries = state.editor.libraries.data.libraries;
}
Object.assign(data, settledAllLibraries(state));
// fetchError = an error involving loading the list of catalog services; formError = an error upon submission
// of the create/edit form.
return {
Expand All @@ -58,7 +61,10 @@ function mapStateToProps(state) {
function mapDispatchToProps(dispatch, ownProps) {
const actions = new ActionCreator(null, ownProps.csrfToken);
return {
fetchData: () => dispatch(actions.fetchCatalogServices()),
fetchData: () => {
fetchLibrariesIfNeeded(dispatch, actions);
return dispatch(actions.fetchCatalogServices());
},
editItem: (data: FormData) => dispatch(actions.editCatalogService(data)),
deleteItem: (identifier: string | number) =>
dispatch(actions.deleteCatalogService(identifier)),
Expand Down
29 changes: 16 additions & 13 deletions src/components/Collections.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ import {
import { connect } from "react-redux";
import * as PropTypes from "prop-types";
import ActionCreator from "../actions";
import {
fetchLibrariesIfNeeded,
settledAllLibraries,
} from "../utils/allLibraries";
import {
CollectionsData,
CollectionData,
Expand All @@ -21,13 +25,11 @@ import CollectionReapButton from "./CollectionReapButton";
import ServiceWithRegistrationsEditForm from "./ServiceWithRegistrationsEditForm";
import TrashIcon from "./icons/TrashIcon";

export interface CollectionsStateProps
extends EditableConfigListStateProps<CollectionsData> {
export interface CollectionsStateProps extends EditableConfigListStateProps<CollectionsData> {
isFetchingLibraryRegistrations?: boolean;
}

export interface CollectionsDispatchProps
extends EditableConfigListDispatchProps<CollectionsData> {
export interface CollectionsDispatchProps extends EditableConfigListDispatchProps<CollectionsData> {
Comment thread
tdilauro marked this conversation as resolved.
registerLibrary: (data: FormData) => Promise<void>;
fetchLibraryRegistrations?: () => Promise<LibraryRegistrationsData>;
importCollection: (
Expand All @@ -38,13 +40,12 @@ export interface CollectionsDispatchProps
}

export interface CollectionsProps
extends CollectionsStateProps,
extends
CollectionsStateProps,
CollectionsDispatchProps,
EditableConfigListOwnProps {}

export class CollectionEditForm extends ServiceWithRegistrationsEditForm<
CollectionsData
> {
export class CollectionEditForm extends ServiceWithRegistrationsEditForm<CollectionsData> {
context: ServiceWithRegistrationsEditForm<CollectionsData>["context"] & {
importCollection: (
collectionId: string | number,
Expand Down Expand Up @@ -114,6 +115,7 @@ export class Collections extends GenericEditableConfigList<
> {
EditForm = CollectionEditForm;
listDataKey = "collections";
usesLibraryList = true;
itemTypeName = "collection";
urlBase = "/admin/web/config/collections/";
identifierKey = "id";
Expand Down Expand Up @@ -146,7 +148,7 @@ export class Collections extends GenericEditableConfigList<
}

protected getAllLibraries() {
return this.props.data?.allLibraries ?? [];
return this.props.data.allLibraries;
}

componentDidMount() {
Expand Down Expand Up @@ -209,9 +211,7 @@ function mapStateToProps(state) {
{},
(state.editor.collections && state.editor.collections.data) || {}
);
if (state.editor.libraries && state.editor.libraries.data) {
data.allLibraries = state.editor.libraries.data.libraries;
}
Object.assign(data, settledAllLibraries(state));
// fetchError = an error involving loading the list of collections; formError = an error upon
// submission of the create/edit form.
return {
Expand All @@ -228,7 +228,10 @@ function mapStateToProps(state) {
function mapDispatchToProps(dispatch, ownProps) {
const actions = new ActionCreator(null, ownProps.csrfToken);
return {
fetchData: () => dispatch(actions.fetchCollections()),
fetchData: () => {
fetchLibrariesIfNeeded(dispatch, actions);
return dispatch(actions.fetchCollections());
},
editItem: (data: FormData) => dispatch(actions.editCollection(data)),
deleteItem: (identifier: string | number) =>
dispatch(actions.deleteCollection(identifier)),
Expand Down
27 changes: 21 additions & 6 deletions src/components/DiscoveryServices.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ import {
import { connect } from "react-redux";
import * as PropTypes from "prop-types";
import ActionCreator from "../actions";
import {
fetchLibrariesIfNeeded,
settledAllLibraries,
} from "../utils/allLibraries";
import {
DiscoveryServicesData,
DiscoveryServiceData,
Expand Down Expand Up @@ -45,6 +49,7 @@ export class DiscoveryServices extends GenericEditableConfigList<
> {
EditForm = DiscoveryServiceEditForm;
listDataKey = "discovery_services";
usesLibraryList = true;
itemTypeName = "discovery service";
urlBase = "/admin/web/config/discovery/";
identifierKey = "id";
Expand Down Expand Up @@ -76,12 +81,18 @@ export class DiscoveryServices extends GenericEditableConfigList<
private registeredLibraries(
item: DiscoveryServiceData
): LibraryDataWithStatus[] | undefined {
const registrations = this.props.data?.libraryRegistrations;
const registrations = this.props.data.libraryRegistrations;
if (!registrations) return undefined;
const serviceReg = registrations.find((r) => r.id === item.id);
return (serviceReg?.libraries ?? []).filter((l) => l.status === "success");
}

// This tab's disclosure lists registered libraries, whose names and links
// fall back to the registration records themselves.
protected librariesUnavailableMessage(): string {
return "The library list failed to load. Registered libraries are shown using registration data, which may be out of date.";
}

protected formatAssociatedCount(count: number): string {
return count === 0
? "no registered libraries"
Expand All @@ -95,7 +106,10 @@ export class DiscoveryServices extends GenericEditableConfigList<
): Array<{ label: string; suffix?: string; href?: string }> | undefined {
const registered = this.registeredLibraries(item);
if (registered === undefined) return undefined;
const allLibraries = this.props.data?.allLibraries ?? [];
const allLibraries = this.getAllLibraries();
// Hold the panel until the sitewide list settles, so labels render
// once, in their final linked form.
if (!allLibraries) return undefined;
return registered.map((lib) => {
const meta = allLibraries.find((l) => l.short_name === lib.short_name);
return {
Expand All @@ -120,9 +134,7 @@ function mapStateToProps(state) {
(state.editor.discoveryServices && state.editor.discoveryServices.data) ||
{}
);
if (state.editor.libraries && state.editor.libraries.data) {
data.allLibraries = state.editor.libraries.data.libraries;
}
Object.assign(data, settledAllLibraries(state));
if (
state.editor.discoveryServiceLibraryRegistrations &&
state.editor.discoveryServiceLibraryRegistrations.data
Expand Down Expand Up @@ -156,7 +168,10 @@ function mapStateToProps(state) {
function mapDispatchToProps(dispatch, ownProps) {
const actions = new ActionCreator(null, ownProps.csrfToken);
return {
fetchData: () => dispatch(actions.fetchDiscoveryServices()),
fetchData: () => {
fetchLibrariesIfNeeded(dispatch, actions);
return dispatch(actions.fetchDiscoveryServices());
},
editItem: (data: FormData) => dispatch(actions.editDiscoveryService(data)),
deleteItem: (identifier: string | number) =>
dispatch(actions.deleteDiscoveryService(identifier)),
Expand Down
53 changes: 49 additions & 4 deletions src/components/EditableConfigList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import Admin from "../models/Admin";
import * as PropTypes from "prop-types";
import { navigateTo } from "../utils/navigate";
import { libraryConfigHref, libraryLabel } from "../utils/sharedFunctions";
import LibrariesRefreshWarning from "./LibrariesRefreshWarning";
import LibrariesLoadStatus from "./LibrariesLoadStatus";

export interface EditableConfigListStateProps<T> {
data?: T;
Expand Down Expand Up @@ -111,6 +113,9 @@ export abstract class GenericEditableConfigList<
abstract labelKey: string;
adminLevel?: number;
limitOne = false;
/** True on lists whose data merges settledAllLibraries; gates the
* library-list status line in list mode. */
usesLibraryList = false;
links?: { [key: string]: JSX.Element };
AdditionalContent?: new (
props: AdditionalContentProps<T, U>
Expand Down Expand Up @@ -177,6 +182,30 @@ export abstract class GenericEditableConfigList<
{this.props.fetchError && !this.props.editOrCreate && (
<ErrorMessage error={this.props.fetchError} />
)}
{/* In list mode only; the edit and create forms raise their own
library-list status line and failure alerts. The status line is
additionally gated on usesLibraryList, since lists that never
merge settledAllLibraries would read as loading forever. */}
{!this.props.editOrCreate && this.usesLibraryList && (
<LibrariesLoadStatus
allLibraries={(this.props.data as any).allLibraries}
allLibrariesError={(this.props.data as any).allLibrariesError}
allLibrariesRefreshError={
(this.props.data as any).allLibrariesRefreshError
}
/>
)}
{(this.props.data as any)?.allLibrariesError &&
!this.props.editOrCreate && (
<Alert bsStyle="danger">{this.librariesUnavailableMessage()}</Alert>
)}
{!this.props.editOrCreate && (
<LibrariesRefreshWarning
allLibrariesRefreshError={
(this.props.data as any)?.allLibrariesRefreshError
}
/>
)}
{this.props.formError && this.props.editOrCreate && (
<ErrorMessage error={this.props.formError} />
)}
Expand Down Expand Up @@ -267,16 +296,17 @@ export abstract class GenericEditableConfigList<

/**
* Returns the full list of libraries known to the server, used to resolve
* short names to display names and UUIDs for the associated-items panel.
* short names to display names and UUIDs for the associated-items panel,
* or undefined while that list has not settled yet.
*
* The base implementation accesses `data.allLibraries` via an `any` cast
* because the generic `T` is not constrained to include that field (e.g.
* `LibrariesData` does not have it). Subclasses whose data type declares
* `allLibraries` (e.g. `Collections`, `IndividualAdmins`) should override
* this method with a type-safe accessor to avoid the cast.
*/
protected getAllLibraries(): LibraryData[] {
return (this.props.data as any)?.allLibraries ?? [];
protected getAllLibraries(): LibraryData[] | undefined {
return (this.props.data as any).allLibraries;
}

/**
Expand All @@ -285,6 +315,15 @@ export abstract class GenericEditableConfigList<
* subclasses that use different terminology (e.g. "registered libraries",
* "roles").
*/
/**
* Message for the list-mode alert shown when the sitewide library list
* failed to load. Override where the disclosure panel lists something
* other than libraries (see IndividualAdmins).
*/
protected librariesUnavailableMessage(): string {
return "The library list failed to load. Associated libraries are shown by short name only.";
}

protected formatAssociatedCount(count: number): string {
return count === 0
? "no libraries"
Expand All @@ -298,7 +337,9 @@ export abstract class GenericEditableConfigList<
* for a given item, or `undefined` if the panel does not apply to this item.
*
* Return semantics (used by `renderLi` to drive toggle visibility):
* - `undefined` → the feature does not apply; no toggle is rendered.
* - `undefined` → the feature does not apply to this item, or the
* sitewide library list has not settled yet; no toggle
* or count is rendered.
* - `[]` → the feature applies but there are no associations;
* a disabled toggle is rendered.
* - `[…entries]` → associations exist; an enabled toggle is rendered.
Expand All @@ -318,6 +359,10 @@ export abstract class GenericEditableConfigList<
?.libraries;
if (libraries === undefined) return undefined;
const allLibraries = this.getAllLibraries();
// Hold the panel until the sitewide list settles, so labels render
// once, in their final linked form, instead of flashing bare short
// names that get rewritten when the list arrives.
if (!allLibraries) return undefined;
return libraries.map((lib) => {
const libraryData = allLibraries.find(
(l) => l.short_name === lib.short_name
Expand Down
Loading
Loading