diff --git a/frontend/graphqlCodegen.ts b/frontend/graphqlCodegen.ts
index 2df78a1e..13bb2ee5 100644
--- a/frontend/graphqlCodegen.ts
+++ b/frontend/graphqlCodegen.ts
@@ -3,7 +3,7 @@ import type { CodegenConfig } from "@graphql-codegen/cli";
const config: CodegenConfig = {
overwrite: true,
- schema: ["./src/instrument_sessions.graphql", "./src/workflows.graphql"],
+ schema: ["./src/ulims_dev.graphql", "./src/workflows.graphql"],
documents: [
"./*/src/components/**/*.{ts,tsx}",
"./unified/src/App.tsx",
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 94d72fab..7bc2004f 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -25,6 +25,7 @@
"js-yaml": "^4.3.0",
"keycloak-js": "^26.2.4",
"lucide-react": "^1.31.0",
+ "material-react-table": "^3.2.1",
"ndarray": "^1.0.19",
"react": "^18.3.1",
"react-dnd": "^16.0.1",
@@ -4322,6 +4323,82 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@tanstack/match-sorter-utils": {
+ "version": "8.19.4",
+ "resolved": "https://registry.npmjs.org/@tanstack/match-sorter-utils/-/match-sorter-utils-8.19.4.tgz",
+ "integrity": "sha512-Wo1iKt2b9OT7d+YGhvEPD3DXvPv2etTusIMhMUoG7fbhmxcXCtIjJDEygy91Y2JFlwGyjqiBPRozme7UD8hoqg==",
+ "license": "MIT",
+ "dependencies": {
+ "remove-accents": "0.5.0"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
+ "node_modules/@tanstack/react-table": {
+ "version": "8.20.6",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.20.6.tgz",
+ "integrity": "sha512-w0jluT718MrOKthRcr2xsjqzx+oEM7B7s/XXyfs19ll++hlId3fjTm+B2zrR3ijpANpkzBAr15j1XGVOMxpggQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@tanstack/table-core": "8.20.5"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "peerDependencies": {
+ "react": ">=16.8",
+ "react-dom": ">=16.8"
+ }
+ },
+ "node_modules/@tanstack/react-virtual": {
+ "version": "3.11.2",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.11.2.tgz",
+ "integrity": "sha512-OuFzMXPF4+xZgx8UzJha0AieuMihhhaWG0tCqpp6tDzlFwOmNBPYMuLOtMJ1Tr4pXLHmgjcWhG6RlknY2oNTdQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@tanstack/virtual-core": "3.11.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/@tanstack/table-core": {
+ "version": "8.20.5",
+ "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.20.5.tgz",
+ "integrity": "sha512-P9dF7XbibHph2PFRz8gfBKEXEY/HJPOhym8CHmjF8y3q5mWpKx9xtZapXQUWCgkqvsK0R46Azuz+VaxD4Xl+Tg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
+ "node_modules/@tanstack/virtual-core": {
+ "version": "3.11.2",
+ "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.11.2.tgz",
+ "integrity": "sha512-vTtpNt7mKCiZ1pwU9hfKPhpdVO2sVzFQsxoVBGtOSHxlrRRzYr8iQ2TlwbAcRYCcEiZ9ECAM8kBzH0v2+VzfKw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
"node_modules/@testing-library/dom": {
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
@@ -8813,6 +8890,16 @@
"node": ">= 0.4"
}
},
+ "node_modules/highlight-words": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/highlight-words/-/highlight-words-2.0.0.tgz",
+ "integrity": "sha512-If5n+IhSBRXTScE7wl16VPmd+44Vy7kof24EdqhjsZsDuHikpv1OCagVcJFpB4fS4UPUniedlWqrjIO8vWOsIQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 20",
+ "npm": ">= 9"
+ }
+ },
"node_modules/hls.js": {
"version": "1.6.16",
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.16.tgz",
@@ -10148,6 +10235,34 @@
"node": ">=0.10.0"
}
},
+ "node_modules/material-react-table": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/material-react-table/-/material-react-table-3.2.1.tgz",
+ "integrity": "sha512-sQtTf7bETpkPN2Hm5BVtz89wrfXCVQguz6XlwMChSnfKFO5QCKAJJC5aSIKnUc3S0AvTz/k/ILi00FnnY1Gixw==",
+ "license": "MIT",
+ "dependencies": {
+ "@tanstack/match-sorter-utils": "8.19.4",
+ "@tanstack/react-table": "8.20.6",
+ "@tanstack/react-virtual": "3.11.2",
+ "highlight-words": "2.0.0"
+ },
+ "engines": {
+ "node": ">=16"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/kevinvandy"
+ },
+ "peerDependencies": {
+ "@emotion/react": ">=11.13",
+ "@emotion/styled": ">=11.13",
+ "@mui/icons-material": ">=6",
+ "@mui/material": ">=6",
+ "@mui/x-date-pickers": ">=7.15",
+ "react": ">=18.0",
+ "react-dom": ">=18.0"
+ }
+ },
"node_modules/math-expression-evaluator": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/math-expression-evaluator/-/math-expression-evaluator-1.4.0.tgz",
@@ -11531,6 +11646,12 @@
"node": "*"
}
},
+ "node_modules/remove-accents": {
+ "version": "0.5.0",
+ "resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.5.0.tgz",
+ "integrity": "sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A==",
+ "license": "MIT"
+ },
"node_modules/remove-trailing-separator": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 6c68bddb..36d07477 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -33,6 +33,7 @@
"js-yaml": "^4.3.0",
"keycloak-js": "^26.2.4",
"lucide-react": "^1.31.0",
+ "material-react-table": "^3.2.1",
"ndarray": "^1.0.19",
"react": "^18.3.1",
"react-dnd": "^16.0.1",
diff --git a/frontend/src/ApolloClient.ts b/frontend/src/ApolloClient.ts
index cbd7bddd..cbd9efa5 100644
--- a/frontend/src/ApolloClient.ts
+++ b/frontend/src/ApolloClient.ts
@@ -9,6 +9,7 @@ import { GraphQLWsLink } from "@apollo/client/link/subscriptions";
import { OperationTypeNode } from "graphql";
import { createClient } from "graphql-ws";
import { readTokenStore } from "./token";
+import { relayStylePagination } from "./pagination";
const httpLinkWorkflows = new HttpLink({
uri: import.meta.env.VITE_WORKFLOWS_GRAPHQL_HTTP_ENDPOINT,
@@ -75,3 +76,18 @@ export const apolloClientUlims = new ApolloClient({
link: authLink.concat(httpLinkUlims),
cache: new InMemoryCache(),
});
+
+export const apolloClientUlimsScans = new ApolloClient({
+ link: authLink.concat(httpLinkUlims),
+ cache: new InMemoryCache({
+ typePolicies: {
+ InstrumentSession: {
+ keyFields: ["proposal"],
+ fields: {
+ // https://www.apollographql.com/docs/react/pagination/cursor-based#relay-style-cursor-pagination
+ datasets: relayStylePagination(["reference", "filter"]), //, "pagination", ["first", "after"]]),
+ },
+ },
+ },
+ }),
+});
diff --git a/frontend/src/Layout.tsx b/frontend/src/Layout.tsx
index 8330cd58..26392f49 100644
--- a/frontend/src/Layout.tsx
+++ b/frontend/src/Layout.tsx
@@ -20,10 +20,11 @@ import { ApolloProvider } from "@apollo/client/react";
import { AppState, AppStatus, OutletContextType } from "../unified/src/types";
const MainContainer = styled(Box)({
- display: "grid",
+ display: "flex",
+ flexDirection: "column",
gap: "30px",
- flex: 1,
- alignItems: "flex-start",
+ minHeight: 0,
+ flexGrow: 1,
});
const Layout = () => {
@@ -53,42 +54,48 @@ const Layout = () => {
};
return (
- <>
+
- }
- >
-
-
- Home
-
-
- Post Processing
-
-
- Tomography
-
-
- I14
-
-
- ePSIC
-
-
+ }
+ >
+
+
+ Home
+
+
+ Post Processing
+
+
+ Tomography
+
+
+ I14
+
+
+ ePSIC
+
+
-
-
-
-
- >
+
+
+
+
}
rightSlot={
<>
@@ -97,12 +104,11 @@ const Layout = () => {
>
}
sx={{ zIndex: (theme) => theme.zIndex.drawer + 1 }}
- position="sticky"
- >
+ />
- >
+
);
};
diff --git a/frontend/src/instrument_sessions.graphql b/frontend/src/instrument_sessions.graphql
deleted file mode 100644
index a888e322..00000000
--- a/frontend/src/instrument_sessions.graphql
+++ /dev/null
@@ -1,376 +0,0 @@
-schema @link(url: "https://specs.apollo.dev/federation/v2.7", import: ["@key", "@shareable"]) {
- query: Query
- mutation: Mutation
-}
-
-type Account {
- username: String!
- emailAddress: String
- title: String
- givenName: String @deprecated(reason: "given_name is deprecated, please use preferred_given_name instead.")
- preferredGivenName: String
- familyName: String
- type: AccountType!
- state: AccountState!
- proposalRoles(first: Int = null, last: Int = null, after: String = null, before: String = null, sortBy: [ProposalRoleSortInput!] = null, filterBy: ProposalRoleFilterInput = null): ProposalAccountConnection!
- instrumentSessionRoles(first: Int = null, last: Int = null, after: String = null, before: String = null, sortBy: [InstrumentSessionRoleSortInput!] = null, filterBy: InstrumentSessionRoleFilterInput = null): InstrumentSessionRoleConnection!
-}
-
-input AccountFilterInput {
- emailAddress: StringFilterInput = null
- givenName: StringFilterInput = null
- preferredGivenName: StringFilterInput = null
- familyName: StringFilterInput = null
-}
-
-enum AccountSortField {
- familyName
- givenName
- preferredGivenName
-}
-
-input AccountSortInput {
- field: AccountSortField!
- orderBy: SortOrder!
-}
-
-enum AccountState {
- enabled
- disabled
-}
-
-enum AccountType {
- user
- staff
- functional
-}
-
-input BooleanFilterInput {
- eq: Boolean = null
- neq: Boolean = null
-}
-
-input CreateInstrumentSessionInput {
- """Number of the proposal the session is for"""
- proposalNumber: Int!
-
- """Name of the instrument the session is for"""
- instrumentName: String!
-
- """
- Instrument Session information that isn't needed by the Session Service but should be passed through the UAS
- """
- additionalInfo: JSON = null
-}
-
-"""Date with time (isoformat)"""
-scalar DateTime
-
-input DateTimeFilterInput {
- eq: DateTime = null
- neq: DateTime = null
- gt: DateTime = null
- lt: DateTime = null
- gte: DateTime = null
- lte: DateTime = null
-}
-
-type Instrument {
- name: String!
- key: String!
- scienceGroup: String
- description: String
- proposals(first: Int = null, last: Int = null, after: String = null, before: String = null, sortBy: [ProposalSortInput!] = null, filterBy: ProposalFilterInput = null): ProposalConnection!
- instrumentSessions(first: Int = null, last: Int = null, after: String = null, before: String = null, sortBy: [InstrumentSessionSortInput!] = null, filterBy: InstrumentSessionFilterInput = null): InstrumentSessionConnection!
-
- """
- Get paginated staff accounts for this instrument. Trusted users see all accounts; fedid users see only accounts linked to sessions or instruments they are associated with.
- """
- staff(first: Int = null, last: Int = null, after: String = null, before: String = null, sortBy: [AccountSortInput!] = null, filterBy: AccountFilterInput = null): StaffConnection!
-}
-
-type InstrumentSession @key(fields: "instrumentSessionNumber proposal {proposalNumber}") @key(fields: "instrumentSessionReference") {
- instrumentSessionNumber: Int!
- instrumentSessionReference: String
- startTime: DateTime
- endTime: DateTime
- type: String
- state: String
- riskRating: String
- proposal: Proposal!
- instrument: Instrument!
- roles: [InstrumentSessionRole!]!
-}
-
-type InstrumentSessionConnection @shareable {
- edges: [InstrumentSessionEdge!]!
- pageInfo: PageInfo!
-}
-
-type InstrumentSessionEdge @shareable {
- cursor: String!
- node: InstrumentSession!
-}
-
-input InstrumentSessionFilterInput {
- instrumentSessionNumber: IntFilterInput = null
- startTime: DateTimeFilterInput = null
- endTime: DateTimeFilterInput = null
- type: StringFilterInput = null
- state: InstrumentSessionStateFilterInput = null
- riskRating: StringFilterInput = null
- proposalNumber: IntFilterInput = null
- proposalCategory: StringFilterInput = null
-}
-
-type InstrumentSessionMutations @key(fields: "instrumentSessionNumber proposalNumber") {
- instrumentSessionNumber: Int!
- proposalNumber: Int!
-}
-
-type InstrumentSessionRole {
- account: Account!
- role: String!
- onSite: Boolean!
- instrumentSession(filterBy: InstrumentSessionFilterInput = null): InstrumentSession!
-}
-
-type InstrumentSessionRoleConnection @shareable {
- edges: [InstrumentSessionRoleEdge!]!
- pageInfo: PageInfo!
-}
-
-type InstrumentSessionRoleEdge @shareable {
- cursor: String!
- node: InstrumentSessionRole!
-}
-
-input InstrumentSessionRoleFilterInput {
- role: StringFilterInput = null
- onSite: BooleanFilterInput = null
-}
-
-enum InstrumentSessionRoleSortField {
- instrumentSessionNumber
- role
- onSite
-}
-
-input InstrumentSessionRoleSortInput {
- field: InstrumentSessionRoleSortField!
- orderBy: SortOrder!
-}
-
-enum InstrumentSessionSortField {
- instrumentSessionNumber
- startTime
- endTime
- type
- state
- riskRating
- proposalNumber
-}
-
-input InstrumentSessionSortInput {
- field: InstrumentSessionSortField!
- orderBy: SortOrder!
-}
-
-enum InstrumentSessionState {
- CANCELLED
- COMPLETED
- FUTURE
- IN_PROGRESS
-}
-
-input InstrumentSessionStateFilterInput {
- eq: InstrumentSessionState = null
- neq: InstrumentSessionState = null
-}
-
-input IntFilterInput {
- eq: Int = null
- neq: Int = null
- gt: Int = null
- lt: Int = null
- gte: Int = null
- lte: Int = null
-}
-
-"""
-The `JSON` scalar type represents JSON values as specified by [ECMA-404](https://ecma-international.org/wp-content/uploads/ECMA-404_2nd_edition_december_2017.pdf).
-"""
-scalar JSON @specifiedBy(url: "https://ecma-international.org/wp-content/uploads/ECMA-404_2nd_edition_december_2017.pdf")
-
-type Mutation {
- createInstrumentSession(input: CreateInstrumentSessionInput!): InstrumentSession!
- instrumentSession(proposalNumber: Int!, instrumentSessionNumber: Int!): InstrumentSessionMutations
-}
-
-type PageInfo @shareable {
- startCursor: String
- endCursor: String
- hasNextPage: Boolean!
- hasPreviousPage: Boolean!
-}
-
-type Proposal @key(fields: "proposalNumber") @key(fields: "proposalReference") {
- proposalNumber: Int!
- proposalCategory: String
- title: String
- summary: String
- state: ProposalState!
- proposalReference: String
- instrumentSessions(first: Int = null, last: Int = null, after: String = null, before: String = null, sortBy: [InstrumentSessionSortInput!] = null, filterBy: InstrumentSessionFilterInput = null): InstrumentSessionConnection!
- instruments: [Instrument!]!
- roles: [ProposalAccount!]!
-}
-
-type ProposalAccount {
- account: Account!
- role: String!
- proposal(filterBy: ProposalFilterInput = null): Proposal
-}
-
-type ProposalAccountConnection @shareable {
- edges: [ProposalAccountEdge!]!
- pageInfo: PageInfo!
-}
-
-type ProposalAccountEdge @shareable {
- cursor: String!
- node: ProposalAccount!
-}
-
-type ProposalConnection @shareable {
- edges: [ProposalEdge!]!
- pageInfo: PageInfo!
-}
-
-type ProposalEdge @shareable {
- cursor: String!
- node: Proposal!
-}
-
-input ProposalFilterInput {
- proposalNumber: IntFilterInput = null
- proposalCategory: StringFilterInput = null
- title: StringFilterInput = null
- state: ProposalStateFilterInput = null
-}
-
-input ProposalRoleFilterInput {
- role: StringFilterInput = null
-}
-
-enum ProposalRoleSortField {
- proposalNumber
- role
-}
-
-input ProposalRoleSortInput {
- field: ProposalRoleSortField!
- orderBy: SortOrder!
-}
-
-enum ProposalSortField {
- proposalNumber
-}
-
-input ProposalSortInput {
- field: ProposalSortField!
- orderBy: SortOrder!
-}
-
-enum ProposalState {
- OPEN
- CLOSED
- CANCELLED
-}
-
-input ProposalStateFilterInput {
- eq: ProposalState = null
- neq: ProposalState = null
-}
-
-type Query {
- _entities(representations: [_Any!]!): [_Entity]!
- _service: _Service!
-
- """Get a proposal by its number"""
- proposal(proposalNumber: Int!): Proposal
-
- """Get a list of proposals"""
- proposals(first: Int = null, last: Int = null, after: String = null, before: String = null, sortBy: [ProposalSortInput!] = null, filterBy: ProposalFilterInput = null): ProposalConnection!
-
- """
- Get a proposal by its reference string e.g. 'MX12345'. The lookup is case-insensitive.
- """
- proposalByReference(reference: String!): Proposal
-
- """Get a instrument session"""
- instrumentSession(proposalNumber: Int!, instrumentSessionNumber: Int!): InstrumentSession
-
- """
- Get an instrument session by its reference string e.g. 'MX12345-1'. The lookup is case-insensitive.
- """
- instrumentSessionByReference(reference: String!): InstrumentSession
-
- """Get a list of instrument sessions"""
- instrumentSessions(proposalNumber: Int = null @deprecated(reason: "proposalNumber is deprecated; use filterBy.proposalNumber instead."), proposalCategory: String = null @deprecated(reason: "proposalCategory is deprecated; use filterBy.proposalCategory instead."), first: Int = null, last: Int = null, after: String = null, before: String = null, sortBy: [InstrumentSessionSortInput!] = null, filterBy: InstrumentSessionFilterInput = null): InstrumentSessionConnection!
-
- """Get an instrument by name"""
- instrumentByName(name: String!): Instrument
-
- """Get an instrument by key"""
- instrumentByKey(key: String!): Instrument
-
- """Get a list of instruments"""
- instruments(scienceGroup: String = null): [Instrument!]!
-
- """Get an account"""
- account(username: String!): Account
-}
-
-enum SortOrder {
- ASC
- DESC
-}
-
-type Staff {
- username: String!
- emailAddress: String
- title: String
- givenName: String @deprecated(reason: "given_name is deprecated, please use preferred_given_name instead.")
- preferredGivenName: String
- familyName: String
- type: AccountType!
- state: AccountState!
-}
-
-type StaffConnection @shareable {
- edges: [StaffEdge!]!
- pageInfo: PageInfo!
-}
-
-type StaffEdge @shareable {
- cursor: String!
- node: Staff!
-}
-
-input StringFilterInput {
- eq: String = null
- neq: String = null
- contains: String = null
- notContains: String = null
- startsWith: String = null
- endsWith: String = null
-}
-
-scalar _Any
-
-union _Entity = InstrumentSession | InstrumentSessionMutations | Proposal
-
-type _Service {
- sdl: String!
-}
diff --git a/frontend/src/pagination.ts b/frontend/src/pagination.ts
new file mode 100644
index 00000000..4d13704c
--- /dev/null
+++ b/frontend/src/pagination.ts
@@ -0,0 +1,311 @@
+// From: https://raw.githubusercontent.com/apollographql/apollo-client/4be5c07a42265b91e1a427cf0710f426b57d4a59/src/utilities/policies/pagination.ts
+// relayStylePagination needs to be modified to account for the pagination args being nested under `pagination`
+
+import { __rest } from "tslib";
+
+import type { FieldPolicy, Reference } from "@apollo/client/cache";
+import { mergeDeep } from "@apollo/client/utilities/internal";
+
+type KeyArgs = FieldPolicy["keyArgs"];
+
+/**
+ * A basic pagination field policy that always concatenates new
+ * results onto the existing array, without examining options.args.
+ *
+ * @param keyArgs - `keyArgs` that should be applied to the field policy
+ * @returns The field policy that handles concatenating field results.
+ */
+export function concatPagination(
+ keyArgs: KeyArgs = false
+): FieldPolicy {
+ return {
+ keyArgs,
+ merge(existing, incoming) {
+ return existing ? [...existing, ...incoming] : incoming;
+ },
+ };
+}
+
+/**
+ * A basic field policy that uses `options.args.{offset,limit}` to splice
+ * the incoming data into the existing array. If your arguments are called
+ * something different (like `args.{start,count}`), feel free to copy/paste
+ * this implementation and make the appropriate changes.
+ *
+ * @param keyArgs - `keyArgs` that should be applied to the field policy
+ * @returns The field policy that handles offset/limit pagination
+ */
+export function offsetLimitPagination(
+ keyArgs: KeyArgs = false
+): FieldPolicy {
+ return {
+ keyArgs,
+ merge(existing, incoming, { args }) {
+ const merged = existing ? existing.slice(0) : [];
+
+ if (incoming) {
+ if (args) {
+ // Assume an offset of 0 if args.offset omitted.
+ const { offset = 0 } = args;
+ for (let i = 0; i < incoming.length; ++i) {
+ merged[offset + i] = incoming[i];
+ }
+ } else {
+ // It's unusual (probably a mistake) for a paginated field not
+ // to receive any arguments, so you might prefer to throw an
+ // exception here, instead of recovering by appending incoming
+ // onto the existing array.
+ merged.push(...incoming);
+ }
+ }
+
+ return merged;
+ },
+ };
+}
+
+// Whether TRelayEdge is a normalized Reference or a non-normalized
+// object, it needs a .cursor property where the relayStylePagination
+// merge function can store cursor strings taken from pageInfo. Storing an
+// extra reference.cursor property should be safe, and is easier than
+// attempting to update the cursor field of the normalized StoreObject
+// that the reference refers to, or managing edge wrapper objects
+// (something I attempted in #7023, but abandoned because of #7088).
+type TRelayEdge =
+ | {
+ cursor?: string;
+ node: TNode;
+ }
+ | (Reference & { cursor?: string });
+
+export type TRelayPageInfo = {
+ hasPreviousPage: boolean;
+ hasNextPage: boolean;
+ startCursor: string;
+ endCursor: string;
+};
+
+type TExistingRelay = Readonly<{
+ edges: TRelayEdge[];
+ pageInfo: TRelayPageInfo;
+}>;
+
+type TIncomingRelay = {
+ edges?: TRelayEdge[];
+ pageInfo?: TRelayPageInfo;
+};
+
+export type RelayFieldPolicy = FieldPolicy<
+ TExistingRelay | null,
+ TIncomingRelay | null,
+ TIncomingRelay | null
+>;
+
+// As proof of the flexibility of field policies, this function generates
+// one that handles Relay-style pagination, without Apollo Client knowing
+// anything about connections, edges, cursors, or pageInfo objects.
+/**
+ * A field policy that attempts to handle pagination for fields that adhere to
+ * the [Relay Connections Spec](https://relay.dev/graphql/connections.htm).
+ *
+ * @param keyArgs - `keyArgs` that should be applied to the field policy
+ * @returns The field policy that handles Relay pagination
+ */
+export function relayStylePagination(
+ keyArgs: KeyArgs = false
+): RelayFieldPolicy {
+ return {
+ keyArgs,
+
+ read(existing, { canRead, readField }) {
+ if (!existing) return existing;
+
+ const edges: TRelayEdge[] = [];
+ let firstEdgeCursor = "";
+ let lastEdgeCursor = "";
+ existing.edges.forEach((edge) => {
+ // Edges themselves could be Reference objects, so it's important
+ // to use readField to access the edge.edge.node property.
+ if (canRead(readField("node", edge))) {
+ edges.push(edge);
+ if (edge.cursor) {
+ firstEdgeCursor = firstEdgeCursor || edge.cursor || "";
+ lastEdgeCursor = edge.cursor || lastEdgeCursor;
+ }
+ }
+ });
+
+ if (edges.length > 1 && firstEdgeCursor === lastEdgeCursor) {
+ firstEdgeCursor = "";
+ }
+
+ const { startCursor, endCursor } = existing.pageInfo || {};
+
+ return {
+ // Some implementations return additional Connection fields, such
+ // as existing.totalCount. These fields are saved by the merge
+ // function, so the read function should also preserve them.
+ ...getExtras(existing),
+ edges,
+ pageInfo: {
+ ...existing.pageInfo,
+ // If existing.pageInfo.{start,end}Cursor are undefined or "", default
+ // to firstEdgeCursor and/or lastEdgeCursor.
+ startCursor: startCursor || firstEdgeCursor,
+ endCursor: endCursor || lastEdgeCursor,
+ },
+ };
+ },
+
+ merge(existing, incoming, { args, isReference, readField }) {
+ if (!existing) {
+ existing = makeEmptyData();
+ }
+
+ if (!incoming) {
+ return existing;
+ }
+
+ const incomingEdges = incoming.edges
+ ? incoming.edges.map((edge) => {
+ if (isReference((edge = { ...edge }))) {
+ // In case edge is a Reference, we read out its cursor field and
+ // store it as an extra property of the Reference object.
+ edge.cursor = readField("cursor", edge);
+ }
+ return edge;
+ })
+ : [];
+
+ if (incoming.pageInfo) {
+ const { pageInfo } = incoming;
+ const { startCursor, endCursor } = pageInfo;
+ const firstEdge = incomingEdges[0];
+ const lastEdge = incomingEdges[incomingEdges.length - 1];
+ // In case we did not request the cursor field for edges in this
+ // query, we can still infer cursors from pageInfo.
+ if (firstEdge && startCursor) {
+ firstEdge.cursor = startCursor;
+ }
+ if (lastEdge && endCursor) {
+ lastEdge.cursor = endCursor;
+ }
+ // Cursors can also come from edges, so we default
+ // pageInfo.{start,end}Cursor to {first,last}Edge.cursor.
+ const firstCursor = firstEdge && firstEdge.cursor;
+ if (firstCursor && !startCursor) {
+ incoming = mergeDeep(incoming, {
+ pageInfo: {
+ startCursor: firstCursor,
+ },
+ });
+ }
+ const lastCursor = lastEdge && lastEdge.cursor;
+ if (lastCursor && !endCursor) {
+ incoming = mergeDeep(incoming, {
+ pageInfo: {
+ endCursor: lastCursor,
+ },
+ });
+ }
+ }
+
+ let prefix = existing.edges;
+ let suffix: typeof prefix = [];
+
+ if (args && args.pagination.after) {
+ // Change args.after to args.pagination.after
+ // This comparison does not need to use readField("cursor", edge),
+ // because we stored the cursor field of any Reference edges as an
+ // extra property of the Reference object.
+ const index = prefix.findIndex(
+ (edge) => edge.cursor === args.pagination.after
+ ); // Change args.after to args.pagination.after
+ if (index >= 0) {
+ prefix = prefix.slice(0, index + 1);
+ // suffix = []; // already true
+ }
+ } else if (args && args.pagination.before) {
+ // Change args.before to args.pagination.before
+ const index = prefix.findIndex(
+ (edge) => edge.cursor === args.pagination.before
+ ); // Change args.before to args.pagination.before
+ suffix = index < 0 ? prefix : prefix.slice(index);
+ prefix = [];
+ } else if (incoming.edges) {
+ // If we have neither args.after nor args.before, the incoming
+ // edges cannot be spliced into the existing edges, so they must
+ // replace the existing edges. See #6592 for a motivating example.
+ prefix = [];
+ }
+
+ const edges = [...prefix, ...incomingEdges, ...suffix];
+
+ const pageInfo: TRelayPageInfo = {
+ // The ordering of these two ...spreads may be surprising, but it
+ // makes sense because we want to combine PageInfo properties with a
+ // preference for existing values, *unless* the existing values are
+ // overridden by the logic below, which is permitted only when the
+ // incoming page falls at the beginning or end of the data.
+ ...incoming.pageInfo,
+ ...existing.pageInfo,
+ };
+
+ if (incoming.pageInfo) {
+ const {
+ hasPreviousPage,
+ hasNextPage,
+ startCursor,
+ endCursor,
+ ...extras
+ } = incoming.pageInfo;
+
+ // If incoming.pageInfo had any extra non-standard properties,
+ // assume they should take precedence over any existing properties
+ // of the same name, regardless of where this page falls with
+ // respect to the existing data.
+ Object.assign(pageInfo, extras);
+
+ // Keep existing.pageInfo.has{Previous,Next}Page unless the
+ // placement of the incoming edges means incoming.hasPreviousPage
+ // or incoming.hasNextPage should become the new values for those
+ // properties in existing.pageInfo. Note that these updates are
+ // only permitted when the beginning or end of the incoming page
+ // coincides with the beginning or end of the existing data, as
+ // determined using prefix.length and suffix.length.
+ if (!prefix.length) {
+ if (void 0 !== hasPreviousPage)
+ pageInfo.hasPreviousPage = hasPreviousPage;
+ if (void 0 !== startCursor) pageInfo.startCursor = startCursor;
+ }
+ if (!suffix.length) {
+ if (void 0 !== hasNextPage) pageInfo.hasNextPage = hasNextPage;
+ if (void 0 !== endCursor) pageInfo.endCursor = endCursor;
+ }
+ }
+
+ return {
+ ...getExtras(existing),
+ ...getExtras(incoming),
+ edges,
+ pageInfo,
+ };
+ },
+ };
+}
+
+// Returns any unrecognized properties of the given object.
+const getExtras = (obj: Record) => __rest(obj, notExtras);
+const notExtras = ["edges", "pageInfo"];
+
+function makeEmptyData(): TExistingRelay {
+ return {
+ edges: [],
+ pageInfo: {
+ hasPreviousPage: false,
+ hasNextPage: true,
+ startCursor: "",
+ endCursor: "",
+ },
+ };
+}
diff --git a/frontend/src/ulims_dev.graphql b/frontend/src/ulims_dev.graphql
new file mode 100644
index 00000000..38c5d545
--- /dev/null
+++ b/frontend/src/ulims_dev.graphql
@@ -0,0 +1,1902 @@
+# Obtained with:
+# `npx get-graphql-schema http://ulims-dev.diamond.ac.uk/graphql > ulims_dev.graphql`
+
+"""Exposes a URL that specifies the behavior of this scalar"""
+directive @specifiedBy(
+ """The URL that specifies the behavior of this scalar."""
+ url: String!
+) on SCALAR
+
+"""
+The @oneOf built-in directive marks an input object as a OneOf Input Object.
+Exactly one field must be provided and its value must be non-null at runtime.
+All fields defined within a @oneOf input must be nullable in the schema.
+"""
+directive @oneOf on INPUT_OBJECT
+
+"""
+Directs the executor to defer this fragment when the if argument is true or undefined.
+"""
+directive @defer(
+ """A unique identifier for the results."""
+ label: String
+
+ """
+ Controls whether the fragment will be deferred, usually via a variable.
+ """
+ if: Boolean! = true
+) on FRAGMENT_SPREAD | INLINE_FRAGMENT
+
+type Account {
+ username: String!
+ emailAddress: String
+ title: String
+ givenName: String @deprecated(reason: "given_name is deprecated, please use preferred_given_name instead.")
+ preferredGivenName: String
+ familyName: String
+ type: AccountType!
+ state: AccountState!
+ proposalRoles(first: Int = null, last: Int = null, after: String = null, before: String = null, sortBy: [ProposalRoleSortInput!] = null, filterBy: ProposalRoleFilterInput = null): ProposalAccountConnection!
+ instrumentSessionRoles(first: Int = null, last: Int = null, after: String = null, before: String = null, sortBy: [InstrumentSessionRoleSortInput!] = null, filterBy: InstrumentSessionRoleFilterInput = null): InstrumentSessionRoleConnection!
+}
+
+input AccountFilterInput {
+ emailAddress: StringFilterInput = null
+ givenName: StringFilterInput = null
+ preferredGivenName: StringFilterInput = null
+ familyName: StringFilterInput = null
+}
+
+enum AccountSortField {
+ familyName
+ givenName
+ preferredGivenName
+}
+
+input AccountSortInput {
+ field: AccountSortField!
+ orderBy: SortOrder!
+}
+
+enum AccountState {
+ enabled
+ disabled
+}
+
+enum AccountType {
+ user
+ staff
+ functional
+}
+
+"""Values required to set a container's children"""
+input AddContainersToContainerInput {
+ containerPositions: [ChildContainerPositionInput!]!
+}
+
+type AddContainersToContainerResponse {
+ success: Boolean!
+ containerPositions: [ContainerPosition!]
+ errors: [String!]!
+}
+
+input AddSampleEventInput {
+ description: String!
+}
+
+"""Values required to add samples to a container"""
+input AddSamplesToContainerInput {
+ samplePositions: [SamplePositionInput!]!
+}
+
+type AddSamplesToContainerResponse {
+ success: Boolean!
+ samplePositions: [SamplePostion!]
+ errors: [String!]!
+}
+
+input AddSampleToContainerInput {
+ containerPosition: ContainerPositionInput!
+}
+
+type AddSampleToContainerResponse {
+ success: Boolean!
+ samplePosition: SamplePostion
+ errors: [String!]!
+}
+
+"""Values required to assign a contianer to an instrument"""
+input AssignContainerToInstrumentInput {
+ """The unique key of the instrument this container should be assigned to"""
+ instrumentKey: String!
+}
+
+type AssignContainerToInstrumentResponse {
+ success: Boolean!
+ errors: [String!]!
+}
+
+"""Values required to assign a contianer to an instrument session"""
+input AssignContainerToInstrumentSessionInput {
+ """The instrument session this container should be assigned to"""
+ instrumentSession: InstrumentSessionInput!
+}
+
+type AssignContainerToInstrumentSessionResponse {
+ success: Boolean!
+ errors: [String!]!
+}
+
+"""Attachment to dataset"""
+type Attachment {
+ """Internal identifier for attachment"""
+ id: Int!
+
+ """Title of attachment"""
+ title: String
+
+ """Short summary of attachment"""
+ description: String
+
+ """Created timestamp"""
+ createdTime: DateTime!
+
+ """Modified timestamp"""
+ updatedTime: DateTime!
+
+ """Where attachment can be accessed"""
+ location: String!
+
+ """Type of attachment (could be file type)"""
+ type: String!
+ dataset: Dataset!
+}
+
+"""Values to add attachment to dataset"""
+input AttachmentCreateInput {
+ """Title of attachment"""
+ title: String
+
+ """Short summary of attachment"""
+ description: String
+
+ """
+ Where attachment can be accessed. This should be an s3 object key if attachment type is 'image'
+ """
+ location: String!
+
+ """Type of attachment (could be file type)"""
+ type: String!
+
+ """Local identifier for dataset."""
+ datasetId: Int!
+}
+
+"""Required fields to get presigned upload URL"""
+input AttachmentCreateUrlInput {
+ """Local identifier for dataset."""
+ datasetId: Int!
+
+ """Name of the attachment file"""
+ fileName: String!
+
+ """Type of attachment (could be file type)"""
+ fileType: String!
+
+ """Size of the attachemnt file (in Bytes)"""
+ fileSize: Int!
+}
+
+"""Values to update an existing attachment"""
+input AttachmentUpdateInput {
+ """Title of attachment"""
+ title: String
+
+ """Short summary of attachment"""
+ description: String
+}
+
+input BooleanFilterInput {
+ eq: Boolean = null
+ neq: Boolean = null
+}
+
+"""Values required to specify a container's position within a container"""
+input ChildContainerPositionInput {
+ childContainerId: UUID!
+ position: Int = null
+}
+
+"""A container that can store samples and/or other containers"""
+type Container {
+ """The unique identifier of this container"""
+ id: UUID!
+
+ """When the container was created in the service"""
+ createdTime: DateTime!
+
+ """When the container was last updated in the service"""
+ updatedTime: DateTime!
+
+ """The name of the container"""
+ name: String!
+
+ """The type of this container"""
+ type: ContainerType!
+
+ """The manufacturer's serial number of the container"""
+ serialNumber: String
+
+ """The facility barcode for the container"""
+ barcode: String
+
+ """Whether or not the container has been marked as discarded"""
+ deleted: Boolean!
+
+ """The user that created the container"""
+ createdBy: String!
+
+ """The user that last modified the container"""
+ modifiedBy: String!
+
+ """
+ The instrument sessions that this container is currently associated with
+ """
+ instrumentSessions: [InstrumentSession!]!
+
+ """Sample positions within this container"""
+ samplePositions: [SamplePostion!]!
+
+ """The top-level fixed location where this container is stored"""
+ location: Container
+ parent: Container
+
+ """The container position occupied by this container"""
+ positionInParent: ContainerPosition
+ children: [Container!]!
+
+ """Container positions within this container"""
+ containerPositions: [ContainerPosition!]!
+}
+
+type ContainerEdge {
+ cursor: String!
+ node: Container!
+}
+
+"""Values required to filter a list of containers"""
+input ContainerFilterInput {
+ """Filter using the id of the container's parent"""
+ parentId: UUIDFilterInputV2 = null
+
+ """Filter using the of the container's children"""
+ childId: UUIDFilterInputV2 = null
+
+ """Include/exclude 'deleted' containers"""
+ includeDeleted: Boolean! = false
+
+ """Filter to containers with/without child containers"""
+ hasChildren: Boolean = null
+
+ """Filter to containers with/without a parent"""
+ hasParent: Boolean = null
+
+ """Filter to the name of the container"""
+ name: StringFilterInputV2 = null
+
+ """Filter to the barcode of the container"""
+ serialNumber: StringFilterInputV2 = null
+
+ """Filter to the serial number of the container"""
+ barcode: StringFilterInputV2 = null
+
+ """Filter to the created time of the container"""
+ createdTime: DatetimeFilterInputV2 = null
+
+ """Filter to the updated time of the container"""
+ updatedTime: DatetimeFilterInputV2 = null
+}
+
+type ContainerMutations {
+ """Update this container"""
+ updateContainer(input: UpdateContainerInput!): UpdateContainerResponse!
+
+ """Add containers to this container"""
+ addContainersToContainer(input: AddContainersToContainerInput!): AddContainersToContainerResponse!
+
+ """Remove containers from this container"""
+ removeContainersFromContainer(input: RemoveContainersFromContainerInput!): RemoveContainersFromContainerResponse!
+
+ """Set the container that this container is contained within"""
+ setParentContainer(input: SetParentContainerInput!): SetParentContainerResponse!
+
+ """Add samples to this container"""
+ addSamplesToContainer(input: AddSamplesToContainerInput!): AddSamplesToContainerResponse!
+
+ """Remove samples from this container"""
+ removeSamplesFromContainer(input: RemoveSamplesFromContainerInput!): RemoveSamplesFromContainerResponse!
+
+ """Associate this container with an instrument session"""
+ assignContainerToInstrumentSession(input: AssignContainerToInstrumentSessionInput!): AssignContainerToInstrumentSessionResponse!
+
+ """Unassign this container from an instrument session"""
+ unassignContainerFromInstrumentSession(input: UnassignContainerFromInstrumentSessionInput!): UnassignContainerFromInstrumentSessionResponse!
+
+ """Associate this container with an instrument"""
+ assignContainerToInstrument(input: AssignContainerToInstrumentInput!): AssignContainerToInstrumentResponse!
+
+ """Unassign this container from an instrument"""
+ unassignContainerFromInstrument(input: UnassignContainerFromInstrumentInput!): UnassignContainerFromInstrumentResponse!
+}
+
+enum ContainerOrderByField {
+ name
+ barcode
+ serialNumber
+ createdTime
+ updatedTime
+}
+
+input ContainerOrderByInput {
+ field: ContainerOrderByField!
+ direction: OrderByDirection!
+}
+
+type ContainerPosition {
+ position: Int
+ container: Container
+}
+
+"""Values required to specify a position within a container"""
+input ContainerPositionInput {
+ parentContainerId: UUID!
+ position: Int = null
+}
+
+type ContainerPublicConnection {
+ edges: [ContainerEdge!]!
+ pageInfo: PageInfo!
+}
+
+"""A type of container"""
+type ContainerType {
+ """The unique name of the container type"""
+ name: String!
+
+ """A description of this type of container"""
+ description: String!
+
+ """The number of containers the container can hold"""
+ numberOfContainerPositions: Int
+
+ """The number of samples the container can hold"""
+ numberOfSamplePositions: Int
+
+ """Used to distingush storage locations from moveable containers"""
+ isFixedLocation: Boolean!
+
+ """The user that created the container type"""
+ createdBy: String!
+
+ """The user user that last modified the container type"""
+ modifiedBy: String!
+}
+
+type ContainerTypeEdge {
+ cursor: String!
+ node: ContainerType!
+}
+
+type ContainerTypeMutations {
+ """Update this container type"""
+ updateContainerType(input: UpdateContainerTypeInput!): UpdateContainerTypeResponse!
+}
+
+type ContainerTypePublicConnection {
+ edges: [ContainerTypeEdge!]!
+ pageInfo: PageInfo!
+}
+
+"""Values required to create a container"""
+input CreateContainerInput {
+ """The unique key of an instrument associated with this container"""
+ instrumentKey: String = null
+
+ """An instrument session associated with this container"""
+ instrumentSession: InstrumentSessionInput = null
+
+ """The name of the type of this container"""
+ type: String!
+
+ """The name of the container"""
+ name: String!
+
+ """The serial number of this container"""
+ serialNumber: String = null
+
+ """The facility barcode of the container"""
+ barcode: String = null
+}
+
+type CreateContainerResponse {
+ success: Boolean!
+ container: Container
+ errors: [String!]!
+}
+
+"""Values required to create a container type"""
+input CreateContainerTypeInput {
+ """The unique key of an instrument associated with this container type"""
+ instrumentKey: String!
+
+ """The unique name of this container type"""
+ name: String!
+
+ """A description of this container type"""
+ description: String!
+
+ """Whether or not this container type is a fixed storage location"""
+ isFixedLocation: Boolean!
+
+ """The number of container positions within this container"""
+ numberOfContainerPositions: Int = 0
+
+ """The number of sample positions within this container"""
+ numberOfSamplePositions: Int = 0
+}
+
+type CreateContainerTypeResponse {
+ success: Boolean!
+ containerType: ContainerType
+ errors: [String!]!
+}
+
+"""Values required to create an experiment definition"""
+input CreateExperimentDefinitionInput {
+ name: String!
+ data: JSON!
+ dataSchemaUrl: String!
+ proposalNumber: Int!
+ instrumentSessionNumber: Int!
+}
+
+"""Return type when creating an experiment definition"""
+type CreateExperimentDefinitionResponse {
+ """Whether the operation has succeeded without validation errors"""
+ success: Boolean!
+
+ """Experiment Definition that has been created"""
+ experimentDefinition: ExperimentDefinition
+
+ """Errors that occurred during validation"""
+ errors: [ExperimentDefinitionErrorDetails!]!
+}
+
+"""Values required for the createExperiments mutation"""
+input CreateExperimentsInput {
+ experiments: [ExperimentInput!]!
+}
+
+"""Return type when updating an experiment definition"""
+type CreateExperimentsResponse {
+ """Whether the operation has succeeded without errors"""
+ success: Boolean!
+
+ """Experiments created"""
+ experiments: [Experiment!]
+
+ """Errors that occurred during experiments creation"""
+ errors: [String!]!
+}
+
+input CreateInstrumentSessionInput {
+ """Number of the proposal the session is for"""
+ proposalNumber: Int!
+
+ """Name of the instrument the session is for"""
+ instrumentName: String!
+
+ """
+ Instrument Session information that isn't needed by the Session Service but should be passed through the UAS
+ """
+ additionalInfo: JSON = null
+}
+
+input CreateOrValidateSampleInput {
+ """URL of the JSON schema the samples' `data` should be validated against"""
+ dataSchemaUrl: String!
+
+ """Samples to be created"""
+ samples: [SampleIn!]!
+
+ """
+ Whether or not the provided samples should only be validated and not created
+ """
+ validateOnly: Boolean! = false
+
+ """Number of the proposal the samples should be associated with"""
+ proposalNumber: Int!
+
+ """Number of the instrument session the samples should be associated with"""
+ instrumentSessionNumber: Int!
+}
+
+input CreateOrValidateSampleInputBase {
+ """URL of the JSON schema the samples' `data` should be validated against"""
+ dataSchemaUrl: String!
+
+ """Samples to be created"""
+ samples: [SampleIn!]!
+
+ """
+ Whether or not the provided samples should only be validated and not created
+ """
+ validateOnly: Boolean! = false
+}
+
+"""Return type when creating or validating samples"""
+type CreateSamplesResponse {
+ """Whether the operation has succeeded without validation errors"""
+ success: Boolean!
+
+ """Samples that have been created"""
+ samples: [Sample!]!
+
+ """Errors that occurred during sample validation"""
+ errors: [SampleValidationError!]!
+}
+
+"""Dataset information"""
+type Dataset {
+ """Local identifier for dataset."""
+ id: Int!
+
+ """Title of dataset"""
+ title: String!
+
+ """Short description about dataset"""
+ description: String
+
+ """Created timestamp"""
+ createdTime: DateTime!
+
+ """Modified timestamp"""
+ updatedTime: DateTime!
+
+ """Type of dataset (raw or derived)"""
+ type: DatasetTypeEnum!
+
+ """Data about the dataset"""
+ data: JSON!
+
+ """JSON schema used to validate data"""
+ dataSchema: String!
+
+ """Scan that produced this dataset"""
+ scanNumber: Int
+ attachments: [Attachment!]!
+ tags: [String!]!
+ groupsIds: [UUID!]!
+
+ """The instrument sessions that this dataset is associated with"""
+ instrumentSession: InstrumentSession!
+}
+
+type DatasetConnection {
+ edges: [DatasetEdge!]!
+ pageInfo: PageInfo!
+ totalCount: Int!
+}
+
+"""Values to create a new dataset"""
+input DatasetCreateInput {
+ """Title of dataset"""
+ title: String!
+
+ """Short description about dataset"""
+ description: String = null
+
+ """Type of dataset (raw or derived)"""
+ type: DatasetTypeEnum!
+
+ """Data about the dataset"""
+ data: JSON!
+
+ """JSON schema used to validate data"""
+ dataSchema: String!
+
+ """Proposal number (often 5 digits)"""
+ proposalNumber: Int!
+
+ """
+ Session dataset or dataset group was created (often single digit appended to proposal)
+ """
+ instrumentSessionNumber: Int!
+
+ """Scan that produced this dataset"""
+ scanNumber: Int = null
+
+ """Group name"""
+ datasetGroupName: String = null
+}
+
+type DatasetEdge {
+ node: Dataset!
+ cursor: String!
+}
+
+input DatasetFilterInput {
+ """Filter on the `name` field of `Dataset`"""
+ title: StringOperatorInput = null
+
+ """Filter on the `data` field of `Dataset`"""
+ data: [JSONOperatorInput!] = null
+
+ """Filter on tags"""
+ tags: TagStringArrayOperatorInput = null
+}
+
+"""Dataset Group information"""
+type DatasetGroup {
+ """Universally Unique Identifier"""
+ id: UUID!
+
+ """Name of the dataset group"""
+ name: String!
+
+ """Created timestamp"""
+ createdTime: DateTime!
+
+ """Modified timestamp"""
+ updatedTime: DateTime!
+
+ """Proposal number (often 5 digits)"""
+ proposalNumber: Int!
+
+ """
+ Session dataset or dataset group was created (often single digit appended to proposal)
+ """
+ instrumentSessionNumber: Int!
+}
+
+enum DatasetTypeEnum {
+ """A raw scan without processing"""
+ RAW
+
+ """The result of a processed scan"""
+ DERIVED
+}
+
+"""Values to update an existing dataset"""
+input DatasetUpdateInput {
+ """Title of dataset"""
+ title: String
+
+ """Short description about dataset"""
+ description: String
+}
+
+"""Date with time (isoformat)"""
+scalar DateTime
+
+input DateTimeFilterInput {
+ eq: DateTime = null
+ neq: DateTime = null
+ gt: DateTime = null
+ lt: DateTime = null
+ gte: DateTime = null
+ lte: DateTime = null
+}
+
+input DatetimeFilterInputV2 {
+ """
+ Will filter to items where the `DateTime` field is greater than (i.e. after) the provided value
+ """
+ greaterThan: DateTime = null
+
+ """
+ Will filter to items where the `DateTime` field is less than (i.e. before) the provided value
+ """
+ lessThan: DateTime = null
+}
+
+input DatetimeOperatorInput {
+ """
+ Will filter to items where the `DateTime` field is greater than (i.e. after) the provided value
+ """
+ gt: DateTime = null
+
+ """
+ Will filter to items where the `DateTime` field is less than (i.e. before) the provided value
+ """
+ lt: DateTime = null
+}
+
+"""Return type when marking an record as deleted"""
+type DeletedExperimentDefinitionResponse {
+ """Whether the operation has succeeded without validation errors"""
+ success: Boolean!
+
+ """Errors that occurred during validation"""
+ errors: [DeleteErrorDetails!]!
+}
+
+"""The details of an deleted record validation error"""
+type DeleteErrorDetails {
+ """The type of error that occurred"""
+ type: String!
+
+ """
+ Tuple of strings identifying where in the record schema the error occurred.
+ """
+ location: [String!]!
+
+ """A human readable error message."""
+ message: String!
+}
+
+"""Return type when marking an record as deleted"""
+type DeleteExperimentResponse {
+ """Whether the operation has succeeded without validation errors"""
+ success: Boolean!
+
+ """Errors that occurred during validation"""
+ errors: [DeleteErrorDetails!]!
+}
+
+"""Return type when deleting a sample"""
+type DeleteSampleResponse {
+ """Whether the operation has succeeded"""
+ success: Boolean!
+
+ """Errors that occurred during the operation"""
+ errors: [String!]!
+}
+
+type EraSampleDefinition {
+ externalId: String!
+ acronym: String
+ materialOrProtein: String!
+ riskRating: String!
+}
+
+type EraSampleDefinitionConnection {
+ edges: [EraSampleDefinitionEdge!]!
+ pageInfo: PageInfo!
+}
+
+type EraSampleDefinitionEdge {
+ cursor: String!
+ node: EraSampleDefinition!
+}
+
+input EraSampleDefinitionFilterInput {
+ acronym: StringFilterInput = null
+ materialOrProtein: StringFilterInput = null
+ riskRating: SampleRiskRatingFilterInput = null
+}
+
+input EraSampleDefinitionSortInput {
+ field: SampleSortField!
+ orderBy: SortOrder!
+}
+
+"""The details of sample validation error"""
+type ErrorDetails {
+ """The type of error that occurred"""
+ type: String!
+
+ """
+ Tuple of strings identifying where in the sample schema the error occurred.
+ """
+ location: [String!]!
+
+ """A human readable error message."""
+ message: String!
+}
+
+type Experiment {
+ id: UUID!
+ name: String!
+ createdTime: DateTime!
+ updatedTime: DateTime!
+ deleted: Boolean!
+ experimentDefinition: ExperimentDefinition!
+ createdBy: String!
+ modifiedBy: String!
+
+ """The sample that this experiment is associated with"""
+ sample: Sample!
+ estimatedRuntime: Int
+}
+
+type ExperimentConnection {
+ edges: [ExperimentEdge!]!
+ pageInfo: PageInfo!
+}
+
+type ExperimentDefinition {
+ id: UUID!
+ name: String!
+ createdTime: DateTime!
+ updatedTime: DateTime!
+ data: JSON!
+ dataSchemaUrl: String!
+ proposalNumber: Int!
+ instrumentSessionNumber: Int!
+ deleted: Boolean!
+ createdBy: String!
+ modifiedBy: String!
+
+ """
+ The instrument session that this experiment definition is associated with
+ """
+ instrumentSession: InstrumentSession!
+
+ """Experiments associated with this experiment definition"""
+ experiments: [Experiment!]!
+}
+
+type ExperimentDefinitionConnection {
+ edges: [ExperimentDefinitionEdge!]!
+ pageInfo: PageInfo!
+}
+
+type ExperimentDefinitionEdge {
+ cursor: String!
+ node: ExperimentDefinition!
+}
+
+"""The details of an experiment definition validation error"""
+type ExperimentDefinitionErrorDetails {
+ """The type of error that occurred"""
+ type: String!
+
+ """
+ Tuple of strings identifying where in the experiment definition schema the error occurred.
+ """
+ location: [String!]!
+
+ """A human readable error message."""
+ message: String!
+}
+
+"""Values required to filter a list of experiment definitions"""
+input ExperimentDefinitionFilterInput {
+ """Filter by name"""
+ name: StringFilterInputV2 = null
+
+ """Filter by created datetime"""
+ createdTime: DatetimeFilterInputV2 = null
+
+ """Filter by update datetime"""
+ updatedTime: DatetimeFilterInputV2 = null
+
+ """Filter by user that created"""
+ createdBy: StringFilterInputV2 = null
+
+ """Filter by last user to modify"""
+ modifiedBy: StringFilterInputV2 = null
+
+ """Filter by data schema URL"""
+ dataSchemaUrl: StringFilterInputV2 = null
+
+ """Filter within the JSON data"""
+ data: [JSONFilterInputV2!] = null
+
+ """Include/exclude 'deleted' items"""
+ includeDeleted: Boolean! = false
+}
+
+"""Mutations for a given experiment defintion"""
+type ExperimentDefinitionMutations {
+ updateExperimentDefinition(input: UpdateExperimentDefinitionInput!): UpdateExperimentDefinitionResponse!
+ createExperiments(input: CreateExperimentsInput!): CreateExperimentsResponse!
+}
+
+enum ExperimentDefinitionOrderByField {
+ name
+ createdTime
+ updatedTime
+ createdBy
+ modifiedBy
+}
+
+input ExperimentDefinitionOrderByInput {
+ field: ExperimentDefinitionOrderByField!
+ direction: OrderByDirection!
+}
+
+type ExperimentDefinitionPublicConnection {
+ edges: [ExperimentDefinitionEdge!]!
+ pageInfo: PageInfo!
+}
+
+type ExperimentEdge {
+ cursor: String!
+ node: Experiment!
+}
+
+"""Values required to filter a list of experiments"""
+input ExperimentFilterInput {
+ """Filter by name"""
+ name: StringFilterInputV2 = null
+
+ """Filter by created datetime"""
+ createdTime: DatetimeFilterInputV2 = null
+
+ """Filter by update datetime"""
+ updatedTime: DatetimeFilterInputV2 = null
+
+ """Filter by user that created"""
+ createdBy: StringFilterInputV2 = null
+
+ """Filter by last user to modify"""
+ modifiedBy: StringFilterInputV2 = null
+
+ """Include/exclude 'deleted' items"""
+ includeDeleted: Boolean! = false
+}
+
+"""Values required to create an experiment"""
+input ExperimentInput {
+ name: String!
+ sampleId: UUID!
+}
+
+"""Mutations for a given experiment"""
+type ExperimentMutations {
+ updateExperiment(input: UpdateExperimentInput!): UpdateExperimentResponse!
+}
+
+enum ExperimentOrderByField {
+ name
+ createdTime
+ updatedTime
+}
+
+input ExperimentOrderByInput {
+ field: ExperimentOrderByField!
+ direction: OrderByDirection!
+}
+
+type ExperimentPublicConnection {
+ edges: [ExperimentEdge!]!
+ pageInfo: PageInfo!
+}
+
+type Instrument {
+ name: String!
+ key: String!
+ scienceGroup: String
+ description: String
+ proposals(first: Int = null, last: Int = null, after: String = null, before: String = null, sortBy: [ProposalSortInput!] = null, filterBy: ProposalFilterInput = null): ProposalConnection!
+ instrumentSessions(first: Int = null, last: Int = null, after: String = null, before: String = null, sortBy: [InstrumentSessionSortInput!] = null, filterBy: InstrumentSessionFilterInput = null): InstrumentSessionConnection!
+
+ """
+ Get paginated staff accounts for this instrument. Trusted users see all
+ accounts; fedid users see only accounts linked to sessions or instruments they
+ are associated with.
+ """
+ staff(first: Int = null, last: Int = null, after: String = null, before: String = null, sortBy: [AccountSortInput!] = null, filterBy: AccountFilterInput = null): StaffConnection!
+}
+
+type InstrumentSession {
+ instrumentSessionNumber: Int!
+ proposal: Proposal
+
+ """Samples associated with a given instrument session"""
+ samples(first: Int = null, filter: SampleFilterInput! = {schemaUrl: null, createdTime: null, updatedTime: null, name: null, data: null, includeDeleted: false}, before: String = null, after: String = null, last: Int = null, orderBy: SampleOrder! = {name: null, createdTime: null, updatedTime: null}): SampleConnection!
+ instrumentSessionReference: String
+ startTime: DateTime
+ endTime: DateTime
+ type: String
+ state: String
+ riskRating: String
+ instrument: Instrument!
+ roles: [InstrumentSessionRole!]!
+ eraSampleDefinitions(first: Int = null, last: Int = null, after: String = null, before: String = null, sortBy: [EraSampleDefinitionSortInput!] = null, filterBy: EraSampleDefinitionFilterInput = null): EraSampleDefinitionConnection!
+
+ """Datasets associated with a given instrument session"""
+ datasets(pagination: PaginationInput = null, filter: DatasetFilterInput = null): DatasetConnection!
+
+ """Experiment Definitions"""
+ experimentDefinitions(filter: ExperimentDefinitionFilterInput = null, orderBy: [ExperimentDefinitionOrderByInput!] = null, first: Int = null, last: Int = null, after: String = null, before: String = null): ExperimentDefinitionConnection!
+
+ """Experiments associated with this session"""
+ experiments(filter: ExperimentFilterInput = null, orderBy: [ExperimentOrderByInput!] = null, first: Int = null, last: Int = null, after: String = null, before: String = null): ExperimentConnection!
+
+ """The status of planned experiments on this session"""
+ experimentStatus: InstrumentSessionExperimentStatus!
+}
+
+type InstrumentSessionConnection {
+ edges: [InstrumentSessionEdge!]!
+ pageInfo: PageInfo!
+}
+
+type InstrumentSessionEdge {
+ cursor: String!
+ node: InstrumentSession!
+}
+
+type InstrumentSessionExperimentStatus {
+ status: String!
+ createdBy: String!
+ modifiedBy: String!
+}
+
+enum InstrumentSessionExperimentStatusInput {
+ open
+ submitted
+}
+
+input InstrumentSessionFilterInput {
+ instrumentSessionNumber: IntFilterInput = null
+ startTime: DateTimeFilterInput = null
+ endTime: DateTimeFilterInput = null
+ type: StringFilterInput = null
+ state: InstrumentSessionStateFilterInput = null
+ riskRating: StringFilterInput = null
+ proposalNumber: IntFilterInput = null
+ proposalCategory: StringFilterInput = null
+}
+
+"""Values required to uniquely identify an instrument session"""
+input InstrumentSessionInput {
+ proposalNumber: Int!
+ instrumentSessionNumber: Int!
+}
+
+"""Values required to uniquely identify an instrument session"""
+input InstrumentSessionKey {
+ """Proposal number (often 5 digits)"""
+ proposalNumber: Int!
+
+ """
+ Session dataset or dataset group was created (often single digit appended to proposal)
+ """
+ instrumentSessionNumber: Int!
+}
+
+type InstrumentSessionMutations {
+ instrumentSessionNumber: Int!
+ proposalNumber: Int!
+
+ """Create or validate samples associated with this instrument session"""
+ createOrValidateSamples(input: CreateOrValidateSampleInputBase!): CreateSamplesResponse!
+
+ """Create or validate samples associated with this instrument session"""
+ updateInstrumentSessionExperimentStatus(input: UpdateInstrumentSessionExperimentStatusInput!): UpdateInstrumentSessionExperimentStatusResponse!
+}
+
+type InstrumentSessionRole {
+ account: Account!
+ role: String!
+ onSite: Boolean!
+ instrumentSession(filterBy: InstrumentSessionFilterInput = null): InstrumentSession!
+}
+
+type InstrumentSessionRoleConnection {
+ edges: [InstrumentSessionRoleEdge!]!
+ pageInfo: PageInfo!
+}
+
+type InstrumentSessionRoleEdge {
+ cursor: String!
+ node: InstrumentSessionRole!
+}
+
+input InstrumentSessionRoleFilterInput {
+ role: StringFilterInput = null
+ onSite: BooleanFilterInput = null
+}
+
+enum InstrumentSessionRoleSortField {
+ instrumentSessionNumber
+ role
+ onSite
+}
+
+input InstrumentSessionRoleSortInput {
+ field: InstrumentSessionRoleSortField!
+ orderBy: SortOrder!
+}
+
+enum InstrumentSessionSortField {
+ instrumentSessionNumber
+ startTime
+ endTime
+ type
+ state
+ riskRating
+ proposalNumber
+}
+
+input InstrumentSessionSortInput {
+ field: InstrumentSessionSortField!
+ orderBy: SortOrder!
+}
+
+enum InstrumentSessionState {
+ CANCELLED
+ COMPLETED
+ FUTURE
+ IN_PROGRESS
+}
+
+input InstrumentSessionStateFilterInput {
+ eq: InstrumentSessionState = null
+ neq: InstrumentSessionState = null
+}
+
+input IntFilterInput {
+ eq: Int = null
+ neq: Int = null
+ gt: Int = null
+ lt: Int = null
+ gte: Int = null
+ lte: Int = null
+}
+
+"""
+The `JSON` scalar type represents JSON values as specified by [ECMA-404](https://ecma-international.org/wp-content/uploads/ECMA-404_2nd_edition_december_2017.pdf).
+"""
+scalar JSON
+
+input JSONFilterConditionInput {
+ stringFilter: StringFilterInputV2 = null
+ datetimeFilter: DatetimeFilterInputV2 = null
+ numericFilter: NumericFilterInputV2 = null
+}
+
+input JSONFilterInputV2 {
+ """The JSON path to the field to filter. Must start with '$.'"""
+ path: String!
+
+ """The filter condition to apply to the field"""
+ condition: JSONFilterConditionInput!
+}
+
+input JSONOperator {
+ stringOperator: StringOperatorInput = null
+ datetimeOperator: DatetimeOperatorInput = null
+ numericOperator: NumericOperatorInput = null
+}
+
+input JSONOperatorInput {
+ """A JSON path specifying the value to filter. Must start with '$.'"""
+ path: String!
+
+ """The operator to apply to the JSON field"""
+ operator: JSONOperator!
+}
+
+"""A JSON schema"""
+type JSONSchema {
+ """The identifier of the schema"""
+ id: String!
+
+ """A URL from which the schema can be accessed"""
+ url: String!
+
+ """The type of object the shema describes (if known)"""
+ type: String
+
+ """The title of the schema"""
+ title: String
+
+ """The version of the schema"""
+ version: String
+
+ """The instrument the schema was created for"""
+ instrument: String
+
+ """The description of the schema"""
+ description: String
+}
+
+type Mutation {
+ createOrValidateSamples(input: CreateOrValidateSampleInput!): CreateSamplesResponse!
+ sample(sampleId: UUID!): SampleMutations
+ deleteSample(sampleId: UUID!): DeleteSampleResponse!
+ restoreSample(sampleId: UUID!): RestoreSampleResponse!
+ createInstrumentSession(input: CreateInstrumentSessionInput!): InstrumentSession!
+ instrumentSession(proposalNumber: Int!, instrumentSessionNumber: Int!): InstrumentSessionMutations
+ addDataset(createInput: DatasetCreateInput!): Dataset!
+ updateDataset(id: Int!, updateInput: DatasetUpdateInput!): Dataset
+ addAttachment(createInput: AttachmentCreateInput!): Attachment!
+ updateAttachment(id: Int!, updateInput: AttachmentUpdateInput!): Attachment
+ createAttachmentUploadUrl(createInput: [AttachmentCreateUrlInput!]!): [PresignedUrlInfo!]!
+ addTags(tagInput: TagCreateInput!): [String!]!
+ deleteDataset(id: Int!): Void
+ deleteAttachment(id: Int!): Void
+ removeTags(tagInput: TagRemoveInput!): Void
+ createExperimentDefinition(input: CreateExperimentDefinitionInput!): CreateExperimentDefinitionResponse
+ experimentDefinition(id: UUID!): ExperimentDefinitionMutations
+ experiment(id: UUID!): ExperimentMutations
+ deleteExperiment(id: UUID!): DeleteExperimentResponse
+ restoreExperiment(id: UUID!): DeleteExperimentResponse
+ deleteExperimentDefinition(id: UUID!): DeletedExperimentDefinitionResponse
+ restoreExperimentDefinition(id: UUID!): DeletedExperimentDefinitionResponse
+
+ """Create a new container type"""
+ createContainerType(input: CreateContainerTypeInput!): CreateContainerTypeResponse!
+
+ """Mutations relating to a specific container type"""
+ containerType(name: String!): ContainerTypeMutations!
+
+ """Create a new container"""
+ createContainer(input: CreateContainerInput!): CreateContainerResponse!
+
+ """Muatations relating to a specific container"""
+ container(id: UUID = null, barcode: String = null): ContainerMutations!
+}
+
+input NumericFilterInputV2 {
+ """
+ Will filter to items where the numeric field is greater than the provided value
+ """
+ greaterThan: Float = null
+
+ """
+ Will filter to items where the numeric field is less than the provided value
+ """
+ lessThan: Float = null
+}
+
+input NumericOperatorInput {
+ """
+ Will filter to items where the numeric field is greater than the provided value
+ """
+ gt: Float = null
+
+ """
+ Will filter to items where the numeric field is less than the provided value
+ """
+ lt: Float = null
+}
+
+enum OrderByDirection {
+ ASC
+ DESC
+}
+
+type PageInfo {
+ startCursor: String
+ endCursor: String
+ hasPreviousPage: Boolean!
+ hasNextPage: Boolean!
+}
+
+input PaginationInput {
+ sortBy: String = null
+ sortOrder: String = null
+ first: Int = null
+ after: String = null
+ last: Int = null
+ before: String = null
+}
+
+type PresignedUrlInfo {
+ datasetId: Int!
+ uploadUrl: String!
+ location: String!
+}
+
+type Proposal {
+ proposalNumber: Int!
+ proposalCategory: String
+ title: String
+ summary: String
+ state: ProposalState!
+ proposalReference: String
+ instrumentSessions(first: Int = null, last: Int = null, after: String = null, before: String = null, sortBy: [InstrumentSessionSortInput!] = null, filterBy: InstrumentSessionFilterInput = null): InstrumentSessionConnection!
+ instruments: [Instrument!]!
+ roles: [ProposalAccount!]! @deprecated(reason: "Replaced with proposal_roles to introduce consistency withaccount.proposal_roles and to allow for pagination, filtering and sorting. See AUTH-330 for more details")
+ proposalRoles(first: Int = null, last: Int = null, after: String = null, before: String = null, sortBy: [ProposalRoleSortInput!] = null, filterBy: ProposalRoleFilterInput = null): ProposalAccountConnection!
+ eraSampleDefinitions(first: Int = null, last: Int = null, after: String = null, before: String = null, sortBy: [EraSampleDefinitionSortInput!] = null, filterBy: EraSampleDefinitionFilterInput = null): EraSampleDefinitionConnection!
+}
+
+type ProposalAccount {
+ role: String!
+ account(filterBy: AccountFilterInput = null): Account!
+ proposal(filterBy: ProposalFilterInput = null): Proposal!
+}
+
+type ProposalAccountConnection {
+ edges: [ProposalAccountEdge!]!
+ pageInfo: PageInfo!
+}
+
+type ProposalAccountEdge {
+ cursor: String!
+ node: ProposalAccount!
+}
+
+type ProposalConnection {
+ edges: [ProposalEdge!]!
+ pageInfo: PageInfo!
+}
+
+type ProposalEdge {
+ cursor: String!
+ node: Proposal!
+}
+
+input ProposalFilterInput {
+ proposalNumber: IntFilterInput = null
+ proposalCategory: StringFilterInput = null
+ title: StringFilterInput = null
+ state: ProposalStateFilterInput = null
+}
+
+input ProposalRoleFilterInput {
+ role: StringFilterInput = null
+}
+
+enum ProposalRoleSortField {
+ proposalNumber
+ role
+ familyName
+ preferredGivenName
+}
+
+input ProposalRoleSortInput {
+ field: ProposalRoleSortField!
+ orderBy: SortOrder!
+}
+
+enum ProposalSortField {
+ proposalNumber
+}
+
+input ProposalSortInput {
+ field: ProposalSortField!
+ orderBy: SortOrder!
+}
+
+enum ProposalState {
+ OPEN
+ CLOSED
+ CANCELLED
+}
+
+input ProposalStateFilterInput {
+ eq: ProposalState = null
+ neq: ProposalState = null
+}
+
+type Query {
+ """Get a sample by its id"""
+ sample(sampleId: UUID!): Sample
+
+ """Get a list of samples associated with a given instrument session"""
+ samples(first: Int = null, instrumentSessions: [InstrumentSessionInput!] = null, filter: SampleFilterInput! = {schemaUrl: null, createdTime: null, updatedTime: null, name: null, data: null, includeDeleted: false}, before: String = null, after: String = null, last: Int = null, orderBy: SampleOrder! = {name: null, createdTime: null, updatedTime: null}): SampleConnection!
+
+ """Get a proposal by its number"""
+ proposal(proposalNumber: Int!): Proposal
+
+ """Get a list of proposals"""
+ proposals(first: Int = null, last: Int = null, after: String = null, before: String = null, sortBy: [ProposalSortInput!] = null, filterBy: ProposalFilterInput = null): ProposalConnection!
+
+ """
+ Get a proposal by its reference string e.g. 'MX12345'. The lookup is case-insensitive.
+ """
+ proposalByReference(reference: String!): Proposal
+
+ """Get a instrument session"""
+ instrumentSession(proposalNumber: Int!, instrumentSessionNumber: Int!): InstrumentSession
+
+ """
+ Get an instrument session by its reference string e.g. 'MX12345-1'. The lookup is case-insensitive.
+ """
+ instrumentSessionByReference(reference: String!): InstrumentSession
+
+ """Get a list of instrument sessions"""
+ instrumentSessions(first: Int = null, last: Int = null, after: String = null, before: String = null, sortBy: [InstrumentSessionSortInput!] = null, filterBy: InstrumentSessionFilterInput = null): InstrumentSessionConnection!
+
+ """Get an instrument by name"""
+ instrumentByName(name: String!): Instrument
+
+ """Get an instrument by key"""
+ instrumentByKey(key: String!): Instrument
+
+ """Get a list of instruments"""
+ instruments(scienceGroup: String = null): [Instrument!]!
+
+ """Get an account"""
+ account(username: String!): Account
+ dataset(id: Int!): Dataset
+ datasets(instrumentSessions: [InstrumentSessionKey!]!, pagination: PaginationInput = null, filter: DatasetFilterInput = null): DatasetConnection!
+ attachment(id: Int!): Attachment
+ tags: [String!]!
+ datasetGroup(proposalNumber: Int!, instrumentSessionNumber: Int!, id: UUID = null, name: String = null): DatasetGroup
+ jsonSchema(url: String!): JSONSchema
+ jsonSchemas(type: String = null, instrument: String = null): [JSONSchema!]!
+ experimentDefinition(id: UUID!): ExperimentDefinition
+ experimentDefinitions(instrumentSessions: [InstrumentSessionInput!]!, filter: ExperimentDefinitionFilterInput = null, orderBy: [ExperimentDefinitionOrderByInput!] = null, first: Int = null, last: Int = null, after: String = null, before: String = null): ExperimentDefinitionPublicConnection
+ experiment(id: UUID!): Experiment
+ experiments(instrumentSessions: [InstrumentSessionInput!]!, filter: ExperimentFilterInput = null, orderBy: [ExperimentOrderByInput!] = null, first: Int = null, last: Int = null, after: String = null, before: String = null): ExperimentPublicConnection
+
+ """Fetch a single container type, by specifying its name"""
+ containerType(name: String!): ContainerType
+
+ """Fetch multiple container types, by specifying associated instruments"""
+ containerTypes(instrumentKeys: [String!]!, first: Int = 20, last: Int = null, before: String = null, after: String = null): ContainerTypePublicConnection!
+
+ """Fetch a single container, by specifying either its id or barcode"""
+ container(id: UUID = null, barcode: String = null): Container
+
+ """
+ Fetch multiple containers, by specifying either its parent, a child, an
+ associated instrument, or associated instrument sessions
+ """
+ containers(instrumentSessions: [InstrumentSessionInput!] = null, instrumentKeys: [String!] = null, filter: ContainerFilterInput = null, orderBy: [ContainerOrderByInput!] = null, first: Int = 20, last: Int = null, before: String = null, after: String = null): ContainerPublicConnection!
+ health: String!
+}
+
+"""Values required to remove containers from a container"""
+input RemoveContainersFromContainerInput {
+ containerIds: [UUID!]!
+}
+
+type RemoveContainersFromContainerResponse {
+ success: Boolean!
+}
+
+"""Values required to remove samples from a container"""
+input RemoveSamplesFromContainerInput {
+ sampleIds: [UUID!]!
+}
+
+type RemoveSamplesFromContainerResponse {
+ success: Boolean!
+}
+
+"""Return type when restoring a deleted sample"""
+type RestoreSampleResponse {
+ """Whether the operation has succeeded"""
+ success: Boolean!
+
+ """Errors that occurred during the operation"""
+ errors: [String!]!
+}
+
+type Sample {
+ id: UUID!
+ name: String!
+ data: JSON!
+ createdTime: DateTime!
+ updatedTime: DateTime!
+ dataSchemaUrl: String!
+ deleted: Boolean!
+ createdBy: String!
+ modifiedBy: String!
+
+ """Samples from which this sample is derived"""
+ parents(first: Int = null, before: String = null, after: String = null, last: Int = null): SampleConnection!
+
+ """Samples derived from this sample"""
+ children(first: Int = null, before: String = null, after: String = null, last: Int = null): SampleConnection!
+
+ """Events linked to this sample"""
+ events(first: Int = null, before: String = null, after: String = null, last: Int = null): SampleEventConnection!
+
+ """The JSON schema that the sample's `data` conforms to"""
+ dataSchema: JSON!
+
+ """The instrument sessions that this sample is associated with"""
+ instrumentSessions: [InstrumentSession!]!
+ images: [SampleImage!]!
+ experiments: [Experiment!]!
+
+ """The container containing the sample"""
+ container: Container
+
+ """The sample's position within a container"""
+ positionInContainer: ContainerPosition
+}
+
+type SampleConnection {
+ edges: [SampleEdge!]!
+ pageInfo: PageInfo!
+}
+
+type SampleEdge {
+ cursor: String!
+ node: Sample!
+}
+
+type SampleEvent {
+ """Dummy field to enable public graph schema composition"""
+ _: Int!
+ id: UUID!
+ timestamp: DateTime!
+ description: String!
+}
+
+type SampleEventConnection {
+ edges: [SampleEventEdge!]!
+ pageInfo: PageInfo!
+}
+
+type SampleEventEdge {
+ cursor: String!
+ node: SampleEvent!
+}
+
+input SampleFilterInput {
+ """Filter on the `schemaUrl` field of `Sample`"""
+ schemaUrl: StringOperatorInput = null
+
+ """Filter on the `createdTime` field of `Sample`"""
+ createdTime: DatetimeOperatorInput = null
+
+ """Filter on the `createdTime` field of `Sample`"""
+ updatedTime: DatetimeOperatorInput = null
+
+ """Filter on the `name` field of `Sample`"""
+ name: StringOperatorInput = null
+
+ """Filter on the `data` field of `Sample`"""
+ data: [JSONOperatorInput!] = null
+
+ """Include/exclude 'deleted' samples"""
+ includeDeleted: Boolean! = false
+}
+
+type SampleImage {
+ url: String!
+ filename: String!
+}
+
+input SampleIn {
+ """Name of the sample"""
+ name: String!
+
+ """Data of the sample"""
+ data: JSON!
+}
+
+type SampleMutations {
+ sampleId: UUID!
+ updateSample(input: UpdateSampleInput!): UpdateSampleResponse!
+ linkInstrumentSessionToSample(proposalNumber: Int!, instrumentSessionNumber: Int!): Void
+ addSampleEvent(sampleEvent: AddSampleEventInput!): SampleEvent!
+ createSampleImageUploadUrl(filename: String!, contentType: String!, contentLength: Int!): String!
+
+ """Assign this sample to a container"""
+ addSampleToContainer(input: AddSampleToContainerInput!): AddSampleToContainerResponse!
+}
+
+input SampleOrder {
+ name: SortingOrder = null
+ createdTime: SortingOrder = null
+ updatedTime: SortingOrder = null
+}
+
+"""Values required to specify a sample's position within a container"""
+input SamplePositionInput {
+ sampleId: UUID!
+ position: Int = null
+}
+
+type SamplePostion {
+ position: Int
+ sample: Sample
+}
+
+enum SampleRiskRating {
+ LOW
+ MEDIUM
+ HIGH
+ NOT_PERMITTED
+}
+
+input SampleRiskRatingFilterInput {
+ eq: SampleRiskRating = null
+ neq: SampleRiskRating = null
+}
+
+enum SampleSortField {
+ acronym
+ materialOrProtein
+}
+
+"""The details of errors occurred when validating a sample"""
+type SampleValidationError {
+ """
+ The index of the sample in CreateSampleInput.samples for which the error occurred
+ """
+ index: Int!
+
+ """Errors that occurred when validating the sample"""
+ errors: [ErrorDetails!]!
+}
+
+"""Values required to set a container's parent"""
+input SetParentContainerInput {
+ containerPosition: ContainerPositionInput!
+}
+
+type SetParentContainerResponse {
+ success: Boolean!
+ containerPosition: ContainerPosition
+ errors: [String!]!
+}
+
+enum SortingOrder {
+ ASC
+ DESC
+}
+
+enum SortOrder {
+ ASC
+ DESC
+}
+
+type Staff {
+ username: String!
+ emailAddress: String
+ title: String
+ givenName: String @deprecated(reason: "given_name is deprecated, please use preferred_given_name instead.")
+ preferredGivenName: String
+ familyName: String
+ type: AccountType!
+ state: AccountState!
+}
+
+type StaffConnection {
+ edges: [StaffEdge!]!
+ pageInfo: PageInfo!
+}
+
+type StaffEdge {
+ cursor: String!
+ node: Staff!
+}
+
+input StringFilterInput {
+ eq: String = null
+ neq: String = null
+ contains: String = null
+ notContains: String = null
+ startsWith: String = null
+ endsWith: String = null
+}
+
+input StringFilterInputV2 {
+ """
+ Will filter to items where the `String` field is a member of the provided value
+ """
+ in: [String!] = null
+
+ """
+ Will filter to items where the `String` field is equal to the provided value
+ """
+ equalTo: String = null
+
+ """
+ Will filter to items where the `String` field is not equal to the provided value
+ """
+ notEqualTo: String = null
+
+ """
+ Will filter to items where the `String` field is not a member of the provided value
+ """
+ notIn: [String!] = null
+
+ """
+ Will filter to items where the `String` field is contains the provided value
+ """
+ contains: String = null
+}
+
+"""Conditions used to filter results based on the value of a String field"""
+input StringOperatorInput {
+ """
+ Will filter to items where the `String` field is equal to the provided value
+ """
+ eq: String = null
+
+ """
+ Will filter to items where the `String` field is not equal to the provided value
+ """
+ ne: String = null
+
+ """
+ Will filter to items where the `String` field is a member of the provided value
+ """
+ in: [String!] = null
+
+ """
+ Will filter to items where the `String` field is not a member of the provided value
+ """
+ nin: [String!] = null
+
+ """
+ Will filter to items where the `String` field is contains the provided value
+ """
+ contains: String = null
+}
+
+"""Values to add tags to dataset"""
+input TagCreateInput {
+ """Tags on a dataset"""
+ values: [String!]!
+
+ """Identifier of the dataset"""
+ datasetId: Int!
+}
+
+"""Values to remove tags from dataset"""
+input TagRemoveInput {
+ """Tags on a dataset"""
+ values: [String!]!
+
+ """Identifier of the dataset"""
+ datasetId: Int!
+}
+
+input TagStringArrayOperatorInput {
+ allOf: [String!]!
+}
+
+"""Values required to unassign a contianer from an instrument"""
+input UnassignContainerFromInstrumentInput {
+ """
+ The unique key of the instrument this container should be unassigned from
+ """
+ instrumentKey: String!
+}
+
+type UnassignContainerFromInstrumentResponse {
+ success: Boolean!
+ errors: [String!]!
+}
+
+"""Values required to unassign a contianer from an instrument session"""
+input UnassignContainerFromInstrumentSessionInput {
+ """The instrument session this container should be unassigned from"""
+ instrumentSession: InstrumentSessionInput!
+}
+
+type UnassignContainerFromInstrumentSessionResponse {
+ success: Boolean!
+ errors: [String!]!
+}
+
+"""Values required to update a container"""
+input UpdateContainerInput {
+ """The name of the container"""
+ name: String
+
+ """The serial number of this container"""
+ serialNumber: String
+
+ """The facility barcode of the container"""
+ barcode: String
+}
+
+type UpdateContainerResponse {
+ success: Boolean!
+ container: Container
+ errors: [String!]!
+}
+
+"""Values required to update a container type"""
+input UpdateContainerTypeInput {
+ """The unique name of the container type"""
+ name: String
+
+ """A description of this container type"""
+ description: String
+
+ """Used to distingush storage locations from moveable containers"""
+ isFixedLocation: Boolean
+}
+
+type UpdateContainerTypeResponse {
+ success: Boolean!
+ containerType: ContainerType
+ errors: [String!]!
+}
+
+"""Values that can be updated on experiment definition"""
+input UpdateExperimentDefinitionInput {
+ name: String
+ data: JSON
+ dataSchemaUrl: String
+}
+
+"""Return type when updating an experiment definition"""
+type UpdateExperimentDefinitionResponse {
+ """Whether the operation has succeeded without validation errors"""
+ success: Boolean!
+
+ """Experiment Definition that has been updated"""
+ experimentDefinition: ExperimentDefinition
+
+ """Errors that occurred during validation"""
+ errors: [ExperimentDefinitionErrorDetails!]!
+}
+
+"""Values required for the updateExperiment mutation"""
+input UpdateExperimentInput {
+ name: String!
+}
+
+"""Return type when updating an experiment"""
+type UpdateExperimentResponse {
+ success: Boolean!
+ experiment: Experiment!
+ errors: [String!]!
+}
+
+input UpdateInstrumentSessionExperimentStatusInput {
+ status: InstrumentSessionExperimentStatusInput!
+}
+
+"""Return type when updating the experiment status of a session"""
+type UpdateInstrumentSessionExperimentStatusResponse {
+ """Whether the operation has succeeded without validation errors"""
+ success: Boolean!
+
+ """The new status"""
+ status: InstrumentSessionExperimentStatus
+
+ """Errors that occurred during validation"""
+ errors: [String!]!
+}
+
+input UpdateSampleInput {
+ """Name of the sample"""
+ name: String
+
+ """Data of the sample"""
+ data: JSON
+
+ """URL of the JSON schema the samples' `data` should be validated against"""
+ dataSchemaUrl: String
+}
+
+"""Return type when updating a sample"""
+type UpdateSampleResponse {
+ """Whether the operation has succeeded"""
+ success: Boolean!
+
+ """Sample that has been updated"""
+ sample: Sample
+
+ """Errors that occurred during the operation"""
+ errors: [SampleValidationError!]!
+}
+
+scalar UUID
+
+input UUIDFilterInputV2 {
+ """
+ Will filter to items where the `UUID` field is a member of the provided value
+ """
+ in: [UUID!] = null
+
+ """
+ Will filter to items where the `UUID` field is equal to the provided value
+ """
+ equalTo: UUID = null
+
+ """
+ Will filter to items where the `UUID` field is not equal to the provided value
+ """
+ notEqualTo: UUID = null
+
+ """
+ Will filter to items where the `UUID` field is not a member of the provided value
+ """
+ notIn: [UUID!] = null
+}
+
+"""Represents NULL values"""
+scalar Void
+
diff --git a/frontend/unified/src/App.tsx b/frontend/unified/src/App.tsx
index 712b9973..9a832ad6 100644
--- a/frontend/unified/src/App.tsx
+++ b/frontend/unified/src/App.tsx
@@ -1,4 +1,4 @@
-import { useState, Suspense } from "react";
+import { useState, Suspense, useEffect } from "react";
import { ApolloProvider } from "@apollo/client/react";
import { Box, Drawer, IconButton, Stack, Tooltip } from "@mui/material";
@@ -7,7 +7,10 @@ import { ChevronsLeft, List, Table } from "lucide-react";
import WorkflowsDataFetcher from "./components/WorkflowsDataFetcher/WorkflowsDataFetcher";
import { Beamline, WorkflowsViewMode, InstrumentSession } from "./types";
-import { apolloClientWorkflows } from "../../src/ApolloClient";
+import {
+ apolloClientWorkflows,
+ apolloClientUlimsScans,
+} from "../../src/ApolloClient";
import { Sidebar } from "./components/Sidebar";
import ProcessingView from "./components/ProcessingView/ProcessingView";
@@ -30,13 +33,20 @@ export const App: React.FC = ({ session }: AppProps) => {
// a capitalised "proposal code", whereas the workflows service only accepts
// it in lowercase
const selectedVisit = {
- proposalCode: session.proposal.proposalCategory?.toLowerCase() ?? "",
- proposalNumber: session.proposal.proposalNumber,
+ proposalCode: session?.proposal?.proposalCategory?.toLowerCase() ?? "",
+ proposalNumber: session.proposal?.proposalNumber ?? NaN,
number: session.instrumentSessionNumber,
};
+ const sessionRef = `${selectedVisit.proposalCode}${selectedVisit.proposalNumber}-${selectedVisit.number}`;
+
const beamline = Beamline[session.instrument.name as keyof typeof Beamline];
+ // Clear selected scan IDs when session changes
+ useEffect(() => {
+ setSelectedScanIds([]);
+ }, [sessionRef]);
+
return (
= ({ session }: AppProps) => {
},
}}
>
-
+
+
+
string } = {
+ DATETIME: (v) => new Date(v).toLocaleString(undefined, { timeZone: "UTC" }),
+ NONE: (v) => `${v}`,
+};
+
+export type MetadataContainer = {
+ groupTitle: string;
+ children: MetadataComponent[];
+};
+
+export type MetadataComponent = {
+ getter: (data: ScanNodeProcessed) => T | undefined | null;
+ group?: string;
+ title?: string;
+ formatter: (v: T) => string;
+};
+
+type BeamlineConfig = {
+ ulimsSchema: string;
+ version: string;
+ startTime: (data: ScanNodeRaw) => string | undefined | null;
+ endTime: (data: ScanNodeRaw) => string | undefined | null;
+ scanNumber: (data: ScanNodeRaw) => number | undefined | null;
+ scanRequest: (data: ScanNodeRaw) => string | undefined | null;
+ components: Array | MetadataContainer>;
+};
+
+export function isMetadataComponent(
+ v: MetadataComponent | MetadataContainer
+): v is MetadataComponent {
+ return (v as MetadataComponent).getter !== undefined;
+}
+
+const fmtDate: (x: string | null | undefined) => Date | null = (x) => {
+ return x ? new Date(x) : null;
+};
+
+export function getProcessedScanNode(
+ data: InstrumentSessionQuery
+): ScanNodeProcessed[] {
+ const nodes: ScanNodeRaw[] = data.instrumentSessionByReference
+ ? data.instrumentSessionByReference?.datasets.edges.map((x) => x.node)
+ : [];
+
+ const schema = getBeamlineConfig(nodes?.[0]?.dataSchema);
+ if (!schema) {
+ return [];
+ }
+
+ // Regex to match scan number in Nexus file name
+ const regex = new RegExp("\\d{5,}");
+
+ const parseScanRequest = (
+ v: string | null | undefined
+ ): { [key: object]: any } | null => {
+ return v ? JSON.parse(v) : null;
+ };
+
+ const processedScanNodes: ScanNodeProcessed[] = nodes.map((x) => {
+ return {
+ ...x,
+ scanNumber: schema.scanNumber(x) ?? Number(regex.exec(x.title)?.[0]),
+ beamline: data?.instrumentSessionByReference?.instrument?.key ?? null,
+ startTime: fmtDate(schema.startTime(x)),
+ endTime: fmtDate(schema.endTime(x)),
+ scanRequest: parseScanRequest(schema.scanRequest(x)),
+ };
+ });
+
+ return processedScanNodes;
+}
+
+export function getBeamlineConfig(
+ schema: string | null
+): BeamlineConfig | null {
+ if (schema) {
+ const baseName = schema.split("/").slice(0, -1).join("/");
+ const versionNumber = schema.split("/").at(-1);
+
+ for (const conf of BEAMLINE_CONFIG) {
+ // TODO: Perform better version number matching
+ if (
+ baseName === conf.ulimsSchema &&
+ versionNumber === `${conf.version}.json`
+ ) {
+ return conf;
+ }
+ }
+ }
+ return null;
+}
+
+const BEAMLINE_CONFIG: BeamlineConfig[] = [
+ {
+ // Provide schema version that this config is valid for
+ ulimsSchema:
+ "https://raw.githubusercontent.com/DiamondLightSource/ulims-json-schemas/main/schemas/data_catalogue/i14/dummy_scan",
+ version: "0.0.1",
+
+ // Functions to fetch commonly used pieces of data
+ startTime: (v) => v.data?.scan_parameters?.start_time,
+ endTime: (v) => v.data?.scan_parameters?.end_time,
+ scanNumber: (_) => null,
+ scanRequest: (_) => null,
+ components: [
+ {
+ groupTitle: "Times",
+ children: [
+ {
+ // Title of metadata component
+ title: "Start time",
+
+ // Function to fetch metadata from a `ScanNodeProcessed` object
+ getter: (v) => v?.startTime,
+
+ // Function to format the metadata value for display
+ formatter: FORMATTER.DATETIME,
+ },
+ {
+ title: "End time",
+ getter: (v) => v?.endTime,
+ formatter: FORMATTER.DATETIME,
+ },
+ ],
+ },
+ ],
+ },
+ {
+ ulimsSchema:
+ "https://raw.githubusercontent.com/DiamondLightSource/ulims-json-schemas/main/schemas/data_catalogue/i14/mapping_scan",
+ version: "1.0.0",
+ startTime: (v) => v.data?.scan_parameters?.start_time,
+ endTime: (v) => v.data?.scan_parameters?.end_time,
+ scanNumber: (v) => v.data?.scan_parameters?.scan_number,
+ scanRequest: (v) => v.data?.scan_parameters?.scan_request,
+ components: [
+ {
+ title: "Sample name",
+ getter: (v) => v.data?.sample_information?.name,
+ formatter: FORMATTER.NONE,
+ },
+ {
+ groupTitle: "Times",
+ children: [
+ {
+ title: "Start time",
+ getter: (v) => v?.startTime,
+ formatter: FORMATTER.DATETIME,
+ },
+ {
+ title: "End time",
+ getter: (v) => v?.endTime,
+ formatter: FORMATTER.DATETIME,
+ },
+ ],
+ },
+ {
+ groupTitle: "Scan info",
+ children: [
+ {
+ title: "Sample rotation",
+ getter: (v) => v.data?.scan_parameters?.rotation,
+ formatter: (v) => `${v.toFixed(2)} deg`,
+ },
+ {
+ title: "Photon energy",
+ getter: (v) => v.data?.scan_parameters?.photon_energy,
+ formatter: (v) => `${v.toFixed(2)} keV`,
+ },
+ {
+ title: "Exposure time",
+ getter: (v) =>
+ v?.scanRequest?.detectors?.["BL14I-ML-SCAN-01"]?.exposureTime,
+ formatter: (v) => `${v} s`,
+ },
+ ],
+ },
+ {
+ groupTitle: "Scan region",
+ children: [
+ {
+ title: "Scan ROI",
+ getter: (v) =>
+ v?.scanRequest?.compoundModel?.regions?.[0]?.roi?.lengths,
+ formatter: (v) =>
+ v.map((x) => `${(x * 1e3).toFixed(2)} μm`).join(" x "),
+ },
+ {
+ title: "X Axis Step",
+ getter: (v) =>
+ v?.scanRequest?.compoundModel?.models?.[0]?.xAxisStep,
+ formatter: (v) => `${v * 1e6} nm`,
+ },
+ {
+ title: "Y Axis Step",
+ getter: (v) =>
+ v?.scanRequest?.compoundModel?.models?.[0]?.yAxisStep,
+ formatter: (v) => `${v * 1e6} nm`,
+ },
+ ],
+ },
+ {
+ groupTitle: "Slits",
+ children: [
+ {
+ title: "Slit Size X",
+ getter: (v) => v.data?.optical_parameters?.slit_size_x,
+ formatter: FORMATTER.NONE,
+ },
+ {
+ title: "Slit Size Y",
+ getter: (v) => v.data?.optical_parameters?.slit_size_y,
+ formatter: FORMATTER.NONE,
+ },
+ ],
+ },
+ {
+ title: "Element list",
+ getter: (v) =>
+ v?.scanRequest?.processingRequest?.request?.["i14-xrf"]?.[0]
+ ?.element_list,
+ formatter: FORMATTER.NONE,
+ },
+ ],
+ },
+];
diff --git a/frontend/unified/src/components/NavbarSessionSelector.tsx b/frontend/unified/src/components/NavbarSessionSelector.tsx
index d7fe4db7..c2e952c0 100644
--- a/frontend/unified/src/components/NavbarSessionSelector.tsx
+++ b/frontend/unified/src/components/NavbarSessionSelector.tsx
@@ -97,7 +97,7 @@ export const NavbarSessionSelector: React.FC = ({
const instSess =
accountData?.account?.instrumentSessionRoles?.edges?.[0]?.node
?.instrumentSession;
- if (instSess) {
+ if (instSess && instSess.proposal) {
const latest = `${instSess.proposal.proposalCategory?.toLowerCase()}${instSess.proposal.proposalNumber}-${instSess.instrumentSessionNumber}`;
latestSession.current = latest;
setSessionInputValue(latest);
diff --git a/frontend/unified/src/components/ParameterConfiguration/ParameterConfiguration.tsx b/frontend/unified/src/components/ParameterConfiguration/ParameterConfiguration.tsx
index 42eb659a..c5e4be82 100644
--- a/frontend/unified/src/components/ParameterConfiguration/ParameterConfiguration.tsx
+++ b/frontend/unified/src/components/ParameterConfiguration/ParameterConfiguration.tsx
@@ -1,9 +1,8 @@
import { Alert, Button, Snackbar, Typography } from "@mui/material";
-import { Beamline, Technique } from "../../types";
+import { Beamline, Technique, InstrumentSession } from "../../types";
import { SUBMIT_WORKFLOW_TEMPLATE } from "../../../../tomography/src/components/workflows/Submission";
import { useMutation, useSuspenseQuery } from "@apollo/client/react";
import { Visit } from "@diamondlightsource/sci-react-ui";
-import { InstrumentSession } from "../../types";
import TemplateSelector from "../ProcessingView/TemplateSelector";
import { getParamConfigComponent } from "./utils/getParamConfigComponent";
import { useState } from "react";
diff --git a/frontend/unified/src/components/ScanSelector.tsx b/frontend/unified/src/components/ScanSelector.tsx
index 1081ab91..6a44729e 100644
--- a/frontend/unified/src/components/ScanSelector.tsx
+++ b/frontend/unified/src/components/ScanSelector.tsx
@@ -1,250 +1,248 @@
-import React from "react";
+import { Typography, Box } from "@mui/material";
import {
- Chip,
- TextField,
- ToggleButton,
- ToggleButtonGroup,
- Stack,
- InputAdornment,
- IconButton,
- Box,
-} from "@mui/material";
-import AddIcon from "@mui/icons-material/Add";
-import { useState } from "react";
-
-enum ScanSelectionMode {
- Single = "Single",
- Multiple = "Multiple",
+ useMaterialReactTable,
+ type MRT_TableInstance,
+ createMRTColumnHelper,
+ type MRT_RowSelectionState,
+ MaterialReactTable,
+ type MRT_Row,
+ type MRT_SortingState,
+} from "material-react-table";
+import React, { useCallback, useEffect, useRef, useState } from "react";
+import { useQuery } from "@apollo/client/react";
+
+import type { ScanNodeProcessed, PageInfo } from "../types";
+import { INSTRUMENTSESSION_QUERY } from "../graphql/InstrumentSessionQuery";
+import { getProcessedScanNode } from "../beamline.config";
+
+export function formatDate(date: Date | null) {
+ if (date) {
+ return date.toLocaleString(undefined, { timeZone: "UTC" });
+ }
+ return "No date";
}
-enum MultiScanSelectionMode {
- Manual = "Manual",
- Range = "Range",
-}
+// Derived from infinite scroll example:
+// https://www.material-react-table.com/docs/examples/infinite-scrolling
+
+const columnHelper = createMRTColumnHelper();
+const columns = [
+ columnHelper.accessor("scanNumber", {
+ header: "Scans",
+ size: 120,
+ filterVariant: "text",
+ Cell: ({ row }) => (
+
+ {row.original.scanNumber}
+
+ {formatDate(row.original.startTime)}
+
+
+ ),
+ }),
+];
-type SingleScanSelectorProps = {
- scanId: number;
- setScanId: (_: number) => void;
+type ScanSelectorProp = {
+ sessionRef: string;
+ onScanSelect: (scans: ScanNodeProcessed[]) => void;
};
+export function ScanSelector({ sessionRef, onScanSelect }: ScanSelectorProp) {
+ const tableContainerRef = useRef(null); //we can get access to the underlying TableContainer element and react to its scroll events
-const SingleScanSelector: React.FC = ({
- scanId,
- setScanId,
-}: SingleScanSelectorProps) => {
- return (
-
- ) => {
- setScanId(Number(e.currentTarget.value));
- }}
- />
-
- );
-};
+ const FETCH_SIZE = 30;
-const MultiScanSelector: React.FC = ({
- scanIds,
- setScanIds,
-}) => {
- const [mode, setMode] = useState(
- MultiScanSelectionMode.Range
- );
+ const descPage = useRef(null);
+ const ascPage = useRef(null);
+ const sortingChanged = useRef(false);
+ const prevDesc = useRef(false);
- return (
- <>
- {
- if (toggleButtonLabel === MultiScanSelectionMode.Manual) {
- setMode(MultiScanSelectionMode.Manual);
- } else if (toggleButtonLabel === MultiScanSelectionMode.Range) {
- setMode(MultiScanSelectionMode.Range);
- }
- }}
- >
-
- {MultiScanSelectionMode.Manual}
-
-
- {MultiScanSelectionMode.Range}
-
-
- {mode === MultiScanSelectionMode.Manual ? (
-
- ) : (
-
- )}
- >
- );
-};
+ const [sorting, setSorting] = useState([]);
-type MultiScanRangeSelectorProps = {
- setScanIds: (_: number[]) => void;
-};
-
-const MultiScanRangeSelector: React.FC = ({
- setScanIds,
-}) => {
- const [start, setStart] = useState(1);
- const [stop, setStop] = useState(2);
- const [step, setStep] = useState(1);
-
- const generateScanIdRange = (start: number, stop: number, step: number) => {
- return Array.from(
- { length: Math.ceil((stop - start) / step) },
- (_, i) => start + i * step
- );
+ const isDescSort = (x: MRT_SortingState) => {
+ let desc = true;
+ if (x.length > 0 && x[0].id == "scanNumber") {
+ desc = x[0].desc;
+ }
+ return desc;
};
- return (
-
- ) => {
- setStart(Number(e.target.value));
- setScanIds(generateScanIdRange(Number(e.target.value), stop, step));
- }}
- />
- ) => {
- setStop(Number(e.target.value));
- setScanIds(generateScanIdRange(start, Number(e.target.value), step));
- }}
- />
- ) => {
- setStep(Number(e.target.value));
- setScanIds(generateScanIdRange(start, stop, Number(e.target.value)));
- }}
- />
-
+ const { data, loading, fetchMore, client } = useQuery(
+ INSTRUMENTSESSION_QUERY,
+ {
+ variables: {
+ reference: sessionRef,
+ first: isDescSort(sorting) ? FETCH_SIZE : null,
+ last: !isDescSort(sorting) ? FETCH_SIZE : null,
+ after: null,
+ before: null,
+ },
+ }
);
-};
-
-const MultiScanManualSelector: React.FC = ({
- scanIds,
- setScanIds,
-}) => {
- const [textInputValue, setTextInputValue] = useState("4");
-
- const handleDeleteScanIdChip = (idToDelete: number) => {
- setScanIds(scanIds.filter((id) => id !== idToDelete));
- };
- const handleClickAddScanIdButton = (idToAdd: number) => {
- if (scanIds.includes(idToAdd)) {
- return;
+ useEffect(() => {
+ if (isDescSort(sorting)) {
+ descPage.current =
+ data?.instrumentSessionByReference?.datasets.pageInfo ?? null;
+ } else {
+ ascPage.current =
+ data?.instrumentSessionByReference?.datasets.pageInfo ?? null;
}
- setScanIds([...scanIds, idToAdd]);
- setTextInputValue(String(Number(textInputValue) + 1));
- };
-
- return (
-
- ) => {
- setTextInputValue(e.currentTarget.value);
- }}
- slotProps={{
- input: {
- endAdornment: (
-
-
- handleClickAddScanIdButton(Number(textInputValue))
- }
- >
-
-
-
- ),
- },
- }}
- />
-
- {scanIds.map((scanId: number) => {
- return (
- handleDeleteScanIdChip(scanId)}
- >
- );
- })}
-
-
+ }, [data, sorting]);
+
+ const [rowSelection, setRowSelection] = useState({});
+
+ const fetchMoreOnBottomReached = useCallback(
+ (containerRefElement?: HTMLDivElement | null) => {
+ if (containerRefElement) {
+ const { scrollHeight, scrollTop, clientHeight } = containerRefElement;
+ //once the user has scrolled within 400px of the bottom of the table, fetch more data if we can
+
+ if (
+ scrollHeight - scrollTop - clientHeight < 400 &&
+ !loading &&
+ ((isDescSort(sorting) && descPage.current?.hasNextPage) ||
+ (!isDescSort(sorting) && ascPage.current?.hasPreviousPage))
+ ) {
+ fetchMore({
+ variables: {
+ reference: sessionRef,
+ first: isDescSort(sorting) ? FETCH_SIZE : null,
+ last: !isDescSort(sorting) ? FETCH_SIZE : null,
+ after: isDescSort(sorting)
+ ? (descPage.current?.endCursor ?? null)
+ : null,
+ before: !isDescSort(sorting)
+ ? (ascPage.current?.startCursor ?? null)
+ : null,
+ },
+ });
+ }
+ }
+ },
+ [loading, fetchMore, sessionRef, sorting]
);
-};
-
-type ScanSelectorProps = {
- scanIds: number[];
- setScanIds: (_: number[]) => void;
-};
-export const ScanSelector: React.FC = ({
- scanIds,
- setScanIds,
-}: ScanSelectorProps) => {
- const [scanSelectionMode, setScanSelectionMode] = useState(
- ScanSelectionMode.Single
- );
+ let parsedNodes: ScanNodeProcessed[] = [];
+ if (data && data.instrumentSessionByReference) {
+ parsedNodes = getProcessedScanNode(data);
+ }
- return (
-
- {
- if (toggleButtonLabel === ScanSelectionMode.Single) {
- setScanSelectionMode(ScanSelectionMode.Single);
- } else if (toggleButtonLabel === ScanSelectionMode.Multiple) {
- setScanSelectionMode(ScanSelectionMode.Multiple);
+ const scanNumbersSorted = parsedNodes.sort((a, b) => {
+ const coeff = isDescSort(sorting) ? 1 : -1;
+ if (b.startTime && a.startTime) {
+ return coeff * (b.startTime.getTime() - a.startTime.getTime());
+ }
+ return 0;
+ });
+
+ const previousRow = useRef | null>(null);
+
+ const table: MRT_TableInstance = useMaterialReactTable({
+ columns: columns,
+ data: scanNumbersSorted,
+ enableKeyboardShortcuts: false,
+ enableColumnActions: false,
+ enableColumnFilters: true,
+ enablePagination: false,
+ manualSorting: true,
+ manualFiltering: true,
+ enableRowSelection: false,
+ enableMultiRowSelection: false,
+ enableBottomToolbar: false,
+ enableTopToolbar: false,
+ initialState: {
+ density: "compact",
+ },
+
+ muiTableBodyRowProps: ({ row }) => ({
+ //implement row selection click events manually
+ onClick: (e) => {
+ setRowSelection((prev) => {
+ // Shift + click selection
+ // https://stackoverflow.com/a/56178061
+ const newState = { ...prev };
+
+ if (e.shiftKey && previousRow.current) {
+ if (previousRow.current.index < row.index) {
+ for (let i = previousRow.current.index; i <= row.index; i++) {
+ newState[i] = true;
+ }
+ } else {
+ for (let i = row.index; i <= previousRow.current.index; i++) {
+ newState[i] = true;
+ }
+ }
+ return newState;
+ } else if (e.ctrlKey) {
+ return {
+ ...prev,
+ [row.id]: !prev[row.id], // Enable multi-select if ctrl key is held down
+ };
+ } else {
+ previousRow.current = row;
+ return {
+ [row.id]: !prev[row.id],
+ };
}
- }}
- >
-
- {ScanSelectionMode.Single}
-
-
- {ScanSelectionMode.Multiple}
-
-
- {scanSelectionMode === ScanSelectionMode.Single ? (
- setScanIds([scanId])}
- />
- ) : (
-
- )}
-
- );
-};
+ });
+ },
+ selected: rowSelection[row.id],
+ sx: {
+ cursor: "pointer",
+ },
+ }),
+ onRowSelectionChange: setRowSelection,
+ onSortingChange: setSorting,
+ state: {
+ rowSelection,
+ sorting,
+ },
+ enableRowVirtualization: true,
+ muiTableContainerProps: {
+ ref: tableContainerRef, //get access to the table container element
+ sx: {
+ maxHeight: "100%",
+ }, //give the table a max height
+ onScroll: (event: React.UIEvent) =>
+ fetchMoreOnBottomReached(event.target as HTMLDivElement), //add an event listener to the table container element
+ },
+ });
+
+ // useEffect to perform an action when the selection state changes:
+ // https://www.material-react-table.com/docs/guides/row-selection#read-row-selection-state-from-table-instance
+ useEffect(() => {
+ // Get session reference of selected row
+ const selectedIdx = Object.keys(table.getState().rowSelection);
+ // console.log("selected ", selectedIdx);
+
+ const selectedData = selectedIdx
+ .filter((x) => rowSelection[x])
+ .map((x) => table.getRow(x).original);
+ onScanSelect(selectedData);
+
+ // `onScanSelect` should not be placed into dependency list as it causes an infinite loop
+ }, [table.getState().rowSelection]);
+
+ useEffect(() => {
+ const pageData = isDescSort(sorting) ? descPage.current : ascPage.current;
+
+ // Only fetch when the sort order has actually changed
+ if (prevDesc.current !== isDescSort(sorting)) {
+ client.resetStore();
+ fetchMore({
+ variables: {
+ reference: sessionRef,
+ first: isDescSort(sorting) ? FETCH_SIZE : null,
+ last: !isDescSort(sorting) ? FETCH_SIZE : null,
+ after: isDescSort(sorting) ? (pageData?.endCursor ?? null) : null,
+ before: !isDescSort(sorting) ? (pageData?.startCursor ?? null) : null,
+ },
+ });
+ }
+
+ sortingChanged.current = true;
+ prevDesc.current = isDescSort(sorting);
+ }, [sorting, client, fetchMore, sessionRef]);
+
+ return ;
+}
diff --git a/frontend/unified/src/components/ScanSelectorCombined.tsx b/frontend/unified/src/components/ScanSelectorCombined.tsx
new file mode 100644
index 00000000..2ea49883
--- /dev/null
+++ b/frontend/unified/src/components/ScanSelectorCombined.tsx
@@ -0,0 +1,47 @@
+import { Stack, ToggleButtonGroup, ToggleButton } from "@mui/material";
+import { useState } from "react";
+import { ScanSelector } from "./ScanSelector";
+import { ScanSelector as ScanSelectorLegacy } from "./ScanSelectorLegacy";
+
+type ScanSelectorCombinedProps = {
+ sessionRef: string;
+ scanIds: number[];
+ setScanIds: (v: number[]) => void;
+};
+export const ScanSelectorCombined = ({
+ sessionRef,
+ scanIds,
+ setScanIds,
+}: ScanSelectorCombinedProps) => {
+ const [selectorType, setSelectorType] = useState("legacy");
+
+ return (
+
+ {
+ if (v !== null) {
+ setSelectorType(v);
+ }
+ }}
+ exclusive
+ fullWidth
+ >
+ New
+ Legacy
+
+
+ {selectorType === "new" ? (
+ setScanIds(v.map((x) => x.scanNumber))}
+ sessionRef={sessionRef}
+ />
+ ) : (
+
+ )}
+
+ );
+};
diff --git a/frontend/unified/src/components/ScanSelectorLegacy.tsx b/frontend/unified/src/components/ScanSelectorLegacy.tsx
new file mode 100644
index 00000000..1081ab91
--- /dev/null
+++ b/frontend/unified/src/components/ScanSelectorLegacy.tsx
@@ -0,0 +1,250 @@
+import React from "react";
+import {
+ Chip,
+ TextField,
+ ToggleButton,
+ ToggleButtonGroup,
+ Stack,
+ InputAdornment,
+ IconButton,
+ Box,
+} from "@mui/material";
+import AddIcon from "@mui/icons-material/Add";
+import { useState } from "react";
+
+enum ScanSelectionMode {
+ Single = "Single",
+ Multiple = "Multiple",
+}
+
+enum MultiScanSelectionMode {
+ Manual = "Manual",
+ Range = "Range",
+}
+
+type SingleScanSelectorProps = {
+ scanId: number;
+ setScanId: (_: number) => void;
+};
+
+const SingleScanSelector: React.FC = ({
+ scanId,
+ setScanId,
+}: SingleScanSelectorProps) => {
+ return (
+
+ ) => {
+ setScanId(Number(e.currentTarget.value));
+ }}
+ />
+
+ );
+};
+
+const MultiScanSelector: React.FC = ({
+ scanIds,
+ setScanIds,
+}) => {
+ const [mode, setMode] = useState(
+ MultiScanSelectionMode.Range
+ );
+
+ return (
+ <>
+ {
+ if (toggleButtonLabel === MultiScanSelectionMode.Manual) {
+ setMode(MultiScanSelectionMode.Manual);
+ } else if (toggleButtonLabel === MultiScanSelectionMode.Range) {
+ setMode(MultiScanSelectionMode.Range);
+ }
+ }}
+ >
+
+ {MultiScanSelectionMode.Manual}
+
+
+ {MultiScanSelectionMode.Range}
+
+
+ {mode === MultiScanSelectionMode.Manual ? (
+
+ ) : (
+
+ )}
+ >
+ );
+};
+
+type MultiScanRangeSelectorProps = {
+ setScanIds: (_: number[]) => void;
+};
+
+const MultiScanRangeSelector: React.FC = ({
+ setScanIds,
+}) => {
+ const [start, setStart] = useState(1);
+ const [stop, setStop] = useState(2);
+ const [step, setStep] = useState(1);
+
+ const generateScanIdRange = (start: number, stop: number, step: number) => {
+ return Array.from(
+ { length: Math.ceil((stop - start) / step) },
+ (_, i) => start + i * step
+ );
+ };
+
+ return (
+
+ ) => {
+ setStart(Number(e.target.value));
+ setScanIds(generateScanIdRange(Number(e.target.value), stop, step));
+ }}
+ />
+ ) => {
+ setStop(Number(e.target.value));
+ setScanIds(generateScanIdRange(start, Number(e.target.value), step));
+ }}
+ />
+ ) => {
+ setStep(Number(e.target.value));
+ setScanIds(generateScanIdRange(start, stop, Number(e.target.value)));
+ }}
+ />
+
+ );
+};
+
+const MultiScanManualSelector: React.FC = ({
+ scanIds,
+ setScanIds,
+}) => {
+ const [textInputValue, setTextInputValue] = useState("4");
+
+ const handleDeleteScanIdChip = (idToDelete: number) => {
+ setScanIds(scanIds.filter((id) => id !== idToDelete));
+ };
+
+ const handleClickAddScanIdButton = (idToAdd: number) => {
+ if (scanIds.includes(idToAdd)) {
+ return;
+ }
+ setScanIds([...scanIds, idToAdd]);
+ setTextInputValue(String(Number(textInputValue) + 1));
+ };
+
+ return (
+
+ ) => {
+ setTextInputValue(e.currentTarget.value);
+ }}
+ slotProps={{
+ input: {
+ endAdornment: (
+
+
+ handleClickAddScanIdButton(Number(textInputValue))
+ }
+ >
+
+
+
+ ),
+ },
+ }}
+ />
+
+ {scanIds.map((scanId: number) => {
+ return (
+ handleDeleteScanIdChip(scanId)}
+ >
+ );
+ })}
+
+
+ );
+};
+
+type ScanSelectorProps = {
+ scanIds: number[];
+ setScanIds: (_: number[]) => void;
+};
+
+export const ScanSelector: React.FC = ({
+ scanIds,
+ setScanIds,
+}: ScanSelectorProps) => {
+ const [scanSelectionMode, setScanSelectionMode] = useState(
+ ScanSelectionMode.Single
+ );
+
+ return (
+
+ {
+ if (toggleButtonLabel === ScanSelectionMode.Single) {
+ setScanSelectionMode(ScanSelectionMode.Single);
+ } else if (toggleButtonLabel === ScanSelectionMode.Multiple) {
+ setScanSelectionMode(ScanSelectionMode.Multiple);
+ }
+ }}
+ >
+
+ {ScanSelectionMode.Single}
+
+
+ {ScanSelectionMode.Multiple}
+
+
+ {scanSelectionMode === ScanSelectionMode.Single ? (
+ setScanIds([scanId])}
+ />
+ ) : (
+
+ )}
+
+ );
+};
diff --git a/frontend/unified/src/components/Sidebar.tsx b/frontend/unified/src/components/Sidebar.tsx
index 7c0c5eab..817b3e63 100644
--- a/frontend/unified/src/components/Sidebar.tsx
+++ b/frontend/unified/src/components/Sidebar.tsx
@@ -1,21 +1,48 @@
import { Navbar } from "@diamondlightsource/sci-react-ui";
-import { Box, Typography } from "@mui/material";
-import { ScanSelector } from "./ScanSelector";
+import { Box, Typography, Stack } from "@mui/material";
+import { ScanSelectorCombined } from "./ScanSelectorCombined";
type SidebarProps = {
scanIds: number[];
updateSelectedScanIds: (_: number[]) => void;
+ sessionRef: string;
};
export const Sidebar: React.FC = ({
scanIds,
updateSelectedScanIds,
+ sessionRef,
}: SidebarProps) => {
return (
-
-
- Scan
-
+
+
+
+ Scan
+
+ {/* Force the (new) scan selector to handle scrolling itself.
+ This is to ensure infinite scroll works correctly. */}
+
+
+
+
);
};
diff --git a/frontend/unified/src/graphql/InstrumentSessionQuery.ts b/frontend/unified/src/graphql/InstrumentSessionQuery.ts
new file mode 100644
index 00000000..76068ca5
--- /dev/null
+++ b/frontend/unified/src/graphql/InstrumentSessionQuery.ts
@@ -0,0 +1,42 @@
+import { gql, TypedDocumentNode } from "@apollo/client";
+import {
+ InstrumentSessionQuery,
+ InstrumentSessionQueryVariables,
+} from "./__generated__/InstrumentSessionQuery.generated";
+
+export const INSTRUMENTSESSION_QUERY: TypedDocumentNode<
+ InstrumentSessionQuery,
+ InstrumentSessionQueryVariables
+> = gql(`
+ query InstrumentSessionQuery($reference: String!, $after: String, $before: String, $first: Int, $last: Int) {
+ instrumentSessionByReference(reference: $reference) {
+ startTime
+ endTime
+ instrument {
+ key
+ }
+ proposal {
+ title
+ }
+ datasets(pagination: {first: $first, last: $last, after: $after, before: $before}) {
+ pageInfo {
+ startCursor
+ hasPreviousPage
+ hasNextPage
+ endCursor
+ }
+ edges {
+ cursor
+ node {
+ data
+ dataSchema
+ description
+ id
+ scanNumber
+ title
+ }
+ }
+ }
+ }
+ }
+`);
diff --git a/frontend/unified/src/types.ts b/frontend/unified/src/types.ts
index 8d48fe05..a08f7404 100644
--- a/frontend/unified/src/types.ts
+++ b/frontend/unified/src/types.ts
@@ -1,5 +1,6 @@
import { ProcessingViewQuery } from "./graphql/__generated__/ProcessingViewQuery.generated";
import { SessionQuery } from "./graphql/__generated__/SessionQuery.generated";
+import { InstrumentSessionQuery } from "./graphql/__generated__/InstrumentSessionQuery.generated";
const Technique = {
Dpc: "Dpc",
@@ -128,3 +129,22 @@ const WorkflowsViewMode = {
type WorkflowsViewMode =
(typeof WorkflowsViewMode)[keyof typeof WorkflowsViewMode];
export { WorkflowsViewMode };
+
+export type ScanNodeRaw = Omit<
+ NonNullable<
+ InstrumentSessionQuery["instrumentSessionByReference"]
+ >["datasets"]["edges"][0]["node"],
+ "data"
+> & { data: { [key: string]: any } };
+
+export type ScanNodeProcessed = ScanNodeRaw & {
+ scanNumber: number;
+ beamline: string | null;
+ startTime: Date | null;
+ endTime: Date | null;
+ scanRequest: { [key: string]: any } | null;
+};
+
+export type PageInfo = NonNullable<
+ InstrumentSessionQuery["instrumentSessionByReference"]
+>["datasets"]["pageInfo"];
diff --git a/frontend/unified/tests/ScanSelector.test.tsx b/frontend/unified/tests/ScanSelector.test.tsx
index 17c69ad6..e61ad8eb 100644
--- a/frontend/unified/tests/ScanSelector.test.tsx
+++ b/frontend/unified/tests/ScanSelector.test.tsx
@@ -2,7 +2,7 @@ import React from "react";
import { expect, test } from "vitest";
import { render, screen } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
-import { ScanSelector } from "../src/components/ScanSelector";
+import { ScanSelector } from "../src/components/ScanSelectorLegacy";
const SCAN_IDS: number[] = [];
const setScanIds = (_: number[]) => console.log("Setting scan IDs");