diff --git a/package-lock.json b/package-lock.json index d4e46211..b7dbd8d9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,8 +15,10 @@ "dependencies": { "@aics/frontend-insights": "0.2.x", "@aics/redux-utils": "0.6.0", + "@dagrejs/dagre": "^1.1.5", "@fluentui/react": "8.67", "@tippyjs/react": "4.2.x", + "@xyflow/react": "^12.8.5", "amazon-s3-url": "^1.0.3", "axios": "0.21.x", "classnames": "2.2.x", @@ -28,6 +30,7 @@ "jszip": "^3.10.1", "lodash": "4.17.x", "lru-cache": "5.1.x", + "markdown-to-jsx": "^8.0.0", "normalize.css": "8.0.x", "ome-zarr.js": "^0.0.15", "react": "17.x", @@ -2083,6 +2086,24 @@ "postcss": "^8.3" } }, + "node_modules/@dagrejs/dagre": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@dagrejs/dagre/-/dagre-1.1.5.tgz", + "integrity": "sha512-Ghgrh08s12DCL5SeiR6AoyE80mQELTWhJBRmXfFoqDiFkR458vPEdgTbbjA0T+9ETNxUblnD0QW55tfdvi5pjQ==", + "license": "MIT", + "dependencies": { + "@dagrejs/graphlib": "2.2.4" + } + }, + "node_modules/@dagrejs/graphlib": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/@dagrejs/graphlib/-/graphlib-2.2.4.tgz", + "integrity": "sha512-mepCf/e9+SKYy1d02/UkvSy6+6MoyXhVxP8lLDfA7BPE1X1d4dR0sZznmbM8/XVJ1GPM+Svnx7Xj6ZweByWUkw==", + "license": "MIT", + "engines": { + "node": ">17.0.0" + } + }, "node_modules/@develar/schema-utils": { "version": "2.6.5", "resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz", @@ -3619,6 +3640,55 @@ "@types/node": "*" } }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, "node_modules/@types/debounce-promise": { "version": "3.1.4", "resolved": "https://registry.npmjs.org/@types/debounce-promise/-/debounce-promise-3.1.4.tgz", @@ -4538,6 +4608,38 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/@xyflow/react": { + "version": "12.8.5", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.8.5.tgz", + "integrity": "sha512-NRwcE8QE7dh6BbaIT7GmNccP7/RMDZJOKtzK4HQw599TAfzC8e5E/zw/7MwtpnSbbkqBYc+jZyOisd57sp/hPQ==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.69", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.69", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.69.tgz", + "integrity": "sha512-+KYwHDnsapZQ1xSgsYwOKYN93fUR770LwfCT5qrvcmzoMaabO1rHa6twiEk7E5VUIceWciF8ukgfq9JC83B5jQ==", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, "node_modules/@zarrita/storage": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/@zarrita/storage/-/storage-0.1.3.tgz", @@ -6523,6 +6625,12 @@ "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==", "dev": true }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, "node_modules/classnames": { "version": "2.2.6", "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.2.6.tgz", @@ -7653,6 +7761,111 @@ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.11.tgz", "integrity": "sha512-sa6P2wJ+CAbgyy4KFssIb/JNMLxFvKF1pCYCSXS8ZMuqZnMsrxqI2E5sPyoTpxoPU/gVZMzr2zjOfg8GIZOMsw==" }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/data-urls": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-3.0.2.tgz", @@ -13917,6 +14130,23 @@ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" }, + "node_modules/markdown-to-jsx": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/markdown-to-jsx/-/markdown-to-jsx-8.0.0.tgz", + "integrity": "sha512-hWEaRxeCDjes1CVUQqU+Ov0mCqBqkGhLKjL98KdbwHSgEWZZSJQeGlJQatVfeZ3RaxrfTrZZ3eczl2dhp5c/pA==", + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "peerDependencies": { + "react": ">= 0.14.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } + } + }, "node_modules/matcher": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", @@ -19827,6 +20057,15 @@ "react": "^16.8.0 || ^17.0.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz", + "integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/utf8-byte-length": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz", @@ -21175,6 +21414,34 @@ "node": ">= 6" } }, + "node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, "packages/desktop": { "name": "fms-file-explorer-desktop", "version": "8.5.2", @@ -22640,6 +22907,19 @@ "postcss-value-parser": "^4.2.0" } }, + "@dagrejs/dagre": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@dagrejs/dagre/-/dagre-1.1.5.tgz", + "integrity": "sha512-Ghgrh08s12DCL5SeiR6AoyE80mQELTWhJBRmXfFoqDiFkR458vPEdgTbbjA0T+9ETNxUblnD0QW55tfdvi5pjQ==", + "requires": { + "@dagrejs/graphlib": "2.2.4" + } + }, + "@dagrejs/graphlib": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/@dagrejs/graphlib/-/graphlib-2.2.4.tgz", + "integrity": "sha512-mepCf/e9+SKYy1d02/UkvSy6+6MoyXhVxP8lLDfA7BPE1X1d4dR0sZznmbM8/XVJ1GPM+Svnx7Xj6ZweByWUkw==" + }, "@develar/schema-utils": { "version": "2.6.5", "resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz", @@ -23829,6 +24109,49 @@ "@types/node": "*" } }, + "@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==" + }, + "@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "requires": { + "@types/d3-selection": "*" + } + }, + "@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "requires": { + "@types/d3-color": "*" + } + }, + "@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==" + }, + "@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "requires": { + "@types/d3-selection": "*" + } + }, + "@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "requires": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, "@types/debounce-promise": { "version": "3.1.4", "resolved": "https://registry.npmjs.org/@types/debounce-promise/-/debounce-promise-3.1.4.tgz", @@ -24598,6 +24921,32 @@ "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", "dev": true }, + "@xyflow/react": { + "version": "12.8.5", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.8.5.tgz", + "integrity": "sha512-NRwcE8QE7dh6BbaIT7GmNccP7/RMDZJOKtzK4HQw599TAfzC8e5E/zw/7MwtpnSbbkqBYc+jZyOisd57sp/hPQ==", + "requires": { + "@xyflow/system": "0.0.69", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + } + }, + "@xyflow/system": { + "version": "0.0.69", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.69.tgz", + "integrity": "sha512-+KYwHDnsapZQ1xSgsYwOKYN93fUR770LwfCT5qrvcmzoMaabO1rHa6twiEk7E5VUIceWciF8ukgfq9JC83B5jQ==", + "requires": { + "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, "@zarrita/storage": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/@zarrita/storage/-/storage-0.1.3.tgz", @@ -26064,6 +26413,11 @@ "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==", "dev": true }, + "classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==" + }, "classnames": { "version": "2.2.6", "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.2.6.tgz", @@ -26943,6 +27297,72 @@ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.11.tgz", "integrity": "sha512-sa6P2wJ+CAbgyy4KFssIb/JNMLxFvKF1pCYCSXS8ZMuqZnMsrxqI2E5sPyoTpxoPU/gVZMzr2zjOfg8GIZOMsw==" }, + "d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==" + }, + "d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==" + }, + "d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "requires": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + } + }, + "d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==" + }, + "d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "requires": { + "d3-color": "1 - 3" + } + }, + "d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==" + }, + "d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==" + }, + "d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "requires": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + } + }, + "d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "requires": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + } + }, "data-urls": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-3.0.2.tgz", @@ -31589,6 +32009,12 @@ } } }, + "markdown-to-jsx": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/markdown-to-jsx/-/markdown-to-jsx-8.0.0.tgz", + "integrity": "sha512-hWEaRxeCDjes1CVUQqU+Ov0mCqBqkGhLKjL98KdbwHSgEWZZSJQeGlJQatVfeZ3RaxrfTrZZ3eczl2dhp5c/pA==", + "requires": {} + }, "matcher": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", @@ -35974,6 +36400,12 @@ "integrity": "sha512-u2qFKtxLsia/r8qG0ZKkbytbztzRb317XCkT7yP8wxL0tZ/CzK2G+WWie5vWvpyeP7+YoPIwbJoIHJ4Ba4k0oQ==", "requires": {} }, + "use-sync-external-store": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz", + "integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==", + "requires": {} + }, "utf8-byte-length": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz", @@ -36960,6 +37392,14 @@ } } } + }, + "zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "requires": { + "use-sync-external-store": "^1.2.2" + } } } } diff --git a/package.json b/package.json index 574112ff..01d8ddae 100644 --- a/package.json +++ b/package.json @@ -69,8 +69,10 @@ "dependencies": { "@aics/frontend-insights": "0.2.x", "@aics/redux-utils": "0.6.0", + "@dagrejs/dagre": "^1.1.5", "@fluentui/react": "8.67", "@tippyjs/react": "4.2.x", + "@xyflow/react": "^12.8.5", "amazon-s3-url": "^1.0.3", "axios": "0.21.x", "classnames": "2.2.x", @@ -82,6 +84,7 @@ "jszip": "^3.10.1", "lodash": "4.17.x", "lru-cache": "5.1.x", + "markdown-to-jsx": "^8.0.0", "normalize.css": "8.0.x", "ome-zarr.js": "^0.0.15", "react": "17.x", diff --git a/packages/core/components/DataSourcePrompt/index.tsx b/packages/core/components/DataSourcePrompt/index.tsx index 47a1d864..52c4257f 100644 --- a/packages/core/components/DataSourcePrompt/index.tsx +++ b/packages/core/components/DataSourcePrompt/index.tsx @@ -23,6 +23,12 @@ const ADDITIONAL_COLUMN_DETAILS = [ 'If an "Uploaded" column is present, it should contain the date the file was uploaded to the storage and be formatted as YYYY-MM-DD HH:MM:SS.Z where Z is a timezone offset. ', ]; +export enum DataSourceType { + default = 0, + metadata = 1, + provenance = 2, +} + /** * Dialog meant to prompt user to select a data source option */ @@ -31,7 +37,8 @@ export default function DataSourcePrompt(props: Props) { const selectedDataSources = useSelector(selection.selectors.getSelectedDataSources); const dataSourceInfo = useSelector(interaction.selectors.getDataSourceInfoForVisibleModal); - const { query } = dataSourceInfo || ({} as DataSourcePromptInfo); + const { query, sourceType = DataSourceType.default } = + dataSourceInfo || ({} as DataSourcePromptInfo); const requiresDataSourceReload = useSelector(selection.selectors.getRequiresDataSourceReload); const [dataSource, setDataSource] = React.useState(); @@ -44,6 +51,13 @@ export default function DataSourcePrompt(props: Props) { }; const onSubmit = (dataSource: Source, metadataSource?: Source) => { + if (sourceType === DataSourceType.provenance) { + if (dataSource) { + dispatch(selection.actions.changeSourceProvenance(dataSource)); + } + // To do: include provenance source in query as with metadatasource + return onDismiss(); + } if (requiresDataSourceReload || query) { if (metadataSource) { dispatch(selection.actions.changeSourceMetadata(metadataSource)); @@ -119,100 +133,116 @@ export default function DataSourcePrompt(props: Props) { parentId={`file-prompt-${props.isModal ? "modal" : "main"}`} lightBackground={props.isModal} /> - {showAdvancedOptions ? ( - advancedOptions - ) : ( - setShowAdvancedOptions(!showAdvancedOptions)} - text="Add metadata descriptor file (optional)" - /> - )} + {showAdvancedOptions + ? advancedOptions + : sourceType === DataSourceType.default && ( + setShowAdvancedOptions(!showAdvancedOptions)} + text="Add metadata descriptor file (optional)" + /> + )}
dataSource && onSubmit(dataSource, metadataSource)} />
-
-
-

