@manta-eu/react 0.8.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. package/dist/components/chart/ForecastLegend.d.ts.map +1 -1
  2. package/dist/components/chart/ForecastLegend.js +10 -2
  3. package/dist/components/chart/ForecastLegend.js.map +1 -1
  4. package/dist/components/chart/forecast-model.d.ts.map +1 -1
  5. package/dist/components/chart/forecast-model.js.map +1 -1
  6. package/dist/components/chat-view/Chat.js +2 -2
  7. package/dist/components/file-dropzone/index.d.ts +25 -0
  8. package/dist/components/file-dropzone/index.d.ts.map +1 -0
  9. package/dist/components/file-dropzone/index.js +70 -0
  10. package/dist/components/file-dropzone/index.js.map +1 -0
  11. package/dist/components/planning/plan-detail-utils.d.ts.map +1 -1
  12. package/dist/components/planning/plan-detail-utils.js +2 -0
  13. package/dist/components/planning/plan-detail-utils.js.map +1 -1
  14. package/dist/components/toast/index.d.ts +1 -0
  15. package/dist/components/toast/index.d.ts.map +1 -1
  16. package/dist/components/toast/index.js +1 -0
  17. package/dist/components/toast/index.js.map +1 -1
  18. package/dist/globals.css +27 -0
  19. package/dist/graph/NodeDetailPanel/TypeSummaryPanel.d.ts.map +1 -1
  20. package/dist/graph/NodeDetailPanel/TypeSummaryPanel.js.map +1 -1
  21. package/dist/hooks/use-sparql-construct.d.ts +2 -0
  22. package/dist/hooks/use-sparql-construct.d.ts.map +1 -1
  23. package/dist/hooks/use-sparql-construct.js +2 -1
  24. package/dist/hooks/use-sparql-construct.js.map +1 -1
  25. package/dist/index.d.ts +1 -0
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +1 -0
  28. package/dist/index.js.map +1 -1
  29. package/dist/lib/datastore-route-table.d.ts +3 -0
  30. package/dist/lib/datastore-route-table.d.ts.map +1 -1
  31. package/dist/lib/datastore-route-table.js +3 -0
  32. package/dist/lib/datastore-route-table.js.map +1 -1
  33. package/dist/lib/hooks/{useChatUpload.d.ts → useFileUpload.d.ts} +3 -3
  34. package/dist/lib/hooks/{useChatUpload.d.ts.map → useFileUpload.d.ts.map} +1 -1
  35. package/dist/lib/hooks/{useChatUpload.js → useFileUpload.js} +2 -2
  36. package/dist/lib/hooks/{useChatUpload.js.map → useFileUpload.js.map} +1 -1
  37. package/dist/routes/data-collection/index.d.ts +3 -0
  38. package/dist/routes/data-collection/index.d.ts.map +1 -0
  39. package/dist/routes/data-collection/index.js +43 -0
  40. package/dist/routes/data-collection/index.js.map +1 -0
  41. package/dist/routes/data-explorer/components/DataExplorerLayout.d.ts.map +1 -1
  42. package/dist/routes/data-explorer/components/DataExplorerLayout.js +5 -2
  43. package/dist/routes/data-explorer/components/DataExplorerLayout.js.map +1 -1
  44. package/dist/routes/data-explorer/components/Rail.d.ts.map +1 -1
  45. package/dist/routes/data-explorer/components/Rail.js +3 -3
  46. package/dist/routes/data-explorer/components/Rail.js.map +1 -1
  47. package/dist/routes/data-explorer/components/uploads/IngestRunsList.d.ts +8 -0
  48. package/dist/routes/data-explorer/components/uploads/IngestRunsList.d.ts.map +1 -0
  49. package/dist/routes/data-explorer/components/uploads/IngestRunsList.js +18 -0
  50. package/dist/routes/data-explorer/components/uploads/IngestRunsList.js.map +1 -0
  51. package/dist/routes/data-explorer/components/uploads/IngestStateStatus.d.ts +14 -0
  52. package/dist/routes/data-explorer/components/uploads/IngestStateStatus.d.ts.map +1 -0
  53. package/dist/routes/data-explorer/components/uploads/IngestStateStatus.js +33 -0
  54. package/dist/routes/data-explorer/components/uploads/IngestStateStatus.js.map +1 -0
  55. package/dist/routes/data-explorer/components/uploads/UploadedFilesList.d.ts +8 -0
  56. package/dist/routes/data-explorer/components/uploads/UploadedFilesList.d.ts.map +1 -0
  57. package/dist/routes/data-explorer/components/uploads/UploadedFilesList.js +34 -0
  58. package/dist/routes/data-explorer/components/uploads/UploadedFilesList.js.map +1 -0
  59. package/dist/routes/data-explorer/components/uploads/UploadsPages.d.ts +7 -0
  60. package/dist/routes/data-explorer/components/uploads/UploadsPages.d.ts.map +1 -0
  61. package/dist/routes/data-explorer/components/uploads/UploadsPages.js +35 -0
  62. package/dist/routes/data-explorer/components/uploads/UploadsPages.js.map +1 -0
  63. package/dist/routes/data-explorer/index.d.ts +1 -0
  64. package/dist/routes/data-explorer/index.d.ts.map +1 -1
  65. package/dist/routes/data-explorer/index.js +1 -0
  66. package/dist/routes/data-explorer/index.js.map +1 -1
  67. package/dist/routes/data-explorer/namespaces.d.ts +4 -0
  68. package/dist/routes/data-explorer/namespaces.d.ts.map +1 -1
  69. package/dist/routes/data-explorer/namespaces.js +6 -1
  70. package/dist/routes/data-explorer/namespaces.js.map +1 -1
  71. package/dist/routes/data-explorer/uploads.d.ts +56 -0
  72. package/dist/routes/data-explorer/uploads.d.ts.map +1 -0
  73. package/dist/routes/data-explorer/uploads.js +140 -0
  74. package/dist/routes/data-explorer/uploads.js.map +1 -0
  75. package/dist/routes/datastores/components/LinkUnlinkedDatastoreDialog.d.ts +1 -0
  76. package/dist/routes/datastores/components/LinkUnlinkedDatastoreDialog.d.ts.map +1 -1
  77. package/dist/routes/datastores/components/LinkUnlinkedDatastoreDialog.js +16 -9
  78. package/dist/routes/datastores/components/LinkUnlinkedDatastoreDialog.js.map +1 -1
  79. package/dist/routes/datastores/components/StatusBadge.d.ts +7 -8
  80. package/dist/routes/datastores/components/StatusBadge.d.ts.map +1 -1
  81. package/dist/routes/datastores/components/StatusBadge.js +17 -1
  82. package/dist/routes/datastores/components/StatusBadge.js.map +1 -1
  83. package/dist/routes/datastores/flatten.d.ts +6 -1
  84. package/dist/routes/datastores/flatten.d.ts.map +1 -1
  85. package/dist/routes/datastores/flatten.js +37 -2
  86. package/dist/routes/datastores/flatten.js.map +1 -1
  87. package/dist/routes/datastores/hooks/useDatastoreColumns.d.ts +2 -0
  88. package/dist/routes/datastores/hooks/useDatastoreColumns.d.ts.map +1 -1
  89. package/dist/routes/datastores/hooks/useDatastoreColumns.js +8 -4
  90. package/dist/routes/datastores/hooks/useDatastoreColumns.js.map +1 -1
  91. package/dist/routes/datastores/index.d.ts.map +1 -1
  92. package/dist/routes/datastores/index.js +53 -18
  93. package/dist/routes/datastores/index.js.map +1 -1
  94. package/dist/routes/integrations/components/connect-forms/GoogleMerchantCenterConnectForm.d.ts +3 -0
  95. package/dist/routes/integrations/components/connect-forms/GoogleMerchantCenterConnectForm.d.ts.map +1 -0
  96. package/dist/routes/integrations/components/connect-forms/GoogleMerchantCenterConnectForm.js +38 -0
  97. package/dist/routes/integrations/components/connect-forms/GoogleMerchantCenterConnectForm.js.map +1 -0
  98. package/dist/routes/integrations/components/connect-forms/google-merchant-center-account-tree.d.ts +4 -0
  99. package/dist/routes/integrations/components/connect-forms/google-merchant-center-account-tree.d.ts.map +1 -0
  100. package/dist/routes/integrations/components/connect-forms/google-merchant-center-account-tree.js +32 -0
  101. package/dist/routes/integrations/components/connect-forms/google-merchant-center-account-tree.js.map +1 -0
  102. package/dist/routes/integrations/components/connect-forms/setup-account-unavailable-reason.d.ts.map +1 -1
  103. package/dist/routes/integrations/components/connect-forms/setup-account-unavailable-reason.js +1 -0
  104. package/dist/routes/integrations/components/connect-forms/setup-account-unavailable-reason.js.map +1 -1
  105. package/dist/routes/integrations/connect-forms.d.ts +1 -0
  106. package/dist/routes/integrations/connect-forms.d.ts.map +1 -1
  107. package/dist/routes/integrations/connect-forms.js +1 -0
  108. package/dist/routes/integrations/connect-forms.js.map +1 -1
  109. package/dist/routes/integrations/connection-view.d.ts.map +1 -1
  110. package/dist/routes/integrations/connection-view.js +1 -0
  111. package/dist/routes/integrations/connection-view.js.map +1 -1
  112. package/dist/shared/graph-derived-queries.d.ts.map +1 -1
  113. package/dist/shared/graph-derived-queries.js +2 -1
  114. package/dist/shared/graph-derived-queries.js.map +1 -1
  115. package/package.json +21 -9
  116. package/src/app-subpaths.json +1 -0
  117. package/src/components/chart/ForecastLegend.tsx +15 -13
  118. package/src/components/chart/forecast-model.ts +6 -8
  119. package/src/components/chat-view/Chat.tsx +2 -2
  120. package/src/components/file-dropzone/index.tsx +165 -0
  121. package/src/components/planning/plan-detail-utils.tsx +2 -0
  122. package/src/components/toast/index.tsx +1 -0
  123. package/src/graph/NodeDetailPanel/TypeSummaryPanel.tsx +12 -14
  124. package/src/hooks/use-sparql-construct.ts +4 -2
  125. package/src/index.ts +1 -0
  126. package/src/lib/datastore-route-table.ts +6 -0
  127. package/src/lib/hooks/{useChatUpload.ts → useFileUpload.ts} +2 -2
  128. package/src/routes/data-collection/index.tsx +82 -0
  129. package/src/routes/data-explorer/components/DataExplorerLayout.tsx +5 -2
  130. package/src/routes/data-explorer/components/Rail.tsx +13 -2
  131. package/src/routes/data-explorer/components/uploads/IngestRunsList.tsx +63 -0
  132. package/src/routes/data-explorer/components/uploads/IngestStateStatus.tsx +45 -0
  133. package/src/routes/data-explorer/components/uploads/UploadedFilesList.tsx +93 -0
  134. package/src/routes/data-explorer/components/uploads/UploadsPages.tsx +80 -0
  135. package/src/routes/data-explorer/index.tsx +6 -0
  136. package/src/routes/data-explorer/namespaces.ts +7 -1
  137. package/src/routes/data-explorer/uploads.ts +205 -0
  138. package/src/routes/datastores/components/LinkUnlinkedDatastoreDialog.tsx +49 -10
  139. package/src/routes/datastores/components/StatusBadge.tsx +22 -7
  140. package/src/routes/datastores/flatten.ts +50 -2
  141. package/src/routes/datastores/hooks/useDatastoreColumns.tsx +21 -1
  142. package/src/routes/datastores/index.tsx +64 -13
  143. package/src/routes/integrations/components/connect-forms/GoogleMerchantCenterConnectForm.tsx +61 -0
  144. package/src/routes/integrations/components/connect-forms/google-merchant-center-account-tree.ts +38 -0
  145. package/src/routes/integrations/components/connect-forms/setup-account-unavailable-reason.ts +1 -0
  146. package/src/routes/integrations/connect-forms.ts +1 -0
  147. package/src/routes/integrations/connection-view.ts +1 -0
  148. package/src/shared/graph-derived-queries.ts +2 -0
