diff --git a/silk-workbench/silk-workbench-workspace/app/controllers/projectApi/ProjectApi.scala b/silk-workbench/silk-workbench-workspace/app/controllers/projectApi/ProjectApi.scala index 04005c90b8..eab0c60861 100644 --- a/silk-workbench/silk-workbench-workspace/app/controllers/projectApi/ProjectApi.scala +++ b/silk-workbench/silk-workbench-workspace/app/controllers/projectApi/ProjectApi.scala @@ -3,7 +3,7 @@ package controllers.projectApi import config.WorkbenchConfig import controllers.core.UserContextActions import controllers.core.util.ControllerUtilsTrait -import controllers.projectApi.ProjectApi.{CreateTagsRequest, ProjectTagsResponse, ProjectUriResponse} +import controllers.projectApi.ProjectApi.{CreateTagsRequest, DetailedProjectPrefixesResponse, ProjectTagsResponse, ProjectUriResponse} import controllers.projectApi.doc.ProjectApiDoc import controllers.projectApi.requests.OriginalTaskDataResponse.OriginalTaskDataJsonFormat import controllers.projectApi.requests.ReloadFailedTaskRequest @@ -395,6 +395,42 @@ class ProjectApi @Inject()(accessMonitor: WorkbenchAccessMonitor) extends Inject Ok(Json.toJson(project.config.prefixes.prefixMap)) } + /** Returns project and workspace prefixes separately. */ + @Operation( + summary = "Detailed project prefixes", + description = "Project namespace prefix definitions split into project-owned, workspace-owned, and built-in default prefixes. If the same prefix exists in both project and workspace maps, the project prefix has precedence.", + responses = Array( + new ApiResponse( + responseCode = "200", + description = "Success", + content = Array(new Content( + mediaType = "application/json", + schema = new Schema(implementation = classOf[DetailedProjectPrefixesResponse]), + examples = Array(new ExampleObject("{ \"projectPrefixes\": { \"customPrefix\": \"http://customPrefix.cc/\" }, \"workspacePrefixes\": { \"rdf\": \"http://www.w3.org/1999/02/22-rdf-syntax-ns#\", \"foaf\": \"http://xmlns.com/foaf/0.1/\" }, \"defaultPrefixes\": { \"rdf\": \"http://www.w3.org/1999/02/22-rdf-syntax-ns#\", \"rdfs\": \"http://www.w3.org/2000/01/rdf-schema#\" } }")) + )) + ), + new ApiResponse( + responseCode = "404", + description = "If the project does not exist." + ) + )) + def fetchDetailedProjectPrefixes(@Parameter( + name = "projectId", + description = "The project identifier", + required = true, + in = ParameterIn.PATH, + schema = new Schema(implementation = classOf[String]) + ) + projectId: String): Action[AnyContent] = UserContextAction { implicit userContext => + val project = getProject(projectId) + accessMonitor.saveProjectAccess(project.config.id) // Only accessed on the project details page + Ok(Json.toJson(DetailedProjectPrefixesResponse( + projectPrefixes = project.config.projectPrefixes.prefixMap, + workspacePrefixes = project.config.workspacePrefixes.prefixMap, + defaultPrefixes = Prefixes.default.prefixMap + ))) + } + /** Add or update project prefix. */ @Operation( summary = "Add project prefix", @@ -840,6 +876,15 @@ class ProjectApi @Inject()(accessMonitor: WorkbenchAccessMonitor) extends Inject } object ProjectApi { + @Schema(description = "Project namespace prefix definitions split by ownership.") + case class DetailedProjectPrefixesResponse( + @Schema(description = "Prefixes owned by the project. These take precedence over equally named workspace prefixes.") + projectPrefixes: Map[String, String], + @Schema(description = "Prefixes provided by the workspace. These are available in the project but not owned by it.") + workspacePrefixes: Map[String, String], + @Schema(description = "Built-in default prefixes that are always available in DI/Silk.") + defaultPrefixes: Map[String, String] + ) @Schema(description = "Lists all user-defined tags.") case class ProjectTagsResponse(@ArraySchema(schema = new Schema(implementation = classOf[FullTag])) @@ -889,7 +934,8 @@ object ProjectApi { } } + implicit val detailedProjectPrefixesResponseFormat: Format[DetailedProjectPrefixesResponse] = Json.format[DetailedProjectPrefixesResponse] implicit val projectTagsResponseFormat: Format[ProjectTagsResponse] = Json.format[ProjectTagsResponse] implicit val createTagFormat: Format[CreateTag] = Json.format[CreateTag] implicit val createTagsRequestFormat: Format[CreateTagsRequest] = Json.format[CreateTagsRequest] -} \ No newline at end of file +} diff --git a/silk-workbench/silk-workbench-workspace/conf/projectsApi.routes b/silk-workbench/silk-workbench-workspace/conf/projectsApi.routes index 196b486ab6..f09a03572b 100644 --- a/silk-workbench/silk-workbench-workspace/conf/projectsApi.routes +++ b/silk-workbench/silk-workbench-workspace/conf/projectsApi.routes @@ -17,6 +17,7 @@ PUT /:projectId/accessControl controlle # Prefixes GET /:projectId/prefixes controllers.projectApi.ProjectApi.fetchProjectPrefixes(projectId: String) +GET /:projectId/prefixes/detailed controllers.projectApi.ProjectApi.fetchDetailedProjectPrefixes(projectId: String) PUT /:projectId/prefixes/:prefixName controllers.projectApi.ProjectApi.addProjectPrefix(projectId: String, prefixName: String) DELETE /:projectId/prefixes/:prefixName controllers.projectApi.ProjectApi.deleteProjectPrefix(projectId: String, prefixName: String) diff --git a/silk-workbench/silk-workbench-workspace/test/controllers/workspaceApi/ProjectApiTest.scala b/silk-workbench/silk-workbench-workspace/test/controllers/workspaceApi/ProjectApiTest.scala index b5cae6796e..4913bb429a 100644 --- a/silk-workbench/silk-workbench-workspace/test/controllers/workspaceApi/ProjectApiTest.scala +++ b/silk-workbench/silk-workbench-workspace/test/controllers/workspaceApi/ProjectApiTest.scala @@ -1,11 +1,11 @@ package controllers.workspaceApi -import controllers.projectApi.ProjectApi.{CreateTag, CreateTagsRequest} +import controllers.projectApi.ProjectApi.{CreateTag, CreateTagsRequest, DetailedProjectPrefixesResponse} import controllers.util.ProjectApiClient import controllers.workspaceApi.project.ProjectApiRestPayloads.{ItemMetaData, CreateProjectRequest} import helper.IntegrationTestTrait -import org.silkframework.config.MetaData +import org.silkframework.config.{MetaData, Prefixes} import org.silkframework.runtime.serialization.{ReadContext, TestReadContext} import org.silkframework.serialization.json.JsonSerializers import org.silkframework.serialization.json.JsonSerializers._ @@ -28,6 +28,7 @@ class ProjectApiTest extends AnyFlatSpec with IntegrationTestTrait with Matchers lazy val projectsUrl: String = controllers.projectApi.routes.ProjectApi.createNewProject().url def projectPrefixesUrl(projectId: String): String = controllers.projectApi.routes.ProjectApi.fetchProjectPrefixes(projectId).url + def detailedProjectPrefixesUrl(projectId: String): String = controllers.projectApi.routes.ProjectApi.fetchDetailedProjectPrefixes(projectId).url def projectPrefixUrl(projectId: String, prefixName: String): String = controllers.projectApi.routes.ProjectApi.addProjectPrefix(projectId, prefixName).url private def projectsMetaDataUrl(projectId: String): String = controllers.projectApi.routes.ProjectApi.updateProjectMetaData(projectId).url implicit val readContext: ReadContext = TestReadContext() @@ -76,6 +77,21 @@ class ProjectApiTest extends AnyFlatSpec with IntegrationTestTrait with Matchers prefixes.get.size must be > 0 } + it should "fetch the detailed project prefixes" in { + val project = retrieveOrCreateProject(prefixProjectId) + val overlappingPrefix = project.config.workspacePrefixes.prefixMap.keys.head + val overridingProjectUri = "http://project.example/override/" + project.config = project.config.copy( + projectPrefixes = Prefixes(project.config.projectPrefixes.prefixMap ++ Map(overlappingPrefix -> overridingProjectUri)) + ) + + val detailedPrefixes = fetchDetailedPrefixes + detailedPrefixes.isSuccess mustBe true + detailedPrefixes.get.projectPrefixes(overlappingPrefix) mustBe overridingProjectUri + detailedPrefixes.get.workspacePrefixes(overlappingPrefix) mustBe project.config.workspacePrefixes(overlappingPrefix) + detailedPrefixes.get.defaultPrefixes mustBe Prefixes.default.prefixMap + } + it should "Update project prefixes" in { retrieveOrCreateProject(prefixProjectId) @@ -149,6 +165,11 @@ class ProjectApiTest extends AnyFlatSpec with IntegrationTestTrait with Matchers Json.fromJson[Map[String, String]](responseJson) } + private def fetchDetailedPrefixes: JsResult[DetailedProjectPrefixesResponse] = { + val responseJson = checkResponse(client.url(s"$baseUrl${detailedProjectPrefixesUrl(prefixProjectId)}").get()).json + Json.fromJson[DetailedProjectPrefixesResponse](responseJson) + } + private def createProjectByLabel(label: String, description: Option[String] = None, id: Option[String] = None): WSResponse = { val responseFuture = client.url(s"$baseUrl$projectsUrl").post(Json.toJson(CreateProjectRequest(ItemMetaData(label, description), id))) val response = checkResponse(responseFuture) diff --git a/workspace/src/app/store/ducks/workspace/initialState/widgetInitials.ts b/workspace/src/app/store/ducks/workspace/initialState/widgetInitials.ts index 2efabc32f6..b2dec6a174 100644 --- a/workspace/src/app/store/ducks/workspace/initialState/widgetInitials.ts +++ b/workspace/src/app/store/ducks/workspace/initialState/widgetInitials.ts @@ -1,20 +1,4 @@ -import { IPrefixDefinition, IWidgetsState } from "@ducks/workspace/typings"; - -export function initialNewPrefixState(): IPrefixDefinition { - return { - prefixName: "", - prefixUri: "", - }; -} - -export function initialConfigurationState() { - return { - prefixes: [], - newPrefix: initialNewPrefixState(), - isLoading: false, - error: {}, - }; -} +import { IWidgetsState } from "@ducks/workspace/typings"; export function initialWarningItemState() { return { @@ -47,7 +31,6 @@ export function initialFilesState() { export function initialWidgetsState(): IWidgetsState { return { - configuration: initialConfigurationState(), warnings: initialWarningState(), files: initialFilesState(), }; diff --git a/workspace/src/app/store/ducks/workspace/operations.ts b/workspace/src/app/store/ducks/workspace/operations.ts index da19cf5b1a..94e52bdbdc 100644 --- a/workspace/src/app/store/ducks/workspace/operations.ts +++ b/workspace/src/app/store/ducks/workspace/operations.ts @@ -7,8 +7,6 @@ import { routerOp } from "@ducks/router"; import { IFacetState } from "@ducks/workspace/typings"; import { workspaceSel } from "@ducks/workspace"; import qs, { ParsedQs } from "qs"; -import { fetchProjectPrefixesAsync } from "@ducks/workspace/widgets/configuration.thunk"; -import { widgetsSlice } from "@ducks/workspace/widgetsSlice"; import { fetchWarningListAsync, fetchWarningMarkdownAsync } from "@ducks/workspace/widgets/warning.thunk"; import { fetchResourcesListAsync } from "@ducks/workspace/widgets/file.thunk"; import { commonSel } from "@ducks/common"; @@ -31,8 +29,6 @@ const { const { setLoading, setError, fetchList, fetchListSuccess } = previewSlice.actions; -const { updateNewPrefix } = widgetsSlice.actions; - const ARRAY_DELIMITER = "|"; const VALUE_DELIMITER = ","; @@ -289,12 +285,10 @@ const workspaceOps = { changeLimitOp, toggleFacetOp, setupFiltersFromQs, - fetchProjectPrefixesAsync, fetchWarningListAsync, fetchWarningMarkdownAsync, fetchResourcesListAsync, resetFilters, - updateNewPrefix, applyFilters, changeProjectsLimit, }; diff --git a/workspace/src/app/store/ducks/workspace/requests.ts b/workspace/src/app/store/ducks/workspace/requests.ts index 1ccb7f7771..077ec157f9 100644 --- a/workspace/src/app/store/ducks/workspace/requests.ts +++ b/workspace/src/app/store/ducks/workspace/requests.ts @@ -1,5 +1,6 @@ import { IAppliedFacetState, + IDetailedProjectPrefixes, IFacetState, IProjectExecutionStatus, IProjectImportDetails, @@ -224,25 +225,22 @@ export const requestCreateProject = async (payload: ICreateProjectPayload): Prom } }; -//missing-type -export const requestProjectPrefixesLegacy = async (projectId: string): Promise => { - try { - const { data } = await fetch({ - url: workspaceApi(`/projects/${projectId}/prefixes`), - }); - return data; - } catch (e) { - throw handleError(e); - } -}; - -/** Fetch project prefixes. */ +/** Fetch effective project prefixes. */ export const requestProjectPrefixes = async (projectId: string): Promise>> => { return fetch({ url: workspaceApi(`/projects/${projectId}/prefixes`), }); }; +/** Fetch project and workspace prefixes separately. */ +export const requestDetailedProjectPrefixes = async ( + projectId: string, +): Promise> => { + return fetch({ + url: workspaceApi(`/projects/${projectId}/prefixes/detailed`), + }); +}; + //missing-type export const requestChangePrefixes = async ( prefixName: string, diff --git a/workspace/src/app/store/ducks/workspace/selectors.ts b/workspace/src/app/store/ducks/workspace/selectors.ts index 81abd03512..44ad79bfef 100644 --- a/workspace/src/app/store/ducks/workspace/selectors.ts +++ b/workspace/src/app/store/ducks/workspace/selectors.ts @@ -25,10 +25,6 @@ const appliedFacetsSelector = createSelector([filtersSelector], (filters) => fil const paginationSelector = createSelector([filtersSelector], (filters) => filters.pagination); -const prefixListSelector = createSelector([widgetsSelector], (widgets) => widgets.configuration.prefixes); - -const widgetErrorSelector = createSelector([widgetsSelector], (widgets) => widgets.configuration.error); - const warningListSelector = createSelector([widgetsSelector], (widgets) => widgets.warnings.results); const filesListSelector = createSelector([widgetsSelector, commonSelector], (widgets, common) => @@ -40,8 +36,6 @@ const filesListSelector = createSelector([widgetsSelector, commonSelector], (wid })), ); -const newPrefixSelector = createSelector([widgetsSelector], (widgets) => widgets.configuration.newPrefix); - const isEmptyPageSelector = createSelector( [isLoadingSelector, resultsSelector, commonSelector], (isLoading, results, commonStore) => !isLoading && !results.length && commonStore.initialSettings.emptyWorkspace, @@ -56,14 +50,11 @@ const workspaceSelectors = { facetsSelector, errorSelector, isLoadingSelector, - prefixListSelector, - newPrefixSelector, warningListSelector, filesListSelector, isEmptyPageSelector, widgetsSelector, commonSelector, - widgetErrorSelector, }; export default workspaceSelectors; diff --git a/workspace/src/app/store/ducks/workspace/typings/IWorkspaceWidgets.ts b/workspace/src/app/store/ducks/workspace/typings/IWorkspaceWidgets.ts index b3c54c07b1..f20619fea4 100644 --- a/workspace/src/app/store/ducks/workspace/typings/IWorkspaceWidgets.ts +++ b/workspace/src/app/store/ducks/workspace/typings/IWorkspaceWidgets.ts @@ -11,19 +11,10 @@ export interface IPrefixDefinition { prefixUri: string; } -export interface IWorkspaceConfigurationWidget { - /** - * Array of prefixes List - */ - prefixes: IPrefixDefinition[]; - /** - * Plain object for new prefix - */ - newPrefix: IPrefixDefinition; - - isLoading: boolean; - - error: any; +export interface IDetailedProjectPrefixes { + projectPrefixes: Record; + workspacePrefixes: Record; + defaultPrefixes: Record; } export interface IWarningWidgetItem { @@ -55,11 +46,6 @@ export interface IFilesWidget { } export interface IWidgetsState { - /** - * Store Project details page all widgets by widget name - */ - configuration: IWorkspaceConfigurationWidget; - warnings: IWarningWidget; files: IFilesWidget; diff --git a/workspace/src/app/store/ducks/workspace/widgets/configuration.thunk.ts b/workspace/src/app/store/ducks/workspace/widgets/configuration.thunk.ts deleted file mode 100644 index d682d45f77..0000000000 --- a/workspace/src/app/store/ducks/workspace/widgets/configuration.thunk.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { widgetsSlice } from "@ducks/workspace/widgetsSlice"; -import { batch } from "react-redux"; -import { - requestChangePrefixes, - requestProjectPrefixesLegacy, - requestRemoveProjectPrefix, -} from "@ducks/workspace/requests"; - -const { setPrefixes, resetNewPrefix, toggleWidgetLoading, setWidgetError } = widgetsSlice.actions; - -const WIDGET_NAME = "configuration"; - -export const updatePrefixList = (data) => { - return (dispatch) => { - const formattedPrefixes = Object.keys(data) - .sort((left, right) => (left < right ? -1 : 1)) - .map((key) => ({ - prefixName: key, - prefixUri: data[key], - })); - dispatch(setPrefixes(formattedPrefixes)); - }; -}; - -export const toggleLoading = () => (dispatch) => { - dispatch(toggleWidgetLoading(WIDGET_NAME)); -}; - -export const setError = (e) => (dispatch) => { - dispatch( - setWidgetError({ - widgetName: WIDGET_NAME, - error: e, - }) - ); -}; - -export const fetchProjectPrefixesAsync = (projectId: string) => { - return async (dispatch) => { - try { - dispatch(toggleLoading()); - const data = await requestProjectPrefixesLegacy(projectId); - dispatch(updatePrefixList(data)); - } catch (e) { - dispatch(setError(e)); - } finally { - dispatch(toggleLoading()); - } - }; -}; diff --git a/workspace/src/app/store/ducks/workspace/widgetsSlice.ts b/workspace/src/app/store/ducks/workspace/widgetsSlice.ts index 898ea038a5..dc9f318286 100644 --- a/workspace/src/app/store/ducks/workspace/widgetsSlice.ts +++ b/workspace/src/app/store/ducks/workspace/widgetsSlice.ts @@ -1,31 +1,25 @@ -import { createSlice } from "@reduxjs/toolkit"; -import { initialNewPrefixState, initialWidgetsState } from "./initialState"; +import { createSlice, PayloadAction } from "@reduxjs/toolkit"; +import { initialWidgetsState } from "./initialState"; +import { IWidgetsState } from "./typings"; export const widgetsSlice = createSlice({ name: "widgets", initialState: initialWidgetsState(), reducers: { - toggleWidgetLoading(state, action) { + toggleWidgetLoading(state, action: PayloadAction) { const widgetName = action.payload; state[widgetName].isLoading = !state[widgetName].isLoading; }, - setWidgetError(state, action) { + setWidgetError( + state, + action: PayloadAction<{ + widgetName: keyof IWidgetsState; + error: unknown; + }>, + ) { const { widgetName, error } = action.payload; state[widgetName].error = error; }, - setPrefixes(state, action) { - state.configuration.prefixes = action.payload; - }, - updateNewPrefix(state, action) { - const { field, value } = action.payload; - state.configuration.newPrefix = { - ...state.configuration.newPrefix, - [field]: value, - }; - }, - resetNewPrefix(state) { - state.configuration.newPrefix = initialNewPrefixState(); - }, setWarnings(state, action) { state.warnings.results = action.payload; }, diff --git a/workspace/src/app/views/pages/Project/ProjectNamespacePrefixManagementWidget/ConfigWidget.tsx b/workspace/src/app/views/pages/Project/ProjectNamespacePrefixManagementWidget/ConfigWidget.tsx index 958cd11d35..30a49c51b0 100644 --- a/workspace/src/app/views/pages/Project/ProjectNamespacePrefixManagementWidget/ConfigWidget.tsx +++ b/workspace/src/app/views/pages/Project/ProjectNamespacePrefixManagementWidget/ConfigWidget.tsx @@ -1,8 +1,7 @@ import React, { useEffect, useState } from "react"; import PrefixesDialog from "./PrefixesDialog"; -import { useDispatch, useSelector } from "react-redux"; -import { workspaceOp, workspaceSel } from "@ducks/workspace"; -import { IPrefixDefinition } from "@ducks/workspace/typings"; +import { useSelector } from "react-redux"; +import { IPrefixDefinition, IDetailedProjectPrefixes } from "@ducks/workspace/typings"; import Loading from "../../../shared/Loading"; import { @@ -21,21 +20,51 @@ import { import { useTranslation } from "react-i18next"; import { commonSel } from "@ducks/common"; import useHotKey from "../../../../views/shared/HotKeyHandler/HotKeyHandler"; -import { AppDispatch } from "store/configureStore"; +import { requestDetailedProjectPrefixes } from "@ducks/workspace/requests"; +import useErrorHandler from "../../../../hooks/useErrorHandler"; const VISIBLE_COUNT = 5; +interface IPrefixLists { + effectivePrefixes: IPrefixDefinition[]; + projectPrefixes: IPrefixDefinition[]; + workspacePrefixes: IPrefixDefinition[]; + defaultPrefixes: IPrefixDefinition[]; +} + +const emptyPrefixLists: IPrefixLists = { + effectivePrefixes: [], + projectPrefixes: [], + workspacePrefixes: [], + defaultPrefixes: [], +}; + +const formatPrefixMap = (prefixes: Record): IPrefixDefinition[] => + Object.keys(prefixes) + .sort((left, right) => left.localeCompare(right)) + .map((prefixName) => ({ + prefixName, + prefixUri: prefixes[prefixName], + })); + +const formatDetailedPrefixLists = (prefixes: IDetailedProjectPrefixes): IPrefixLists => ({ + effectivePrefixes: formatPrefixMap({ + ...prefixes.workspacePrefixes, + ...prefixes.projectPrefixes, + }), + projectPrefixes: formatPrefixMap(prefixes.projectPrefixes), + workspacePrefixes: formatPrefixMap(prefixes.workspacePrefixes), + defaultPrefixes: formatPrefixMap(prefixes.defaultPrefixes), +}); + /** The project namespace prefix management widget that allows adding, updating and removing namespace prefixes. */ export const ProjectNamespacePrefixManagementWidget = () => { - const dispatch = useDispatch(); - const prefixList = useSelector(workspaceSel.prefixListSelector); - - const [visiblePrefixes, setVisiblePrefixes] = useState([]); + const [prefixLists, setPrefixLists] = useState(emptyPrefixLists); + const [isLoading, setIsLoading] = useState(false); const [isOpen, setIsOpen] = useState(false); - const configurationWidget = useSelector(workspaceSel.widgetsSelector).configuration; const projectId = useSelector(commonSel.currentProjectIdSelector); - - const { isLoading } = configurationWidget; + const { registerErrorI18N } = useErrorHandler(); + const visiblePrefixes = prefixLists.effectivePrefixes.slice(0, VISIBLE_COUNT); useHotKey({ hotkey: "e p", @@ -45,18 +74,36 @@ export const ProjectNamespacePrefixManagementWidget = () => { }, }); - useEffect(() => { - if (projectId) { - dispatch(workspaceOp.fetchProjectPrefixesAsync(projectId)); + const refreshPrefixes = React.useCallback(async (): Promise => { + if (!projectId) { + setPrefixLists(emptyPrefixLists); + return { + projectPrefixes: {}, + workspacePrefixes: {}, + defaultPrefixes: {}, + }; } - }, [workspaceOp, projectId]); + setIsLoading(true); + try { + const { data } = await requestDetailedProjectPrefixes(projectId); + setPrefixLists(formatDetailedPrefixLists(data)); + return data; + } finally { + setIsLoading(false); + } + }, [projectId]); useEffect(() => { - const visibleItems = prefixList.slice(0, VISIBLE_COUNT); - setVisiblePrefixes(visibleItems); - }, [prefixList]); + if (projectId) { + void refreshPrefixes().catch((error) => { + registerErrorI18N("widget.ConfigWidget.errors.prefixLoadFailure", error); + }); + } else { + setPrefixLists(emptyPrefixLists); + } + }, [projectId, refreshPrefixes, registerErrorI18N]); - const getFullSizeOfList = () => Object.keys(prefixList).length; + const getFullSizeOfList = () => prefixLists.effectivePrefixes.length; const handleOpen = () => setIsOpen(true); const handleClose = () => setIsOpen(false); @@ -86,9 +133,9 @@ export const ProjectNamespacePrefixManagementWidget = () => { - {visiblePrefixes.map((o, index) => ( - - {o.prefixName} + {visiblePrefixes.map((prefix, index) => ( + + {prefix.prefixName} {index < visiblePrefixes.length - 1 ? ", " : moreCount > 0 && ( @@ -118,7 +165,10 @@ export const ProjectNamespacePrefixManagementWidget = () => { projectId={projectId} isOpen={isOpen} onCloseModal={handleClose} - existingPrefixes={new Set(prefixList.map((p) => p.prefixName))} + projectPrefixes={prefixLists.projectPrefixes} + workspacePrefixes={prefixLists.workspacePrefixes} + defaultPrefixes={prefixLists.defaultPrefixes} + refreshPrefixes={refreshPrefixes} /> )} diff --git a/workspace/src/app/views/pages/Project/ProjectNamespacePrefixManagementWidget/PrefixNew.tsx b/workspace/src/app/views/pages/Project/ProjectNamespacePrefixManagementWidget/PrefixNew.tsx index bfd12d351d..4b0d70ea0a 100644 --- a/workspace/src/app/views/pages/Project/ProjectNamespacePrefixManagementWidget/PrefixNew.tsx +++ b/workspace/src/app/views/pages/Project/ProjectNamespacePrefixManagementWidget/PrefixNew.tsx @@ -1,4 +1,4 @@ -import React, { KeyboardEventHandler, useEffect, useState } from "react"; +import React, { KeyboardEventHandler, useState } from "react"; import { AlertDialog, Button, FieldItem, FieldItemRow, FieldSet, Icon, TextField } from "@eccenca/gui-elements"; import { useTranslation } from "react-i18next"; import { IPrefixDefinition } from "@ducks/workspace/typings"; @@ -6,7 +6,8 @@ import useHotKey from "../../../shared/HotKeyHandler/HotKeyHandler"; interface IProps { onAdd: (prefixDefinition: IPrefixDefinition) => any; - existingPrefixes: Set; + existingProjectPrefixes: Set; + existingWorkspacePrefixes: Set; } /** From https://www.w3.org/TR/turtle/#grammar-production-PN_PREFIX */ @@ -50,7 +51,7 @@ export const validatePrefixValue = (prefixValue: string): boolean | number => { }; /** Component for entering a new prefix. */ -const PrefixNew = ({ onAdd, existingPrefixes }: IProps) => { +const PrefixNew = ({ onAdd, existingProjectPrefixes, existingWorkspacePrefixes }: IProps) => { const [t] = useTranslation(); const [prefixDefinition, setPrefixDefinition] = useState({ prefixName: "", prefixUri: "" }); const [isValidPrefixName, setIsValidPrefixName] = useState(false); @@ -58,9 +59,8 @@ const PrefixNew = ({ onAdd, existingPrefixes }: IProps) => { const [isValidPrefixValue, setIsValidPrefixValue] = useState(false); const [overwriteDialogOpen, setOverwriteDialogOpen] = useState(false); - useEffect(() => {}, [prefixDefinition.prefixName + prefixDefinition.prefixUri]); - - const isUpdatePrefix = existingPrefixes.has(prefixDefinition.prefixName); + const isUpdatePrefix = existingProjectPrefixes.has(prefixDefinition.prefixName); + const isWorkspaceOverride = !isUpdatePrefix && existingWorkspacePrefixes.has(prefixDefinition.prefixName); const onPrefixNameChange = (e) => { const value = e?.target?.value; @@ -104,9 +104,9 @@ const PrefixNew = ({ onAdd, existingPrefixes }: IProps) => { const handleSubmit = React.useCallback(() => { if (!submitButtonDisabled) { - isUpdatePrefix ? setOverwriteDialogOpen(true) : onAdd(prefixDefinition); + isUpdatePrefix || isWorkspaceOverride ? setOverwriteDialogOpen(true) : onAdd(prefixDefinition); } - }, [prefixDefinition, existingPrefixes, submitButtonDisabled]); + }, [prefixDefinition, isUpdatePrefix, isWorkspaceOverride, submitButtonDisabled]); const enterHandler: KeyboardEventHandler = React.useCallback( (event): void => { @@ -125,6 +125,17 @@ const PrefixNew = ({ onAdd, existingPrefixes }: IProps) => { onAdd(prefixDefinition); }, []); + const confirmLabel = isUpdatePrefix + ? t("common.action.update") + : t("PrefixDialog.overrideWorkspacePrefixAction", "Override in project"); + const message = isUpdatePrefix + ? t("PrefixDialog.overwritePrefix", { prefixName: prefixDefinition.prefixName }) + : t("PrefixDialog.overrideWorkspacePrefix", { + defaultValue: + "The workspace prefix '{{prefixName}}' will stay unchanged. This project prefix will override it only inside this project.", + prefixName: prefixDefinition.prefixName, + }); + useHotKey({ hotkey: "enter", handler: submitHandler }); return ( @@ -136,21 +147,21 @@ const PrefixNew = ({ onAdd, existingPrefixes }: IProps) => { data-test-id={"update-prefix-dialog"} actions={[ , , ]} > -

{t("PrefixDialog.overwritePrefix", { prefixName: prefixDefinition.prefixName })}

+

{message}

); }; return ( <> -
+
{ diff --git a/workspace/src/app/views/pages/Project/ProjectNamespacePrefixManagementWidget/PrefixRow.tsx b/workspace/src/app/views/pages/Project/ProjectNamespacePrefixManagementWidget/PrefixRow.tsx index deb87731da..9b5b3c5c41 100644 --- a/workspace/src/app/views/pages/Project/ProjectNamespacePrefixManagementWidget/PrefixRow.tsx +++ b/workspace/src/app/views/pages/Project/ProjectNamespacePrefixManagementWidget/PrefixRow.tsx @@ -2,38 +2,93 @@ import React from "react"; import { IPrefixDefinition } from "@ducks/workspace/typings"; import { IconButton, + Icon, OverviewItem, OverviewItemActions, OverviewItemDescription, OverviewItemLine, + Tag, + Spacing, } from "@eccenca/gui-elements"; import { useTranslation } from "react-i18next"; +import styles from "./index.module.scss"; interface IProps { prefix: IPrefixDefinition; - - onRemove(); + ownership: "project" | "workspace"; + rowId?: string; + rowClassName?: string; + overridesWorkspacePrefix?: boolean; + overriddenInProject?: boolean; + onJumpToProjectPrefix?: () => void; + onRemove?: () => void; } -const PrefixRow = ({ prefix, onRemove }: IProps) => { +const PrefixRow = ({ + prefix, + ownership, + rowId, + rowClassName, + overridesWorkspacePrefix = false, + overriddenInProject = false, + onJumpToProjectPrefix, + onRemove, +}: IProps) => { const [t] = useTranslation(); + const isWorkspacePrefix = ownership === "workspace"; return ( - + - {prefix.prefixName} + + {prefix.prefixName} + {overridesWorkspacePrefix && ( + <> + + + {t("PrefixDialog.overridesWorkspacePrefixBadge", "Overrides workspace prefix")} + + + )} + {overriddenInProject && ( + <> + + + {t("PrefixDialog.overriddenInProjectBadge", "Overridden in project")} + + + )} + {prefix.prefixUri} - + {isWorkspacePrefix ? ( + <> + {onJumpToProjectPrefix && ( + + )} + + + ) : ( + onRemove && ( + + ) + )} ); diff --git a/workspace/src/app/views/pages/Project/ProjectNamespacePrefixManagementWidget/PrefixesDialog.tsx b/workspace/src/app/views/pages/Project/ProjectNamespacePrefixManagementWidget/PrefixesDialog.tsx index 87558e5730..c23800b30e 100644 --- a/workspace/src/app/views/pages/Project/ProjectNamespacePrefixManagementWidget/PrefixesDialog.tsx +++ b/workspace/src/app/views/pages/Project/ProjectNamespacePrefixManagementWidget/PrefixesDialog.tsx @@ -1,37 +1,59 @@ import React, { useState } from "react"; -import { batch, useDispatch, useSelector } from "react-redux"; -import { IPrefixDefinition } from "@ducks/workspace/typings"; -import { workspaceOp, workspaceSel } from "@ducks/workspace"; -import { Button, Notification, SimpleDialog } from "@eccenca/gui-elements"; +import { useSelector } from "react-redux"; +import { IDetailedProjectPrefixes, IPrefixDefinition } from "@ducks/workspace/typings"; +import { commonSel } from "@ducks/common"; +import { + Button, + Divider, + HtmlContentBlock, + Notification, + Section, + SectionHeader, + SimpleDialog, + Spacing, + TitleSubsection, +} from "@eccenca/gui-elements"; import PrefixRow from "./PrefixRow"; import DeleteModal from "../../../shared/modals/DeleteModal"; import PrefixNew from "./PrefixNew"; import DataList from "../../../shared/Datalist"; -import { useTranslation } from "react-i18next"; -import { updatePrefixList } from "@ducks/workspace/widgets/configuration.thunk"; +import { Trans, useTranslation } from "react-i18next"; import { requestChangePrefixes, requestRemoveProjectPrefix } from "@ducks/workspace/requests"; -import { widgetsSlice } from "@ducks/workspace/widgetsSlice"; import { ErrorResponse } from "../../../../services/fetch/responseInterceptor"; import { useModalError } from "../../../../hooks/useModalError"; -import { AppDispatch } from "store/configureStore"; +import Loading from "../../../shared/Loading"; +import styles from "./index.module.scss"; interface IProps { projectId: string; onCloseModal: () => any; isOpen: boolean; - existingPrefixes: Set; + projectPrefixes: IPrefixDefinition[]; + workspacePrefixes: IPrefixDefinition[]; + defaultPrefixes: IPrefixDefinition[]; + refreshPrefixes: () => Promise; } +const projectPrefixRowId = (prefixName: string): string => `project-prefix-${encodeURIComponent(prefixName)}`; + /** Manages project prefix definitions. */ -const PrefixesDialog = ({ onCloseModal, isOpen, existingPrefixes, projectId }: IProps) => { - const dispatch = useDispatch(); - const prefixList = useSelector(workspaceSel.prefixListSelector); +const PrefixesDialog = ({ + onCloseModal, + isOpen, + projectId, + projectPrefixes, + workspacePrefixes, + defaultPrefixes, + refreshPrefixes, +}: IProps) => { + const { dmBaseUrl } = useSelector(commonSel.initialSettingsSelector); const [loading, setLoading] = React.useState(false); const [error, setError] = React.useState(); const checkAndDisplayPrefixError = useModalError({ setError }); const [isOpenRemove, setIsOpenRemove] = useState(false); const [selectedPrefix, setSelectedPrefix] = useState(undefined); + const [highlightedProjectPrefix, setHighlightedProjectPrefix] = useState(undefined); const [t] = useTranslation(); @@ -50,17 +72,38 @@ const PrefixesDialog = ({ onCloseModal, isOpen, existingPrefixes, projectId }: I setError(undefined); }, [isOpen]); + React.useEffect(() => { + if (!highlightedProjectPrefix) { + return undefined; + } + const timeoutId = window.setTimeout(() => setHighlightedProjectPrefix(undefined), 1800); + return () => window.clearTimeout(timeoutId); + }, [highlightedProjectPrefix]); + + const refreshPrefixesAfterChanges = React.useCallback( + async (failureMessage: string) => { + try { + await refreshPrefixes(); + } catch (err) { + checkAndDisplayPrefixError(err, failureMessage); + } + }, + [checkAndDisplayPrefixError, refreshPrefixes], + ); + const handleConfirmRemove = React.useCallback(async () => { try { setLoading(true); if (selectedPrefix) { setError(undefined); - const data = await requestRemoveProjectPrefix(selectedPrefix.prefixName, projectId); - dispatch(updatePrefixList(data)); - - if (data) { - toggleRemoveDialog(); - } + await requestRemoveProjectPrefix(selectedPrefix.prefixName, projectId); + toggleRemoveDialog(); + await refreshPrefixesAfterChanges( + t( + "widget.ConfigWidget.modal.errors.prefixRefreshAfterDeletionFailure", + "Prefix deleted, but refreshing the prefix list failed", + ), + ); } } catch (err) { checkAndDisplayPrefixError( @@ -70,30 +113,103 @@ const PrefixesDialog = ({ onCloseModal, isOpen, existingPrefixes, projectId }: I } finally { setLoading(false); } - }, [projectId, selectedPrefix, error]); + }, [checkAndDisplayPrefixError, projectId, refreshPrefixesAfterChanges, selectedPrefix, t]); - const handleAddOrUpdatePrefix = React.useCallback(async (prefix: IPrefixDefinition) => { - try { - setLoading(true); - setError(undefined); - const { prefixName, prefixUri } = prefix; - const data = await requestChangePrefixes(prefixName, JSON.stringify(prefixUri), projectId); - if (data) { - batch(() => { - dispatch(widgetsSlice.actions.resetNewPrefix()); - dispatch(updatePrefixList(data)); - }); + const handleAddOrUpdatePrefix = React.useCallback( + async (prefix: IPrefixDefinition) => { + try { + setLoading(true); + setError(undefined); + const { prefixName, prefixUri } = prefix; + await requestChangePrefixes(prefixName, JSON.stringify(prefixUri), projectId); + await refreshPrefixesAfterChanges( + t( + "widget.ConfigWidget.modal.errors.prefixRefreshAfterChangeFailure", + "Prefix updated, but refreshing the prefix list failed", + ), + ); + } catch (err) { + checkAndDisplayPrefixError( + err, + t("widget.ConfigWidget.modal.errors.prefixChangeFailure", "Prefix change failed"), + ); + } finally { + setLoading(false); } - } catch (err) { - checkAndDisplayPrefixError( - err, - t("widget.ConfigWidget.modal.errors.prefixChangeFailure", "Prefix change failed"), - ); - } finally { - setLoading(false); + }, + [checkAndDisplayPrefixError, projectId, refreshPrefixesAfterChanges, t], + ); + + const existingProjectPrefixes = React.useMemo( + () => new Set(projectPrefixes.map((prefix) => prefix.prefixName)), + [projectPrefixes], + ); + const existingWorkspacePrefixes = React.useMemo( + () => new Set(workspacePrefixes.map((prefix) => prefix.prefixName)), + [workspacePrefixes], + ); + const explorePrefixes = React.useMemo( + () => + workspacePrefixes.filter( + (prefix) => + !defaultPrefixes.some( + (defaultPrefix) => + defaultPrefix.prefixName === prefix.prefixName && + defaultPrefix.prefixUri === prefix.prefixUri, + ), + ), + [defaultPrefixes, workspacePrefixes], + ); + const overriddenReadonlyPrefixes = React.useMemo( + () => + new Set( + workspacePrefixes + .map((prefix) => prefix.prefixName) + .filter((prefixName) => existingProjectPrefixes.has(prefixName)), + ), + [existingProjectPrefixes, workspacePrefixes], + ); + + const workspaceVocabUrl = React.useMemo(() => { + if (!dmBaseUrl) { + return undefined; } + return `${dmBaseUrl.replace(/\/+$/, "")}/vocab`; + }, [dmBaseUrl]); + + const jumpToProjectPrefix = React.useCallback((prefixName: string) => { + const targetRow = document.getElementById(projectPrefixRowId(prefixName)); + setHighlightedProjectPrefix(prefixName); + targetRow?.scrollIntoView({ + behavior: "smooth", + block: "center", + }); }, []); + const workspaceSectionDescription = workspaceVocabUrl ? ( +

+ , + }} + /> +

+ ) : null; + const defaultSectionDescription = ( +

+ {t( + "PrefixDialog.defaultPrefixesDescription", + "These built-in prefixes are always available in DI and cannot be edited here.", + )} +

+ ); + + const exploreEmptyMessage = t( + "PrefixDialog.explorePrefixesEmpty", + "No Explore prefixes are currently registered. Manage them in Explore's vocabulary module.", + ); + return ( handleAddOrUpdatePrefix(newPrefix)} - existingPrefixes={existingPrefixes} + existingProjectPrefixes={existingProjectPrefixes} + existingWorkspacePrefixes={existingWorkspacePrefixes} /> - - {prefixList.map((prefix, i) => ( - toggleRemoveDialog(prefix)} /> - ))} - + {loading && } + {!loading && ( + <> +
+ + + {t("PrefixDialog.projectPrefixesTitle", "Project prefixes")} + + +

+ {t( + "PrefixDialog.projectPrefixesDescription", + "Project prefixes can be added, updated, and removed here. They are exported with the project.", + )} +

+
+
+ + + {projectPrefixes.map((prefix) => ( + toggleRemoveDialog(prefix)} + /> + ))} + +
+ + {workspaceVocabUrl && ( +
+ + + {t("PrefixDialog.workspacePrefixesTitle", "Explore prefixes")} + + {workspaceSectionDescription} + + + + {explorePrefixes.map((prefix) => ( + jumpToProjectPrefix(prefix.prefixName) + : undefined + } + /> + ))} + +
+ )} + +
+ + + {t("PrefixDialog.defaultPrefixesTitle", "Default prefixes")} + + {defaultSectionDescription} + + + + {defaultPrefixes.map((prefix) => ( + jumpToProjectPrefix(prefix.prefixName) + : undefined + } + /> + ))} + +
+ + )} { await waitFor(() => expect(screen.queryByRole("button", { name: "remove-deprecated-node" })).not.toBeInTheDocument(), ); - expect(screen.getByTestId("sidebar-item-normalPort")).toHaveAttribute("data-warning", "false"); + // The sidebar's pre-configured operator list is rebuilt asynchronously when the used-port set + // changes (reloadToken bump -> loadExternalOperators), so wait for it to settle before asserting. + await waitFor(() => + expect(screen.getByTestId("sidebar-item-normalPort")).toHaveAttribute("data-warning", "false"), + ); fireEvent.click(screen.getByRole("button", { name: "remove-normal-node" })); diff --git a/workspace/src/locales/manual/en.json b/workspace/src/locales/manual/en.json index 60c51a0c78..44c5d9ef71 100644 --- a/workspace/src/locales/manual/en.json +++ b/workspace/src/locales/manual/en.json @@ -197,11 +197,27 @@ "parameterDocLinkText": "See documentation" }, "PrefixDialog": { + "addProjectPrefix": "Add project prefix", + "defaultPrefixesDescription": "These built-in prefixes are always available in DI and cannot be edited here.", + "defaultPrefixesEmpty": "No default prefixes are currently configured.", + "defaultPrefixesTitle": "Default prefixes", "deletePrefix": "Prefix '{{prefixName}}' will be deleted.", + "explorePrefixesEmpty": "No Explore prefixes are currently registered. Manage them in Explore's vocabulary module.", + "overriddenInProjectBadge": "Overridden in project", + "overrideWorkspacePrefix": "The workspace prefix '{{prefixName}}' will stay unchanged. This project prefix will override it only inside this project.", + "overrideWorkspacePrefixAction": "Override in project", + "overridesWorkspacePrefixBadge": "Overrides workspace prefix", "overwritePrefix": "Update existing prefix '{{prefixName}}'?", "prefixNameInvalid": "The prefix name you have entered is invalid", "prefixUriInvalid": "The entered URI prefix must be a valid URI / IRI.", - "prefixUriInvalidChar": "The entered URI prefix contains invalid characters. First invalid character at position {{idx}} is '{{char}}'." + "prefixUriInvalidChar": "The entered URI prefix contains invalid characters. First invalid character at position {{idx}} is '{{char}}'.", + "projectPrefixesDescription": "Project prefixes can be added, updated, and removed here. They are exported with the project.", + "projectPrefixesEmpty": "No project prefixes have been defined yet.", + "projectPrefixesTitle": "Project prefixes", + "showProjectOverride": "Show project override", + "workspacePrefixReadOnly": "Workspace prefix, read-only here", + "workspacePrefixesDescription": "These prefixes are provided by Explore. Manage them in the vocabulary module; they are read-only here.", + "workspacePrefixesTitle": "Explore prefixes" }, "ProjectImportModal": { "importBtn": "Import project", @@ -986,10 +1002,15 @@ "prefixTitle": "Manage prefixes", "prefix_plural": "Prefixes", "title": "Configuration", + "errors": { + "prefixLoadFailure": "Loading prefixes failed" + }, "modal": { "errors": { "prefixDeletionFailure": "Prefix deletion failed", - "prefixChangeFailure": "Prefix change failed" + "prefixChangeFailure": "Prefix change failed", + "prefixRefreshAfterDeletionFailure": "Prefix deleted, but refreshing the prefix list failed", + "prefixRefreshAfterChangeFailure": "Prefix updated, but refreshing the prefix list failed" } } }, diff --git a/workspace/test/integration/Project/Project.test.tsx b/workspace/test/integration/Project/Project.test.tsx index a07bed9637..8c6cfa9b3b 100644 --- a/workspace/test/integration/Project/Project.test.tsx +++ b/workspace/test/integration/Project/Project.test.tsx @@ -147,7 +147,7 @@ describe("Project page", () => { it("should get prefixes for configuration widget", () => { renderProjectPage(); - checkRequestMade(apiUrl("/workspace/projects/" + testProjectId + "/prefixes")); + checkRequestMade(apiUrl("/workspace/projects/" + testProjectId + "/prefixes/detailed")); }); it("should search items for that project", () => {