Getting started guidance and example CSV

- - - - - - - - - - - - - - - - - - - - -
- File Path (required metadata key) - - Gene (example metadata key) - - Color (example metadata key) -
/folder/folder/my_storage/filename.zarrCDH2Blue
/folder/my_storage/filename.txtVIMGreen
-

Minimum requirements

-
    -
  • - The first row should contain metadata keys (i.e., column headers), with - "File Path" being the only required key. -
  • -
  • - Each subsequent row should contain the values of corresponding keys for each - file. -
  • -
+ {sourceType === DataSourceType.default && ( +
+
+

Getting started guidance and example CSV

+ + + + + + + + + + + + + + + + + + + + +
+ File Path (required metadata key) + + Gene (example metadata key) + + Color (example metadata key) +
/folder/folder/my_storage/filename.zarrCDH2Blue
/folder/my_storage/filename.txtVIMGreen
+

Minimum requirements

+
    +
  • + The first row should contain metadata keys (i.e., column headers), with + "File Path" being the only required key. +
  • +
  • + Each subsequent row should contain the values of corresponding keys for + each file. +
  • +
- {isDataSourceDetailExpanded ? ( - <> -

Advanced:

-
    -
  • - Data source files can be generated by this application by selecting - some files, right-clicking, and selecting one of the "Save - metadata as" options. -
  • -
  • - The following are optional pre-defined columns that are handled as - special cases: -
  • + {isDataSourceDetailExpanded ? ( + <> +

    Advanced:

      - {ADDITIONAL_COLUMN_DETAILS.map((text) => ( -
    • - {text} -
    • - ))} +
    • + Data source files can be generated by this application by + selecting some files, right-clicking, and selecting one of the + "Save metadata as" options. +
    • +
    • + The following are optional pre-defined columns that are handled + as special cases: +
    • +
        + {ADDITIONAL_COLUMN_DETAILS.map((text) => ( +
      • + {text} +
      • + ))} +
      +
    • + Optionally, you can supply an additional metadata descriptor + file to add more information about the data source. This file + should have a header row column named "Column Name" + and another column named "Description". Each + subsequent row should contain the details for any columns + present in the actual data source you would like to describe. +
    -
  • - Optionally, you can supply an additional metadata descriptor file to - add more information about the data source. This file should have a - header row column named "Column Name" and another column - named "Description". Each subsequent row should contain - the details for any columns present in the actual data source you - would like to describe. -
  • -
+
+ setIsDataSourceDetailExpanded(false)} + > + Show less   + + +
+ + ) : (
setIsDataSourceDetailExpanded(false)} + onClick={() => setIsDataSourceDetailExpanded(true)} > - Show less   - + Show more   +
- - ) : ( -
- setIsDataSourceDetailExpanded(true)} - > - Show more   - - -
- )} -
+ )} +
+ )} ); } diff --git a/packages/core/components/FileDetails/index.tsx b/packages/core/components/FileDetails/index.tsx index eb0b5658..25c5d2ca 100644 --- a/packages/core/components/FileDetails/index.tsx +++ b/packages/core/components/FileDetails/index.tsx @@ -8,6 +8,7 @@ import FileAnnotationList from "./FileAnnotationList"; import Pagination from "./Pagination"; import useFileDetails from "./useFileDetails"; import { PrimaryButton } from "../Buttons"; +import { ModalType } from "../Modal"; import Tooltip from "../Tooltip"; import { ROOT_ELEMENT_ID } from "../../App"; import FileThumbnail from "../../components/FileThumbnail"; @@ -15,7 +16,7 @@ import AnnotationName from "../../entity/Annotation/AnnotationName"; import annotationFormatterFactory, { AnnotationType } from "../../entity/AnnotationFormatter"; import useOpenWithMenuItems from "../../hooks/useOpenWithMenuItems"; import { MAX_DOWNLOAD_SIZE_WEB } from "../../services/FileDownloadService"; -import { interaction } from "../../state"; +import { interaction, provenance } from "../../state"; import styles from "./FileDetails.module.css"; @@ -127,7 +128,7 @@ export default function FileDetails(props: Props) { } } } - }, [fileDetails, fileDownloadService, isOnWeb, isZarr]); + }, [dispatch, fileDetails, fileDownloadService, isOnWeb, isZarr]); const processStatuses = useSelector(interaction.selectors.getProcessStatuses); const openWithMenuItems = useOpenWithMenuItems(fileDetails || undefined); @@ -184,6 +185,15 @@ export default function FileDetails(props: Props) { }, 1000); // 1s, in ms (arbitrary) }, [dispatch, fileDetails, fileDownloadService.isFileSystemAccessible]); + const onClickProvenance = React.useCallback(async () => { + if (!fileDetails) { + return; + } + // Start generating nodes and edges for selected file + dispatch(provenance.actions.constructProvenanceGraph(fileDetails)); + dispatch(interaction.actions.setVisibleModal(ModalType.Provenance)); + }, [dispatch, fileDetails]); + return (
+ + +

