From 7c677ffed9f5528a8aed149397f438c906665c62 Mon Sep 17 00:00:00 2001 From: Eugene Chen <332385214+echen-dls@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:30:18 +0000 Subject: [PATCH 1/5] Add new scan selector --- frontend/graphqlCodegen.ts | 2 +- frontend/package-lock.json | 121 ++ frontend/package.json | 1 + frontend/src/ApolloClient.ts | 16 + frontend/src/Layout.tsx | 78 +- frontend/src/instrument_sessions.graphql | 376 ---- frontend/src/pagination.ts | 311 +++ frontend/src/ulims_dev.graphql | 1902 +++++++++++++++++ frontend/unified/src/App.tsx | 21 +- frontend/unified/src/AppWrapper.tsx | 2 + frontend/unified/src/beamline.config.ts | 248 +++ .../unified/src/components/ScanSelector.tsx | 484 +++-- .../src/components/ScanSelectorCombined.tsx | 44 + .../src/components/ScanSelectorLegacy.tsx | 250 +++ frontend/unified/src/components/Sidebar.tsx | 39 +- .../src/graphql/InstrumentSessionQuery.ts | 42 + frontend/unified/src/types.ts | 19 + frontend/unified/tests/ScanSelector.test.tsx | 2 +- 18 files changed, 3304 insertions(+), 654 deletions(-) delete mode 100644 frontend/src/instrument_sessions.graphql create mode 100644 frontend/src/pagination.ts create mode 100644 frontend/src/ulims_dev.graphql create mode 100644 frontend/unified/src/beamline.config.ts create mode 100644 frontend/unified/src/components/ScanSelectorCombined.tsx create mode 100644 frontend/unified/src/components/ScanSelectorLegacy.tsx create mode 100644 frontend/unified/src/graphql/InstrumentSessionQuery.ts 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 6d1a2e20..30ecb089 100644 --- a/frontend/unified/src/App.tsx +++ b/frontend/unified/src/App.tsx @@ -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"; @@ -35,10 +38,12 @@ export const App: React.FC = ({ session }: AppProps) => { number: session.instrumentSessionNumber, }; + const sessionRef = `${selectedVisit.proposalCode}${selectedVisit.proposalNumber}-${selectedVisit.number}`; + const beamline = Beamline[session.instrument.name as keyof typeof Beamline]; return ( - + = ({ session }: AppProps) => { flexShrink: 0, "& .MuiDrawer-paper": { width: SIDEBAR_WIDTH, + height: "100%", boxSizing: "border-box", }, }} > - + + + = ({ const { appStatusVal } = outletCtx.appStatus; + console.log("appstatus", appStatusVal); + switch (appStatusVal.state) { case "VALID": { // Render the main app diff --git a/frontend/unified/src/beamline.config.ts b/frontend/unified/src/beamline.config.ts new file mode 100644 index 00000000..7a9723ed --- /dev/null +++ b/frontend/unified/src/beamline.config.ts @@ -0,0 +1,248 @@ +import type { InstrumentSessionQuery } from "./graphql/__generated__/InstrumentSessionQuery.generated"; +import type { ScanNodeProcessed, ScanNodeRaw } from "./types"; + +const FORMATTER: { [key: string]: (v: any) => 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: ScanNodeRaw) => T | undefined | null; + group?: string; + title?: string; + formatter: (v: T) => string; +}; + +type BeamlineConfig2 = { + ulims_schema: string; + version: string; + startTime: (data: ScanNodeRaw) => string | undefined | null; + endTime: (data: ScanNodeRaw) => string | undefined | null; + scanNumber: (data: ScanNodeRaw) => number | undefined | null; + components: Array | MetadataContainer>; +}; + +export function isMetadataComponent( + v: MetadataComponent | MetadataContainer +): v is MetadataComponent { + return (v as MetadataComponent).getter !== undefined; +} + +export function convertScanRequestToObject(data: ScanNodeRaw): ScanNodeRaw { + if (data?.data?.scan_parameters?.scan_request) { + console.log(data.data.scan_parameters.scan_request); + var scanParameters = { + ...data.data.scan_parameters, + scan_request: JSON.parse(data.data.scan_parameters.scan_request), + }; + var newData = { ...data.data, scan_parameters: scanParameters }; + var newScanData = { ...data, data: newData }; + + return newScanData; + } + return data; +} + +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 parsedNodes = nodes.map((x) => convertScanRequestToObject(x)); + + const processedScanNodes: ScanNodeProcessed[] = parsedNodes.map((x) => { + return { + ...x, + scanNumber: + schema.scanNumber(x) ?? Number(regex.exec(x.title)?.[0]) ?? null, + beamline: data?.instrumentSessionByReference?.instrument?.key ?? null, + startTime: fmtDate(schema.startTime(x)), + endTime: fmtDate(schema.endTime(x)), + }; + }); + + return processedScanNodes; +} + +export function getBeamlineConfig( + schema: string | null +): BeamlineConfig2 | null { + if (schema) { + const baseName = schema.split("/").slice(0, -1).join("/"); + console.log("baseName", baseName); + const versionNumber = schema.split("/").at(-1); + + for (const conf of BEAMLINE_CONFIG) { + console.log( + "baseName === conf.ulims_schema", + baseName, + conf.ulims_schema + ); + console.log( + "`${versionNumber}.json` === conf.version", + `${versionNumber}.json`, + conf.version + ); + + // TODO: Perform better version number matching + if ( + baseName === conf.ulims_schema && + versionNumber === `${conf.version}.json` + ) { + return conf; + } + } + } + + return null; +} + +const BEAMLINE_CONFIG: BeamlineConfig2[] = [ + { + ulims_schema: + "https://raw.githubusercontent.com/DiamondLightSource/ulims-json-schemas/main/schemas/data_catalogue/i14/dummy_scan", + version: "0.0.1", + startTime: (v) => v.data?.scan_parameters?.start_time, + endTime: (v) => v.data?.scan_parameters?.end_time, + scanNumber: (v) => null, + components: [ + { + groupTitle: "Times", + children: [ + { + title: "Start time", + getter: (v) => v.data?.scan_parameters?.start_time, + formatter: FORMATTER.DATETIME, + }, + { + title: "End time", + getter: (v) => v.data?.scan_parameters?.end_time, + formatter: FORMATTER.DATETIME, + }, + ], + }, + ], + }, + { + ulims_schema: + "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, + components: [ + { + title: "Sample name", + getter: (v) => v.data?.sample_information?.name, + formatter: FORMATTER.NONE, + }, + { + groupTitle: "Times", + children: [ + { + title: "Start time", + getter: (v) => v.data?.scan_parameters?.start_time, + formatter: FORMATTER.DATETIME, + }, + { + title: "End time", + getter: (v) => v.data?.scan_parameters?.end_time, + 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.data?.scan_parameters?.scan_request?.detectors?.[ + "BL14I-ML-SCAN-01" + ]?.exposureTime, + formatter: (v) => `${v} s`, + }, + ], + }, + { + groupTitle: "Scan region", + children: [ + { + title: "Scan ROI", + getter: (v) => + v.data?.scan_parameters?.scan_request?.compoundModel?.regions?.[0] + ?.roi?.lengths, + formatter: (v) => + v.map((x) => `${(x * 1e3).toFixed(2)} μm`).join(" x "), + }, + { + title: "X Axis Step", + getter: (v) => + v.data?.scan_parameters?.scan_request?.compoundModel?.models?.[0] + ?.xAxisStep, + formatter: (v) => `${v * 1e6} nm`, + }, + { + title: "Y Axis Step", + getter: (v) => + v.data?.scan_parameters?.scan_request?.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: (v) => FORMATTER.NONE, + }, + { + title: "Slit Size Y", + getter: (v) => v.data?.optical_parameters?.slit_size_y, + formatter: (v) => FORMATTER.NONE, + }, + ], + }, + { + title: "Element list", + getter: (v) => + v.data?.scan_parameters?.scan_request?.processingRequest?.request?.[ + "i14-xrf" + ]?.[0]?.element_list, + formatter: FORMATTER.NONE, + }, + ], + }, +]; diff --git a/frontend/unified/src/components/ScanSelector.tsx b/frontend/unified/src/components/ScanSelector.tsx index 1081ab91..4897ed07 100644 --- a/frontend/unified/src/components/ScanSelector.tsx +++ b/frontend/unified/src/components/ScanSelector.tsx @@ -1,250 +1,278 @@ -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"; -enum MultiScanSelectionMode { - Manual = "Manual", - Range = "Range", +export function formatDate(date: Date | null) { + if (date) { + return date.toLocaleString(undefined, { timeZone: "UTC" }); + } + return "No date"; } -type SingleScanSelectorProps = { - scanId: number; - setScanId: (_: number) => void; -}; +// Infinite scroll example: +// https://www.material-react-table.com/docs/examples/infinite-scrolling -const SingleScanSelector: React.FC = ({ - scanId, - setScanId, -}: SingleScanSelectorProps) => { - return ( - - ) => { - setScanId(Number(e.currentTarget.value)); - }} - /> - - ); +const columnHelper = createMRTColumnHelper(); +const columns = [ + columnHelper.accessor("scanNumber", { + header: "Scans", + size: 120, + filterVariant: "text", + Cell: ({ row }) => ( + + {row.original.scanNumber} + + {formatDate(row.original.startTime)} + + + ), + }), +]; + +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 MultiScanSelector: React.FC = ({ - scanIds, - setScanIds, -}) => { - const [mode, setMode] = useState( - MultiScanSelectionMode.Range - ); + const FETCH_SIZE = 30; - return ( - <> - { - if (toggleButtonLabel === MultiScanSelectionMode.Manual) { - setMode(MultiScanSelectionMode.Manual); - } else if (toggleButtonLabel === MultiScanSelectionMode.Range) { - setMode(MultiScanSelectionMode.Range); - } - }} - > - - {MultiScanSelectionMode.Manual} - - - {MultiScanSelectionMode.Range} - - - {mode === MultiScanSelectionMode.Manual ? ( - - ) : ( - - )} - - ); -}; + const descPage = useRef(null); + const ascPage = useRef(null); + const sortingChanged = useRef(false); + const prevDesc = useRef(false); -type MultiScanRangeSelectorProps = { - setScanIds: (_: number[]) => void; -}; + const [sorting, setSorting] = useState([]); -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))); - }} - /> - - ); -}; + console.log("sorting ", isDescSort(sorting)); -const MultiScanManualSelector: React.FC = ({ - scanIds, - setScanIds, -}) => { - const [textInputValue, setTextInputValue] = useState("4"); + console.log("scanselector"); - const handleDeleteScanIdChip = (idToDelete: number) => { - setScanIds(scanIds.filter((id) => id !== idToDelete)); - }; + // let cursor: string | null = null; + 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 handleClickAddScanIdButton = (idToAdd: number) => { - if (scanIds.includes(idToAdd)) { - return; + useEffect(() => { + if (isDescSort(sorting)) { + console.log("assign descPage"); + descPage.current = + data?.instrumentSessionByReference?.datasets.pageInfo ?? null; + } else { + console.log("assign ascPage"); + ascPage.current = + data?.instrumentSessionByReference?.datasets.pageInfo ?? null; } - setScanIds([...scanIds, idToAdd]); - setTextInputValue(String(Number(textInputValue) + 1)); - }; + }, [data]); - return ( - - ) => { - setTextInputValue(e.currentTarget.value); - }} - slotProps={{ - input: { - endAdornment: ( - - - handleClickAddScanIdButton(Number(textInputValue)) - } - > - - - - ), - }, - }} - /> - - {scanIds.map((scanId: number) => { - return ( - handleDeleteScanIdChip(scanId)} - > - ); - })} - - - ); -}; + console.log(data); -type ScanSelectorProps = { - scanIds: number[]; - setScanIds: (_: number[]) => void; -}; + const [rowSelection, setRowSelection] = useState({}); -export const ScanSelector: React.FC = ({ - scanIds, - setScanIds, -}: ScanSelectorProps) => { - const [scanSelectionMode, setScanSelectionMode] = useState( - ScanSelectionMode.Single + // console.log("scans[0].title", (scans[0]) ? scans[0].title : ""); + 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] ); - return ( - - { - if (toggleButtonLabel === ScanSelectionMode.Single) { - setScanSelectionMode(ScanSelectionMode.Single); - } else if (toggleButtonLabel === ScanSelectionMode.Multiple) { - setScanSelectionMode(ScanSelectionMode.Multiple); + let parsedNodes: ScanNodeProcessed[] = []; + if (data && data.instrumentSessionByReference) { + parsedNodes = getProcessedScanNode(data); + } + + 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; + }); + + console.log("sortDesc ", isDescSort(sorting)); + + const previousRow = useRef | null>(null); + console.log("previousRow ", previousRow, ", rowSelection ", rowSelection); + + const table: MRT_TableInstance = useMaterialReactTable({ + columns: columns, + data: scanNumbersSorted, + enableKeyboardShortcuts: false, + enableColumnActions: false, + enableColumnFilters: true, + enablePagination: false, + manualSorting: true, + manualFiltering: true, + // mrtTheme: DiamondDSIntegrations.mrtTheme, + // ...DiamondDSIntegrations.mrtOptions(), + 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 + let newState = { ...prev }; + + console.log("e.shiftKey ", e.shiftKey); + + if (e.shiftKey && previousRow.current) { + if (previousRow.current.index < row.index) { + for (let i = previousRow.current.index; i <= row.index; i++) { + newState[i] = true; + } + // or the opposite + } else { + for (let i = row.index; i <= previousRow.current.index; i++) { + newState[i] = true; + } + } + console.log("newState ", newState); + 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, + // rowVirtualizerInstanceRef: rowVirtualizerInstanceRef, + // rowVirtualizerOptions: { overscan: 4 }, + + 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(() => { + // 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); + }, [table.getState().rowSelection]); + + useEffect(() => { + const pageData = isDescSort(sorting) ? descPage.current : ascPage.current; + console.log("fetchMore sorting ", isDescSort(sorting)); + console.log("first ", isDescSort(sorting) ? FETCH_SIZE : null); + console.log("last ", !isDescSort(sorting) ? FETCH_SIZE : null); + console.log( + "after ", + isDescSort(sorting) ? (pageData?.endCursor ?? null) : null + ); + console.log( + "before ", + !isDescSort(sorting) ? (pageData?.startCursor ?? null) : null + ); + + // Only fetch is 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]); + + return ; +} diff --git a/frontend/unified/src/components/ScanSelectorCombined.tsx b/frontend/unified/src/components/ScanSelectorCombined.tsx new file mode 100644 index 00000000..3373df88 --- /dev/null +++ b/frontend/unified/src/components/ScanSelectorCombined.tsx @@ -0,0 +1,44 @@ +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" ? ( + + ) : ( + + )} + + ); +}; 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 48575263..b29362fe 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,21 @@ 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; +}; + +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"); From 1beecde328fa44da52391c4488acda34dfc1a3a9 Mon Sep 17 00:00:00 2001 From: Eugene Chen <332385214+echen-dls@users.noreply.github.com> Date: Wed, 28 Oct 2026 17:08:54 +0000 Subject: [PATCH 2/5] Clean up code --- frontend/unified/src/App.tsx | 6 ++- frontend/unified/src/beamline.config.ts | 35 ++++--------- .../ParameterConfiguration.tsx | 3 +- .../unified/src/components/ScanSelector.tsx | 52 ++++--------------- .../src/components/ScanSelectorCombined.tsx | 5 +- 5 files changed, 30 insertions(+), 71 deletions(-) diff --git a/frontend/unified/src/App.tsx b/frontend/unified/src/App.tsx index 30ecb089..c3576015 100644 --- a/frontend/unified/src/App.tsx +++ b/frontend/unified/src/App.tsx @@ -33,8 +33,8 @@ 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, }; @@ -42,6 +42,8 @@ export const App: React.FC = ({ session }: AppProps) => { const beamline = Beamline[session.instrument.name as keyof typeof Beamline]; + console.log("scanIds", selectedScanIds); + return ( = { formatter: (v: T) => string; }; -type BeamlineConfig2 = { +type BeamlineConfig = { ulims_schema: string; version: string; startTime: (data: ScanNodeRaw) => string | undefined | null; @@ -35,13 +35,12 @@ export function isMetadataComponent( export function convertScanRequestToObject(data: ScanNodeRaw): ScanNodeRaw { if (data?.data?.scan_parameters?.scan_request) { - console.log(data.data.scan_parameters.scan_request); - var scanParameters = { + const scanParameters = { ...data.data.scan_parameters, scan_request: JSON.parse(data.data.scan_parameters.scan_request), }; - var newData = { ...data.data, scan_parameters: scanParameters }; - var newScanData = { ...data, data: newData }; + const newData = { ...data.data, scan_parameters: scanParameters }; + const newScanData = { ...data, data: newData }; return newScanData; } @@ -72,8 +71,7 @@ export function getProcessedScanNode( const processedScanNodes: ScanNodeProcessed[] = parsedNodes.map((x) => { return { ...x, - scanNumber: - schema.scanNumber(x) ?? Number(regex.exec(x.title)?.[0]) ?? null, + 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)), @@ -85,24 +83,12 @@ export function getProcessedScanNode( export function getBeamlineConfig( schema: string | null -): BeamlineConfig2 | null { +): BeamlineConfig | null { if (schema) { const baseName = schema.split("/").slice(0, -1).join("/"); - console.log("baseName", baseName); const versionNumber = schema.split("/").at(-1); for (const conf of BEAMLINE_CONFIG) { - console.log( - "baseName === conf.ulims_schema", - baseName, - conf.ulims_schema - ); - console.log( - "`${versionNumber}.json` === conf.version", - `${versionNumber}.json`, - conf.version - ); - // TODO: Perform better version number matching if ( baseName === conf.ulims_schema && @@ -112,18 +98,17 @@ export function getBeamlineConfig( } } } - return null; } -const BEAMLINE_CONFIG: BeamlineConfig2[] = [ +const BEAMLINE_CONFIG: BeamlineConfig[] = [ { ulims_schema: "https://raw.githubusercontent.com/DiamondLightSource/ulims-json-schemas/main/schemas/data_catalogue/i14/dummy_scan", version: "0.0.1", startTime: (v) => v.data?.scan_parameters?.start_time, endTime: (v) => v.data?.scan_parameters?.end_time, - scanNumber: (v) => null, + scanNumber: (_) => null, components: [ { groupTitle: "Times", @@ -226,12 +211,12 @@ const BEAMLINE_CONFIG: BeamlineConfig2[] = [ { title: "Slit Size X", getter: (v) => v.data?.optical_parameters?.slit_size_x, - formatter: (v) => FORMATTER.NONE, + formatter: FORMATTER.NONE, }, { title: "Slit Size Y", getter: (v) => v.data?.optical_parameters?.slit_size_y, - formatter: (v) => FORMATTER.NONE, + formatter: FORMATTER.NONE, }, ], }, diff --git a/frontend/unified/src/components/ParameterConfiguration/ParameterConfiguration.tsx b/frontend/unified/src/components/ParameterConfiguration/ParameterConfiguration.tsx index e550a9e6..75caa1b4 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 "../SessionSelector"; 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 4897ed07..6a44729e 100644 --- a/frontend/unified/src/components/ScanSelector.tsx +++ b/frontend/unified/src/components/ScanSelector.tsx @@ -22,7 +22,7 @@ export function formatDate(date: Date | null) { return "No date"; } -// Infinite scroll example: +// Derived from infinite scroll example: // https://www.material-react-table.com/docs/examples/infinite-scrolling const columnHelper = createMRTColumnHelper(); @@ -66,11 +66,6 @@ export function ScanSelector({ sessionRef, onScanSelect }: ScanSelectorProp) { return desc; }; - console.log("sorting ", isDescSort(sorting)); - - console.log("scanselector"); - - // let cursor: string | null = null; const { data, loading, fetchMore, client } = useQuery( INSTRUMENTSESSION_QUERY, { @@ -86,21 +81,16 @@ export function ScanSelector({ sessionRef, onScanSelect }: ScanSelectorProp) { useEffect(() => { if (isDescSort(sorting)) { - console.log("assign descPage"); descPage.current = data?.instrumentSessionByReference?.datasets.pageInfo ?? null; } else { - console.log("assign ascPage"); ascPage.current = data?.instrumentSessionByReference?.datasets.pageInfo ?? null; } - }, [data]); - - console.log(data); + }, [data, sorting]); const [rowSelection, setRowSelection] = useState({}); - // console.log("scans[0].title", (scans[0]) ? scans[0].title : ""); const fetchMoreOnBottomReached = useCallback( (containerRefElement?: HTMLDivElement | null) => { if (containerRefElement) { @@ -129,7 +119,7 @@ export function ScanSelector({ sessionRef, onScanSelect }: ScanSelectorProp) { } } }, - [loading] + [loading, fetchMore, sessionRef, sorting] ); let parsedNodes: ScanNodeProcessed[] = []; @@ -145,10 +135,7 @@ export function ScanSelector({ sessionRef, onScanSelect }: ScanSelectorProp) { return 0; }); - console.log("sortDesc ", isDescSort(sorting)); - const previousRow = useRef | null>(null); - console.log("previousRow ", previousRow, ", rowSelection ", rowSelection); const table: MRT_TableInstance = useMaterialReactTable({ columns: columns, @@ -159,8 +146,6 @@ export function ScanSelector({ sessionRef, onScanSelect }: ScanSelectorProp) { enablePagination: false, manualSorting: true, manualFiltering: true, - // mrtTheme: DiamondDSIntegrations.mrtTheme, - // ...DiamondDSIntegrations.mrtOptions(), enableRowSelection: false, enableMultiRowSelection: false, enableBottomToolbar: false, @@ -175,22 +160,18 @@ export function ScanSelector({ sessionRef, onScanSelect }: ScanSelectorProp) { setRowSelection((prev) => { // Shift + click selection // https://stackoverflow.com/a/56178061 - let newState = { ...prev }; - - console.log("e.shiftKey ", e.shiftKey); + 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; } - // or the opposite } else { for (let i = row.index; i <= previousRow.current.index; i++) { newState[i] = true; } } - console.log("newState ", newState); return newState; } else if (e.ctrlKey) { return { @@ -216,11 +197,7 @@ export function ScanSelector({ sessionRef, onScanSelect }: ScanSelectorProp) { rowSelection, sorting, }, - enableRowVirtualization: true, - // rowVirtualizerInstanceRef: rowVirtualizerInstanceRef, - // rowVirtualizerOptions: { overscan: 4 }, - muiTableContainerProps: { ref: tableContainerRef, //get access to the table container element sx: { @@ -231,32 +208,25 @@ export function ScanSelector({ sessionRef, onScanSelect }: ScanSelectorProp) { }, }); + // 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); + // 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; - console.log("fetchMore sorting ", isDescSort(sorting)); - console.log("first ", isDescSort(sorting) ? FETCH_SIZE : null); - console.log("last ", !isDescSort(sorting) ? FETCH_SIZE : null); - console.log( - "after ", - isDescSort(sorting) ? (pageData?.endCursor ?? null) : null - ); - console.log( - "before ", - !isDescSort(sorting) ? (pageData?.startCursor ?? null) : null - ); - // Only fetch is the sort order has actually changed + // Only fetch when the sort order has actually changed if (prevDesc.current !== isDescSort(sorting)) { client.resetStore(); fetchMore({ @@ -272,7 +242,7 @@ export function ScanSelector({ sessionRef, onScanSelect }: ScanSelectorProp) { sortingChanged.current = true; prevDesc.current = isDescSort(sorting); - }, [sorting]); + }, [sorting, client, fetchMore, sessionRef]); return ; } diff --git a/frontend/unified/src/components/ScanSelectorCombined.tsx b/frontend/unified/src/components/ScanSelectorCombined.tsx index 3373df88..2ea49883 100644 --- a/frontend/unified/src/components/ScanSelectorCombined.tsx +++ b/frontend/unified/src/components/ScanSelectorCombined.tsx @@ -35,7 +35,10 @@ export const ScanSelectorCombined = ({ {selectorType === "new" ? ( - + setScanIds(v.map((x) => x.scanNumber))} + sessionRef={sessionRef} + /> ) : ( )} From 2ad1d8651ccd1224e775b69f1cca5bdb5defa8c8 Mon Sep 17 00:00:00 2001 From: Eugene Chen <332385214+echen-dls@users.noreply.github.com> Date: Thu, 29 Oct 2026 09:42:09 +0000 Subject: [PATCH 3/5] Clear selected scan IDs on session change, change scanRequest parsing --- frontend/unified/src/App.tsx | 7 ++- frontend/unified/src/beamline.config.ts | 62 +++++++++---------- .../src/components/NavbarSessionSelector.tsx | 2 +- frontend/unified/src/types.ts | 1 + 4 files changed, 36 insertions(+), 36 deletions(-) diff --git a/frontend/unified/src/App.tsx b/frontend/unified/src/App.tsx index c3576015..6796db68 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"; @@ -42,7 +42,10 @@ export const App: React.FC = ({ session }: AppProps) => { const beamline = Beamline[session.instrument.name as keyof typeof Beamline]; - console.log("scanIds", selectedScanIds); + // Clear selected scan IDs when session changes + useEffect(() => { + setSelectedScanIds([]); + }, [sessionRef]); return ( diff --git a/frontend/unified/src/beamline.config.ts b/frontend/unified/src/beamline.config.ts index f790f83a..d423c5c5 100644 --- a/frontend/unified/src/beamline.config.ts +++ b/frontend/unified/src/beamline.config.ts @@ -12,7 +12,7 @@ export type MetadataContainer = { }; export type MetadataComponent = { - getter: (data: ScanNodeRaw) => T | undefined | null; + getter: (data: ScanNodeProcessed) => T | undefined | null; group?: string; title?: string; formatter: (v: T) => string; @@ -24,6 +24,7 @@ type BeamlineConfig = { 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>; }; @@ -33,20 +34,6 @@ export function isMetadataComponent( return (v as MetadataComponent).getter !== undefined; } -export function convertScanRequestToObject(data: ScanNodeRaw): ScanNodeRaw { - if (data?.data?.scan_parameters?.scan_request) { - const scanParameters = { - ...data.data.scan_parameters, - scan_request: JSON.parse(data.data.scan_parameters.scan_request), - }; - const newData = { ...data.data, scan_parameters: scanParameters }; - const newScanData = { ...data, data: newData }; - - return newScanData; - } - return data; -} - const fmtDate: (x: string | null | undefined) => Date | null = (x) => { return x ? new Date(x) : null; }; @@ -66,15 +53,20 @@ export function getProcessedScanNode( // Regex to match scan number in Nexus file name const regex = new RegExp("\\d{5,}"); - const parsedNodes = nodes.map((x) => convertScanRequestToObject(x)); + const parseScanRequest = ( + v: string | null | undefined + ): { [key: object]: any } | null => { + return v ? JSON.parse(v) : null; + }; - const processedScanNodes: ScanNodeProcessed[] = parsedNodes.map((x) => { + 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)), }; }); @@ -103,24 +95,33 @@ export function getBeamlineConfig( const BEAMLINE_CONFIG: BeamlineConfig[] = [ { + // Provide schema version that this config is valid for ulims_schema: "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", - getter: (v) => v.data?.scan_parameters?.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.data?.scan_parameters?.end_time, + getter: (v) => v?.endTime, formatter: FORMATTER.DATETIME, }, ], @@ -134,6 +135,7 @@ const BEAMLINE_CONFIG: BeamlineConfig[] = [ 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", @@ -145,12 +147,12 @@ const BEAMLINE_CONFIG: BeamlineConfig[] = [ children: [ { title: "Start time", - getter: (v) => v.data?.scan_parameters?.start_time, + getter: (v) => v?.startTime, formatter: FORMATTER.DATETIME, }, { title: "End time", - getter: (v) => v.data?.scan_parameters?.end_time, + getter: (v) => v?.endTime, formatter: FORMATTER.DATETIME, }, ], @@ -171,9 +173,7 @@ const BEAMLINE_CONFIG: BeamlineConfig[] = [ { title: "Exposure time", getter: (v) => - v.data?.scan_parameters?.scan_request?.detectors?.[ - "BL14I-ML-SCAN-01" - ]?.exposureTime, + v?.scanRequest?.detectors?.["BL14I-ML-SCAN-01"]?.exposureTime, formatter: (v) => `${v} s`, }, ], @@ -184,23 +184,20 @@ const BEAMLINE_CONFIG: BeamlineConfig[] = [ { title: "Scan ROI", getter: (v) => - v.data?.scan_parameters?.scan_request?.compoundModel?.regions?.[0] - ?.roi?.lengths, + 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.data?.scan_parameters?.scan_request?.compoundModel?.models?.[0] - ?.xAxisStep, + v?.scanRequest?.compoundModel?.models?.[0]?.xAxisStep, formatter: (v) => `${v * 1e6} nm`, }, { title: "Y Axis Step", getter: (v) => - v.data?.scan_parameters?.scan_request?.compoundModel?.models?.[0] - ?.yAxisStep, + v?.scanRequest?.compoundModel?.models?.[0]?.yAxisStep, formatter: (v) => `${v * 1e6} nm`, }, ], @@ -223,9 +220,8 @@ const BEAMLINE_CONFIG: BeamlineConfig[] = [ { title: "Element list", getter: (v) => - v.data?.scan_parameters?.scan_request?.processingRequest?.request?.[ - "i14-xrf" - ]?.[0]?.element_list, + 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/types.ts b/frontend/unified/src/types.ts index b29362fe..61ce32e0 100644 --- a/frontend/unified/src/types.ts +++ b/frontend/unified/src/types.ts @@ -142,6 +142,7 @@ export type ScanNodeProcessed = ScanNodeRaw & { beamline: string | null; startTime: Date | null; endTime: Date | null; + scanRequest: { [key: string]: any } | null; }; export type PageInfo = NonNullable< From 668e46575f4728e14d8e8576d619c8246cdc4b12 Mon Sep 17 00:00:00 2001 From: Eugene Chen <332385214+echen-dls@users.noreply.github.com> Date: Thu, 29 Oct 2026 09:50:24 +0000 Subject: [PATCH 4/5] Clean up code --- frontend/unified/src/App.tsx | 3 +-- frontend/unified/src/AppWrapper.tsx | 2 -- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/frontend/unified/src/App.tsx b/frontend/unified/src/App.tsx index c52237f3..9a832ad6 100644 --- a/frontend/unified/src/App.tsx +++ b/frontend/unified/src/App.tsx @@ -48,7 +48,7 @@ export const App: React.FC = ({ session }: AppProps) => { }, [sessionRef]); return ( - + = ({ session }: AppProps) => { flexShrink: 0, "& .MuiDrawer-paper": { width: SIDEBAR_WIDTH, - height: "100%", boxSizing: "border-box", }, }} diff --git a/frontend/unified/src/AppWrapper.tsx b/frontend/unified/src/AppWrapper.tsx index 0faa9927..387e4c40 100644 --- a/frontend/unified/src/AppWrapper.tsx +++ b/frontend/unified/src/AppWrapper.tsx @@ -18,8 +18,6 @@ export const AppWrapper: React.FC = ({ const { appStatusVal } = outletCtx.appStatus; - console.log("appstatus", appStatusVal); - switch (appStatusVal.state) { case "VALID": { // Render the main app From 3a3e1ae41885e31786d5b7f781860b78fa823b39 Mon Sep 17 00:00:00 2001 From: Eugene Chen <332385214+echen-dls@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:31:13 +0000 Subject: [PATCH 5/5] Change ulims_schema to camel case --- frontend/unified/src/beamline.config.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/unified/src/beamline.config.ts b/frontend/unified/src/beamline.config.ts index d423c5c5..a6c8f774 100644 --- a/frontend/unified/src/beamline.config.ts +++ b/frontend/unified/src/beamline.config.ts @@ -19,7 +19,7 @@ export type MetadataComponent = { }; type BeamlineConfig = { - ulims_schema: string; + ulimsSchema: string; version: string; startTime: (data: ScanNodeRaw) => string | undefined | null; endTime: (data: ScanNodeRaw) => string | undefined | null; @@ -83,7 +83,7 @@ export function getBeamlineConfig( for (const conf of BEAMLINE_CONFIG) { // TODO: Perform better version number matching if ( - baseName === conf.ulims_schema && + baseName === conf.ulimsSchema && versionNumber === `${conf.version}.json` ) { return conf; @@ -96,7 +96,7 @@ export function getBeamlineConfig( const BEAMLINE_CONFIG: BeamlineConfig[] = [ { // Provide schema version that this config is valid for - ulims_schema: + ulimsSchema: "https://raw.githubusercontent.com/DiamondLightSource/ulims-json-schemas/main/schemas/data_catalogue/i14/dummy_scan", version: "0.0.1", @@ -129,7 +129,7 @@ const BEAMLINE_CONFIG: BeamlineConfig[] = [ ], }, { - ulims_schema: + 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,