@@ -0,0 +1,63 @@
1
+ import { Link } from "react-router";
2
+ import { entityPath } from "../../namespaces";
3
+ import type { IngestRun, UploadedFile } from "../../uploads";
4
+ import { ArrowLinkIcon, arrowLinkClass } from "../ArrowLink";
5
+ import { ModifiedAgo } from "../ModifiedAgo";
6
+ import { EntityName, LinkList } from "../RawTriples";
7
+ import { IngestStateStatus } from "./IngestStateStatus";
8
+ import { TruncateTooltip } from "#components/tooltip/index";
9
+ import { cn } from "#lib/utils";
10
+
11
+ type IngestRunRowProps = { run: IngestRun; file: UploadedFile | undefined };
12
+
13
+ function IngestRunRow(props: IngestRunRowProps) {
14
+ const { run, file } = props;
15
+
16
+ return (
17
+ <div className="flex flex-col gap-2 px-4 py-3 sm:flex-row sm:items-center sm:gap-3">
18
+ <div className="flex min-w-0 flex-1 flex-col gap-0.5">
19
+ <Link to={entityPath(run.file)} className="min-w-0 hover:underline">
20
+ {file ? (
21
+ <TruncateTooltip
22
+ text={file.fileName}
23
+ className="typography-small-medium block truncate text-default-foreground"
24
+ />
25
+ ) : (
26
+ <EntityName iri={run.file} />
27
+ )}
28
+ </Link>
29
+
30
+ <div className="typography-tiny flex flex-wrap items-center gap-x-2 text-muted-foreground">
31
+ {run.endedAt && <ModifiedAgo modified={run.endedAt} />}
32
+ <Link to={entityPath(run.iri)} className="hover:underline">
33
+ Run details
34
+ </Link>
35
+ </div>
36
+ </div>
37
+
38
+ <div className="flex items-center gap-3">
39
+ <IngestStateStatus state={{ kind: "run", run }} />
40
+ {run.dataset && (
41
+ <Link to={entityPath(run.dataset)} className={cn("typography-small", arrowLinkClass)}>
42
+ Dataset
43
+ <ArrowLinkIcon />
44
+ </Link>
45
+ )}
46
+ </div>
47
+ </div>
48
+ );
49
+ }
50
+
51
+ type IngestRunsListProps = { runs: readonly IngestRun[]; fileByIri: ReadonlyMap<string, UploadedFile> };
52
+
53
+ export function IngestRunsList(props: IngestRunsListProps) {
54
+ const { runs, fileByIri } = props;
55
+
56
+ return (
57
+ <LinkList>
58
+ {runs.map((run) => (
59
+ <IngestRunRow key={run.iri} run={run} file={fileByIri.get(run.file)} />
60
+ ))}
61
+ </LinkList>
62
+ );
63
+ }
@@ -0,0 +1,45 @@
1
+ import { match } from "ts-pattern";
2
+ import type { FileIngestState, IngestRun } from "../../uploads";
3
+ import { Status, type StatusVariant } from "#components/status/index";
4
+
5
+ type StatusDisplay = { label: string; variant: StatusVariant };
6
+
7
+ const countFormat = new Intl.NumberFormat("en-US");
8
+
9
+ function recordCount(count: number): string {
10
+ return `${countFormat.format(count)} ${count === 1 ? "record" : "records"}`;
11
+ }
12
+
13
+ function ingestedDisplay(run: IngestRun): StatusDisplay {
14
+ const read = run.recordsRead === undefined ? "Ingested" : `Ingested ${recordCount(run.recordsRead)}`;
15
+ const dropped = run.recordsDropped ? `, ${countFormat.format(run.recordsDropped)} dropped` : "";
16
+
17
+ return { label: `${read}${dropped}`, variant: run.recordsDropped ? "warning" : "success" };
18
+ }
19
+
20
+ function runDisplay(run: IngestRun): StatusDisplay {
21
+ return match(run.status)
22
+ .with("ingested", () => ingestedDisplay(run))
23
+ .with("unparsable", (): StatusDisplay => ({ label: "Could not be read", variant: "error" }))
24
+ .with("too_large", (): StatusDisplay => ({ label: "Too large to ingest", variant: "warning" }))
25
+ .with("gone", (): StatusDisplay => ({ label: "Removed before ingest", variant: "neutral" }))
26
+ .exhaustive();
27
+ }
28
+
29
+ export function ingestStateDisplay(state: FileIngestState): StatusDisplay {
30
+ return match(state)
31
+ .with({ kind: "waiting" }, (): StatusDisplay => ({ label: "Waiting for ingest", variant: "neutral" }))
32
+ .with({ kind: "stored" }, (): StatusDisplay => ({ label: "Stored", variant: "neutral" }))
33
+ .with({ kind: "run" }, ({ run }) => runDisplay(run))
34
+ .exhaustive();
35
+ }
36
+
37
+ type IngestStateStatusProps = { state: FileIngestState; className?: string };
38
+
39
+ export function IngestStateStatus(props: IngestStateStatusProps) {
40
+ const { state, className } = props;
41
+
42
+ const { label, variant } = ingestStateDisplay(state);
43
+
44
+ return <Status label={label} variant={variant} className={className} />;
45
+ }
@@ -0,0 +1,93 @@
1
+ import { userIriPrefix } from "@manta-eu/client/shared/stakeholder-iri";
2
+ import { redirectPath } from "@manta-eu/ldo/redirect-queries";
3
+ import { humanBytes } from "@manta-eu/lib/humanize";
4
+ import { isPresent } from "@manta-eu/lib/present";
5
+ import { Download, FileText } from "lucide-react";
6
+ import { Link } from "react-router";
7
+ import { entityPath } from "../../namespaces";
8
+ import type { UploadedFileWithState } from "../../uploads";
9
+ import { ModifiedAgo } from "../ModifiedAgo";
10
+ import { EntityName, LinkList } from "../RawTriples";
11
+ import { IngestStateStatus } from "./IngestStateStatus";
12
+ import { Button } from "#components/button/index";
13
+ import { TruncateTooltip } from "#components/tooltip/index";
14
+ import { useRedirectEndpoint } from "#ldo-ui";
15
+
16
+ type UploaderProps = { iri: string | undefined; currentUserIri: string | undefined };
17
+
18
+ function Uploader(props: UploaderProps) {
19
+ const { iri, currentUserIri } = props;
20
+
21
+ if (!iri) return null;
22
+
23
+ if (iri === currentUserIri) return <span>by you</span>;
24
+
25
+ if (iri.startsWith(userIriPrefix)) return <span>by a teammate</span>;
26
+
27
+ return (
28
+ <span className="flex min-w-0 items-center gap-1">
29
+ by <EntityName iri={iri} />
30
+ </span>
31
+ );
32
+ }
33
+
34
+ type UploadedFileRowProps = { file: UploadedFileWithState; currentUserIri: string | undefined };
35
+
36
+ function UploadedFileRow(props: UploadedFileRowProps) {
37
+ const { file, currentUserIri } = props;
38
+
39
+ const redirectEndpoint = useRedirectEndpoint();
40
+
41
+ return (
42
+ <div className="flex flex-col gap-2 px-4 py-3 sm:flex-row sm:items-center sm:gap-3">
43
+ <div className="flex min-w-0 flex-1 items-center gap-3">
44
+ <FileText className="size-4 shrink-0 text-muted-foreground" />
45
+
46
+ <div className="flex min-w-0 flex-1 flex-col gap-0.5">
47
+ <Link to={entityPath(file.iri)} className="min-w-0 hover:underline">
48
+ <TruncateTooltip
49
+ text={file.fileName}
50
+ className="typography-small-medium block truncate text-default-foreground"
51
+ />
52
+ </Link>
53
+
54
+ <div className="typography-tiny flex min-w-0 flex-wrap items-center gap-x-2 text-muted-foreground">
55
+ {file.byteSize !== undefined && <span>{humanBytes(file.byteSize)}</span>}
56
+ {isPresent(file.extension) && <span title={file.mediaType}>{file.extension.toUpperCase()}</span>}
57
+ {file.uploadedAt && <ModifiedAgo modified={file.uploadedAt} />}
58
+ <Uploader iri={file.uploader} currentUserIri={currentUserIri} />
59
+ </div>
60
+ </div>
61
+ </div>
62
+
63
+ <div className="flex items-center justify-between gap-3 pl-7 sm:pl-0">
64
+ <IngestStateStatus state={file.state} />
65
+
66
+ <Button asChild variant="ghost" size="xs" className="size-7 shrink-0 p-0">
67
+ <a
68
+ href={redirectPath(file.iri, redirectEndpoint)}
69
+ target="_blank"
70
+ rel="noreferrer"
71
+ aria-label={`Download ${file.fileName}`}
72
+ >
73
+ <Download className="size-4" />
74
+ </a>
75
+ </Button>
76
+ </div>
77
+ </div>
78
+ );
79
+ }
80
+
81
+ type UploadedFilesListProps = { files: readonly UploadedFileWithState[]; currentUserIri?: string };
82
+
83
+ export function UploadedFilesList(props: UploadedFilesListProps) {
84
+ const { files, currentUserIri } = props;
85
+
86
+ return (
87
+ <LinkList>
88
+ {files.map((file) => (
89
+ <UploadedFileRow key={file.iri} file={file} currentUserIri={currentUserIri} />
90
+ ))}
91
+ </LinkList>
92
+ );
93
+ }
@@ -0,0 +1,80 @@
1
+ import { getUserIri } from "@manta-eu/client/shared/stakeholder-iri";
2
+ import { isPresent } from "@manta-eu/lib/present";
3
+ import { explorerCrumb } from "../../namespaces";
4
+ import { uploadsLimit, useUploads } from "../../uploads";
5
+ import { Breadcrumbs } from "../Breadcrumbs";
6
+ import { DetailHeader } from "../DetailHeader";
7
+ import { LoadingRows, StatusText } from "../RawTriples";
8
+ import { IngestRunsList } from "./IngestRunsList";
9
+ import { UploadedFilesList } from "./UploadedFilesList";
10
+ import { useAuthContext } from "#providers/AuthContext";
11
+
12
+ export type UploadedFilesSectionProps = { emptyText?: string };
13
+
14
+ export function UploadedFilesSection(props: UploadedFilesSectionProps) {
15
+ const { emptyText = "No files uploaded yet." } = props;
16
+
17
+ const { isOperator, user } = useAuthContext();
18
+ const { files, isLoading, error } = useUploads({ includeAllFiles: isOperator });
19
+
20
+ if (isLoading) return <LoadingRows />;
21
+
22
+ if (error) return <StatusText role="alert">Failed to load files.</StatusText>;
23
+
24
+ if (!isPresent(files)) return <StatusText>{emptyText}</StatusText>;
25
+
26
+ return (
27
+ <div className="flex flex-col gap-2">
28
+ <UploadedFilesList files={files} currentUserIri={getUserIri(user.id)} />
29
+ {files.length >= uploadsLimit && (
30
+ <p className="typography-caption text-muted-foreground">Showing the newest {uploadsLimit} files.</p>
31
+ )}
32
+ </div>
33
+ );
34
+ }
35
+
36
+ export function UploadedFilesPage() {
37
+ const { isOperator } = useAuthContext();
38
+
39
+ return (
40
+ <div className="flex max-w-5xl flex-col gap-6">
41
+ <Breadcrumbs trail={[explorerCrumb, { label: "Files" }]} />
42
+
43
+ <DetailHeader title="Files" />
44
+
45
+ <p className="typography-small -mt-3 max-w-prose text-muted-foreground">
46
+ {isOperator
47
+ ? "Every file in this datastore, newest first, with how its ingest into the knowledge graph ended."
48
+ : "Files uploaded to this datastore, newest first, with how their ingest into the knowledge graph ended."}{" "}
49
+ CSV, Parquet, Turtle and N-Triples files are ingested automatically; other files are stored.
50
+ </p>
51
+
52
+ <UploadedFilesSection />
53
+ </div>
54
+ );
55
+ }
56
+
57
+ export function IngestRunsPage() {
58
+ const { isOperator } = useAuthContext();
59
+ const { runs, fileByIri, isLoading, error } = useUploads({ includeAllFiles: isOperator });
60
+
61
+ return (
62
+ <div className="flex max-w-5xl flex-col gap-6">
63
+ <Breadcrumbs trail={[explorerCrumb, { label: "Ingest runs" }]} />
64
+
65
+ <DetailHeader title="Ingest runs" />
66
+
67
+ <p className="typography-small -mt-3 max-w-prose text-muted-foreground">
68
+ Each attempt to add an uploaded file to the knowledge graph, newest first.
69
+ </p>
70
+
71
+ {isLoading && <LoadingRows />}
72
+
73
+ {error && <StatusText role="alert">Failed to load ingest runs.</StatusText>}
74
+
75
+ {!isLoading && !error && !isPresent(runs) && <StatusText>No file has been ingested yet.</StatusText>}
76
+
77
+ {isPresent(runs) && <IngestRunsList runs={runs} fileByIri={fileByIri} />}
78
+ </div>
79
+ );
80
+ }
@@ -9,6 +9,12 @@ export { DataExplorerLayout } from "./components/DataExplorerLayout";
9
9
  export { ExplorerEmptyState as DataExplorerIndexRoute } from "./components/ExplorerEmptyState";