{fileDetails?.name}

Information

diff --git a/packages/core/components/Modal/BaseModal/BaseModal.module.css b/packages/core/components/Modal/BaseModal/BaseModal.module.css index 287d0d65..afd98549 100644 --- a/packages/core/components/Modal/BaseModal/BaseModal.module.css +++ b/packages/core/components/Modal/BaseModal/BaseModal.module.css @@ -14,6 +14,11 @@ flex-direction: column; } +.full-screen { + max-width: 90%; + height: 90%; +} + .scrollable-container { overflow: hidden; } diff --git a/packages/core/components/Modal/BaseModal/index.tsx b/packages/core/components/Modal/BaseModal/index.tsx index c6a51cad..da27beb7 100644 --- a/packages/core/components/Modal/BaseModal/index.tsx +++ b/packages/core/components/Modal/BaseModal/index.tsx @@ -1,4 +1,5 @@ import { Modal } from "@fluentui/react"; +import classNames from "classnames"; import { noop } from "lodash"; import * as React from "react"; @@ -11,6 +12,7 @@ interface BaseModalProps { onDismiss?: () => void; title?: string; isStatic?: boolean; // Not draggable + isFullScreen?: boolean; // Override width constraints } /** @@ -25,7 +27,9 @@ export default function BaseModal(props: BaseModalProps) { @@ -47,4 +51,5 @@ BaseModal.defaultProps = { footer: null, onDismiss: noop, isStatic: false, + isFullScreen: false, }; diff --git a/packages/core/components/Modal/Provenance/Provenance.module.css b/packages/core/components/Modal/Provenance/Provenance.module.css new file mode 100644 index 00000000..2d816d76 --- /dev/null +++ b/packages/core/components/Modal/Provenance/Provenance.module.css @@ -0,0 +1,7 @@ +.network-graph-container { + position: relative; + width: 80vw; + height: 80vh; + overflow-y: hidden; + overflow-x: hidden; +} diff --git a/packages/core/components/Modal/Provenance/index.tsx b/packages/core/components/Modal/Provenance/index.tsx new file mode 100644 index 00000000..9af31b02 --- /dev/null +++ b/packages/core/components/Modal/Provenance/index.tsx @@ -0,0 +1,28 @@ +import * as React from "react"; +import { useSelector } from "react-redux"; + +import { ModalProps } from ".."; +import BaseModal from "../BaseModal"; +import NetworkGraph from "../../NetworkGraph"; +import { provenance } from "../../../state"; + +import styles from "./Provenance.module.css"; + +/** + * Modal overlay for displaying a provenance network graph + * Should use as much of the screen as possible + */ +export default function Provenance({ onDismiss }: ModalProps) { + const nodes = useSelector(provenance.selectors.getNodesForModal); + const edges = useSelector(provenance.selectors.getEdgesForModal); + + const body = ( +
+ +
+ ); + + return ( + + ); +} diff --git a/packages/core/components/Modal/index.tsx b/packages/core/components/Modal/index.tsx index cdce1700..c2bb9fac 100644 --- a/packages/core/components/Modal/index.tsx +++ b/packages/core/components/Modal/index.tsx @@ -4,12 +4,13 @@ import { useDispatch, useSelector } from "react-redux"; import { interaction } from "../../state"; import About from "./About"; import QueryCodeSnippet from "./QueryCodeSnippet"; +import CopyFileManifest from "./CopyFileManifest"; import DataSource from "./DataSource"; import EditMetadata from "./EditMetadata"; +import ExtractMetadataCodeSnippet from "./ExtractMetadataCodeSnippet"; import MetadataManifest from "./MetadataManifest"; +import Provenance from "./Provenance"; import SmallScreenWarning from "./SmallScreenWarning"; -import CopyFileManifest from "./CopyFileManifest"; -import ExtractMetadataCodeSnippet from "./ExtractMetadataCodeSnippet"; import ConvertFiles from "./ZarrConversionModal"; export interface ModalProps { @@ -26,6 +27,7 @@ export enum ModalType { SmallScreenWarning = 7, ExtractMetadataCodeSnippet = 8, ConvertFiles = 9, + Provenance = 10, } /** @@ -52,6 +54,8 @@ export default function Modal() { return ; case ModalType.MetadataManifest: return ; + case ModalType.Provenance: + return ; case ModalType.SmallScreenWarning: return ; case ModalType.ExtractMetadataCodeSnippet: diff --git a/packages/core/components/NetworkGraph/CustomEdge.module.css b/packages/core/components/NetworkGraph/CustomEdge.module.css new file mode 100644 index 00000000..74a3bee2 --- /dev/null +++ b/packages/core/components/NetworkGraph/CustomEdge.module.css @@ -0,0 +1,18 @@ +.custom-edge { + padding: 5px 10px; + position: absolute; + color: var(--primary-text-color); + font-size: var(--xs-paragraph-size); + font-weight: 400; +} + +.custom-edge a { + color: var(--aqua); + pointer-events: all; +} + +.custom-edge a:hover { + color: var(--bright-aqua); + pointer-events: all; + text-decoration: underline; +} \ No newline at end of file diff --git a/packages/core/components/NetworkGraph/CustomEdge.tsx b/packages/core/components/NetworkGraph/CustomEdge.tsx new file mode 100644 index 00000000..1f7c8d20 --- /dev/null +++ b/packages/core/components/NetworkGraph/CustomEdge.tsx @@ -0,0 +1,78 @@ +import { + getBezierPath, + EdgeLabelRenderer, + BaseEdge, + EdgeProps, + Edge, + MarkerType, +} from "@xyflow/react"; +import Markdown from "markdown-to-jsx"; +import React, { FC } from "react"; + +import styles from "./CustomEdge.module.css"; + +// Returns a customizable edge in a ReactFlow network graph +const CustomEdge: FC>> = ({ + id, + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + data, +}) => { + /** + * External util from reactflow that returns a "bezier" type path between two nodes + * + * Inputs: The x and y coordinates for the source and target nodes, + * and the position of the edge connectors to use relative to each node (e.g., top, bottom, left, right) + * + * @returns a fully described edge + * - `edgePath`: string describing the path to use in the SVG `` element + * - `labelX`, `labelY`: the x, y default location for the edge's label + */ + const [edgePath, labelX, labelY] = getBezierPath({ + sourceX, + sourceY, + sourcePosition, + targetX, + targetY, + targetPosition, + }); + + // Uses the default edge component, but allows us to apply styling or hyperlinks and change the location of the label + return ( + <> + + +
+ {/* Safely render markdown using external library */} + + + {data?.label} + + +
+
+ + ); +}; + +export default CustomEdge; diff --git a/packages/core/components/NetworkGraph/FileNode.module.css b/packages/core/components/NetworkGraph/FileNode.module.css new file mode 100644 index 00000000..1211681a --- /dev/null +++ b/packages/core/components/NetworkGraph/FileNode.module.css @@ -0,0 +1,62 @@ +.file-node { + width: 200px; + border: 1px solid var(--aqua); + padding: 10px; + border-radius: 5px; + overflow-wrap: anywhere; + white-space: normal; + background: var(--info-status-background-color); + display: flex; +} + +.file-node > a { + color: var(--bright-aqua); + font-size: var(--s-paragraph-size) +} + +.file-node > a:hover { + text-decoration: underline; + cursor: pointer; +} + +.file-node-label { + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box !important; + -webkit-line-clamp: 3 !important; /* number of lines to show */ + line-clamp: 3; + -webkit-box-orient: vertical !important; + word-wrap: break-word !important; +} + +.current-file { + background: var(--aqua); +} + +.current-file > a { + color: var(--white); +} + +.menu-button { + top: -10px; + background: none; + position: relative; + padding-right: 0; +} + +.menu-button i { + color: var(--aqua); +} + +.menu-button:hover { + background: none !important; + background-color: unset !important; +} + +.menu-button:hover i { + color: var(--bright-aqua); +} + +.subheader { + font-weight: 600; +} \ No newline at end of file diff --git a/packages/core/components/NetworkGraph/FileNode.tsx b/packages/core/components/NetworkGraph/FileNode.tsx new file mode 100644 index 00000000..88bc0f13 --- /dev/null +++ b/packages/core/components/NetworkGraph/FileNode.tsx @@ -0,0 +1,55 @@ +import { IContextualMenuItem } from "@fluentui/react"; +// prettier-ignore +import { Handle, Position, NodeProps } from '@xyflow/react'; +import React from "react"; +import { ProvenanceNode } from "../../state/provenance/reducer"; + +import styles from "./FileNode.module.css"; +import classNames from "classnames"; +import Tooltip from "../Tooltip"; +import { TertiaryButton } from "../Buttons"; + +// This is a proof-of-concept example of a custom node +// Note that we are able to apply styling to the node, and can include custom buttons as content +export default function FileNode(props: NodeProps) { + const shareQueryOptions: IContextualMenuItem[] = [ + { + key: "graph-menu-option-1", + text: "Placeholder for a filter action", + iconProps: { iconName: "Filter" }, + onClick: () => { + console.debug("placeholder"); + }, + }, + { + key: "graph-menu-option-2", + text: "Placeholder for opening link to file info", + iconProps: { iconName: "Link" }, + title: "Open file info in new tab", + onClick: () => { + console.debug("placeholder"); + }, + }, + ]; + + return ( + +
+ {/* The handle component is where edges can connect to */} + +
{props.data.label}
+ + +
+
+ ); +} diff --git a/packages/core/components/NetworkGraph/NetworkGraph.module.css b/packages/core/components/NetworkGraph/NetworkGraph.module.css new file mode 100644 index 00000000..ecb677e0 --- /dev/null +++ b/packages/core/components/NetworkGraph/NetworkGraph.module.css @@ -0,0 +1,4 @@ +.react-flow-container { + width: 100%; + height: 100%; +} \ No newline at end of file diff --git a/packages/core/components/NetworkGraph/index.tsx b/packages/core/components/NetworkGraph/index.tsx new file mode 100644 index 00000000..3e781960 --- /dev/null +++ b/packages/core/components/NetworkGraph/index.tsx @@ -0,0 +1,108 @@ +import React from "react"; +import { ReactFlow, useNodesState, useEdgesState, Node, Edge, EdgeTypes } from "@xyflow/react"; +import dagre from "@dagrejs/dagre"; + +import "@xyflow/react/dist/style.css"; +import styles from "./NetworkGraph.module.css"; + +import CustomEdge from "./CustomEdge"; +import FileNode from "./FileNode"; +import { ProvenanceNode } from "../../state/provenance/reducer"; + +interface NetworkGraphProps { + initialNodes: ProvenanceNode[]; + initialEdges: Edge[]; +} + +const edgeTypes: EdgeTypes = { + "custom-edge": CustomEdge, +}; + +const nodeTypes = { + "file-node": FileNode, +}; + +// Currently arbitrary placeholder values +const NODE_WIDTH = 180; +const NODE_HEIGHT = 36; + +export default function NetworkGraph(props: NetworkGraphProps) { + const { initialNodes, initialEdges } = props; + + const dagreGraph = new dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({})); + + const getLayoutedElements = (nodes: Node[], edges: Edge[]) => { + // Graph customization + // - direction: top to bottom (as opposed to left/right) + // - (node/rank)sep: distance between individual nodes and between each generation of nodes + dagreGraph.setGraph({ rankdir: "TB", nodesep: NODE_WIDTH, ranksep: NODE_WIDTH }); + + nodes.forEach((node) => { + dagreGraph.setNode(node.id, { width: NODE_WIDTH, height: NODE_HEIGHT }); + }); + + edges.forEach((edge) => { + dagreGraph.setEdge(edge.source, edge.target); + }); + + dagre.layout(dagreGraph); + + const newNodes = nodes.map((node) => { + const nodeWithPosition = dagreGraph.node(node.id); + const newNode = { + ...node, + targetPosition: "top", + sourcePosition: "bottom", + // Shift the dagre node position (anchor=center center) to the top left + // so it matches the React Flow node anchor point (top left). + position: { + x: nodeWithPosition.x - NODE_WIDTH / 2, + y: nodeWithPosition.y - NODE_HEIGHT / 2, + }, + }; + + return newNode as Node; + }); + + return { nodes: newNodes, edges }; + }; + + const { nodes: layoutedNodes, edges: layoutedEdges } = getLayoutedElements( + initialNodes, + initialEdges + ); + const [nodes, setNodes, onNodesChange] = useNodesState(layoutedNodes); + const [edges, setEdges, onEdgesChange] = useEdgesState(layoutedEdges); + + // Re-generate the layout of the nodes and edges if they change + // To do: This and below is an improper use of callback logic w/ dependencies + const onLayout = React.useCallback(() => { + const { nodes: layoutedNodes, edges: layoutedEdges } = getLayoutedElements( + initialNodes, + initialEdges + ); + + setNodes([...layoutedNodes]); + setEdges([...layoutedEdges]); + }, [initialNodes, initialEdges, setNodes, setEdges]); + + // Watch for changes to the component props and re-render graph + React.useEffect(() => { + onLayout(); + }, [initialNodes, initialEdges]); + + return ( +
+ +
+ ); +} diff --git a/packages/core/components/QueryPart/QueryDataSource.tsx b/packages/core/components/QueryPart/QueryDataSource.tsx index 17ce9caa..8bd1c732 100644 --- a/packages/core/components/QueryPart/QueryDataSource.tsx +++ b/packages/core/components/QueryPart/QueryDataSource.tsx @@ -3,6 +3,7 @@ import * as React from "react"; import { useDispatch, useSelector } from "react-redux"; import QueryPart from "."; +import { DataSourceType } from "../DataSourcePrompt"; import { AICS_FMS_DATA_SOURCE_NAME } from "../../constants"; import { Source } from "../../entity/SearchParams"; import { interaction, metadata, selection } from "../../state"; @@ -10,6 +11,7 @@ import { interaction, metadata, selection } from "../../state"; interface Props { dataSources: Source[]; sourceMetadata?: Source; + sourceProvenance?: Source; } /** @@ -74,7 +76,26 @@ export default function QueryDataSource(props: Props) { text: "New data source", iconProps: { iconName: "NewFolder" }, onClick: () => { - dispatch(interaction.actions.promptForDataSource({ query: selectedQuery })); + dispatch( + interaction.actions.promptForDataSource({ + query: selectedQuery, + source: selectedDataSources[0], + }) + ); + }, + }, + // Temporary menu item for adding provenance data + { + key: "New Provenance Data Source", + text: "New provenance data source", + onClick: () => { + dispatch( + interaction.actions.promptForDataSource({ + query: selectedQuery, + source: selectedDataSources[0], + sourceType: DataSourceType.provenance, + }) + ); }, }, ]} @@ -91,6 +112,14 @@ export default function QueryDataSource(props: Props) { }, ] : []), + ...(props.sourceProvenance + ? [ + { + id: "sourceProvenance", + title: `provenance from: ${props.sourceProvenance.name}`, + }, + ] + : []), ]} /> ); diff --git a/packages/core/components/QuerySidebar/Query.tsx b/packages/core/components/QuerySidebar/Query.tsx index 2aad5221..d0a73e1a 100644 --- a/packages/core/components/QuerySidebar/Query.tsx +++ b/packages/core/components/QuerySidebar/Query.tsx @@ -226,6 +226,7 @@ export default function Query(props: QueryProps) { diff --git a/packages/core/entity/SearchParams/index.ts b/packages/core/entity/SearchParams/index.ts index 2a32f969..91cc48fe 100644 --- a/packages/core/entity/SearchParams/index.ts +++ b/packages/core/entity/SearchParams/index.ts @@ -28,6 +28,7 @@ export interface SearchParamsComponents { fileView?: FileView; sources: Source[]; sourceMetadata?: Source; + sourceProvenance?: Source; filters: FileFilter[]; openFolders: FileFolder[]; sortColumn?: FileSort; @@ -170,6 +171,19 @@ export default class SearchParams { }) ); } + if (urlComponents.sourceProvenance) { + params.append( + "sourceProvenance", + JSON.stringify({ + ...urlComponents.sourceProvenance, + uri: + typeof urlComponents.sourceProvenance.uri === "string" || + urlComponents.sourceProvenance.uri instanceof String + ? urlComponents.sourceProvenance.uri + : undefined, + }) + ); + } if (urlComponents.sortColumn) { params.append("sort", JSON.stringify(urlComponents.sortColumn.toJSON())); } @@ -206,6 +220,7 @@ export default class SearchParams { private static decodeComplexParams(params: URLSearchParams): SearchParamsComponents { const unparsedSourceMetadata = params.get("sourceMetadata"); + const unparsedSourceProvenance = params.get("sourceProvenance"); const unparsedOpenFolders = params.getAll("openFolder"); const unparsedFilters = params.getAll("filter"); const unparsedSources = params.getAll("source"); @@ -246,6 +261,9 @@ export default class SearchParams { : undefined, sources: unparsedSources.map((unparsedSource) => JSON.parse(unparsedSource)), sourceMetadata: unparsedSourceMetadata ? JSON.parse(unparsedSourceMetadata) : undefined, + sourceProvenance: unparsedSourceProvenance + ? JSON.parse(unparsedSourceProvenance) + : undefined, }; } diff --git a/packages/core/entity/SearchParams/test/searchparams.test.ts b/packages/core/entity/SearchParams/test/searchparams.test.ts index 3a48d792..6e5b3715 100644 --- a/packages/core/entity/SearchParams/test/searchparams.test.ts +++ b/packages/core/entity/SearchParams/test/searchparams.test.ts @@ -220,6 +220,7 @@ describe("SearchParams", () => { showNoValueGroups: false, sortColumn: new FileSort(AnnotationName.UPLOADED, SortOrder.DESC), sourceMetadata: undefined, + sourceProvenance: undefined, sources: [mockSource], }; const encodedUrl = SearchParams.encode(components); @@ -243,6 +244,7 @@ describe("SearchParams", () => { showNoValueGroups: false, sortColumn: undefined, sourceMetadata: undefined, + sourceProvenance: undefined, sources: [], }; const encodedUrl = SearchParams.encode(components); diff --git a/packages/core/services/DatabaseService/DatabaseServiceNoop.ts b/packages/core/services/DatabaseService/DatabaseServiceNoop.ts index f4e24e5c..0da5d58a 100644 --- a/packages/core/services/DatabaseService/DatabaseServiceNoop.ts +++ b/packages/core/services/DatabaseService/DatabaseServiceNoop.ts @@ -5,6 +5,10 @@ export default class DatabaseServiceNoop extends DatabaseService { return Promise.reject("DatabaseServiceNoop:deleteSourceMetadata"); } + public deleteSourceProvenance(): Promise { + return Promise.reject("DatabaseServiceNoop:deleteSourceProvenance"); + } + public execute(): Promise { return Promise.reject("DatabaseServiceNoop:execute"); } diff --git a/packages/core/services/DatabaseService/index.ts b/packages/core/services/DatabaseService/index.ts index ff0f5a21..d03b9c33 100644 --- a/packages/core/services/DatabaseService/index.ts +++ b/packages/core/services/DatabaseService/index.ts @@ -1,5 +1,5 @@ import axios from "axios"; -import { isEmpty } from "lodash"; +import { isEmpty, uniqWith } from "lodash"; import { AICS_FMS_DATA_SOURCE_NAME } from "../../constants"; import Annotation from "../../entity/Annotation"; @@ -7,6 +7,7 @@ import { AnnotationType } from "../../entity/AnnotationFormatter"; import { Source } from "../../entity/SearchParams"; import SQLBuilder from "../../entity/SQLBuilder"; import DataSourcePreparationError from "../../errors/DataSourcePreparationError"; +import { EdgeDefinition } from "../../state/provenance/reducer"; enum PreDefinedColumn { FILE_ID = "File ID", @@ -26,6 +27,7 @@ export default abstract class DatabaseService { // Name of the hidden column BFF uses to uniquely identify rows public static readonly HIDDEN_UID_ANNOTATION = "hidden_bff_uid"; protected readonly SOURCE_METADATA_TABLE = "source_metadata"; + protected readonly SOURCE_PROVENANCE_TABLE = "source_provenance"; // "Open file link" as a datatype must be hardcoded, and CAN NOT change // without BREAKING visibility in the dataset released in 2024 as part // of the EMT Data Release paper @@ -35,10 +37,12 @@ export default abstract class DatabaseService { DatabaseService.OPEN_FILE_LINK_TYPE, ]); private sourceMetadataName?: string; + private sourceProvenanceName?: string; private currentAggregateSource?: string; // Initialize with AICS FMS data source name to pretend it always exists protected readonly existingDataSources = new Set([AICS_FMS_DATA_SOURCE_NAME]); private readonly dataSourceToAnnotationsMap: Map = new Map(); + private readonly dataSourceToProvenanceMap: Map = new Map(); public abstract saveQuery( _destination: string, @@ -180,6 +184,27 @@ export default abstract class DatabaseService { this.sourceMetadataName = sourceMetadata.name; } + public async prepareSourceProvenance(sourceProvenance: Source): Promise { + const isPreviousSource = sourceProvenance.name === this.sourceProvenanceName; + if (isPreviousSource) { + return; + } + await this.deleteSourceProvenance(); + await this.prepareDataSource( + { + ...sourceProvenance, + name: this.SOURCE_PROVENANCE_TABLE, + }, + true + ); + this.sourceProvenanceName = sourceProvenance.name; + } + + public async deleteSourceProvenance(): Promise { + await this.deleteDataSource(this.SOURCE_PROVENANCE_TABLE); + this.dataSourceToProvenanceMap.clear(); + } + public async deleteSourceMetadata(): Promise { await this.deleteDataSource(this.SOURCE_METADATA_TABLE); this.dataSourceToAnnotationsMap.clear(); @@ -461,6 +486,50 @@ export default abstract class DatabaseService { await this.execute(this.getUpdateHiddenUIDSQL(viewName)); } + public async processProvenance(dataSourceNames: string[]) { + // no provenance data + if (!this.existingDataSources.has(this.SOURCE_PROVENANCE_TABLE)) { + return {}; + } + const aggregateDataSourceName = dataSourceNames.sort().join(", "); + // To do: situation where this would be true/action to take? + // const hasEdgeDefinitions = this.dataSourceToProvenanceMap.has(aggregateDataSourceName); + const sql = new SQLBuilder().select("*").from(`${this.SOURCE_PROVENANCE_TABLE}`).toSQL(); + const edges: EdgeDefinition[] = []; + try { + // Get list of edge definitions for provenance schema + const rows = await this.query(sql); + rows.forEach((row) => { + const parent = row["Parent"]; + const child = row["Child"]; + // fully defined + if (parent && child && row["Relationship"]) { + const newEdge = { + parent, + child, + label: row["Relationship"], + }; + edges.push(newEdge); + } + }); + this.dataSourceToProvenanceMap.set(aggregateDataSourceName, uniqWith(edges)); + } catch (err) { + // Source provenance file may not have been supplied + // and/or the columns may not exist + const errMsg = typeof err === "string" ? err : err instanceof Error ? err.message : ""; + if (errMsg.includes("does not exist") || errMsg.includes("not found in FROM clause")) { + return {}; + } + throw err; + } + return this.dataSourceToProvenanceMap.get(aggregateDataSourceName) || []; + } + + public async fetchProvenanceDefinitions(dataSourceNames: string[]): Promise { + const aggregateDataSourceName = dataSourceNames.sort().join(", "); + return this.dataSourceToProvenanceMap.get(aggregateDataSourceName) || []; + } + public async fetchAnnotations(dataSourceNames: string[]): Promise { const aggregateDataSourceName = dataSourceNames.sort().join(", "); const hasAnnotations = this.dataSourceToAnnotationsMap.has(aggregateDataSourceName); diff --git a/packages/core/state/index.ts b/packages/core/state/index.ts index 56d9d307..21df5703 100644 --- a/packages/core/state/index.ts +++ b/packages/core/state/index.ts @@ -5,6 +5,7 @@ import { createLogicMiddleware } from "redux-logic"; import interaction, { InteractionStateBranch } from "./interaction"; import metadata, { MetadataStateBranch } from "./metadata"; +import provenance, { ProvenanceStateBranch } from "./provenance"; import selection, { SelectionStateBranch } from "./selection"; import { PlatformDependentServices } from "../services"; import { PersistedConfig, PersistedConfigKeys } from "../services/PersistentConfigService"; @@ -13,18 +14,20 @@ import FileFilter from "../entity/FileFilter"; import FileFolder from "../entity/FileFolder"; import { Query } from "./selection/actions"; -export { interaction, metadata, selection }; +export { interaction, metadata, provenance, selection }; // -- STATE export interface State { interaction: InteractionStateBranch; metadata: MetadataStateBranch; + provenance: ProvenanceStateBranch; selection: SelectionStateBranch; } export const initialState: State = Object.freeze({ interaction: interaction.initialState, metadata: metadata.initialState, + provenance: provenance.initialState, selection: selection.initialState, }); @@ -32,6 +35,7 @@ export const initialState: State = Object.freeze({ export const reducer = combineReducers({ interaction: interaction.reducer, metadata: metadata.reducer, + provenance: provenance.reducer, selection: selection.reducer, }); @@ -48,7 +52,12 @@ export const reduxLogicDependencies: Partial = { httpClient: axios, }; -export const reduxLogics = [...metadata.logics, ...selection.logics, ...interaction.logics]; +export const reduxLogics = [ + ...metadata.logics, + ...selection.logics, + ...interaction.logics, + ...provenance.logics, +]; const logicMiddleware = createLogicMiddleware(reduxLogics); logicMiddleware.addDeps(reduxLogicDependencies); diff --git a/packages/core/state/interaction/actions.ts b/packages/core/state/interaction/actions.ts index b79d0a99..908ebe95 100644 --- a/packages/core/state/interaction/actions.ts +++ b/packages/core/state/interaction/actions.ts @@ -3,6 +3,7 @@ import { uniqueId } from "lodash"; import { ContextMenuItem, PositionReference } from "../../components/ContextMenu"; import FileFilter from "../../entity/FileFilter"; +import { DataSourceType } from "../../components/DataSourcePrompt"; import { ModalType } from "../../components/Modal"; import { AnnotationValue } from "../../services/AnnotationService"; import { UserSelectedApplication } from "../../services/PersistentConfigService"; @@ -26,6 +27,7 @@ type PartialSource = Omit; export interface DataSourcePromptInfo { source?: PartialSource; query?: string; + sourceType?: DataSourceType; } export interface PromptForDataSource { diff --git a/packages/core/state/provenance/actions.ts b/packages/core/state/provenance/actions.ts new file mode 100644 index 00000000..8dab1c36 --- /dev/null +++ b/packages/core/state/provenance/actions.ts @@ -0,0 +1,63 @@ +import { makeConstant } from "@aics/redux-utils"; +import { Edge, Node } from "@xyflow/react"; + +import FileDetail from "../../entity/FileDetail"; + +const STATE_BRANCH_NAME = "provenance"; + +/** + * SET_GRAPH_NODES + * + * Wholesale replacement of provenance graph node data in state + */ +export const SET_GRAPH_NODES = makeConstant(STATE_BRANCH_NAME, "set-graph-nodes"); + +export interface SetGraphNodesAction { + payload: Node[]; + type: string; +} + +export function setGraphNodes(nodes: Node[]): SetGraphNodesAction { + return { + payload: nodes, + type: SET_GRAPH_NODES, + }; +} + +/** + * SET_GRAPH_EDGES + * + * Wholesale replacement of provenance graph edge data in state + */ +export const SET_GRAPH_EDGES = makeConstant(STATE_BRANCH_NAME, "set-graph-edges"); + +export interface SetGraphEdgesAction { + payload: Edge[]; + type: string; +} + +export function setGraphEdges(edges: Edge[]): SetGraphEdgesAction { + return { + payload: edges, + type: SET_GRAPH_EDGES, + }; +} + +/** + * CONSTRUCT_PROVENANCE_GRAPH + * + * Intention to construct the nodes and edges for the provenance graph + */ +export const CONSTRUCT_PROVENANCE_GRAPH = makeConstant(STATE_BRANCH_NAME, "construct-provenance"); + +export interface ConstructProvenanceGraph { + payload: FileDetail; + type: string; +} + +export function constructProvenanceGraph(fileDetails: FileDetail): ConstructProvenanceGraph { + return { + payload: fileDetails, + type: CONSTRUCT_PROVENANCE_GRAPH, + }; +} diff --git a/packages/core/state/provenance/index.ts b/packages/core/state/provenance/index.ts new file mode 100644 index 00000000..cc079908 --- /dev/null +++ b/packages/core/state/provenance/index.ts @@ -0,0 +1,15 @@ +import * as actions from "./actions"; +import logics from "./logics"; +import reducer, { initialState, ProvenanceStateBranch as _ProvenanceStateBranch } from "./reducer"; +import * as selectors from "./selectors"; + +// Branch intended for managing the provenance graph itself, not the data source +export type ProvenanceStateBranch = _ProvenanceStateBranch; + +export default { + actions, + initialState, + logics, + reducer, + selectors, +}; diff --git a/packages/core/state/provenance/logics.ts b/packages/core/state/provenance/logics.ts new file mode 100644 index 00000000..785e1e8e --- /dev/null +++ b/packages/core/state/provenance/logics.ts @@ -0,0 +1,195 @@ +import { Edge } from "@xyflow/react"; +import { createLogic } from "redux-logic"; + +import { + CONSTRUCT_PROVENANCE_GRAPH, + ConstructProvenanceGraph, + setGraphEdges, + setGraphNodes, +} from "./actions"; +import { EdgeDefinition, ProvenanceNode } from "./reducer"; +import { ReduxLogicDeps, selection } from "../"; +import interaction from "../interaction"; +import FileDetail from "../../entity/FileDetail"; +import FileFilter from "../../entity/FileFilter"; +import FileSet from "../../entity/FileSet"; + +/** + * Interceptor responsible for responding to CONSTRUCT_PROVENANCE_GRAPH actions + * by processing edge definitions into nodes & edges that are stored in state + */ +const constructProvenanceLogic = createLogic({ + async process(deps: ReduxLogicDeps, dispatch, done) { + const { getState } = deps; + const { payload: fileDetails } = deps.action as ConstructProvenanceGraph; + const fileService = interaction.selectors.getFileService(getState()); + const fileID = fileDetails.id; + const { databaseService } = interaction.selectors.getPlatformDependentServices( + deps.getState() + ); + const selectedDataSources = selection.selectors.getSelectedDataSources(deps.getState()); + + const edgeDefs = await databaseService.fetchProvenanceDefinitions( + selectedDataSources.map((source) => source.name) + ); + + // Initialize graph data using selected file + const { nodeMap, edges, parentMap } = constructGraphForFile(fileDetails, edgeDefs, true); + + // To do: Make this smarter & separate into its own function + // (or move this whole logic elsewhere?) + // Construct parent pathways by working backwards up the graph (DFS) + // using parentMap (adjacency map), starting from the selected file node + function dfs(start: string, visited = new Set()) { + const stack = []; + stack.push([start]); + visited.add([start].toString()); + const fullPaths = []; + while (stack.length > 0) { + const currentPath = stack.pop(); + if (currentPath) { + // Get the parent of the last node in the path + const parents = parentMap.get(currentPath.slice(-1)[0]); + if (!parents || parents.length === 0) { + // No more parents, this is a root + fullPaths.push(currentPath); + } else { + parents?.forEach((parent) => { + const newPath = [...currentPath, parent]; + if (!visited.has(newPath.toString())) { + stack.push(newPath); + visited.add(newPath.toString()); + } + // Start a new path working from current node + if (!visited.has([parent].toString())) { + stack.push([parent]); + visited.add([parent].toString()); + } + }); + } + } + } + return fullPaths; + } + // Sort parent pathways by longest first + const parentPathways = dfs(`File ID-${fileID}`).sort((a, b) => b.length - a.length); + // To do: 0=siblings, 1=cousins, etc... how far back to go? + const testPath = parentPathways[2]; + if (testPath?.length) { + // Create fileset with filters that match current file selection + const filters: FileFilter[] = []; + testPath.forEach((nodeId) => { + const annotation = nodeMap.get(nodeId)?.data?.annotation; + if (annotation?.name) { + filters.push(new FileFilter(annotation.name, annotation.values)); + } + }); + const fileSet = new FileSet({ + fileService, + filters, + }); + // Arbitrary limit to select first 5 files + const files = await fileSet.fetchFileRange(0, 5); + + // This should maybe happen on demand (e.g., button click) and not on initial graph render? + files.forEach((relatedFile) => { + const { nodeMap: newNodeMap, edges: newEdges } = constructGraphForFile( + relatedFile, + edgeDefs + ); + + // Merge maps. To do: Pass the map? Or store it in state? so that we avoid + // generating duplicate nodes and don't have to do the merge logic after + newNodeMap.forEach((value, key) => { + if (!nodeMap.has(key)) { + nodeMap.set(key, value); + } + }); + newEdges.forEach((newEdge) => { + if (!edges.some((e) => e.id === newEdge.id)) { + edges.push(newEdge); + } + }); + }); + } + + dispatch(setGraphEdges(edges)); + dispatch(setGraphNodes(Array.from(nodeMap.values()))); + done(); + }, + type: CONSTRUCT_PROVENANCE_GRAPH, +}); + +// To do: Move elsewhere? +// For a given file, construct nodes and edges based on provenance schema +function constructGraphForFile( + fileDetails: FileDetail, + edgeDefs: EdgeDefinition[], + isSelectedFile?: boolean +) { + // To do: make sure this works with uid + const fileID = fileDetails.id; + const annotationDetails = fileDetails.details.annotations; + const edges: Edge[] = []; + const nodeMap = new Map(); + // Note: This hopefully shouldn't be necessary in the long term, using temporarily to make traversal easier + const parentMap = new Map(); + + // Add a node for the specific file + nodeMap.set(`File ID-${fileID}`, { + id: `File ID-${fileID}`, + data: { label: `${fileDetails.name}`, isCurrentFile: !!isSelectedFile, fileDetails }, + position: { x: 0, y: 0 }, + type: "file-node", + }); + + edgeDefs.forEach((edge) => { + const { parent, child, label } = edge; + const [parentId, childId] = [parent, child].map((entityName) => { + const annotationInFile = annotationDetails?.find((a) => a.name === entityName); + if (!annotationInFile) return; // Don't generate node + const entityId = `${entityName}-${annotationInFile?.values.join(", ")}`; + if (!nodeMap.has(entityId)) { + // To do: only generate node if BOTH annotations exist; e.g., outside of .map() + nodeMap.set(entityId, { + id: entityId, + data: { + label: `${entityName}: ${annotationInFile?.values.join(", ")}`, + annotation: annotationInFile, + }, + position: { x: 0, y: 0 }, + }); + } + return entityId; + }); + // If we weren't able to generate an ID, the annotation didn't exist in the file + // To do: Create a blank node so that the graph is still connected + if (!parentId || !childId) return; + + const id = `e${parentId}-${childId}-${label}`; + if (edges.some((edge) => edge.id === id)) return; // Edge already exists + if (childId && parentId) { + edges.push({ + id, + data: { + label: `${label}`, + }, + markerEnd: { type: "arrow" }, + source: `${parentId}`, + target: `${childId}`, + type: "custom-edge", + }); + + if (isSelectedFile) { + // Add to adjacency map to be able to traverse graph via parents + // To do: Find another way to track this? + const currentParents = parentMap.get(childId) || []; + parentMap.set(childId, [...currentParents, parentId]); + } + } + }); + + return { nodeMap, edges, parentMap }; +} + +export default [constructProvenanceLogic]; diff --git a/packages/core/state/provenance/reducer.ts b/packages/core/state/provenance/reducer.ts new file mode 100644 index 00000000..a877e1e2 --- /dev/null +++ b/packages/core/state/provenance/reducer.ts @@ -0,0 +1,45 @@ +import { makeReducer } from "@aics/redux-utils"; +import { Edge, Node } from "@xyflow/react"; + +import { SET_GRAPH_EDGES, SET_GRAPH_NODES } from "./actions"; +import FileDetail from "../../entity/FileDetail"; +import { FmsFileAnnotation } from "../../services/FileService"; + +export interface ProvenanceStateBranch { + edges: Edge[]; + nodes: ProvenanceNode[]; +} + +export const initialState = { + nodes: [], + edges: [], +}; + +export interface EdgeDefinition { + parent: string; + child: string; + label: string; +} + +export interface ProvenanceNode extends Node { + data: { + label?: string; + annotation?: FmsFileAnnotation; + isCurrentFile?: boolean; + fileDetails?: FileDetail; + }; +} + +export default makeReducer( + { + [SET_GRAPH_EDGES]: (state, action) => ({ + ...state, + edges: action.payload, + }), + [SET_GRAPH_NODES]: (state, action) => ({ + ...state, + nodes: action.payload, + }), + }, + initialState +); diff --git a/packages/core/state/provenance/selectors.ts b/packages/core/state/provenance/selectors.ts new file mode 100644 index 00000000..08a951fd --- /dev/null +++ b/packages/core/state/provenance/selectors.ts @@ -0,0 +1,5 @@ +import { State } from "../"; + +// BASIC SELECTORS +export const getNodesForModal = (state: State) => state.provenance.nodes; +export const getEdgesForModal = (state: State) => state.provenance.edges; diff --git a/packages/core/state/selection/actions.ts b/packages/core/state/selection/actions.ts index ce939def..2db5f08c 100644 --- a/packages/core/state/selection/actions.ts +++ b/packages/core/state/selection/actions.ts @@ -666,6 +666,25 @@ export function changeSourceMetadata(source?: Source): ChangeSourceMetadataActio }; } +/** + * CHANGE_SOURCE_PROVENANCE + * + * Intention to update the source file supplying provenance info about the selected data sources + */ +export const CHANGE_SOURCE_PROVENANCE = makeConstant(STATE_BRANCH_NAME, "change-source-provenance"); + +export interface ChangeSourceProvenanceAction { + payload?: Source; + type: string; +} + +export function changeSourceProvenance(source?: Source): ChangeSourceProvenanceAction { + return { + payload: source, + type: CHANGE_SOURCE_PROVENANCE, + }; +} + /** * SELECT_TUTORIAL * diff --git a/packages/core/state/selection/logics.ts b/packages/core/state/selection/logics.ts index 9080df48..b6773788 100644 --- a/packages/core/state/selection/logics.ts +++ b/packages/core/state/selection/logics.ts @@ -40,6 +40,9 @@ import { CHANGE_SOURCE_METADATA, ChangeSourceMetadataAction, changeSourceMetadata, + CHANGE_SOURCE_PROVENANCE, + ChangeSourceProvenanceAction, + changeSourceProvenance, setRequiresDataSourceReload, addDataSourceReloadError, removeDataSourceReloadError, @@ -428,10 +431,12 @@ const decodeSearchParamsLogics = createLogic({ sortColumn, sources, sourceMetadata, + sourceProvenance, } = SearchParams.decode(encodedURL); batch(() => { dispatch(changeSourceMetadata(sourceMetadata)); + dispatch(changeSourceProvenance(sourceProvenance)); dispatch(changeDataSources(sources)); dispatch(setAnnotationHierarchy(hierarchy)); columns && dispatch(setColumns(columns)); @@ -643,6 +648,35 @@ const changeSourceMetadataLogic = createLogic({ }, }); +/** + * Interceptor responsible for passing the CHANGE_SOURCE_PROVENANCE action to the database service. + */ +const changeSourceProvenanceLogic = createLogic({ + type: CHANGE_SOURCE_PROVENANCE, + async process(deps: ReduxLogicDeps, _dispatch, done) { + const { payload: selectedSourceProvenance } = deps.action as ChangeSourceProvenanceAction; + const { databaseService } = interaction.selectors.getPlatformDependentServices( + deps.getState() + ); + if (selectedSourceProvenance) { + await databaseService.prepareSourceProvenance(selectedSourceProvenance); + } else { + await databaseService.deleteSourceProvenance(); + } + const existingDataSources = selection.selectors.getSelectedDataSources(deps.getState()); + + try { + await databaseService.processProvenance( + existingDataSources.map((source) => source.name) + ); + } catch (err) { + // To do: error handling + console.error("Failed to fetch provenance", err); + } + done(); + }, +}); + /** * Interceptor responsible for processing the added query to accurate/unique names */ @@ -681,13 +715,10 @@ const addQueryLogic = createLogic({ const { payload: newQuery } = deps.action as AddQuery; // Map the query names to their occurrences so that queries with the same name // have their occurences appended to their name to make them unique - const queryNameToOccurrence = queries.reduce( - (acc, query) => { - const nameWithoutOccurence = query.name.replace(/ \(\d+\)$/, ""); - return { ...acc, [nameWithoutOccurence]: (acc[nameWithoutOccurence] || 0) + 1 }; - }, - {} as Record - ); + const queryNameToOccurrence = queries.reduce((acc, query) => { + const nameWithoutOccurence = query.name.replace(/ \(\d+\)$/, ""); + return { ...acc, [nameWithoutOccurence]: (acc[nameWithoutOccurence] || 0) + 1 }; + }, {} as Record); const newQueryName = newQuery.name.replace(/ \(\d+\)$/, ""); next({ @@ -844,6 +875,7 @@ export default [ setAvailableAnnotationsLogic, changeDataSourceLogic, changeSourceMetadataLogic, + changeSourceProvenanceLogic, addQueryLogic, replaceDataSourceLogic, setDataSourceReloadErrorLogic, diff --git a/packages/core/state/selection/reducer.ts b/packages/core/state/selection/reducer.ts index cebe0737..efa85f22 100644 --- a/packages/core/state/selection/reducer.ts +++ b/packages/core/state/selection/reducer.ts @@ -36,6 +36,7 @@ import { SET_COLUMNS, COLLAPSE_ALL_FILE_FOLDERS, TOGGLE_NULL_VALUE_GROUPS, + CHANGE_SOURCE_PROVENANCE, } from "./actions"; import interaction from "../interaction"; import { FileView, Source } from "../../entity/SearchParams"; @@ -62,6 +63,7 @@ export interface SelectionStateBranch { shouldShowNullGroups: boolean; sortColumn?: FileSort; sourceMetadata?: Source; + sourceProvenance?: Source; queries: Query[]; tutorial?: Tutorial; } @@ -142,6 +144,10 @@ export default makeReducer( ...state, sourceMetadata: action.payload, }), + [CHANGE_SOURCE_PROVENANCE]: (state, action) => ({ + ...state, + sourceProvenance: action.payload, + }), [ADD_QUERY]: (state, action) => ({ ...state, queries: [action.payload, ...state.queries], diff --git a/packages/core/state/selection/selectors.ts b/packages/core/state/selection/selectors.ts index 9b27fc86..ec6c5472 100644 --- a/packages/core/state/selection/selectors.ts +++ b/packages/core/state/selection/selectors.ts @@ -24,6 +24,7 @@ export const getRequiresDataSourceReload = (state: State) => state.selection.requiresDataSourceReload; export const getSelectedDataSources = (state: State) => state.selection.dataSources; export const getSelectedSourceMetadata = (state: State) => state.selection.sourceMetadata; +export const getSelectedSourceProvenance = (state: State) => state.selection.sourceProvenance; export const getSelectedQuery = (state: State) => state.selection.selectedQuery; export const getShouldDisplaySmallFont = (state: State) => state.selection.shouldDisplaySmallFont; export const getShouldShowNullGroups = (state: State) => state.selection.shouldShowNullGroups; @@ -87,6 +88,7 @@ export const getCurrentQueryParts = createSelector( getSortColumn, getSelectedDataSources, getSelectedSourceMetadata, + getSelectedSourceProvenance, ], ( hierarchy, @@ -97,7 +99,8 @@ export const getCurrentQueryParts = createSelector( showNoValueGroups, sortColumn, sources, - sourceMetadata + sourceMetadata, + sourceProvenance ): SearchParamsComponents => ({ columns, hierarchy, @@ -108,6 +111,7 @@ export const getCurrentQueryParts = createSelector( sortColumn, sources, sourceMetadata, + sourceProvenance, }) ); diff --git a/packages/core/state/selection/test/logics.test.ts b/packages/core/state/selection/test/logics.test.ts index 527df4e4..514ab080 100644 --- a/packages/core/state/selection/test/logics.test.ts +++ b/packages/core/state/selection/test/logics.test.ts @@ -14,6 +14,7 @@ import { addQuery, changeDataSources, changeSourceMetadata, + changeSourceProvenance, decodeSearchParams, expandAllFileFolders, reorderAnnotationHierarchy, @@ -1234,6 +1235,9 @@ describe("Selection logics", () => { public deleteSourceMetadata(): Promise { return Promise.resolve(); } + public deleteSourceProvenance(): Promise { + return Promise.resolve(); + } } const state = mergeState(initialState, { interaction: { @@ -1292,6 +1296,7 @@ describe("Selection logics", () => { }) ).to.be.true; expect(actions.includesMatch(changeSourceMetadata())).to.be.true; + expect(actions.includesMatch(changeSourceProvenance())).to.be.true; expect(actions.includesMatch(changeDataSources(mockDataSources))).to.be.true; }); });