From 0d2b2e3fe84ce0208662eba60dd867d8b203ccd4 Mon Sep 17 00:00:00 2001 From: Ian Scott Date: Thu, 19 Dec 2024 22:10:27 -0500 Subject: [PATCH] fix(names, accessibility, collections, user-data): Infrastructure and cli command to customize users' name parts; a11y tweaks to detail page; visual tweaks to collection detail page; bug fix preventing user data service from picking up profile changes --- README.md | 2 +- .../members/components/MembersEmptyResults.js | 52 +++++++++ .../fields/CreatibutorsField.js | 19 +++- .../overridableRegistry/mapping.js | 4 + assets/less/site/collections/grid.overrides | 2 +- invenio.cfg | 1 + site/CHANGES.md | 17 +++ site/kcworks/__init__.py | 2 +- .../CommunityRecordsSearchAppLayout.js | 4 +- .../assets/semantic-ui/js/kcworks/names.js | 55 +++++++++ site/kcworks/cli.py | 19 +++- .../dependencies/invenio-modular-detail-page | 2 +- .../invenio-record-importer-kcworks | 2 +- .../invenio-remote-user-data-kcworks | 2 +- site/kcworks/services/users/cli.py | 107 ++++++++++++++++++ site/kcworks/services/users/service.py | 48 ++++++++ site/pyproject.toml | 8 +- 17 files changed, 327 insertions(+), 19 deletions(-) create mode 100644 assets/js/invenio_app_rdm/overridableRegistry/collections/members/components/MembersEmptyResults.js create mode 100644 site/kcworks/assets/semantic-ui/js/kcworks/names.js create mode 100644 site/kcworks/services/users/cli.py create mode 100644 site/kcworks/services/users/service.py diff --git a/README.md b/README.md index 334cc57f..8d5fd2e3 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Knowledge Commons Works is a collaborative tool for storing and sharing academic research. It is part of Knowledge Commons and is built on an instance of the InvenioRDM repository system. -Version 0.3.2-beta5 +Version 0.3.3-beta6 ## Copyright diff --git a/assets/js/invenio_app_rdm/overridableRegistry/collections/members/components/MembersEmptyResults.js b/assets/js/invenio_app_rdm/overridableRegistry/collections/members/components/MembersEmptyResults.js new file mode 100644 index 00000000..911e3750 --- /dev/null +++ b/assets/js/invenio_app_rdm/overridableRegistry/collections/members/components/MembersEmptyResults.js @@ -0,0 +1,52 @@ +import React, { Component } from "react"; +import { Button, Header, Icon, Segment } from "semantic-ui-react"; +import { withState } from "react-searchkit"; +import { i18next } from "@translations/invenio_communities/i18next"; +import PropTypes from "prop-types"; + +const MembersEmptyResultsComponent = ({ + resetQuery, + extraContent = null, + queryString, + currentQueryState, + currentResultsState, +}) => { + const isEmptyPageAfterSearch = currentQueryState.page < 0; + const isEmptyPage = + currentQueryState.page === 1 && currentResultsState.data.total === 0; + + return ( + +
+ + {isEmptyPage && i18next.t("This collection has no public members.")} + {isEmptyPageAfterSearch && i18next.t("No matching members found.")} +
+ {queryString && ( +

+ + {i18next.t("Current search")} "{queryString}" + +

+ )} + {isEmptyPageAfterSearch && ( + + )} + {extraContent} +
+ ); +}; + +MembersEmptyResultsComponent.propTypes = { + resetQuery: PropTypes.func.isRequired, + queryString: PropTypes.string.isRequired, + currentQueryState: PropTypes.object.isRequired, + currentResultsState: PropTypes.object.isRequired, + extraContent: PropTypes.node, +}; + +const MembersEmptyResults = withState(MembersEmptyResultsComponent); + +export { MembersEmptyResults }; diff --git a/assets/js/invenio_app_rdm/overridableRegistry/fields/CreatibutorsField.js b/assets/js/invenio_app_rdm/overridableRegistry/fields/CreatibutorsField.js index 65190dbb..91615ca3 100644 --- a/assets/js/invenio_app_rdm/overridableRegistry/fields/CreatibutorsField.js +++ b/assets/js/invenio_app_rdm/overridableRegistry/fields/CreatibutorsField.js @@ -18,6 +18,7 @@ import { CreatibutorsFieldItem } from "./creatibutors_components/CreatibutorsFie import { CREATIBUTOR_TYPE } from "./types"; import { FormUIStateContext } from "@js/invenio_modular_deposit_form/InnerDepositForm"; import { i18next } from "@translations/invenio_rdm_records/i18next"; +import { getFamilyName, getGivenName } from "../../../kcworks/names"; /** * Sort a list of string values (options). @@ -114,11 +115,15 @@ const makeSelfCreatibutor = (currentUserprofile) => { let myNameParts = {}; if ( - !!currentUserprofile?.name_parts && - currentUserprofile?.name_parts !== "" + !!currentUserprofile?.name_parts_local && + currentUserprofile?.name_parts_local !== "" ) { + myNameParts = JSON.parse(currentUserprofile.name_parts_local); + } else if (!!currentUserprofile?.name_parts && currentUserprofile?.name_parts !== "") { myNameParts = JSON.parse(currentUserprofile.name_parts); } + const part1 = [myNameParts?.given, myNameParts?.first, myNameParts?.middle, myNameParts?.nickname].filter(Boolean).join(" "); + const part2 = [myNameParts?.family_prefix, myNameParts?.family_prefix_fixed, myNameParts?.spousal, myNameParts?.parental, myNameParts?.family, myNameParts?.last, ].filter(Boolean).join(" "); let myIdentifiers = undefined; const rawIdentifiers = Object.fromEntries( @@ -136,8 +141,14 @@ const makeSelfCreatibutor = (currentUserprofile) => { let selfCreatibutor = { person_or_org: { - family_name: myNameParts?.last || currentUserprofile?.full_name || "", - given_name: myNameParts?.first || "", + family_name: + getFamilyName(myNameParts) || + currentUserprofile?.full_name || + "", + given_name: + getGivenName(myNameParts) || + myNameParts?.first || + "", name: currentUserprofile?.full_name || "", type: "personal", identifiers: myIdentifiers?.length > 0 ? myIdentifiers : [], diff --git a/assets/js/invenio_app_rdm/overridableRegistry/mapping.js b/assets/js/invenio_app_rdm/overridableRegistry/mapping.js index d4b143f0..95fd76b4 100644 --- a/assets/js/invenio_app_rdm/overridableRegistry/mapping.js +++ b/assets/js/invenio_app_rdm/overridableRegistry/mapping.js @@ -24,6 +24,7 @@ import { InvitationResultItemWithConfig } from "./collections/invitations/Invita import { LicenseField } from "./fields/LicenseField"; import { LogoUploader } from "./collections/settings/profile/LogoUploader"; import { ManagerMembersResultItemWithConfig } from "./collections/members/manager_view/ManagerMembersResultItem"; +import { MembersEmptyResults } from "./collections/members/components/MembersEmptyResults"; import { MembersSearchBarElement } from "./collections/members/components/MembersSearchBarElement"; import { MetadataOnlyToggle } from "./fields/MetadataOnlyToggle"; import Pagination from "./search/Pagination"; @@ -120,12 +121,15 @@ export const overriddenComponents = { "InvenioCommunities.RequestSearch.ResultsList.item": RequestsResultsItemTemplateWithCommunity, "InvenioCommunities.RequestSearch.SearchApp.layout": CommunityRequestsSearchLayoutWithApp, "InvenioCommunities.InvitationsSearch.ResultsList.item": InvitationResultItemWithConfig, + "InvenioCommunities.ManagerSearch.EmptyResults.element": MembersEmptyResults, "InvenioCommunities.ManagerSearch.ResultsList.item": ManagerMembersResultItemWithConfig, "InvenioCommunities.ManagerSearch.SearchBar.element": MembersSearchBarElement, "InvenioCommunities.MemberSearch.ResultsList.item": ManagerMembersResultItemWithConfig, + "InvenioCommunities.MemberSearch.EmptyResults.element": MembersEmptyResults, "InvenioCommunities.MemberSearch.SearchBar.element": MembersSearchBarElement, "InvenioCommunities.PublicSearch.ResultsList.item": PublicMembersResultsItemWithCommunity, "InvenioCommunities.PublicSearch.SearchBar.element": MembersSearchBarElement, + "InvenioCommunities.PublicSearch.EmptyResults.element": MembersEmptyResults, "InvenioModularDetailPage.MobileActionMenu.container": MobileActionMenu, // "InvenioAppRdm.Deposit.ResourceTypeField.container": ResourceTypeField // InvenioCommunities.Search.SearchApp.layout: CommunityRecordsSearchAppLayout, diff --git a/assets/less/site/collections/grid.overrides b/assets/less/site/collections/grid.overrides index 55ef5352..a1750918 100644 --- a/assets/less/site/collections/grid.overrides +++ b/assets/less/site/collections/grid.overrides @@ -57,7 +57,7 @@ width: 1.5rem; height: 1.5rem; background-color: @white; - padding-left: 1px; + padding-left: 1.5px; padding-bottom: 2px; } } diff --git a/invenio.cfg b/invenio.cfg index 09108166..d405e883 100644 --- a/invenio.cfg +++ b/invenio.cfg @@ -5744,6 +5744,7 @@ class CustomUserProfileSchema(Schema): full_name = fields.String() affiliations = fields.String() name_parts = fields.String() + name_parts_local = fields.String() identifier_email = fields.String() identifier_orcid = fields.String() identifier_kc_username = fields.String() diff --git a/site/CHANGES.md b/site/CHANGES.md index 84d129a4..95043b92 100644 --- a/site/CHANGES.md +++ b/site/CHANGES.md @@ -3,6 +3,23 @@ # Changes +## 0.3.3-beta6 (2024-12-18) + +- Names + - Added the infrastructure to customize the division of users' names into parts so that it can be divided as desired when, e.g., the user's name is being auto-filled in the name fields of the upload form. This involves + - a new "name_parts_local" field to the user profile schema. This field contains the user's name parts if they have been modified within the KCWorks system. This is sometimes necessary when the user data synced from the remote user data service does not divide the user's name correctly. + - a cli command to update the user's name parts. + - a new "names" js module that contains functions to get the user's full name, full name in inverted order, family name, and given name from the user's name parts. + - updates to the CreatibutorsField component to use the new "names" js module and the customized name parts if they are present in a user's profile. +- Detail page + - Added missing aria-label properties for accessibility +- Collections + - Fixed wording of empty results message for collection members search + - Previously, the empty results message used "community" instead of "collection". + - Tweaks to layout of collection detail page header +- Remote user data service + - Fixed bug where user profile data was not being updated because comparison with initial data was not being made correctly. This means that, among other things, ORCID ids will now be added correctly when the user chooses "add self" on the upload form. + ## 0.3.2-beta5 (2024-12-11) - Added Bluesky sharing option to detail page diff --git a/site/kcworks/__init__.py b/site/kcworks/__init__.py index aa7e331d..beec5bf9 100644 --- a/site/kcworks/__init__.py +++ b/site/kcworks/__init__.py @@ -18,4 +18,4 @@ """KCWorks customizations to InvenioRDM.""" -__version__ = "0.3.2-beta5" +__version__ = "0.3.3-beta6" diff --git a/site/kcworks/assets/semantic-ui/js/collections/communityRecordsSearch/CommunityRecordsSearchAppLayout.js b/site/kcworks/assets/semantic-ui/js/collections/communityRecordsSearch/CommunityRecordsSearchAppLayout.js index 00a90a98..e01c6ecc 100644 --- a/site/kcworks/assets/semantic-ui/js/collections/communityRecordsSearch/CommunityRecordsSearchAppLayout.js +++ b/site/kcworks/assets/semantic-ui/js/collections/communityRecordsSearch/CommunityRecordsSearchAppLayout.js @@ -34,7 +34,7 @@ export const CommunityRecordsSearchAppLayout = ({ config, appName }) => { aria-label={i18next.t("Filter results")} /> - + ( @@ -45,7 +45,7 @@ export const CommunityRecordsSearchAppLayout = ({ config, appName }) => { )} /> - + ( diff --git a/site/kcworks/assets/semantic-ui/js/kcworks/names.js b/site/kcworks/assets/semantic-ui/js/kcworks/names.js new file mode 100644 index 00000000..2af971f3 --- /dev/null +++ b/site/kcworks/assets/semantic-ui/js/kcworks/names.js @@ -0,0 +1,55 @@ +function getFullName(nameParts) { + let fullName = [ + getGivenName(nameParts), + nameParts?.family_prefix, + getFamilyName(nameParts), + ] + .filter(Boolean) + .join(" "); + if (nameParts?.suffix) { + fullName += ", " + nameParts?.suffix; + } + return fullName; +} + +function getFullNameInverted(nameParts) { + const beforeComma = [ + getFamilyName(nameParts), + ] + const afterComma = [ + getGivenName(nameParts), + nameParts?.family_prefix, + ] + .filter(Boolean) + .join(" "); + let fullNameInverted = `${beforeComma}, ${afterComma}`; + if (nameParts?.suffix) { + fullNameInverted += ", " + nameParts?.suffix; + } + return fullNameInverted; +} + +function getFamilyName(nameParts) { + return [ + nameParts?.family_prefix_fixed, + nameParts?.parental, + nameParts?.spousal, + nameParts?.family, + nameParts?.last, + ] + .filter(Boolean) + .join(" "); +} + +function getGivenName(nameParts) { + return [ + nameParts?.given, + nameParts?.first, + nameParts?.middle, + nameParts?.nickname, + ] + .filter(Boolean) + .join(" "); +} + +export { getFullName, getFullNameInverted, getFamilyName, getGivenName }; diff --git a/site/kcworks/cli.py b/site/kcworks/cli.py index 9fcbbf52..debb47f9 100644 --- a/site/kcworks/cli.py +++ b/site/kcworks/cli.py @@ -22,6 +22,8 @@ from kcworks.services.search.indices import delete_index import sys +from kcworks.services.users.cli import name_parts as name_parts_command + UNMANAGED_INDICES = [ "kcworks-stats-record-view", "kcworks-stats-file-download", @@ -37,12 +39,21 @@ @click.group() -def index(): - """Utility commands for search index management.""" +def kcworks_users(): + """Utility commands for Knowledge Commons Works.""" + pass + + +kcworks_users.add_command(name_parts_command) + + +@click.group() +def kcworks_index(): + """KCWorks utility commands for search index management.""" pass -@index.command("destroy-indices") +@kcworks_index.command("destroy-indices") @click.option( "--yes-i-know", is_flag=True, @@ -53,7 +64,7 @@ def index(): @click.option("--force", is_flag=True, default=False) @with_appcontext @search_version_check -def destroy(force): +def destroy_indices(force): """Destroy all indices that are not destroyed by invenio_search THIS COMMAND WILL WIPE ALL DATA ON USAGE STATS. ONLY RUN THIS WHEN YOU KNOW diff --git a/site/kcworks/dependencies/invenio-modular-detail-page b/site/kcworks/dependencies/invenio-modular-detail-page index c037ec60..a16adf95 160000 --- a/site/kcworks/dependencies/invenio-modular-detail-page +++ b/site/kcworks/dependencies/invenio-modular-detail-page @@ -1 +1 @@ -Subproject commit c037ec60a52c9004fee060f8c7d8c870694aabb6 +Subproject commit a16adf95fbfb3d25e1084bd8a91719036ca6ab26 diff --git a/site/kcworks/dependencies/invenio-record-importer-kcworks b/site/kcworks/dependencies/invenio-record-importer-kcworks index 90d12908..d4de8840 160000 --- a/site/kcworks/dependencies/invenio-record-importer-kcworks +++ b/site/kcworks/dependencies/invenio-record-importer-kcworks @@ -1 +1 @@ -Subproject commit 90d129083d0f304ba2cd3f0c541dc6753c2101ca +Subproject commit d4de884081bd6155335cfe3b7e6af3e06e148547 diff --git a/site/kcworks/dependencies/invenio-remote-user-data-kcworks b/site/kcworks/dependencies/invenio-remote-user-data-kcworks index d74bb67e..fba8c135 160000 --- a/site/kcworks/dependencies/invenio-remote-user-data-kcworks +++ b/site/kcworks/dependencies/invenio-remote-user-data-kcworks @@ -1 +1 @@ -Subproject commit d74bb67e48450a484dd6693a409f3fc5e369ed4d +Subproject commit fba8c135cc45ebf1948e1d75da87ecb513a3e1bd diff --git a/site/kcworks/services/users/cli.py b/site/kcworks/services/users/cli.py new file mode 100644 index 00000000..c91692df --- /dev/null +++ b/site/kcworks/services/users/cli.py @@ -0,0 +1,107 @@ +import click +from .service import UserProfileService +from flask.cli import with_appcontext +from pprint import pprint + + +@click.command("name-parts") +@click.argument("user_id", type=str) +@click.option("-g", "--given", type=str, required=False) +@click.option("-f", "--family", type=str, required=False) +@click.option( + "-m", + "--middle", + type=str, + required=False, + help="One or more middle names, separated by spaces.", +) +@click.option( + "-s", + "--suffix", + type=str, + required=False, + help="A suffix that follows the last name (e.g. 'Jr., III'). " + "This is moved behind the first name when names are listed " + "with the last name first.", +) +@click.option( + "-r", + "--family-prefix", + type=str, + required=False, + help="A prefix introducing the family name (like 'van der', 'de la', 'de', " + "'von', etc.) that is not kept in front of the family name for " + "alphabetical sorting", +) +@click.option( + "-x", + "--family-prefix-fixed", + type=str, + required=False, + help="A prefix introducing the family name (like 'van der', 'de la', 'de', " + "'von', etc.) that is kept in front of the family name for alphabetical " + "sorting", +) +@click.option( + "-u", + "--spousal", + type=str, + required=False, + help="A spousal family name that is kept in front of the family name for " + "alphabetical sorting (e.g. 'Garcia' + 'Martinez' -> 'Garcia Martinez')", +) +@click.option("-p", "--parental", type=str, required=False) +@click.option( + "-n", + "--undivided", + type=str, + required=False, + help="A name string that should not be divided into parts, " + "but should be kept the same in any alphabetical list.", +) +@click.option("-k", "--nickname", type=str, required=False) +@with_appcontext +def name_parts( + user_id, + given, + family, + middle, + suffix, + family_prefix, + family_prefix_fixed, + spousal, + parental, + undivided, + nickname, +): + """Update the name parts for the specified user.""" + name_parts = { + "given": given, + "family": family, + "middle": middle, + "suffix": suffix, + "family_prefix": family_prefix, + "family_prefix_fixed": family_prefix_fixed, + "spousal": spousal, + "parental": parental, + "undivided": undivided, + "nickname": nickname, + } + if not any(name_parts.values()): + print(f"Reading current local name parts for user {user_id}.") + try: + name_parts = UserProfileService.read_local_name_parts(user_id) + print("Current name parts:") + pprint(name_parts) + except KeyError: + print(f"No local name parts found for user {user_id}.") + return + else: + print(f"Updating name parts for user {user_id}") + new_user = UserProfileService.update_local_name_parts( + user_id, {k: v for k, v in name_parts.items() if v is not None} + ) + pprint(new_user.user_profile) + print("Updated name parts:") + pprint(new_user.user_profile["name_parts_local"]) + return diff --git a/site/kcworks/services/users/service.py b/site/kcworks/services/users/service.py new file mode 100644 index 00000000..d66b633e --- /dev/null +++ b/site/kcworks/services/users/service.py @@ -0,0 +1,48 @@ +# -*- coding: utf-8 -*- +# +# This file is part of Knowledge Commons Works +# Copyright (C) 2023-2024, MESH Research +# +# Knowledge Commons Works is free software; you can redistribute it and/or +# modify it under the terms of the MIT License; see LICENSE file for more +# details. +# +# Knowledge Commons Works is an extended instance of InvenioRDM: +# Copyright (C) 2019-2024 CERN. +# Copyright (C) 2019-2024 Northwestern University. +# Copyright (C) 2021-2024 TU Wien. +# Copyright (C) 2023-2024 Graz University of Technology. +# InvenioRDM is also free software; you can redistribute it and/or modify it +# under the terms of the MIT License. See the LICENSE file in the +# invenio-app-rdm package for more details. + +from flask import current_app +from invenio_accounts.proxies import current_accounts +import json +from invenio_accounts.models import User + + +class UserProfileService: + + @classmethod + def update_local_name_parts(cls, user_id: str, name_parts: dict) -> User: + """Update the locally edited name parts for the specified user. + + Returns: + User: The updated user object. + """ + + user_object = current_accounts.datastore.get_user_by_id(user_id) + profile = user_object.user_profile + profile["name_parts_local"] = json.dumps(name_parts) + user_object.user_profile = profile + current_app.logger.info(f"Updating name parts for user {user_id}") + current_app.logger.info(f"New profile: {profile}") + current_accounts.datastore.commit() + return user_object + + @classmethod + def read_local_name_parts(cls, user_id: str) -> dict: + """Read the locally edited name parts for the specified user.""" + user_object = current_accounts.datastore.get_user_by_id(user_id) + return json.loads(user_object.user_profile.get("name_parts_local", "{}")) diff --git a/site/pyproject.toml b/site/pyproject.toml index 4f9f88d4..eb65cd41 100644 --- a/site/pyproject.toml +++ b/site/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "kcworks" -version = "0.3.2-beta5" +version = "0.3.3-beta6" [project.optional-dependencies] tests = ["pytest-invenio>=2.1.0,<3.0.0"] @@ -37,10 +37,12 @@ custom_latex_viewer = "kcworks.views.previewers.invenio_custom_latex_viewer.cust kcworks = "kcworks.services.search.index_templates.templates:get_index_templates" [project.entry-points."flask.commands"] -kcworks-index = "kcworks.cli:index" +kcworks-users = "kcworks.cli:kcworks_users" +kcworks-index = "kcworks.cli:kcworks_index" [project.scripts] -kcworks-index = "kcworks.cli:index" +kcworks-users = "kcworks.cli:kcworks_users" +kcworks-index = "kcworks.cli:kcworks_index" [tool.pytest.ini_options] testpaths = ["tests", "kcworks"]