10
10
  export { CoverageOverview as DataExplorerCoverageRoute } from "./components/ontology-coverage";
11
11
  export { RecentTypes as DataExplorerRecentRoute } from "./components/RecentTypes";
12
+ export {
13
+ IngestRunsPage as DataExplorerIngestRoute,
14
+ UploadedFilesPage as DataExplorerFilesRoute,
15
+ UploadedFilesSection,
16
+ type UploadedFilesSectionProps,
17
+ } from "./components/uploads/UploadsPages";
12
18
 
13
19
  export function DataExplorerPrefixRoute() {
14
20
  const { prefix } = useParams<{ prefix: string }>();
@@ -96,8 +96,12 @@ export function localName(iri: string, prefix: string): string {
96
96
  export { entityPath, entitySegment, explorerPath };
97
97
  export const recentSegment = "recent";
98
98
  export const coverageSegment = "coverage";
99
+ export const filesSegment = "files";
100
+ export const ingestRunsSegment = "ingest-runs";
99
101
  export const recentPath = `${explorerPath}/${recentSegment}`;
100
102
  export const coveragePath = `${explorerPath}/${coverageSegment}`;
103
+ export const filesPath = `${explorerPath}/${filesSegment}`;
104
+ export const ingestRunsPath = `${explorerPath}/${ingestRunsSegment}`;
101
105
  export const explorerCrumb = { label: "Data", to: explorerPath };
102
106
 
103
107
  export function isExplorerPrefix(value: string | undefined): value is string {
@@ -105,7 +109,9 @@ export function isExplorerPrefix(value: string | undefined): value is string {
105
109
  }
106
110
 
107
111
  export function isNamespaceSegment(segment: string | undefined): segment is string {
108
- return isPresent(segment) && ![recentSegment, coverageSegment, entitySegment].includes(segment);
112
+ const reserved = [recentSegment, coverageSegment, filesSegment, ingestRunsSegment, entitySegment];
113
+
114
+ return isPresent(segment) && !reserved.includes(segment);
109
115
  }
110
116
 
111
117
  export function namespacePath(key: string): string {
@@ -0,0 +1,205 @@
1
+ import { userIriPrefix } from "@manta-eu/client/shared/stakeholder-iri";
2
+ import { isIngestableExtension } from "@manta-eu/client/shared/upload";
3
+ import { ingestManual } from "@manta-eu/ldo";
4
+ import { ingest, nfo, nmo, prov } from "@manta-eu/ldo/namespaces";
5
+ import { getExtension } from "@manta-eu/lib/file";
6
+ import { useQueryClient } from "@tanstack/react-query";
7
+ import type { RDF } from "ldkit/rdf";
8
+ import { useCallback, useMemo } from "react";
9
+ import { bindingCount, bindingValue, useSparqlSelect } from "#ldo-ui";
10
+
11
+ export const uploadsLimit = 500;
12
+
13
+ export const ingestPollIntervalMs = 15_000;
14
+
15
+ const uploadsQueryKey = ["uploaded-files"] as const;
16
+
17
+ export type UploadedFile = {
18
+ iri: string;
19
+ fileName: string;
20
+ mediaType?: string;
21
+ byteSize?: number;
22
+ extension?: string;
23
+ uploadedAt?: string;
24
+ uploader?: string;
25
+ purpose?: string;
26
+ };
27
+
28
+ export type IngestStatus = "ingested" | "unparsable" | "gone" | "too_large";
29
+
30
+ export type IngestRun = {
31
+ iri: string;
32
+ file: string;
33
+ status: IngestStatus;
34
+ endedAt?: string;
35
+ recordsRead?: number;
36
+ recordsDropped?: number;
37
+ dataset?: string;
38
+ };
39
+
40
+ export type FileIngestState = { kind: "waiting" } | { kind: "stored" } | { kind: "run"; run: IngestRun };
41
+
42
+ const ingestStatuses = new Set<string>(["ingested", "unparsable", "gone", "too_large"]);
43
+
44
+ function isIngestStatus(value: string | undefined): value is IngestStatus {
45
+ return value !== undefined && ingestStatuses.has(value);
46
+ }
47
+
48
+ // Operators see every file node; members see the files a user uploaded.
49
+ export function uploadedFilesQuery(includeAllFiles: boolean): string {
50
+ const uploaderPattern = includeAllFiles
51
+ ? `OPTIONAL { ?file <${prov.wasAttributedTo}> ?uploader }`
52
+ : `?file <${prov.wasAttributedTo}> ?uploader .
53
+ FILTER(STRSTARTS(STR(?uploader), "${userIriPrefix}"))`;
54
+
55
+ return `SELECT ?file ?fileName ?mediaType ?byteSize ?extension ?uploadedAt ?uploader ?purpose WHERE {
56
+ ?file a <${nfo.FileDataObject}> .
57
+ FILTER(isIRI(?file))
58
+ ${uploaderPattern}
59
+ OPTIONAL { ?file <${nfo.fileName}> ?fileName }
60
+ OPTIONAL { ?file <${nmo.contentMimeType}> ?mediaType }
61
+ OPTIONAL { ?file <${nfo.fileSize}> ?byteSize }
62
+ OPTIONAL { ?file <${nfo.fileExtension}> ?extension }
63
+ OPTIONAL { ?file <${prov.generatedAtTime}> ?uploadedAt }
64
+ OPTIONAL { ?file <${ingestManual.uploadPurpose}> ?purpose }
65
+ }
66
+ ORDER BY DESC(STR(?uploadedAt)) STR(?file)
67
+ LIMIT ${uploadsLimit}`;
68
+ }
69
+
70
+ export const ingestRunsQuery = `SELECT ?run ?file ?status ?endedAt ?recordsRead ?recordsDropped ?dataset WHERE {
71
+ ?run a <${ingest.IngestRun}> ;
72
+ <${prov.used}> ?file ;
73
+ <${ingest.status}> ?status .
74
+ FILTER(isIRI(?run))
75
+ OPTIONAL { ?run <${prov.endedAtTime}> ?endedAt }
76
+ OPTIONAL { ?run <${ingest.recordsRead}> ?recordsRead }
77
+ OPTIONAL { ?run <${ingest.recordsDropped}> ?recordsDropped }
78
+ OPTIONAL { ?run <${prov.generated}> ?dataset }
79
+ }
80
+ ORDER BY DESC(STR(?endedAt)) STR(?run)
81
+ LIMIT ${uploadsLimit}`;
82
+
83
+ export function uploadedFilesFromBindings(bindings: RDF.Bindings[]): UploadedFile[] {
84
+ const files = new Map<string, UploadedFile>();
85
+
86
+ for (const binding of bindings) {
87
+ const iri = bindingValue(binding, "file");
88
+
89
+ if (!iri || files.has(iri)) continue;
90
+
91
+ const fileName = bindingValue(binding, "fileName") ?? iri;
92
+
93
+ files.set(iri, {
94
+ iri,
95
+ fileName,
96
+ mediaType: bindingValue(binding, "mediaType"),
97
+ byteSize: bindingCount(binding, "byteSize"),
98
+ extension: bindingValue(binding, "extension") ?? getExtension(fileName),
99
+ uploadedAt: bindingValue(binding, "uploadedAt"),
100
+ uploader: bindingValue(binding, "uploader"),
101
+ purpose: bindingValue(binding, "purpose"),
102
+ });
103
+ }
104
+
105
+ return [...files.values()];
106
+ }
107
+
108
+ export function ingestRunsFromBindings(bindings: RDF.Bindings[]): IngestRun[] {
109
+ const runs = new Map<string, IngestRun>();
110
+
111
+ for (const binding of bindings) {
112
+ const iri = bindingValue(binding, "run");
113
+ const file = bindingValue(binding, "file");
114
+ const status = bindingValue(binding, "status");
115
+
116
+ if (!iri || !file || !isIngestStatus(status) || runs.has(iri)) continue;
117
+
118
+ runs.set(iri, {
119
+ iri,
120
+ file,
121
+ status,
122
+ endedAt: bindingValue(binding, "endedAt"),
123
+ recordsRead: bindingCount(binding, "recordsRead"),
124
+ recordsDropped: bindingCount(binding, "recordsDropped"),
125
+ dataset: bindingValue(binding, "dataset"),
126
+ });
127
+ }
128
+
129
+ return [...runs.values()];
130
+ }
131
+
132
+ export function latestRunByFile(runs: readonly IngestRun[]): Map<string, IngestRun> {
133
+ const latest = new Map<string, IngestRun>();
134
+
135
+ for (const run of runs) {
136
+ const current = latest.get(run.file);
137
+
138
+ if (!current || (run.endedAt ?? "") > (current.endedAt ?? "")) latest.set(run.file, run);
139
+ }
140
+
141
+ return latest;
142
+ }
143
+
144
+ // Only a file recorded with the "file" purpose is picked up by ingest; a bundle or an older upload is only stored.
145
+ export function fileIngestState(file: UploadedFile, run: IngestRun | undefined): FileIngestState {
146
+ if (run) return { kind: "run", run };
147
+
148
+ return file.purpose === "file" && isIngestableExtension(file.extension) ? { kind: "waiting" } : { kind: "stored" };
149
+ }
150
+
151
+ export function filesWithIngestState(
152
+ files: readonly UploadedFile[],
153
+ runs: readonly IngestRun[],
154
+ ): UploadedFileWithState[] {
155
+ const latest = latestRunByFile(runs);
156
+
157
+ return files.map((file) => ({ ...file, state: fileIngestState(file, latest.get(file.iri)) }));
158
+ }
159
+
160
+ export function hasWaitingFile(files: readonly UploadedFileWithState[]): boolean {
161
+ return files.some((file) => file.state.kind === "waiting");
162
+ }
163
+
164
+ export type UploadedFileWithState = UploadedFile & { state: FileIngestState };
165
+
166
+ type UseUploadsOptions = { includeAllFiles: boolean };
167
+
168
+ export function useUploads(options: UseUploadsOptions) {
169
+ const { includeAllFiles } = options;
170
+
171
+ const filesQuery = useSparqlSelect({
172
+ query: uploadedFilesQuery(includeAllFiles),
173
+ queryKey: [...uploadsQueryKey, "files", includeAllFiles],
174
+ });
175
+
176
+ const files = useMemo(() => uploadedFilesFromBindings(filesQuery.data ?? []), [filesQuery.data]);
177
+
178
+ const runsQuery = useSparqlSelect({
179
+ query: ingestRunsQuery,
180
+ queryKey: [...uploadsQueryKey, "runs"],
181
+ refetchInterval: (query) =>
182
+ hasWaitingFile(filesWithIngestState(files, ingestRunsFromBindings(query.state.data ?? [])))
183
+ ? ingestPollIntervalMs
184
+ : false,
185
+ });
186
+
187
+ const runs = useMemo(() => ingestRunsFromBindings(runsQuery.data ?? []), [runsQuery.data]);
188
+ const filesWithState = useMemo(() => filesWithIngestState(files, runs), [files, runs]);
189
+ const fileByIri = useMemo(() => new Map(files.map((file) => [file.iri, file])), [files]);
190
+
191
+ return {
192
+ files: filesWithState,
193
+ runs,
194
+ fileByIri,
195
+ hasWaiting: hasWaitingFile(filesWithState),
196
+ isLoading: filesQuery.isLoading || runsQuery.isLoading,
197
+ error: filesQuery.error ?? runsQuery.error,
198
+ };
199
+ }
200
+
201
+ export function useInvalidateUploads() {
202
+ const queryClient = useQueryClient();
203
+
204
+ return useCallback(() => queryClient.invalidateQueries({ queryKey: uploadsQueryKey }), [queryClient]);
205
+ }
@@ -1,5 +1,6 @@
1
1
  import { useLinkUnlinkedDatastoreMutation, useDatastoreTreeQuery, useUnlinkedDatastoresQuery } from "@manta-eu/client";
2
2
  import { parseError } from "@manta-eu/client/shared/error-utils";
3
+ import { normalizeGroupPath, pathSegments } from "@manta-eu/client/shared/team";
3
4
  import { isPresent } from "@manta-eu/lib/present";
4
5
  import { useQueryClient } from "@tanstack/react-query";
5
6
  import { useState } from "react";
@@ -30,6 +31,7 @@ export type SuggestedLinkTarget = {
30
31
  export type LinkUnlinkedDatastoreDialogProps = {
31
32
  datastoreId: string;
32
33
  organizationNames: string[];
34
+ groupPathsByOrganization: Map<string, string[]>;
33
35
  existingOrganizationNames: Set<string>;
34
36
  suggestedMatch?: SuggestedLinkTarget;
35
37
  onClose: () => void;
@@ -37,8 +39,17 @@ export type LinkUnlinkedDatastoreDialogProps = {
37
39
 
38
40
  type Mode = "new" | "existing";
39
41
 
42
+ const organizationRootPath = "/";
43
+
40
44
  export function LinkUnlinkedDatastoreDialog(props: LinkUnlinkedDatastoreDialogProps) {
41
- const { datastoreId, organizationNames, existingOrganizationNames, suggestedMatch, onClose } = props;
45
+ const {
46
+ datastoreId,
47
+ organizationNames,
48
+ groupPathsByOrganization,
49
+ existingOrganizationNames,
50
+ suggestedMatch,
51
+ onClose,
52
+ } = props;
42
53
  const { mantaClient } = useMantaClient();
43
54
  const queryClient = useQueryClient();
44
55
 
@@ -46,6 +57,11 @@ export function LinkUnlinkedDatastoreDialog(props: LinkUnlinkedDatastoreDialogPr
46
57
  const [organizationName, setOrganizationName] = useState("");
47
58
  const [existingOrganizationName, setExistingOrganizationName] = useState(suggestedMatch?.organizationName ?? "");
48
59
  const [name, setName] = useState(suggestedMatch?.name ?? "");
60
+ const [parentGroupPath, setParentGroupPath] = useState(
61
+ suggestedMatch ? normalizeGroupPath(suggestedMatch.parentPath.join("/")) : organizationRootPath,
62
+ );
63
+ const parentGroupPaths = groupPathsByOrganization.get(existingOrganizationName) ?? [];
64
+ const offeredParentGroupPath = parentGroupPaths.includes(parentGroupPath) ? parentGroupPath : organizationRootPath;
49
65
 
50
66
  const linkMutation = useLinkUnlinkedDatastoreMutation(mantaClient, {
51
67
  onSuccess: (result) => {
@@ -53,7 +69,11 @@ export function LinkUnlinkedDatastoreDialog(props: LinkUnlinkedDatastoreDialogPr
53
69
  void queryClient.invalidateQueries({ queryKey: useDatastoreTreeQuery.getKey() });
54
70
  void queryClient.invalidateQueries({ queryKey: useUnlinkedDatastoresQuery.getKey() });
55
71
  void queryClient.invalidateQueries({ queryKey: ["DatastoreProvisioningStatuses"] });
56
- toast.success(`Linked to the existing datastore '${datastoreId}'`);
72
+ toast.success(
73
+ result.linkUnlinkedDatastore.pending
74
+ ? `Linked to '${datastoreId}', which is still being built`
75
+ : `Linked to the existing datastore '${datastoreId}'`,
76
+ );
57
77
  toastProviderWarning(result.linkUnlinkedDatastore.providerWarning);
58
78
  onClose();
59
79
  } else {
@@ -72,12 +92,12 @@ export function LinkUnlinkedDatastoreDialog(props: LinkUnlinkedDatastoreDialogPr
72
92
 
73
93
  const canSubmit = mode === "new" ? !!organizationName.trim() && !tenantCheck.blocked : !!existingOrganizationName;
74
94
  const atExistingOrganizationRoot = mode === "existing" && !name.trim();
75
- const suggestedParentPath =
76
- suggestedMatch &&
77
- existingOrganizationName === suggestedMatch.organizationName &&
78
- name.trim() === suggestedMatch.name
79
- ? suggestedMatch.parentPath
80
- : [];
95
+ const parentPath = pathSegments(offeredParentGroupPath);
96
+
97
+ function selectOrganization(organization: string) {
98
+ setExistingOrganizationName(organization);
99
+ setParentGroupPath(organizationRootPath);
100
+ }
81
101
 
82
102
  function submit() {
83
103
  if (!canSubmit) return;
@@ -107,7 +127,7 @@ export function LinkUnlinkedDatastoreDialog(props: LinkUnlinkedDatastoreDialogPr
107
127
  : {
108
128
  datastoreId,
109
129
  organizationName: existingOrganizationName,
110
- ...(isPresent(suggestedParentPath) ? { parentPath: suggestedParentPath } : {}),
130
+ ...(isPresent(parentPath) ? { parentPath } : {}),
111
131
  name: name.trim(),
112
132
  atOrganizationRoot: false,
113
133
  };
@@ -143,7 +163,7 @@ export function LinkUnlinkedDatastoreDialog(props: LinkUnlinkedDatastoreDialogPr
143
163
  </>
144
164
  ) : (
145
165
  <>
146
- <Select value={existingOrganizationName} onValueChange={setExistingOrganizationName}>
166
+ <Select value={existingOrganizationName} onValueChange={selectOrganization}>
147
167
  <SelectTrigger label="Organization" id="link-organization">
148
168
  <SelectValue placeholder="Select an organization…" />
149
169
  </SelectTrigger>
@@ -155,6 +175,25 @@ export function LinkUnlinkedDatastoreDialog(props: LinkUnlinkedDatastoreDialogPr
155
175
  ))}
156
176
  </SelectContent>
157
177
  </Select>
178
+ {isPresent(parentGroupPaths) && (
179
+ <Select
180
+ value={offeredParentGroupPath}
181
+ onValueChange={setParentGroupPath}
182
+ disabled={atExistingOrganizationRoot}
183
+ >
184
+ <SelectTrigger label="Under" id="link-parent-group">
185
+ <SelectValue />
186
+ </SelectTrigger>
187
+ <SelectContent>
188
+ <SelectItem value={organizationRootPath}>Organization root</SelectItem>
189
+ {parentGroupPaths.map((path) => (
190
+ <SelectItem key={path} value={path}>
191
+ {path}
192
+ </SelectItem>
193
+ ))}
194
+ </SelectContent>
195
+ </Select>
196
+ )}
158
197
  <Input
159
198
  label="Datastore name"
160
199
  placeholder="Leave empty to link to the organization root"