From 461cfafef4f22f2604334c82f44575a93e00b794 Mon Sep 17 00:00:00 2001 From: Patrick Kalita Date: Wed, 20 Nov 2024 13:57:19 -0800 Subject: [PATCH 01/16] Save slot visibility on submission server data instead of local settings --- src/Router.tsx | 6 -- src/api.ts | 21 ++++- src/components/SampleView/SampleView.tsx | 60 ++----------- .../SettingsAppearanceList.tsx | 8 +- .../SlotSelectorModal/SlotSelectorModal.tsx | 56 +++++------- src/components/StudyView/StudyView.tsx | 86 ++++++++++++++++++- src/mocks/fixtures.ts | 5 ++ .../FieldVisibilitySettingsPage.tsx | 84 ------------------ src/pages/SamplePage/SamplePage.tsx | 5 ++ src/paths.ts | 1 - 10 files changed, 140 insertions(+), 192 deletions(-) delete mode 100644 src/pages/FieldVisibilitySettingsPage/FieldVisibilitySettingsPage.tsx diff --git a/src/Router.tsx b/src/Router.tsx index d8ca064..159384c 100644 --- a/src/Router.tsx +++ b/src/Router.tsx @@ -17,7 +17,6 @@ import GuidePage from "./pages/GuidePage/GuidePage"; import SettingsPage from "./pages/SettingsPage/SettingsPage"; import AppUrlListener from "./components/AppUrlListener/AppUrlListener"; import RootPage from "./pages/RootPage/RootPage"; -import FieldVisibilitySettingsPage from "./pages/FieldVisibilitySettingsPage/FieldVisibilitySettingsPage"; import paths, { IN } from "./paths"; const Router: React.FC = () => { @@ -78,11 +77,6 @@ const Router: React.FC = () => { - {/* SETTINGS ROUTES */} - - - - diff --git a/src/api.ts b/src/api.ts index 9dea07c..833896f 100644 --- a/src/api.ts +++ b/src/api.ts @@ -179,10 +179,15 @@ export interface SubmissionMetadataCreate extends SubmissionMetadataBase { export interface SubmissionMetadataUpdate extends SubmissionMetadataBase { id: string; status?: string; + field_notes_metadata?: Nullable; // Map of ORCID iD to permission level permissions?: Record; } +export interface FieldNotesMetadata { + fieldVisibility?: Record; +} + export interface SubmissionMetadata extends SubmissionMetadataCreate { status: string; id: string; @@ -192,6 +197,7 @@ export interface SubmissionMetadata extends SubmissionMetadataCreate { lock_updated?: string; locked_by: Nullable; permission_level?: string; + field_notes_metadata?: Nullable; } export interface PaginationOptions { @@ -288,6 +294,11 @@ export class FetchClient { } } +interface GetSubmissionListOptions extends PaginationOptions { + column_sort?: string; + sort_order?: "asc" | "desc"; +} + class NmdcServerClient extends FetchClient { private refreshToken: string | null = null; private exchangeRefreshTokenCache: Promise | null = null; @@ -340,13 +351,15 @@ class NmdcServerClient extends FetchClient { } } - async getSubmissionList(pagination: PaginationOptions = {}) { - pagination = { + async getSubmissionList(options: GetSubmissionListOptions = {}) { + options = { limit: 10, offset: 0, - ...pagination, + column_sort: "created", + sort_order: "desc", + ...options, }; - const query = new URLSearchParams(pagination as Record); + const query = new URLSearchParams(options as Record); return await this.fetchJson>( `/api/metadata_submission?${query}`, ); diff --git a/src/components/SampleView/SampleView.tsx b/src/components/SampleView/SampleView.tsx index 6334631..6afd6c8 100644 --- a/src/components/SampleView/SampleView.tsx +++ b/src/components/SampleView/SampleView.tsx @@ -1,13 +1,10 @@ import React from "react"; import { SampleData, SampleDataValue, TEMPLATES } from "../../api"; import { groupClassSlots } from "../../utils"; -import Banner from "../Banner/Banner"; import SectionHeader from "../SectionHeader/SectionHeader"; -import { IonButton, IonIcon, IonItem, IonLabel, IonList } from "@ionic/react"; +import { IonIcon, IonItem, IonLabel, IonList } from "@ionic/react"; import { SchemaDefinition, SlotDefinition } from "../../linkml-metamodel"; import { warningOutline } from "ionicons/icons"; -import { useStore } from "../../Store"; -import SlotSelectorModal from "../SlotSelectorModal/SlotSelectorModal"; function formatSlotValue(value: SampleDataValue) { if (value == null) { @@ -25,6 +22,7 @@ interface SampleViewProps { sample?: SampleData; schema: SchemaDefinition; validationResults?: Record; + visibleSlots?: string[]; } const SampleView: React.FC = ({ onSlotClick, @@ -32,49 +30,25 @@ const SampleView: React.FC = ({ sample, schema, validationResults, + visibleSlots, }) => { - const [isModalOpen, setIsModalOpen] = React.useState(false); - const { getHiddenSlotsForSchemaClass, setHiddenSlotsForSchemaClass } = - useStore(); - const schemaClass = TEMPLATES[packageName].schemaClass; - const hiddenSlots = getHiddenSlotsForSchemaClass(schemaClass); const slotGroups = schemaClass ? groupClassSlots(schema, schemaClass) : []; - const handleDismiss = () => { - setHiddenSlotsForSchemaClass(schemaClass, []); - }; - if (!sample) { return null; } return ( <> - {hiddenSlots === undefined && ( - - Too many fields? - setIsModalOpen(true)} - > - Customize List - - - Dismiss - - - )} - {slotGroups.map((group) => ( {group.title} {group.slots.map( (slot) => - (hiddenSlots === undefined || - !hiddenSlots.includes(slot.name)) && ( + (visibleSlots === undefined || + visibleSlots.includes(slot.name)) && ( onSlotClick(slot)}> {validationResults?.[slot.name] && ( = ({ ))} - - {hiddenSlots !== undefined && hiddenSlots.length > 0 && ( - - setIsModalOpen(true)} - > - -

- Not seeing a field you were looking for? Tap here to update - field visibility settings. -

-
-
-
- )} - - setIsModalOpen(false)} - isOpen={isModalOpen} - packageName={packageName} - /> ); }; diff --git a/src/components/SettingsAppearanceList/SettingsAppearanceList.tsx b/src/components/SettingsAppearanceList/SettingsAppearanceList.tsx index 54857a0..be5dcbf 100644 --- a/src/components/SettingsAppearanceList/SettingsAppearanceList.tsx +++ b/src/components/SettingsAppearanceList/SettingsAppearanceList.tsx @@ -1,7 +1,6 @@ -import { IonItem, IonLabel, IonList } from "@ionic/react"; +import { IonItem, IonList } from "@ionic/react"; import ColorPaletteModeSelector from "../ColorPaletteModeSelector/ColorPaletteModeSelector"; import React from "react"; -import paths from "../../paths"; import TourVisibilityManager from "../TourVisibilityManager/TourVisibilityManager"; const SettingsAppearanceList: React.FC = () => { @@ -10,11 +9,6 @@ const SettingsAppearanceList: React.FC = () => { - - -

Field Visibility

-
-
diff --git a/src/components/SlotSelectorModal/SlotSelectorModal.tsx b/src/components/SlotSelectorModal/SlotSelectorModal.tsx index 4e0eb7c..eab1f9c 100644 --- a/src/components/SlotSelectorModal/SlotSelectorModal.tsx +++ b/src/components/SlotSelectorModal/SlotSelectorModal.tsx @@ -4,36 +4,40 @@ import { IonButtons, IonContent, IonHeader, + IonIcon, IonModal, IonTitle, IonToolbar, } from "@ionic/react"; +import { closeOutline } from "ionicons/icons"; import RequiredMark from "../RequiredMark/RequiredMark"; import SlotSelector from "../SlotSelector/SlotSelector"; import { useSubmissionSchema } from "../../queries"; import { groupClassSlots } from "../../utils"; import { TEMPLATES } from "../../api"; -import { useStore } from "../../Store"; import styles from "./SlotSelectorModal.module.css"; export interface SlotSelectorModalProps { - onDismiss: () => void; + defaultSelectedSlots?: string[]; isOpen: boolean; - packageName?: string; + onDismiss: () => void; + onSave: (selectedSlots: string[]) => void; + templateName?: string; } const SlotSelectorModal: React.FC = ({ - onDismiss, + defaultSelectedSlots, isOpen, - packageName, + onDismiss, + onSave, + templateName, }) => { const [selectedSlots, setSelectedSlots] = React.useState([]); const schema = useSubmissionSchema(); - const { getHiddenSlotsForSchemaClass, setHiddenSlotsForSchemaClass } = - useStore(); - const schemaClassName = packageName && TEMPLATES[packageName].schemaClass; - const templateName = packageName && TEMPLATES[packageName].displayName; + const schemaClassName = templateName && TEMPLATES[templateName].schemaClass; + const templateDisplayName = + templateName && TEMPLATES[templateName].displayName; const slotGroups = useMemo( () => @@ -42,41 +46,25 @@ const SlotSelectorModal: React.FC = ({ : [], [schema.data, schemaClassName], ); - const allSlotNames = useMemo( - () => slotGroups.flatMap((group) => group.slots.map((s) => s.name)), - [slotGroups], - ); - // This translates a list of hidden slots from the store into a list of selected slots for the - // SlotSelector component. If there are no hidden slots, all slots are selected by default. The - // isOpen state is used to reset the selected slots when the modal is closed (i.e. don't keep + // The isOpen state is used to reset the selected slots when the modal is closed (i.e. don't keep // changes if the user cancels out of the modal). useEffect(() => { - if (isOpen && schemaClassName !== undefined) { - const hiddenSlotsFromStore = - getHiddenSlotsForSchemaClass(schemaClassName); - if (hiddenSlotsFromStore === undefined) { - setSelectedSlots(allSlotNames); + if (isOpen) { + if (defaultSelectedSlots === undefined) { + setSelectedSlots([]); } else { - setSelectedSlots( - allSlotNames.filter((s) => !hiddenSlotsFromStore.includes(s)), - ); + setSelectedSlots(defaultSelectedSlots); } } else { setSelectedSlots([]); } - }, [getHiddenSlotsForSchemaClass, isOpen, schemaClassName, allSlotNames]); + }, [isOpen, defaultSelectedSlots]); // When the user taps the Save button, translate the selected slots back into a list of hidden // slots and save them to the store. Then close the modal. const handleSave = () => { - if (schemaClassName !== undefined) { - const hiddenSlots = allSlotNames.filter( - (s) => !selectedSlots.includes(s), - ); - setHiddenSlotsForSchemaClass(schemaClassName, hiddenSlots); - } - onDismiss(); + onSave(selectedSlots); }; return ( @@ -89,7 +77,7 @@ const SlotSelectorModal: React.FC = ({ - Cancel + Select Fields @@ -104,7 +92,7 @@ const SlotSelectorModal: React.FC = ({

Select the fields you would like to see when viewing and editing - sample metadata for the {templateName} template. These + sample metadata for the {templateDisplayName} template. These choices can be updated any time in Settings.

diff --git a/src/components/StudyView/StudyView.tsx b/src/components/StudyView/StudyView.tsx index 0ee9b58..526503b 100644 --- a/src/components/StudyView/StudyView.tsx +++ b/src/components/StudyView/StudyView.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useMemo } from "react"; import { useSubmission } from "../../queries"; import { IonItem, @@ -18,6 +18,15 @@ import paths from "../../paths"; import { useNetworkStatus } from "../../NetworkStatus"; import QueryErrorBanner from "../QueryErrorBanner/QueryErrorBanner"; import MutationErrorBanner from "../MutationErrorBanner/MutationErrorBanner"; +import SlotSelectorModal from "../SlotSelectorModal/SlotSelectorModal"; +import { TEMPLATES } from "../../api"; +import Pluralize from "../Pluralize/Pluralize"; + +interface TemplateVisibleSlots { + template: string; + templateDisplay: string; + visibleSlots: string[] | undefined; +} interface StudyViewProps { submissionId: string; @@ -31,6 +40,23 @@ const StudyView: React.FC = ({ submissionId }) => { lockMutation, } = useSubmission(submissionId); const { isOnline } = useNetworkStatus(); + const [modalTemplateVisibleSlots, setModalTemplateVisibleSlots] = + React.useState(undefined); + + const templateVisibleSlots = useMemo(() => { + const fieldVisibilityInfo: TemplateVisibleSlots[] = []; + if (submission.data) { + const packageName = submission.data.metadata_submission.packageName; + const template = TEMPLATES[packageName]; + fieldVisibilityInfo.push({ + template: packageName, + templateDisplay: template.displayName, + visibleSlots: + submission.data.field_notes_metadata?.fieldVisibility?.[packageName], + }); + } + return fieldVisibilityInfo; + }, [submission.data]); const handleSampleCreate = async () => { if (!submission.data) { @@ -68,6 +94,28 @@ const StudyView: React.FC = ({ submissionId }) => { event.detail.complete(); }; + const handleSlotSelectorSave = (selectedSlots: string[]) => { + if (!submission.data) { + return; + } + const updatedSubmission = produce(submission.data, (draft) => { + if (!draft.field_notes_metadata) { + draft.field_notes_metadata = {}; + } + if (!draft.field_notes_metadata.fieldVisibility) { + draft.field_notes_metadata.fieldVisibility = {}; + } + draft.field_notes_metadata.fieldVisibility[ + modalTemplateVisibleSlots!.template + ] = selectedSlots; + }); + updateMutation.mutate(updatedSubmission, { + onSuccess: () => { + setModalTemplateVisibleSlots(undefined); + }, + }); + }; + return ( <> @@ -150,6 +198,34 @@ const StudyView: React.FC = ({ submissionId }) => { + Templates + + {templateVisibleSlots.map((item) => ( + setModalTemplateVisibleSlots(item)} + > + +

{item.templateDisplay}

+

+ {item.visibleSlots === undefined ? ( + "Not customized" + ) : ( + <> + {" "} + chosen + + )} +

+ + + ))} + + = ({ submissionId }) => { : undefined } /> + + setModalTemplateVisibleSlots(undefined)} + isOpen={modalTemplateVisibleSlots !== undefined} + templateName={modalTemplateVisibleSlots?.template} + defaultSelectedSlots={modalTemplateVisibleSlots?.visibleSlots} + /> )} diff --git a/src/mocks/fixtures.ts b/src/mocks/fixtures.ts index 8158b06..10eda4c 100644 --- a/src/mocks/fixtures.ts +++ b/src/mocks/fixtures.ts @@ -42,6 +42,11 @@ export function generateSubmission( }, locked_by: null, source_client: "field_notes", + field_notes_metadata: { + fieldVisibility: { + soil: ["samp_name"], + }, + }, }; } diff --git a/src/pages/FieldVisibilitySettingsPage/FieldVisibilitySettingsPage.tsx b/src/pages/FieldVisibilitySettingsPage/FieldVisibilitySettingsPage.tsx deleted file mode 100644 index f959427..0000000 --- a/src/pages/FieldVisibilitySettingsPage/FieldVisibilitySettingsPage.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import React from "react"; -import { - IonBackButton, - IonButtons, - IonContent, - IonHeader, - IonItem, - IonLabel, - IonList, - IonPage, - IonTitle, -} from "@ionic/react"; -import paths from "../../paths"; -import { TEMPLATES } from "../../api"; -import ThemedToolbar from "../../components/ThemedToolbar/ThemedToolbar"; -import SlotSelectorModal from "../../components/SlotSelectorModal/SlotSelectorModal"; -import { useStore } from "../../Store"; -import Pluralize from "../../components/Pluralize/Pluralize"; - -interface TemplateItemLabelProps { - template: string; -} -const TemplateItemLabel: React.FC = ({ template }) => { - const { getHiddenSlotsForSchemaClass } = useStore(); - const templateInfo = TEMPLATES[template]; - const hiddenSlots = getHiddenSlotsForSchemaClass(templateInfo.schemaClass); - return ( - -

{templateInfo.displayName}

-

- {hiddenSlots === undefined ? ( - "Not customized" - ) : ( - <> - {" "} - hidden - - )} -

-
- ); -}; - -const FieldVisibilitySettingsPage: React.FC = () => { - const [selectedTemplate, setSelectedTemplate] = React.useState< - string | undefined - >(undefined); - - return ( - - - - - - - Field Visibility - - - -

- Select a template to customize which fields are visible when viewing - and editing sample metadata for that template. -

- - {Object.keys(TEMPLATES).map((template) => ( - setSelectedTemplate(template)} - > - - - ))} - - setSelectedTemplate(undefined)} - isOpen={selectedTemplate !== undefined} - packageName={selectedTemplate} - /> -
-
- ); -}; - -export default FieldVisibilitySettingsPage; diff --git a/src/pages/SamplePage/SamplePage.tsx b/src/pages/SamplePage/SamplePage.tsx index 762c89f..dd9ae4f 100644 --- a/src/pages/SamplePage/SamplePage.tsx +++ b/src/pages/SamplePage/SamplePage.tsx @@ -264,6 +264,11 @@ const SamplePage: React.FC = () => { sample={sample} schema={schema.data.schema} validationResults={validationResults?.[sampleIndexInt]} + visibleSlots={ + submission.data?.field_notes_metadata?.fieldVisibility?.[ + packageName! + ] + } /> Date: Wed, 20 Nov 2024 14:15:47 -0800 Subject: [PATCH 02/16] Remove unused Store functions for handling hidden slots --- src/Store.test.tsx | 76 +--------------------------------------------- src/Store.tsx | 59 ----------------------------------- 2 files changed, 1 insertion(+), 134 deletions(-) diff --git a/src/Store.test.tsx b/src/Store.test.tsx index 9119ac5..3113097 100644 --- a/src/Store.test.tsx +++ b/src/Store.test.tsx @@ -7,15 +7,7 @@ import userEvent from "@testing-library/user-event"; import { server, tokenExchangeError } from "./mocks/server"; const TestStoreConsumer: React.FC = () => { - const { - login, - logout, - isLoggedIn, - loggedInUser, - store, - getHiddenSlotsForSchemaClass, - setHiddenSlotsForSchemaClass, - } = useStore(); + const { login, logout, isLoggedIn, loggedInUser, store } = useStore(); const handleLoginClick = async () => { await login("access-token", "refresh-token"); @@ -25,9 +17,6 @@ const TestStoreConsumer: React.FC = () => { await logout(); }; - const soilHiddenSlots = getHiddenSlotsForSchemaClass("soil"); - const waterHiddenSlots = getHiddenSlotsForSchemaClass("water"); - return ( <>
@@ -42,23 +31,6 @@ const TestStoreConsumer: React.FC = () => { - -
- {soilHiddenSlots === undefined - ? "undefined" - : soilHiddenSlots.join(", ")} -
-
- {waterHiddenSlots === undefined - ? "undefined" - : waterHiddenSlots.join(", ")} -
- ); }; @@ -84,9 +56,6 @@ const renderTestStoreConsumer = () => { loggedInUser: screen.getByTestId("logged-in-user"), loginButton: screen.getByTestId("login-button"), logoutButton: screen.getByTestId("logout-button"), - hiddenSlotsSoil: screen.getByTestId("hidden-slots-soil"), - hiddenSlotsWater: screen.getByTestId("hidden-slots-water"), - setHiddenSlotsSoil: screen.getByTestId("set-hidden-slots-soil"), }, }; }; @@ -197,47 +166,4 @@ describe("Store", () => { expect(elements.loggedInUser.textContent).toBe(""); expect(setTokensSpy).not.toHaveBeenCalled(); }); - - it("getHiddenSlotsForSchemaClass should return undefined by default", async () => { - const { elements } = renderTestStoreConsumer(); - - await waitFor(() => - expect(elements.storeStatus.textContent).toBe("store created"), - ); - expect(elements.hiddenSlotsWater.textContent).toBe("undefined"); - expect(elements.hiddenSlotsSoil.textContent).toBe("undefined"); - }); - - it("getHiddenSlotsForSchemaClass should return values from storage if defined", async () => { - window.localStorage.setItem( - "nmdc_field_notes/app_store/hiddenSlots", - '{"soil":["slotA","slotB"]}', - ); - - const { elements } = renderTestStoreConsumer(); - - await waitFor(() => - expect(elements.storeStatus.textContent).toBe("store created"), - ); - expect(elements.hiddenSlotsSoil.textContent).toBe("slotA, slotB"); - expect(elements.hiddenSlotsWater.textContent).toBe("undefined"); - }); - - it("setHiddenSlotsForSchemaClass should update the store", async () => { - const { elements, user } = renderTestStoreConsumer(); - - await waitFor(() => - expect(elements.storeStatus.textContent).toBe("store created"), - ); - expect(elements.hiddenSlotsSoil.textContent).toBe("undefined"); - expect(elements.hiddenSlotsWater.textContent).toBe("undefined"); - - await user.click(elements.setHiddenSlotsSoil); - - expect(elements.hiddenSlotsSoil.textContent).toBe("slot1, slot2"); - expect(elements.hiddenSlotsWater.textContent).toBe("undefined"); - expect( - window.localStorage.getItem("nmdc_field_notes/app_store/hiddenSlots"), - ).toBe('{"soil":["slot1","slot2"]}'); - }); }); diff --git a/src/Store.tsx b/src/Store.tsx index 6760813..3ddde2f 100644 --- a/src/Store.tsx +++ b/src/Store.tsx @@ -12,7 +12,6 @@ import { ColorPaletteMode, isValidColorPaletteMode, } from "./theme/colorPalette"; -import { produce } from "immer"; import { Network } from "@capacitor/network"; import { TourId } from "./components/AppTourProvider/AppTourProvider"; @@ -20,7 +19,6 @@ enum StorageKey { REFRESH_TOKEN = "refreshToken", LOGGED_IN_USER = "loggedInUser", COLOR_PALETTE_MODE = "colorPaletteMode", - HIDDEN_SLOTS = "hiddenSlots", PRESENTED_TOUR_IDS = "presentedTourIds", } @@ -35,12 +33,6 @@ interface StoreContextValue { colorPaletteMode: ColorPaletteMode | null; setColorPaletteMode: (colorPaletteMode: ColorPaletteMode) => void; - getHiddenSlotsForSchemaClass: (className: string) => string[] | undefined; - setHiddenSlotsForSchemaClass: ( - className: string, - hiddenSlots: string[], - ) => void; - checkWhetherTourHasBeenPresented: (tourId: TourId) => boolean; rememberTourHasBeenPresented: (tourId: TourId | null) => void; forgetTourHasBeenPresented: (tourId: TourId | null) => void; @@ -63,13 +55,6 @@ const StoreContext = createContext({ throw new Error("setColorPaletteMode called outside of provider"); }, - getHiddenSlotsForSchemaClass: () => { - throw new Error("getHiddenSlotsForSchemaClass called outside of provider"); - }, - setHiddenSlotsForSchemaClass: () => { - throw new Error("setHiddenSlotsForSchemaClass called outside of provider"); - }, - checkWhetherTourHasBeenPresented: () => { throw new Error( "checkWhetherTourHasBeenPresented called outside of provider", @@ -89,7 +74,6 @@ const StoreProvider: React.FC = ({ children }) => { const [loggedInUser, setLoggedInUser] = useState(null); const [colorPaletteMode, setColorPaletteMode] = useState(null); - const [hiddenSlots, setHiddenSlots] = useState>({}); const [presentedTourIds, setPresentedTourIds] = useState>( new Set(), ); @@ -150,12 +134,6 @@ const StoreProvider: React.FC = ({ children }) => { setColorPaletteMode(sanitizedColorPaletteMode); applyColorPalette(sanitizedColorPaletteMode); - // If persistent storage contains hidden slots, load them into the Context. - const hiddenSlotsFromStorage = await storage.get(StorageKey.HIDDEN_SLOTS); - if (hiddenSlotsFromStorage) { - setHiddenSlots(hiddenSlotsFromStorage); - } - // If persistent storage contains presented tour IDs, load them into the Context. // // Note: If we were storing our `Set` into browser storage directly, we'd have to convert it into an array first. @@ -255,40 +233,6 @@ const StoreProvider: React.FC = ({ children }) => { } } - /** - * Returns a list of hidden slot names for the specified schema class or undefined if the given - * class name is not in the hidden slots map yet. The implication is that `undefined` means the - * user has not made any choice about which slots to hide for this schema class yet. Whereas an - * empty array means the user has made a choice to hide no slots for this schema class. - */ - function getHiddenSlotsForSchemaClass( - className: string, - ): string[] | undefined { - return hiddenSlots[className]; - } - - /** - * Updates the hidden slots for the specified schema class in the Context and the store. - */ - async function setHiddenSlotsForSchemaClass( - className: string, - slotNames: string[], - ) { - const updatedHiddenSlots = produce(hiddenSlots, (draft) => { - draft[className] = slotNames; - }); - - setHiddenSlots(updatedHiddenSlots); - if (store === null) { - console.warn( - "setHiddenSlotsForSchemaClass called before storage initialization", - ); - return; - } else { - return store.set(StorageKey.HIDDEN_SLOTS, updatedHiddenSlots); - } - } - /** * Returns `true` if the specified tour has been presented; otherwise returns `false`. * @@ -357,9 +301,6 @@ const StoreProvider: React.FC = ({ children }) => { colorPaletteMode: colorPaletteMode, setColorPaletteMode: _setColorPaletteMode, - getHiddenSlotsForSchemaClass, - setHiddenSlotsForSchemaClass, - checkWhetherTourHasBeenPresented, rememberTourHasBeenPresented, forgetTourHasBeenPresented, From a98bb3bf89077af15a58d0764504d8a5c411576b Mon Sep 17 00:00:00 2001 From: Patrick Kalita Date: Thu, 21 Nov 2024 13:36:03 -0800 Subject: [PATCH 03/16] Allow the slot selector modal to be launched automatically based on location state --- src/components/StudyForm/StudyForm.tsx | 2 +- src/components/StudyView/StudyView.tsx | 14 ++++++- src/pages/StudyCreatePage/StudyCreatePage.tsx | 13 ++++-- src/pages/StudyViewPage/StudyViewPage.tsx | 12 +++++- src/useNavigateWithState.ts | 29 +++++++++++++ src/useNavigationState.ts | 41 +++++++++++++++++++ 6 files changed, 105 insertions(+), 6 deletions(-) create mode 100644 src/useNavigateWithState.ts create mode 100644 src/useNavigationState.ts diff --git a/src/components/StudyForm/StudyForm.tsx b/src/components/StudyForm/StudyForm.tsx index d4e5810..9f45605 100644 --- a/src/components/StudyForm/StudyForm.tsx +++ b/src/components/StudyForm/StudyForm.tsx @@ -301,7 +301,7 @@ const StudyForm: React.FC = ({ expand="block" className="ion-margin-top" type="submit" - disabled={disabled || (formState.isDirty && !formState.isValid)} + disabled={disabled || (formState.isSubmitted && !formState.isValid)} > {formState.isSubmitting ? "Saving" : "Save"} diff --git a/src/components/StudyView/StudyView.tsx b/src/components/StudyView/StudyView.tsx index 526503b..fdd9dc5 100644 --- a/src/components/StudyView/StudyView.tsx +++ b/src/components/StudyView/StudyView.tsx @@ -8,6 +8,7 @@ import { IonRefresherContent, RefresherEventDetail, useIonRouter, + useIonViewDidEnter, } from "@ionic/react"; import SectionHeader from "../SectionHeader/SectionHeader"; import NoneOr from "../NoneOr/NoneOr"; @@ -30,9 +31,13 @@ interface TemplateVisibleSlots { interface StudyViewProps { submissionId: string; + openSlotSelectorModalOnEnter?: boolean; } -const StudyView: React.FC = ({ submissionId }) => { +const StudyView: React.FC = ({ + submissionId, + openSlotSelectorModalOnEnter = false, +}) => { const router = useIonRouter(); const { query: submission, @@ -58,6 +63,12 @@ const StudyView: React.FC = ({ submissionId }) => { return fieldVisibilityInfo; }, [submission.data]); + useIonViewDidEnter(() => { + if (openSlotSelectorModalOnEnter) { + setModalTemplateVisibleSlots(templateVisibleSlots[0]); + } + }, [openSlotSelectorModalOnEnter, templateVisibleSlots]); + const handleSampleCreate = async () => { if (!submission.data) { return; @@ -236,6 +247,7 @@ const StudyView: React.FC = ({ submissionId }) => { } /> + {/* TODO: lock/unlock submission when opening/closing the modal */} setModalTemplateVisibleSlots(undefined)} diff --git a/src/pages/StudyCreatePage/StudyCreatePage.tsx b/src/pages/StudyCreatePage/StudyCreatePage.tsx index 175e32d..ce4b077 100644 --- a/src/pages/StudyCreatePage/StudyCreatePage.tsx +++ b/src/pages/StudyCreatePage/StudyCreatePage.tsx @@ -6,7 +6,6 @@ import { IonHeader, IonPage, IonTitle, - useIonRouter, useIonToast, } from "@ionic/react"; import StudyForm from "../../components/StudyForm/StudyForm"; @@ -18,9 +17,11 @@ import { checkmark } from "ionicons/icons"; import ThemedToolbar from "../../components/ThemedToolbar/ThemedToolbar"; import { useNetworkStatus } from "../../NetworkStatus"; import FixedCenteredMessage from "../../components/FixedCenteredMessage/FixedCenteredMessage"; +import { StudyViewPageLocationState } from "../StudyViewPage/StudyViewPage"; +import useNavigateWithState from "../../useNavigateWithState"; const StudyCreatePage: React.FC = () => { - const router = useIonRouter(); + const navigate = useNavigateWithState(); const [present] = useIonToast(); const submissionCreate = useSubmissionCreate(); const submission = useMemo(initSubmission, []); @@ -34,7 +35,13 @@ const StudyCreatePage: React.FC = () => { duration: 3000, icon: checkmark, }); - router.push(paths.studyView(created.id), "forward", "replace"); + navigate( + paths.studyView(created.id), + { + openSlotSelectorModalOnEnter: true, + }, + true, + ); }, }); }; diff --git a/src/pages/StudyViewPage/StudyViewPage.tsx b/src/pages/StudyViewPage/StudyViewPage.tsx index 1b57121..39c4e0a 100644 --- a/src/pages/StudyViewPage/StudyViewPage.tsx +++ b/src/pages/StudyViewPage/StudyViewPage.tsx @@ -12,13 +12,20 @@ import { import StudyView from "../../components/StudyView/StudyView"; import paths from "../../paths"; import ThemedToolbar from "../../components/ThemedToolbar/ThemedToolbar"; +import useNavigationState from "../../useNavigationState"; interface StudyViewPageParams { submissionId: string; } +export interface StudyViewPageLocationState { + openSlotSelectorModalOnEnter?: boolean; +} + const StudyViewPage: React.FC = () => { const { submissionId } = useParams(); + const state = useNavigationState(); + return ( @@ -35,7 +42,10 @@ const StudyViewPage: React.FC = () => { - + ); diff --git a/src/useNavigateWithState.ts b/src/useNavigateWithState.ts new file mode 100644 index 0000000..3b3b139 --- /dev/null +++ b/src/useNavigateWithState.ts @@ -0,0 +1,29 @@ +import { useHistory } from "react-router-dom"; +import { useCallback } from "react"; + +/** + * Hook to navigate to a new URL with state. + * + * See also: useNavigationState to retrieve the state after navigating. + * + * @template StateType The type of the state. + * @returns A function to navigate to a new URL with state. The function accepts the URL as a + * string, the state, and a boolean indicating whether to replace the current entry in the history + * stack (default is false). + */ +export default function useNavigateWithState() { + const history = useHistory(); + + const navigate = useCallback( + (url: string, state: StateType, replace: boolean = false) => { + if (replace) { + history.replace(url, state); + } else { + history.push(url, state); + } + }, + [history], + ); + + return navigate; +} diff --git a/src/useNavigationState.ts b/src/useNavigationState.ts new file mode 100644 index 0000000..1b80158 --- /dev/null +++ b/src/useNavigationState.ts @@ -0,0 +1,41 @@ +import { useHistory, useLocation } from "react-router-dom"; +import { useIonViewDidEnter } from "@ionic/react"; +import { produce } from "immer"; + +interface UseNavigationStateOptions { + clearOnEnter?: boolean; +} + +/** + * Hook to retrieve the state from the current location. + * + * @template StateType The type of the state. + * @param options Options object with the following properties: + * - clearOnEnter: Whether to clear the state when the view enters (default is true). + * @returns The state from the current location. + */ +export default function useNavigationState( + options: UseNavigationStateOptions = {}, +): StateType | undefined { + const history = useHistory(); + const location = useLocation(); + + const { clearOnEnter } = { + clearOnEnter: true, + ...options, + }; + + useIonViewDidEnter(() => { + if (!clearOnEnter) { + return; + } + if (location.state !== undefined) { + const newLocation = produce(location, (draft) => { + draft.state = undefined; + }); + history.replace(newLocation); + } + }, [location, history, clearOnEnter]); + + return location.state; +} From 8a069b32ac3266cbaa4bf84aac505536765774f0 Mon Sep 17 00:00:00 2001 From: Patrick Kalita Date: Mon, 25 Nov 2024 14:08:56 -0800 Subject: [PATCH 04/16] Standardize font sizes with new custom variables and global classes --- src/App.tsx | 3 +++ src/components/Banner/Banner.module.css | 2 +- src/components/Checklist/Checklist.module.css | 3 +-- .../SectionHeader/SectionHeader.module.css | 2 +- src/components/StudyForm/StudyForm.module.css | 2 +- .../ThemedToolbar/ThemedToolbar.module.css | 2 +- src/theme/global.css | 15 +++++++++++++++ src/theme/variables.css | 11 +++++++++++ 8 files changed, 34 insertions(+), 6 deletions(-) create mode 100644 src/theme/global.css diff --git a/src/App.tsx b/src/App.tsx index a672c60..1ef1aa5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -43,6 +43,9 @@ import "@ionic/react/css/palettes/dark.class.css"; /* Theme variables */ import "./theme/variables.css"; +/* Our own global styles */ +import "./theme/global.css"; + setupIonicReact(); const App: React.FC = () => { diff --git a/src/components/Banner/Banner.module.css b/src/components/Banner/Banner.module.css index 31ce2b2..6ff2587 100644 --- a/src/components/Banner/Banner.module.css +++ b/src/components/Banner/Banner.module.css @@ -3,7 +3,7 @@ ion-list.banner { & ion-item { --min-height: auto; - font-size: 0.875rem; + font-size: var(--nmdc-font-size-sm); } & ion-button { diff --git a/src/components/Checklist/Checklist.module.css b/src/components/Checklist/Checklist.module.css index 83180ad..b1f7403 100644 --- a/src/components/Checklist/Checklist.module.css +++ b/src/components/Checklist/Checklist.module.css @@ -1,6 +1,6 @@ .info { padding: 0px 15px 0px 15px; - font-size: 0.875rem; + font-size: var(--nmdc-font-size-sm); opacity: 0.6; } @@ -11,7 +11,6 @@ } .listIcon { - position: absolute; position: absolute; top: 5px; left: 15px; diff --git a/src/components/SectionHeader/SectionHeader.module.css b/src/components/SectionHeader/SectionHeader.module.css index 8208ecc..bd3dfb4 100644 --- a/src/components/SectionHeader/SectionHeader.module.css +++ b/src/components/SectionHeader/SectionHeader.module.css @@ -1,5 +1,5 @@ .header { - font-size: 0.875rem; + font-size: var(--nmdc-font-size-sm); font-weight: 700; color: var(--ion-color-primary); } diff --git a/src/components/StudyForm/StudyForm.module.css b/src/components/StudyForm/StudyForm.module.css index fe4964b..9b74572 100644 --- a/src/components/StudyForm/StudyForm.module.css +++ b/src/components/StudyForm/StudyForm.module.css @@ -1,4 +1,4 @@ .errorMessage { - font-size: 0.75rem; + font-size: var(--nmdc-font-size-xs); color: var(--ion-color-danger); } diff --git a/src/components/ThemedToolbar/ThemedToolbar.module.css b/src/components/ThemedToolbar/ThemedToolbar.module.css index 062341c..118a6b2 100644 --- a/src/components/ThemedToolbar/ThemedToolbar.module.css +++ b/src/components/ThemedToolbar/ThemedToolbar.module.css @@ -22,7 +22,7 @@ ion-toolbar.offlineToolbar { } .offlineWarning { - font-size: 0.875rem; + font-size: var(--nmdc-font-size-sm); display: flex; flex-direction: row; align-items: center; diff --git a/src/theme/global.css b/src/theme/global.css new file mode 100644 index 0000000..db3c2b1 --- /dev/null +++ b/src/theme/global.css @@ -0,0 +1,15 @@ +.nmdc-text-xs { + font-size: var(--nmdc-font-size-xs); +} + +.nmdc-text-sm { + font-size: var(--nmdc-font-size-sm); +} + +.nmdc-text-lg { + font-size: var(--nmdc-font-size-lg); +} + +.nmdc-text-xl { + font-size: var(--nmdc-font-size-xl); +} diff --git a/src/theme/variables.css b/src/theme/variables.css index 685c0ed..ae6f71a 100644 --- a/src/theme/variables.css +++ b/src/theme/variables.css @@ -97,3 +97,14 @@ --ion-background-color: #000000; --ion-background-color-rgb: rgb(0, 0, 0); } + +/** + * Custom variables that aren't part of the Ionic theme, using the `--nmdc-` prefix + * to avoid naming conflicts. + */ +:root { + --nmdc-font-size-xs: 0.75rem; + --nmdc-font-size-sm: 0.875rem; + --nmdc-font-size-lg: 1.125rem; + --nmdc-font-size-xl: 1.25rem; +} From c7acb94629a4bee4c8c9960a906c779c3e579333 Mon Sep 17 00:00:00 2001 From: Patrick Kalita Date: Mon, 25 Nov 2024 14:44:40 -0800 Subject: [PATCH 05/16] Use output of slot visibility curation in slot selector modal --- src/components/SlotSelector/SlotSelector.tsx | 11 +- .../SlotSelectorModal/SlotSelectorModal.tsx | 130 +++++- .../SlotSelectorModal/slotVisibility.json | 424 ++++++++++++++++++ src/components/StudyView/StudyView.tsx | 1 + src/utils.ts | 1 + 5 files changed, 543 insertions(+), 24 deletions(-) create mode 100644 src/components/SlotSelectorModal/slotVisibility.json diff --git a/src/components/SlotSelector/SlotSelector.tsx b/src/components/SlotSelector/SlotSelector.tsx index 149c9f0..f76fcfc 100644 --- a/src/components/SlotSelector/SlotSelector.tsx +++ b/src/components/SlotSelector/SlotSelector.tsx @@ -1,6 +1,5 @@ import React from "react"; import { SlotGroup } from "../../utils"; -import RequiredMark from "../RequiredMark/RequiredMark"; import SectionHeader from "../SectionHeader/SectionHeader"; import { IonCheckbox, IonItem, IonLabel, IonList } from "@ionic/react"; import { produce } from "immer"; @@ -105,6 +104,11 @@ const SlotSelector: React.FC = ({ + {group.description && ( +
+ {group.description} +
+ )} {group.slots.map((slot) => ( = ({ disabled={disabledSlots?.includes(slot.name)} > -

- {slot.title || slot.name} - {(slot.required || slot.key) && } -

+

{slot.title || slot.name}

{slot.description}

diff --git a/src/components/SlotSelectorModal/SlotSelectorModal.tsx b/src/components/SlotSelectorModal/SlotSelectorModal.tsx index eab1f9c..e2eb910 100644 --- a/src/components/SlotSelectorModal/SlotSelectorModal.tsx +++ b/src/components/SlotSelectorModal/SlotSelectorModal.tsx @@ -10,15 +10,108 @@ import { IonToolbar, } from "@ionic/react"; import { closeOutline } from "ionicons/icons"; -import RequiredMark from "../RequiredMark/RequiredMark"; import SlotSelector from "../SlotSelector/SlotSelector"; import { useSubmissionSchema } from "../../queries"; -import { groupClassSlots } from "../../utils"; +import { SlotGroup } from "../../utils"; import { TEMPLATES } from "../../api"; +import { SchemaDefinition } from "../../linkml-metamodel"; + +/** + * This JSON file encodes which slots are commonly, uncommonly, or rarely measured in the field. + * This was the output of a manual curation process. At some point we might consider encoding this + * directly in the submission schema somehow. But for now, this is a simple way to get the job done. + * The structure of the JSON file is: + * { + * : { + * _default?: "common" | "uncommon", + * ?: "common" | "uncommon", + * ... + * }, + * ... + * } + * Each key in the top-level object is a slot name. The value is an object that can contain a + * "_default" key and/or keys for specific template class names (e.g. SoilInterface). The value of + * these keys is either "common" or "uncommon". The setting in the "_default" key is used if there + * is no specific setting for a given template class. + */ +import slotVisibility from "./slotVisibility.json"; import styles from "./SlotSelectorModal.module.css"; +const FIXED_ORDER_SLOTS = ["samp_name", "collection_date", "lat_lon"]; + +function groupClassSlots( + schemaDefinition: SchemaDefinition, + className: string, +): SlotGroup[] { + const classDefinition = schemaDefinition.classes?.[className]; + if (!classDefinition) { + throw new Error(`Class ${className} not found in schema`); + } + const commonGroup: SlotGroup = { + name: "common", + description: + "These fields will commonly be measured or collected in the field.", + title: "Common", + slots: [], + }; + const uncommonGroup: SlotGroup = { + name: "uncommon", + description: + "These fields may sometimes be measured or collected in the field.", + title: "Uncommon", + slots: [], + }; + const otherGroup: SlotGroup = { + name: "other", + description: "These fields are rarely measured or collected in the field.", + title: "Other", + slots: [], + }; + if (!classDefinition.attributes) { + return []; + } + Object.values(classDefinition.attributes).forEach((slot) => { + // @ts-expect-error next-lint + const visibility = slotVisibility[slot.name] || {}; + const group = visibility[className] || visibility["_default"]; + if (group === "common") { + commonGroup.slots.push(slot); + } else if (group === "uncommon") { + uncommonGroup.slots.push(slot); + } else { + otherGroup.slots.push(slot); + } + }); + const groupedSlots: SlotGroup[] = [commonGroup, uncommonGroup, otherGroup]; + groupedSlots.forEach((group) => { + group.slots.sort((a, b) => { + // First sort the fixed slots to the top + const aFixedOrder = FIXED_ORDER_SLOTS.indexOf(a.name); + const bFixedOrder = FIXED_ORDER_SLOTS.indexOf(b.name); + if (aFixedOrder !== -1 && bFixedOrder !== -1) { + return aFixedOrder - bFixedOrder; + } else if (aFixedOrder !== -1) { + return -1; + } else if (bFixedOrder !== -1) { + return 1; + } + + // Then sort alphabetically by title + const titleCompare = (a.title || "").localeCompare(b.title || ""); + if (titleCompare !== 0) { + return titleCompare; + } + + // Finally sort by name (since some oddball slots may not have a title) + return a.name.localeCompare(b.name); + }); + }); + return groupedSlots; +} + export interface SlotSelectorModalProps { + allowDismiss?: boolean; defaultSelectedSlots?: string[]; isOpen: boolean; onDismiss: () => void; @@ -26,6 +119,7 @@ export interface SlotSelectorModalProps { templateName?: string; } const SlotSelectorModal: React.FC = ({ + allowDismiss = true, defaultSelectedSlots, isOpen, onDismiss, @@ -51,15 +145,18 @@ const SlotSelectorModal: React.FC = ({ // changes if the user cancels out of the modal). useEffect(() => { if (isOpen) { + // If the modal is open and no defaultSelectedSlots were provided, preselect the first group + // (the "common" group) by default. Otherwise, use the provided defaultSelectedSlots. if (defaultSelectedSlots === undefined) { - setSelectedSlots([]); + setSelectedSlots(slotGroups[0]?.slots.map((slot) => slot.name) || []); } else { setSelectedSlots(defaultSelectedSlots); } } else { + // If the modal is closed, reset the selected slots to an empty list. setSelectedSlots([]); } - }, [isOpen, defaultSelectedSlots]); + }, [isOpen, defaultSelectedSlots, slotGroups]); // When the user taps the Save button, translate the selected slots back into a list of hidden // slots and save them to the store. Then close the modal. @@ -75,11 +172,13 @@ const SlotSelectorModal: React.FC = ({ > - - - - - + {allowDismiss && ( + + + + + + )} Select Fields @@ -89,16 +188,9 @@ const SlotSelectorModal: React.FC = ({ -
-

- Select the fields you would like to see when viewing and editing - sample metadata for the {templateDisplayName} template. These - choices can be updated any time in Settings. -

-

- fields are required before finalizing a submission - with NMDC. Be careful about hiding them here. -

+
+ Select the fields you would like to see when viewing and editing + sample metadata for the {templateDisplayName} template.
= ({ {/* TODO: lock/unlock submission when opening/closing the modal */} setModalTemplateVisibleSlots(undefined)} isOpen={modalTemplateVisibleSlots !== undefined} diff --git a/src/utils.ts b/src/utils.ts index 032af13..816e0cd 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -47,6 +47,7 @@ function compareByRank(a: { rank?: number }, b: { rank?: number }): number { export interface SlotGroup { name: string; + description?: string; title?: string; rank?: number; slots: SlotDefinition[]; From e9a23831bc79ee29364ebc5a5f3d4d1ee7169489 Mon Sep 17 00:00:00 2001 From: Patrick Kalita Date: Mon, 25 Nov 2024 16:24:46 -0800 Subject: [PATCH 06/16] Do not use schema slot grouping; use application-defined custom ordering --- src/components/SampleView/SampleView.tsx | 58 ++++++------ .../SlotSelectorModal/SlotSelectorModal.tsx | 26 +----- src/utils.ts | 93 +++++++------------ 3 files changed, 64 insertions(+), 113 deletions(-) diff --git a/src/components/SampleView/SampleView.tsx b/src/components/SampleView/SampleView.tsx index 6afd6c8..df6c2a7 100644 --- a/src/components/SampleView/SampleView.tsx +++ b/src/components/SampleView/SampleView.tsx @@ -1,7 +1,6 @@ -import React from "react"; +import React, { useMemo } from "react"; import { SampleData, SampleDataValue, TEMPLATES } from "../../api"; -import { groupClassSlots } from "../../utils"; -import SectionHeader from "../SectionHeader/SectionHeader"; +import { sortSlots } from "../../utils"; import { IonIcon, IonItem, IonLabel, IonList } from "@ionic/react"; import { SchemaDefinition, SlotDefinition } from "../../linkml-metamodel"; import { warningOutline } from "ionicons/icons"; @@ -33,7 +32,15 @@ const SampleView: React.FC = ({ visibleSlots, }) => { const schemaClass = TEMPLATES[packageName].schemaClass; - const slotGroups = schemaClass ? groupClassSlots(schema, schemaClass) : []; + const slots: SlotDefinition[] = useMemo(() => { + const allSlots = Object.values( + schema.classes?.[schemaClass]?.attributes || {}, + ); + const visibleSlotsSet = allSlots.filter( + (slot) => !visibleSlots || visibleSlots.includes(slot.name), + ); + return sortSlots(visibleSlotsSet); + }, [schema.classes, schemaClass, visibleSlots]); if (!sample) { return null; @@ -41,33 +48,24 @@ const SampleView: React.FC = ({ return ( <> - {slotGroups.map((group) => ( - - {group.title} - - {group.slots.map( - (slot) => - (visibleSlots === undefined || - visibleSlots.includes(slot.name)) && ( - onSlotClick(slot)}> - {validationResults?.[slot.name] && ( - - ), + + {slots.map((slot) => ( + onSlotClick(slot)}> + {validationResults?.[slot.name] && ( + - - ))} + +

{slot.title || slot.name}

+

{formatSlotValue(sample?.[slot.name])}

+
+ + ))} + ); }; diff --git a/src/components/SlotSelectorModal/SlotSelectorModal.tsx b/src/components/SlotSelectorModal/SlotSelectorModal.tsx index e2eb910..35c648e 100644 --- a/src/components/SlotSelectorModal/SlotSelectorModal.tsx +++ b/src/components/SlotSelectorModal/SlotSelectorModal.tsx @@ -12,7 +12,7 @@ import { import { closeOutline } from "ionicons/icons"; import SlotSelector from "../SlotSelector/SlotSelector"; import { useSubmissionSchema } from "../../queries"; -import { SlotGroup } from "../../utils"; +import { SlotGroup, sortSlots } from "../../utils"; import { TEMPLATES } from "../../api"; import { SchemaDefinition } from "../../linkml-metamodel"; @@ -38,8 +38,6 @@ import slotVisibility from "./slotVisibility.json"; import styles from "./SlotSelectorModal.module.css"; -const FIXED_ORDER_SLOTS = ["samp_name", "collection_date", "lat_lon"]; - function groupClassSlots( schemaDefinition: SchemaDefinition, className: string, @@ -85,27 +83,7 @@ function groupClassSlots( }); const groupedSlots: SlotGroup[] = [commonGroup, uncommonGroup, otherGroup]; groupedSlots.forEach((group) => { - group.slots.sort((a, b) => { - // First sort the fixed slots to the top - const aFixedOrder = FIXED_ORDER_SLOTS.indexOf(a.name); - const bFixedOrder = FIXED_ORDER_SLOTS.indexOf(b.name); - if (aFixedOrder !== -1 && bFixedOrder !== -1) { - return aFixedOrder - bFixedOrder; - } else if (aFixedOrder !== -1) { - return -1; - } else if (bFixedOrder !== -1) { - return 1; - } - - // Then sort alphabetically by title - const titleCompare = (a.title || "").localeCompare(b.title || ""); - if (titleCompare !== 0) { - return titleCompare; - } - - // Finally sort by name (since some oddball slots may not have a title) - return a.name.localeCompare(b.name); - }); + sortSlots(group.slots); }); return groupedSlots; } diff --git a/src/utils.ts b/src/utils.ts index 816e0cd..f1b2ca9 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,5 +1,5 @@ import { SubmissionMetadata, TEMPLATES } from "./api"; -import { SchemaDefinition, SlotDefinition } from "./linkml-metamodel"; +import { SlotDefinition } from "./linkml-metamodel"; export interface GetSubmissionSamplesOptions { createSampleDataFieldIfMissing?: boolean; @@ -35,16 +35,6 @@ export function getSubmissionSample( return getSubmissionSamples(submission)[index]; } -function compareByRank(a: { rank?: number }, b: { rank?: number }): number { - if (a.rank === undefined) { - return -1; - } - if (b.rank === undefined) { - return 1; - } - return a.rank - b.rank; -} - export interface SlotGroup { name: string; description?: string; @@ -52,54 +42,39 @@ export interface SlotGroup { rank?: number; slots: SlotDefinition[]; } -export function groupClassSlots( - schemaDefinition: SchemaDefinition, - className: string, -): SlotGroup[] { - const classDefinition = schemaDefinition.classes?.[className]; - if (!classDefinition) { - throw new Error(`Class ${className} not found in schema`); - } - const groupedSlots: SlotGroup[] = []; - if (!classDefinition.attributes) { - return groupedSlots; - } - Object.values(classDefinition.attributes).forEach((slot) => { - let slotGroup = groupedSlots.find((g) => g.name === slot.slot_group); - if (!slotGroup) { - // We need to add a new slot group to groupedSlots - const slotGroupSlot = slot.slot_group - ? (schemaDefinition.slots as Record)[ - slot.slot_group - ] - : undefined; - if (!slotGroupSlot) { - // The root-level grouping slot couldn't be found, put it in an "other" group - slotGroup = groupedSlots.find((g) => g.name === "other"); - if (!slotGroup) { - slotGroup = { - name: "other", - title: "Other", - rank: 9999, - slots: [], - }; - groupedSlots.push(slotGroup); - } - } else { - slotGroup = { - name: slotGroupSlot.name, - title: slotGroupSlot.title, - rank: slotGroupSlot.rank, - slots: [], - }; - groupedSlots.push(slotGroup); - } + +const FIXED_ORDER_SLOTS = ["samp_name", "collection_date", "lat_lon"]; + +/** + * Sort a list of slots according to our custom rules. + * + * This function sorts a list of slots for presentation in the app. First, it sorts the slots that + * are in the FIXED_ORDER_SLOTS array to the top. Then it sorts the remaining slots alphabetically. + * + * This ordering specifically ignores the ordering hints (`slot_group` and `rank`) that are present + * in the schema definition. That order was defined with the Submission Portal interface in mind. In + * the future, we can consider adding the Field Notes ordering hints to the schema definition, but + * it isn't immediately obvious what LinkML mechanism would be appropriate for that. + * + * @param slots + */ +export function sortSlots(slots: SlotDefinition[]): SlotDefinition[] { + return slots.sort((a, b) => { + // First sort the fixed slots to the top + const aFixedOrder = FIXED_ORDER_SLOTS.indexOf(a.name); + const bFixedOrder = FIXED_ORDER_SLOTS.indexOf(b.name); + if (aFixedOrder !== -1 && bFixedOrder !== -1) { + return aFixedOrder - bFixedOrder; + } else if (aFixedOrder !== -1) { + return -1; + } else if (bFixedOrder !== -1) { + return 1; } - slotGroup.slots.push(slot); - }); - groupedSlots.sort(compareByRank); - groupedSlots.forEach((group) => { - group.slots.sort(compareByRank); + + // Then sort alphabetically by title (if present) or name (some oddball slots may not have a + // title) + const aTitle = a.title || a.name; + const bTitle = b.title || b.name; + return aTitle.localeCompare(bTitle); }); - return groupedSlots; } From 162c3de11b03fa14d29d65284f1b95e0c410b346 Mon Sep 17 00:00:00 2001 From: Patrick Kalita Date: Wed, 27 Nov 2024 13:29:12 -0800 Subject: [PATCH 07/16] Store slot visibility info in TypeScript file instead of JSON; add stronger typing around template names --- src/api.ts | 7 +- src/components/Checklist/md-in-js/util.tsx | 5 +- src/components/SampleView/SampleView.tsx | 9 +- .../SlotSelectorModal/SlotSelectorModal.tsx | 73 ++- .../SlotSelectorModal/slotVisibility.json | 424 ---------------- .../SlotSelectorModal/slotVisibility.ts | 453 ++++++++++++++++++ src/components/StudyView/StudyView.tsx | 22 +- src/pages/SamplePage/SamplePage.tsx | 6 +- src/utils.ts | 4 +- 9 files changed, 516 insertions(+), 487 deletions(-) delete mode 100644 src/components/SlotSelectorModal/slotVisibility.json create mode 100644 src/components/SlotSelectorModal/slotVisibility.ts diff --git a/src/api.ts b/src/api.ts index 833896f..e046028 100644 --- a/src/api.ts +++ b/src/api.ts @@ -92,7 +92,7 @@ export interface TemplateInfo { schemaClass: string; sampleDataSlot: string; } -export const TEMPLATES: Record = { +export const TEMPLATES = { air: { displayName: "air", schemaClass: "AirInterface", @@ -149,9 +149,10 @@ export const TEMPLATES: Record = { sampleDataSlot: "water_data", }, }; +export type TemplateName = keyof typeof TEMPLATES; export interface MetadataSubmission { - packageName: keyof typeof TEMPLATES; + packageName: TemplateName | ""; contextForm: ContextForm; addressForm: AddressForm; templates: string[]; @@ -185,7 +186,7 @@ export interface SubmissionMetadataUpdate extends SubmissionMetadataBase { } export interface FieldNotesMetadata { - fieldVisibility?: Record; + fieldVisibility?: Partial>; } export interface SubmissionMetadata extends SubmissionMetadataCreate { diff --git a/src/components/Checklist/md-in-js/util.tsx b/src/components/Checklist/md-in-js/util.tsx index 029e754..b9365f4 100644 --- a/src/components/Checklist/md-in-js/util.tsx +++ b/src/components/Checklist/md-in-js/util.tsx @@ -1,12 +1,13 @@ import config from "../../../config"; -import { TEMPLATES } from "../../../api"; +import { TemplateName, TEMPLATES } from "../../../api"; // Generate schemas Markdown from the TEMPLATES export function createSchemasMD() { let schemasMD = ""; Object.keys(TEMPLATES).forEach((schema) => { - schemasMD += `* [${TEMPLATES[schema].displayName}](${config.NMDC_SUBMISSION_SCHEMA_DOCS_BASE_URL}/${TEMPLATES[schema].schemaClass}/)\n`; + const template = TEMPLATES[schema as TemplateName]; + schemasMD += `* [${template.displayName}](${config.NMDC_SUBMISSION_SCHEMA_DOCS_BASE_URL}/${template.schemaClass}/)\n`; }); return schemasMD; diff --git a/src/components/SampleView/SampleView.tsx b/src/components/SampleView/SampleView.tsx index df6c2a7..8b49766 100644 --- a/src/components/SampleView/SampleView.tsx +++ b/src/components/SampleView/SampleView.tsx @@ -1,5 +1,10 @@ import React, { useMemo } from "react"; -import { SampleData, SampleDataValue, TEMPLATES } from "../../api"; +import { + SampleData, + SampleDataValue, + TemplateName, + TEMPLATES, +} from "../../api"; import { sortSlots } from "../../utils"; import { IonIcon, IonItem, IonLabel, IonList } from "@ionic/react"; import { SchemaDefinition, SlotDefinition } from "../../linkml-metamodel"; @@ -17,7 +22,7 @@ function formatSlotValue(value: SampleDataValue) { interface SampleViewProps { onSlotClick: (slot: SlotDefinition) => void; - packageName: string; + packageName: TemplateName; sample?: SampleData; schema: SchemaDefinition; validationResults?: Record; diff --git a/src/components/SlotSelectorModal/SlotSelectorModal.tsx b/src/components/SlotSelectorModal/SlotSelectorModal.tsx index 35c648e..483dccc 100644 --- a/src/components/SlotSelectorModal/SlotSelectorModal.tsx +++ b/src/components/SlotSelectorModal/SlotSelectorModal.tsx @@ -13,39 +13,16 @@ import { closeOutline } from "ionicons/icons"; import SlotSelector from "../SlotSelector/SlotSelector"; import { useSubmissionSchema } from "../../queries"; import { SlotGroup, sortSlots } from "../../utils"; -import { TEMPLATES } from "../../api"; -import { SchemaDefinition } from "../../linkml-metamodel"; - -/** - * This JSON file encodes which slots are commonly, uncommonly, or rarely measured in the field. - * This was the output of a manual curation process. At some point we might consider encoding this - * directly in the submission schema somehow. But for now, this is a simple way to get the job done. - * The structure of the JSON file is: - * { - * : { - * _default?: "common" | "uncommon", - * ?: "common" | "uncommon", - * ... - * }, - * ... - * } - * Each key in the top-level object is a slot name. The value is an object that can contain a - * "_default" key and/or keys for specific template class names (e.g. SoilInterface). The value of - * these keys is either "common" or "uncommon". The setting in the "_default" key is used if there - * is no specific setting for a given template class. - */ -import slotVisibility from "./slotVisibility.json"; +import { TemplateName, TEMPLATES } from "../../api"; +import { SlotDefinition } from "../../linkml-metamodel"; +import slotVisibilities from "./slotVisibility"; import styles from "./SlotSelectorModal.module.css"; function groupClassSlots( - schemaDefinition: SchemaDefinition, - className: string, + slotDefinitions: SlotDefinition[], + templateName: TemplateName, ): SlotGroup[] { - const classDefinition = schemaDefinition.classes?.[className]; - if (!classDefinition) { - throw new Error(`Class ${className} not found in schema`); - } const commonGroup: SlotGroup = { name: "common", description: @@ -66,13 +43,10 @@ function groupClassSlots( title: "Other", slots: [], }; - if (!classDefinition.attributes) { - return []; - } - Object.values(classDefinition.attributes).forEach((slot) => { - // @ts-expect-error next-lint - const visibility = slotVisibility[slot.name] || {}; - const group = visibility[className] || visibility["_default"]; + + slotDefinitions.forEach((slot) => { + const visibility = slotVisibilities[slot.name] || {}; + const group = visibility[templateName] || visibility["_default"]; if (group === "common") { commonGroup.slots.push(slot); } else if (group === "uncommon") { @@ -94,7 +68,7 @@ export interface SlotSelectorModalProps { isOpen: boolean; onDismiss: () => void; onSave: (selectedSlots: string[]) => void; - templateName?: string; + templateName?: TemplateName; } const SlotSelectorModal: React.FC = ({ allowDismiss = true, @@ -111,13 +85,26 @@ const SlotSelectorModal: React.FC = ({ const templateDisplayName = templateName && TEMPLATES[templateName].displayName; - const slotGroups = useMemo( - () => - schema.data && schemaClassName !== undefined - ? groupClassSlots(schema.data.schema, schemaClassName) - : [], - [schema.data, schemaClassName], - ); + const slotGroups = useMemo(() => { + if ( + schema.data === undefined || + schemaClassName === undefined || + templateName === undefined + ) { + return []; + } + const classDefinition = schema.data.schema.classes?.[schemaClassName]; + if (!classDefinition) { + throw new Error(`Class ${schemaClassName} not found in schema`); + } + if (!classDefinition.attributes) { + return []; + } + return groupClassSlots( + Object.values(classDefinition.attributes), + templateName, + ); + }, [schema.data, schemaClassName, templateName]); // The isOpen state is used to reset the selected slots when the modal is closed (i.e. don't keep // changes if the user cancels out of the modal). diff --git a/src/components/SlotSelectorModal/slotVisibility.json b/src/components/SlotSelectorModal/slotVisibility.json deleted file mode 100644 index 777787a..0000000 --- a/src/components/SlotSelectorModal/slotVisibility.json +++ /dev/null @@ -1,424 +0,0 @@ -{ - "agrochem_addition": { - "_default": "uncommon" - }, - "air_PM_concen": { - "_default": "uncommon" - }, - "air_temp_regm": { - "_default": "uncommon" - }, - "alt": { - "AirInterface": "common" - }, - "ances_data": { - "_default": "uncommon" - }, - "antibiotic_regm": { - "_default": "uncommon" - }, - "bac_resp": { - "_default": "uncommon" - }, - "barometric_press": { - "_default": "uncommon" - }, - "biol_stat": { - "_default": "uncommon" - }, - "biotic_regm": { - "_default": "uncommon" - }, - "blood_press_diast": { - "_default": "common" - }, - "blood_press_syst": { - "_default": "common" - }, - "bulk_elect_conductivity": { - "_default": "uncommon" - }, - "carb_dioxide": { - "AirInterface": "uncommon" - }, - "carb_monoxide": { - "_default": "uncommon" - }, - "chem_administration": { - "_default": "uncommon" - }, - "chem_mutagen": { - "_default": "uncommon" - }, - "collection_date": { - "_default": "common" - }, - "collection_date_inc": { - "_default": "uncommon" - }, - "collection_time": { - "_default": "common" - }, - "collection_time_inc": { - "_default": "uncommon" - }, - "conduc": { - "_default": "uncommon" - }, - "cult_root_med": { - "_default": "uncommon" - }, - "cur_land_use": { - "_default": "common" - }, - "cur_vegetation": { - "_default": "common" - }, - "cur_vegetation_meth": { - "_default": "uncommon" - }, - "density": { - "_default": "uncommon" - }, - "depth": { - "_default": "uncommon", - "SoilInterface": "common", - "HostAssociatedInterface": "common", - "SedimentInterface": "common", - "WaterInterface": "common" - }, - "down_par": { - "_default": "uncommon" - }, - "drainage_class": { - "_default": "uncommon" - }, - "elev": { - "_default": "uncommon", - "SoilInterface": "common", - "AirInterface": "common", - "BiofilmInterface": "common", - "BuiltEnvInterface": "common", - "PlantAssociatedInterface": "common", - "WaterInterface": "common" - }, - "experimental_factor_other": { - "_default": "uncommon" - }, - "fertilizer_regm": { - "_default": "uncommon" - }, - "filter_method": { - "_default": "uncommon" - }, - "fungicide_regm": { - "_default": "uncommon" - }, - "gaseous_environment": { - "_default": "uncommon" - }, - "genetic_mod": { - "_default": "uncommon" - }, - "geo_loc_name": { - "_default": "common" - }, - "gravidity": { - "_default": "uncommon" - }, - "growth_facil": { - "_default": "common" - }, - "growth_habit": { - "_default": "uncommon" - }, - "growth_hormone_regm": { - "_default": "uncommon" - }, - "herbicide_regm": { - "_default": "uncommon" - }, - "horizon_meth": { - "_default": "uncommon" - }, - "host_age": { - "_default": "common" - }, - "host_body_habitat": { - "_default": "uncommon" - }, - "host_body_product": { - "_default": "uncommon" - }, - "host_body_site": { - "_default": "common" - }, - "host_body_temp": { - "_default": "common" - }, - "host_color": { - "_default": "uncommon" - }, - "host_common_name": { - "_default": "common" - }, - "host_disease_stat": { - "_default": "uncommon" - }, - "host_dry_mass": { - "_default": "uncommon" - }, - "host_genotype": { - "_default": "uncommon" - }, - "host_height": { - "_default": "uncommon" - }, - "host_last_meal": { - "_default": "uncommon" - }, - "host_length": { - "_default": "uncommon" - }, - "host_life_stage": { - "_default": "uncommon" - }, - "host_phenotype": { - "_default": "uncommon" - }, - "host_sex": { - "_default": "uncommon" - }, - "host_shape": { - "_default": "uncommon" - }, - "host_subject_id": { - "_default": "common" - }, - "host_subspecf_genlin": { - "_default": "uncommon" - }, - "host_substrate": { - "_default": "uncommon" - }, - "host_symbiont": { - "_default": "uncommon" - }, - "host_tot_mass": { - "_default": "uncommon" - }, - "host_wet_mass": { - "_default": "uncommon" - }, - "humidity": { - "_default": "common" - }, - "humidity_regm": { - "_default": "uncommon" - }, - "infiltrations": { - "_default": "uncommon" - }, - "isotope_exposure": { - "_default": "uncommon" - }, - "lat_lon": { - "_default": "common" - }, - "light_intensity": { - "_default": "uncommon" - }, - "light_regm": { - "_default": "uncommon" - }, - "local_class": { - "_default": "uncommon" - }, - "local_class_meth": { - "_default": "uncommon" - }, - "mechanical_damage": { - "_default": "uncommon" - }, - "methane": { - "_default": "uncommon" - }, - "mineral_nutr_regm": { - "_default": "uncommon" - }, - "misc_param": { - "_default": "uncommon" - }, - "non_min_nutr_regm": { - "_default": "uncommon" - }, - "other_treatment": { - "_default": "uncommon" - }, - "oxy_stat_samp": { - "_default": "uncommon" - }, - "oxygen": { - "_default": "uncommon" - }, - "pesticide_regm": { - "_default": "uncommon" - }, - "ph": { - "_default": "uncommon" - }, - "ph_meth": { - "_default": "uncommon" - }, - "ph_regm": { - "_default": "uncommon" - }, - "plant_growth_med": { - "_default": "uncommon" - }, - "plant_product": { - "_default": "uncommon" - }, - "plant_sex": { - "_default": "uncommon" - }, - "plant_struc": { - "_default": "common" - }, - "pollutants": { - "_default": "uncommon" - }, - "pressure": { - "_default": "uncommon" - }, - "profile_position": { - "_default": "uncommon" - }, - "radiation_regm": { - "_default": "uncommon" - }, - "rainfall_regm": { - "_default": "uncommon" - }, - "redox_potential": { - "_default": "uncommon" - }, - "root_cond": { - "_default": "uncommon" - }, - "samp_capt_status": { - "_default": "uncommon" - }, - "samp_collec_device": { - "_default": "uncommon", - "SoilInterface": "common", - "SedimentInterface": "common", - "WaterInterface": "common" - }, - "samp_collec_method": { - "_default": "uncommon" - }, - "samp_dis_stage": { - "_default": "uncommon" - }, - "samp_mat_process": { - "_default": "uncommon" - }, - "samp_name": { - "_default": "common" - }, - "samp_size": { - "_default": "common" - }, - "samp_store_temp": { - "_default": "uncommon", - "SoilInterface": "common", - "SedimentInterface": "common" - }, - "sediment_type": { - "_default": "uncommon" - }, - "sieving": { - "_default": "uncommon" - }, - "size_frac": { - "AirInterface": "uncommon", - "BiofilmInterface": "uncommon", - "PlantAssociatedInterface": "uncommon", - "SedimentInterface": "uncommon", - "WaterInterface": "uncommon" - }, - "size_frac_low": { - "_default": "uncommon" - }, - "size_frac_up": { - "_default": "uncommon" - }, - "slope_aspect": { - "_default": "uncommon" - }, - "slope_gradient": { - "_default": "uncommon" - }, - "soil_horizon": { - "_default": "uncommon" - }, - "solar_irradiance": { - "_default": "uncommon" - }, - "standing_water_regm": { - "_default": "uncommon" - }, - "start_date_inc": { - "_default": "uncommon" - }, - "start_time_inc": { - "_default": "uncommon" - }, - "store_cond": { - "_default": "uncommon" - }, - "temp": { - "_default": "uncommon", - "AirInterface": "common" - }, - "tidal_stage": { - "_default": "uncommon" - }, - "tillage": { - "_default": "uncommon" - }, - "tot_depth_water_col": { - "_default": "uncommon" - }, - "turbidity": { - "_default": "uncommon" - }, - "ventilation_rate": { - "_default": "uncommon" - }, - "ventilation_type": { - "_default": "uncommon" - }, - "water_cont_soil_meth": { - "_default": "uncommon" - }, - "water_content": { - "_default": "uncommon" - }, - "water_current": { - "_default": "uncommon" - }, - "water_temp_regm": { - "_default": "uncommon" - }, - "watering_regm": { - "_default": "uncommon" - }, - "wind_direction": { - "_default": "uncommon" - }, - "wind_speed": { - "_default": "uncommon" - } -} diff --git a/src/components/SlotSelectorModal/slotVisibility.ts b/src/components/SlotSelectorModal/slotVisibility.ts new file mode 100644 index 0000000..03bc32d --- /dev/null +++ b/src/components/SlotSelectorModal/slotVisibility.ts @@ -0,0 +1,453 @@ +import { TemplateName } from "../../api"; + +type VisibilityLevel = "common" | "uncommon"; + +interface SlotVisibility + extends Partial> { + _default?: VisibilityLevel; +} + +/** + * This object encodes which slots are commonly, uncommonly, or rarely measured in the field. This + * was the output of a manual curation process. At some point we might consider encoding this + * directly in the submission schema somehow. But for now, this is a simple way to get the job done. + * The structure of the object is: + * { + * : { + * _default?: "common" | "uncommon", + * ?: "common" | "uncommon", + * ... + * }, + * ... + * } + * Each key in the top-level object is a slot name. The value is an object that can contain a + * "_default" key and/or keys for specific template names (e.g. soil). The value of these keys is + * either "common" or "uncommon". The setting in the "_default" key is used if there is no specific + * setting for a given template. + */ +const slotVisibilities: Record = { + agrochem_addition: { + _default: "uncommon", + }, + air_PM_concen: { + _default: "uncommon", + }, + air_temp_regm: { + _default: "uncommon", + }, + alt: { + air: "common", + }, + ances_data: { + _default: "uncommon", + }, + antibiotic_regm: { + _default: "uncommon", + }, + bac_resp: { + _default: "uncommon", + }, + barometric_press: { + _default: "uncommon", + }, + biol_stat: { + _default: "uncommon", + }, + biotic_regm: { + _default: "uncommon", + }, + blood_press_diast: { + _default: "common", + }, + blood_press_syst: { + _default: "common", + }, + bulk_elect_conductivity: { + _default: "uncommon", + }, + carb_dioxide: { + air: "uncommon", + }, + carb_monoxide: { + _default: "uncommon", + }, + chem_administration: { + _default: "uncommon", + }, + chem_mutagen: { + _default: "uncommon", + }, + collection_date: { + _default: "common", + }, + collection_date_inc: { + _default: "uncommon", + }, + collection_time: { + _default: "common", + }, + collection_time_inc: { + _default: "uncommon", + }, + conduc: { + _default: "uncommon", + }, + cult_root_med: { + _default: "uncommon", + }, + cur_land_use: { + _default: "common", + }, + cur_vegetation: { + _default: "common", + }, + cur_vegetation_meth: { + _default: "uncommon", + }, + density: { + _default: "uncommon", + }, + depth: { + _default: "uncommon", + soil: "common", + "host-associated": "common", + sediment: "common", + water: "common", + }, + down_par: { + _default: "uncommon", + }, + drainage_class: { + _default: "uncommon", + }, + elev: { + _default: "uncommon", + soil: "common", + air: "common", + "microbial mat_biofilm": "common", + "built environment": "common", + "plant-associated": "common", + water: "common", + }, + experimental_factor_other: { + _default: "uncommon", + }, + fertilizer_regm: { + _default: "uncommon", + }, + filter_method: { + _default: "uncommon", + }, + fungicide_regm: { + _default: "uncommon", + }, + gaseous_environment: { + _default: "uncommon", + }, + genetic_mod: { + _default: "uncommon", + }, + geo_loc_name: { + _default: "common", + }, + gravidity: { + _default: "uncommon", + }, + growth_facil: { + _default: "common", + }, + growth_habit: { + _default: "uncommon", + }, + growth_hormone_regm: { + _default: "uncommon", + }, + herbicide_regm: { + _default: "uncommon", + }, + horizon_meth: { + _default: "uncommon", + }, + host_age: { + _default: "common", + }, + host_body_habitat: { + _default: "uncommon", + }, + host_body_product: { + _default: "uncommon", + }, + host_body_site: { + _default: "common", + }, + host_body_temp: { + _default: "common", + }, + host_color: { + _default: "uncommon", + }, + host_common_name: { + _default: "common", + }, + host_disease_stat: { + _default: "uncommon", + }, + host_dry_mass: { + _default: "uncommon", + }, + host_genotype: { + _default: "uncommon", + }, + host_height: { + _default: "uncommon", + }, + host_last_meal: { + _default: "uncommon", + }, + host_length: { + _default: "uncommon", + }, + host_life_stage: { + _default: "uncommon", + }, + host_phenotype: { + _default: "uncommon", + }, + host_sex: { + _default: "uncommon", + }, + host_shape: { + _default: "uncommon", + }, + host_subject_id: { + _default: "common", + }, + host_subspecf_genlin: { + _default: "uncommon", + }, + host_substrate: { + _default: "uncommon", + }, + host_symbiont: { + _default: "uncommon", + }, + host_tot_mass: { + _default: "uncommon", + }, + host_wet_mass: { + _default: "uncommon", + }, + humidity: { + _default: "common", + }, + humidity_regm: { + _default: "uncommon", + }, + infiltrations: { + _default: "uncommon", + }, + isotope_exposure: { + _default: "uncommon", + }, + lat_lon: { + _default: "common", + }, + light_intensity: { + _default: "uncommon", + }, + light_regm: { + _default: "uncommon", + }, + local_class: { + _default: "uncommon", + }, + local_class_meth: { + _default: "uncommon", + }, + mechanical_damage: { + _default: "uncommon", + }, + methane: { + _default: "uncommon", + }, + mineral_nutr_regm: { + _default: "uncommon", + }, + misc_param: { + _default: "uncommon", + }, + non_min_nutr_regm: { + _default: "uncommon", + }, + other_treatment: { + _default: "uncommon", + }, + oxy_stat_samp: { + _default: "uncommon", + }, + oxygen: { + _default: "uncommon", + }, + pesticide_regm: { + _default: "uncommon", + }, + ph: { + _default: "uncommon", + }, + ph_meth: { + _default: "uncommon", + }, + ph_regm: { + _default: "uncommon", + }, + plant_growth_med: { + _default: "uncommon", + }, + plant_product: { + _default: "uncommon", + }, + plant_sex: { + _default: "uncommon", + }, + plant_struc: { + _default: "common", + }, + pollutants: { + _default: "uncommon", + }, + pressure: { + _default: "uncommon", + }, + profile_position: { + _default: "uncommon", + }, + radiation_regm: { + _default: "uncommon", + }, + rainfall_regm: { + _default: "uncommon", + }, + redox_potential: { + _default: "uncommon", + }, + root_cond: { + _default: "uncommon", + }, + samp_capt_status: { + _default: "uncommon", + }, + samp_collec_device: { + _default: "uncommon", + soil: "common", + sediment: "common", + water: "common", + }, + samp_collec_method: { + _default: "uncommon", + }, + samp_dis_stage: { + _default: "uncommon", + }, + samp_mat_process: { + _default: "uncommon", + }, + samp_name: { + _default: "common", + }, + samp_size: { + _default: "common", + }, + samp_store_temp: { + _default: "uncommon", + soil: "common", + sediment: "common", + }, + sediment_type: { + _default: "uncommon", + }, + sieving: { + _default: "uncommon", + }, + size_frac: { + air: "uncommon", + "microbial mat_biofilm": "uncommon", + "plant-associated": "uncommon", + sediment: "uncommon", + water: "uncommon", + }, + size_frac_low: { + _default: "uncommon", + }, + size_frac_up: { + _default: "uncommon", + }, + slope_aspect: { + _default: "uncommon", + }, + slope_gradient: { + _default: "uncommon", + }, + soil_horizon: { + _default: "uncommon", + }, + solar_irradiance: { + _default: "uncommon", + }, + standing_water_regm: { + _default: "uncommon", + }, + start_date_inc: { + _default: "uncommon", + }, + start_time_inc: { + _default: "uncommon", + }, + store_cond: { + _default: "uncommon", + }, + temp: { + _default: "uncommon", + air: "common", + }, + tidal_stage: { + _default: "uncommon", + }, + tillage: { + _default: "uncommon", + }, + tot_depth_water_col: { + _default: "uncommon", + }, + turbidity: { + _default: "uncommon", + }, + ventilation_rate: { + _default: "uncommon", + }, + ventilation_type: { + _default: "uncommon", + }, + water_cont_soil_meth: { + _default: "uncommon", + }, + water_content: { + _default: "uncommon", + }, + water_current: { + _default: "uncommon", + }, + water_temp_regm: { + _default: "uncommon", + }, + watering_regm: { + _default: "uncommon", + }, + wind_direction: { + _default: "uncommon", + }, + wind_speed: { + _default: "uncommon", + }, +}; + +export default slotVisibilities; diff --git a/src/components/StudyView/StudyView.tsx b/src/components/StudyView/StudyView.tsx index d07161f..a100473 100644 --- a/src/components/StudyView/StudyView.tsx +++ b/src/components/StudyView/StudyView.tsx @@ -20,11 +20,11 @@ import { useNetworkStatus } from "../../NetworkStatus"; import QueryErrorBanner from "../QueryErrorBanner/QueryErrorBanner"; import MutationErrorBanner from "../MutationErrorBanner/MutationErrorBanner"; import SlotSelectorModal from "../SlotSelectorModal/SlotSelectorModal"; -import { TEMPLATES } from "../../api"; +import { TemplateName, TEMPLATES } from "../../api"; import Pluralize from "../Pluralize/Pluralize"; interface TemplateVisibleSlots { - template: string; + template: TemplateName; templateDisplay: string; visibleSlots: string[] | undefined; } @@ -52,13 +52,17 @@ const StudyView: React.FC = ({ const fieldVisibilityInfo: TemplateVisibleSlots[] = []; if (submission.data) { const packageName = submission.data.metadata_submission.packageName; - const template = TEMPLATES[packageName]; - fieldVisibilityInfo.push({ - template: packageName, - templateDisplay: template.displayName, - visibleSlots: - submission.data.field_notes_metadata?.fieldVisibility?.[packageName], - }); + if (packageName !== "") { + const template = TEMPLATES[packageName]; + fieldVisibilityInfo.push({ + template: packageName, + templateDisplay: template.displayName, + visibleSlots: + submission.data.field_notes_metadata?.fieldVisibility?.[ + packageName + ], + }); + } } return fieldVisibilityInfo; }, [submission.data]); diff --git a/src/pages/SamplePage/SamplePage.tsx b/src/pages/SamplePage/SamplePage.tsx index dd9ae4f..4c57402 100644 --- a/src/pages/SamplePage/SamplePage.tsx +++ b/src/pages/SamplePage/SamplePage.tsx @@ -256,17 +256,17 @@ const SamplePage: React.FC = () => { )} - {schema.data && ( + {schema.data && packageName && ( <> diff --git a/src/utils.ts b/src/utils.ts index f1b2ca9..c951982 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -12,7 +12,9 @@ export function getSubmissionSamples( return []; } const environmentalPackageName = submission.metadata_submission.packageName; - const sampleDataField = TEMPLATES[environmentalPackageName]?.sampleDataSlot; + const sampleDataField = environmentalPackageName + ? TEMPLATES[environmentalPackageName].sampleDataSlot + : undefined; if (!sampleDataField) { return []; } From ecfb28fe5172005f752bb78d039c4d0bf2a021cd Mon Sep 17 00:00:00 2001 From: Patrick Kalita Date: Wed, 27 Nov 2024 13:29:58 -0800 Subject: [PATCH 08/16] Rename slot visibilities file --- src/components/SlotSelectorModal/SlotSelectorModal.tsx | 2 +- .../{slotVisibility.ts => slotVisibilities.ts} | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) rename src/components/SlotSelectorModal/{slotVisibility.ts => slotVisibilities.ts} (98%) diff --git a/src/components/SlotSelectorModal/SlotSelectorModal.tsx b/src/components/SlotSelectorModal/SlotSelectorModal.tsx index 483dccc..b954806 100644 --- a/src/components/SlotSelectorModal/SlotSelectorModal.tsx +++ b/src/components/SlotSelectorModal/SlotSelectorModal.tsx @@ -15,7 +15,7 @@ import { useSubmissionSchema } from "../../queries"; import { SlotGroup, sortSlots } from "../../utils"; import { TemplateName, TEMPLATES } from "../../api"; import { SlotDefinition } from "../../linkml-metamodel"; -import slotVisibilities from "./slotVisibility"; +import slotVisibilities from "./slotVisibilities"; import styles from "./SlotSelectorModal.module.css"; diff --git a/src/components/SlotSelectorModal/slotVisibility.ts b/src/components/SlotSelectorModal/slotVisibilities.ts similarity index 98% rename from src/components/SlotSelectorModal/slotVisibility.ts rename to src/components/SlotSelectorModal/slotVisibilities.ts index 03bc32d..3d9bcc9 100644 --- a/src/components/SlotSelectorModal/slotVisibility.ts +++ b/src/components/SlotSelectorModal/slotVisibilities.ts @@ -2,7 +2,7 @@ import { TemplateName } from "../../api"; type VisibilityLevel = "common" | "uncommon"; -interface SlotVisibility +interface SlotVisibilities extends Partial> { _default?: VisibilityLevel; } @@ -25,7 +25,7 @@ interface SlotVisibility * either "common" or "uncommon". The setting in the "_default" key is used if there is no specific * setting for a given template. */ -const slotVisibilities: Record = { +const slotVisibilities: Record = { agrochem_addition: { _default: "uncommon", }, From 114b8a8cce989c6f31a174d8a37a92438b36a342 Mon Sep 17 00:00:00 2001 From: Patrick Kalita Date: Mon, 2 Dec 2024 13:49:38 -0800 Subject: [PATCH 09/16] Clarify user-facing wording for occasionally used and uncommonly used slots --- src/components/SlotSelector/SlotSelector.tsx | 7 +- .../SlotSelectorModal/SlotSelectorModal.tsx | 34 ++- .../SlotSelectorModal/slotVisibilities.ts | 254 +++++++++--------- 3 files changed, 153 insertions(+), 142 deletions(-) diff --git a/src/components/SlotSelector/SlotSelector.tsx b/src/components/SlotSelector/SlotSelector.tsx index f76fcfc..d371059 100644 --- a/src/components/SlotSelector/SlotSelector.tsx +++ b/src/components/SlotSelector/SlotSelector.tsx @@ -105,9 +105,10 @@ const SlotSelector: React.FC = ({ {group.description && ( -
- {group.description} -
+
)} {group.slots.map((slot) => ( diff --git a/src/components/SlotSelectorModal/SlotSelectorModal.tsx b/src/components/SlotSelectorModal/SlotSelectorModal.tsx index b954806..6641094 100644 --- a/src/components/SlotSelectorModal/SlotSelectorModal.tsx +++ b/src/components/SlotSelectorModal/SlotSelectorModal.tsx @@ -26,36 +26,46 @@ function groupClassSlots( const commonGroup: SlotGroup = { name: "common", description: - "These fields will commonly be measured or collected in the field.", + "The values for these fields are commonly measured at the time of sample collection. These " + + "fields will be selected automatically when setting up a new study.", title: "Common", slots: [], }; + const occasionalGroup: SlotGroup = { + name: "occasional", + description: + "These fields may sometimes be measured at the time of sample collection. Review this list " + + "and select any fields that are relevant to your study.", + title: "Occasional", + slots: [], + }; const uncommonGroup: SlotGroup = { name: "uncommon", description: - "These fields may sometimes be measured or collected in the field.", + "These fields are rarely measured at the time of sample collection. These field are more " + + "often entered after sampling via the " + + 'NMDC ' + + "Submission Portal.", title: "Uncommon", slots: [], }; - const otherGroup: SlotGroup = { - name: "other", - description: "These fields are rarely measured or collected in the field.", - title: "Other", - slots: [], - }; slotDefinitions.forEach((slot) => { const visibility = slotVisibilities[slot.name] || {}; const group = visibility[templateName] || visibility["_default"]; if (group === "common") { commonGroup.slots.push(slot); - } else if (group === "uncommon") { - uncommonGroup.slots.push(slot); + } else if (group === "occasional") { + occasionalGroup.slots.push(slot); } else { - otherGroup.slots.push(slot); + uncommonGroup.slots.push(slot); } }); - const groupedSlots: SlotGroup[] = [commonGroup, uncommonGroup, otherGroup]; + const groupedSlots: SlotGroup[] = [ + commonGroup, + occasionalGroup, + uncommonGroup, + ]; groupedSlots.forEach((group) => { sortSlots(group.slots); }); diff --git a/src/components/SlotSelectorModal/slotVisibilities.ts b/src/components/SlotSelectorModal/slotVisibilities.ts index 3d9bcc9..192cbfe 100644 --- a/src/components/SlotSelectorModal/slotVisibilities.ts +++ b/src/components/SlotSelectorModal/slotVisibilities.ts @@ -1,6 +1,6 @@ import { TemplateName } from "../../api"; -type VisibilityLevel = "common" | "uncommon"; +type VisibilityLevel = "common" | "occasional"; interface SlotVisibilities extends Partial> { @@ -8,53 +8,53 @@ interface SlotVisibilities } /** - * This object encodes which slots are commonly, uncommonly, or rarely measured in the field. This - * was the output of a manual curation process. At some point we might consider encoding this - * directly in the submission schema somehow. But for now, this is a simple way to get the job done. - * The structure of the object is: + * This object encodes which slots are commonly, occasionally, or uncommonly measured at sample + * collection time. This was the output of a manual curation process. At some point we might + * consider encoding this directly in the submission schema somehow. But for now, this is a simple + * way to get the job done. The structure of the object is: * { * : { - * _default?: "common" | "uncommon", - * ?: "common" | "uncommon", + * _default?: "common" | "occasional", + * ?: "common" | "occasional", * ... * }, * ... * } * Each key in the top-level object is a slot name. The value is an object that can contain a * "_default" key and/or keys for specific template names (e.g. soil). The value of these keys is - * either "common" or "uncommon". The setting in the "_default" key is used if there is no specific + * either "common" or "occasional". The setting in the "_default" key is used if there is no specific * setting for a given template. */ const slotVisibilities: Record = { agrochem_addition: { - _default: "uncommon", + _default: "occasional", }, air_PM_concen: { - _default: "uncommon", + _default: "occasional", }, air_temp_regm: { - _default: "uncommon", + _default: "occasional", }, alt: { air: "common", }, ances_data: { - _default: "uncommon", + _default: "occasional", }, antibiotic_regm: { - _default: "uncommon", + _default: "occasional", }, bac_resp: { - _default: "uncommon", + _default: "occasional", }, barometric_press: { - _default: "uncommon", + _default: "occasional", }, biol_stat: { - _default: "uncommon", + _default: "occasional", }, biotic_regm: { - _default: "uncommon", + _default: "occasional", }, blood_press_diast: { _default: "common", @@ -63,37 +63,37 @@ const slotVisibilities: Record = { _default: "common", }, bulk_elect_conductivity: { - _default: "uncommon", + _default: "occasional", }, carb_dioxide: { - air: "uncommon", + air: "occasional", }, carb_monoxide: { - _default: "uncommon", + _default: "occasional", }, chem_administration: { - _default: "uncommon", + _default: "occasional", }, chem_mutagen: { - _default: "uncommon", + _default: "occasional", }, collection_date: { _default: "common", }, collection_date_inc: { - _default: "uncommon", + _default: "occasional", }, collection_time: { _default: "common", }, collection_time_inc: { - _default: "uncommon", + _default: "occasional", }, conduc: { - _default: "uncommon", + _default: "occasional", }, cult_root_med: { - _default: "uncommon", + _default: "occasional", }, cur_land_use: { _default: "common", @@ -102,26 +102,26 @@ const slotVisibilities: Record = { _default: "common", }, cur_vegetation_meth: { - _default: "uncommon", + _default: "occasional", }, density: { - _default: "uncommon", + _default: "occasional", }, depth: { - _default: "uncommon", + _default: "occasional", soil: "common", "host-associated": "common", sediment: "common", water: "common", }, down_par: { - _default: "uncommon", + _default: "occasional", }, drainage_class: { - _default: "uncommon", + _default: "occasional", }, elev: { - _default: "uncommon", + _default: "occasional", soil: "common", air: "common", "microbial mat_biofilm": "common", @@ -130,52 +130,52 @@ const slotVisibilities: Record = { water: "common", }, experimental_factor_other: { - _default: "uncommon", + _default: "occasional", }, fertilizer_regm: { - _default: "uncommon", + _default: "occasional", }, filter_method: { - _default: "uncommon", + _default: "occasional", }, fungicide_regm: { - _default: "uncommon", + _default: "occasional", }, gaseous_environment: { - _default: "uncommon", + _default: "occasional", }, genetic_mod: { - _default: "uncommon", + _default: "occasional", }, geo_loc_name: { _default: "common", }, gravidity: { - _default: "uncommon", + _default: "occasional", }, growth_facil: { _default: "common", }, growth_habit: { - _default: "uncommon", + _default: "occasional", }, growth_hormone_regm: { - _default: "uncommon", + _default: "occasional", }, herbicide_regm: { - _default: "uncommon", + _default: "occasional", }, horizon_meth: { - _default: "uncommon", + _default: "occasional", }, host_age: { _default: "common", }, host_body_habitat: { - _default: "uncommon", + _default: "occasional", }, host_body_product: { - _default: "uncommon", + _default: "occasional", }, host_body_site: { _default: "common", @@ -184,172 +184,172 @@ const slotVisibilities: Record = { _default: "common", }, host_color: { - _default: "uncommon", + _default: "occasional", }, host_common_name: { _default: "common", }, host_disease_stat: { - _default: "uncommon", + _default: "occasional", }, host_dry_mass: { - _default: "uncommon", + _default: "occasional", }, host_genotype: { - _default: "uncommon", + _default: "occasional", }, host_height: { - _default: "uncommon", + _default: "occasional", }, host_last_meal: { - _default: "uncommon", + _default: "occasional", }, host_length: { - _default: "uncommon", + _default: "occasional", }, host_life_stage: { - _default: "uncommon", + _default: "occasional", }, host_phenotype: { - _default: "uncommon", + _default: "occasional", }, host_sex: { - _default: "uncommon", + _default: "occasional", }, host_shape: { - _default: "uncommon", + _default: "occasional", }, host_subject_id: { _default: "common", }, host_subspecf_genlin: { - _default: "uncommon", + _default: "occasional", }, host_substrate: { - _default: "uncommon", + _default: "occasional", }, host_symbiont: { - _default: "uncommon", + _default: "occasional", }, host_tot_mass: { - _default: "uncommon", + _default: "occasional", }, host_wet_mass: { - _default: "uncommon", + _default: "occasional", }, humidity: { _default: "common", }, humidity_regm: { - _default: "uncommon", + _default: "occasional", }, infiltrations: { - _default: "uncommon", + _default: "occasional", }, isotope_exposure: { - _default: "uncommon", + _default: "occasional", }, lat_lon: { _default: "common", }, light_intensity: { - _default: "uncommon", + _default: "occasional", }, light_regm: { - _default: "uncommon", + _default: "occasional", }, local_class: { - _default: "uncommon", + _default: "occasional", }, local_class_meth: { - _default: "uncommon", + _default: "occasional", }, mechanical_damage: { - _default: "uncommon", + _default: "occasional", }, methane: { - _default: "uncommon", + _default: "occasional", }, mineral_nutr_regm: { - _default: "uncommon", + _default: "occasional", }, misc_param: { - _default: "uncommon", + _default: "occasional", }, non_min_nutr_regm: { - _default: "uncommon", + _default: "occasional", }, other_treatment: { - _default: "uncommon", + _default: "occasional", }, oxy_stat_samp: { - _default: "uncommon", + _default: "occasional", }, oxygen: { - _default: "uncommon", + _default: "occasional", }, pesticide_regm: { - _default: "uncommon", + _default: "occasional", }, ph: { - _default: "uncommon", + _default: "occasional", }, ph_meth: { - _default: "uncommon", + _default: "occasional", }, ph_regm: { - _default: "uncommon", + _default: "occasional", }, plant_growth_med: { - _default: "uncommon", + _default: "occasional", }, plant_product: { - _default: "uncommon", + _default: "occasional", }, plant_sex: { - _default: "uncommon", + _default: "occasional", }, plant_struc: { _default: "common", }, pollutants: { - _default: "uncommon", + _default: "occasional", }, pressure: { - _default: "uncommon", + _default: "occasional", }, profile_position: { - _default: "uncommon", + _default: "occasional", }, radiation_regm: { - _default: "uncommon", + _default: "occasional", }, rainfall_regm: { - _default: "uncommon", + _default: "occasional", }, redox_potential: { - _default: "uncommon", + _default: "occasional", }, root_cond: { - _default: "uncommon", + _default: "occasional", }, samp_capt_status: { - _default: "uncommon", + _default: "occasional", }, samp_collec_device: { - _default: "uncommon", + _default: "occasional", soil: "common", sediment: "common", water: "common", }, samp_collec_method: { - _default: "uncommon", + _default: "occasional", }, samp_dis_stage: { - _default: "uncommon", + _default: "occasional", }, samp_mat_process: { - _default: "uncommon", + _default: "occasional", }, samp_name: { _default: "common", @@ -358,95 +358,95 @@ const slotVisibilities: Record = { _default: "common", }, samp_store_temp: { - _default: "uncommon", + _default: "occasional", soil: "common", sediment: "common", }, sediment_type: { - _default: "uncommon", + _default: "occasional", }, sieving: { - _default: "uncommon", + _default: "occasional", }, size_frac: { - air: "uncommon", - "microbial mat_biofilm": "uncommon", - "plant-associated": "uncommon", - sediment: "uncommon", - water: "uncommon", + air: "occasional", + "microbial mat_biofilm": "occasional", + "plant-associated": "occasional", + sediment: "occasional", + water: "occasional", }, size_frac_low: { - _default: "uncommon", + _default: "occasional", }, size_frac_up: { - _default: "uncommon", + _default: "occasional", }, slope_aspect: { - _default: "uncommon", + _default: "occasional", }, slope_gradient: { - _default: "uncommon", + _default: "occasional", }, soil_horizon: { - _default: "uncommon", + _default: "occasional", }, solar_irradiance: { - _default: "uncommon", + _default: "occasional", }, standing_water_regm: { - _default: "uncommon", + _default: "occasional", }, start_date_inc: { - _default: "uncommon", + _default: "occasional", }, start_time_inc: { - _default: "uncommon", + _default: "occasional", }, store_cond: { - _default: "uncommon", + _default: "occasional", }, temp: { - _default: "uncommon", + _default: "occasional", air: "common", }, tidal_stage: { - _default: "uncommon", + _default: "occasional", }, tillage: { - _default: "uncommon", + _default: "occasional", }, tot_depth_water_col: { - _default: "uncommon", + _default: "occasional", }, turbidity: { - _default: "uncommon", + _default: "occasional", }, ventilation_rate: { - _default: "uncommon", + _default: "occasional", }, ventilation_type: { - _default: "uncommon", + _default: "occasional", }, water_cont_soil_meth: { - _default: "uncommon", + _default: "occasional", }, water_content: { - _default: "uncommon", + _default: "occasional", }, water_current: { - _default: "uncommon", + _default: "occasional", }, water_temp_regm: { - _default: "uncommon", + _default: "occasional", }, watering_regm: { - _default: "uncommon", + _default: "occasional", }, wind_direction: { - _default: "uncommon", + _default: "occasional", }, wind_speed: { - _default: "uncommon", + _default: "occasional", }, }; From fc0ab84c68a407351fa77b43c8927cebc63cc59a Mon Sep 17 00:00:00 2001 From: Patrick Kalita Date: Mon, 2 Dec 2024 15:43:09 -0800 Subject: [PATCH 10/16] Use previously defined TemplateName type --- src/api.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/api.ts b/src/api.ts index e046028..0028b59 100644 --- a/src/api.ts +++ b/src/api.ts @@ -186,7 +186,7 @@ export interface SubmissionMetadataUpdate extends SubmissionMetadataBase { } export interface FieldNotesMetadata { - fieldVisibility?: Partial>; + fieldVisibility?: Partial>; } export interface SubmissionMetadata extends SubmissionMetadataCreate { From 3864569ec241621878af95951ac52c81809106e9 Mon Sep 17 00:00:00 2001 From: Patrick Kalita Date: Mon, 2 Dec 2024 15:47:12 -0800 Subject: [PATCH 11/16] Change function name to reflect usage --- src/components/SlotSelectorModal/SlotSelectorModal.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/SlotSelectorModal/SlotSelectorModal.tsx b/src/components/SlotSelectorModal/SlotSelectorModal.tsx index 6641094..0afe90a 100644 --- a/src/components/SlotSelectorModal/SlotSelectorModal.tsx +++ b/src/components/SlotSelectorModal/SlotSelectorModal.tsx @@ -19,7 +19,7 @@ import slotVisibilities from "./slotVisibilities"; import styles from "./SlotSelectorModal.module.css"; -function groupClassSlots( +function groupTemplateSlots( slotDefinitions: SlotDefinition[], templateName: TemplateName, ): SlotGroup[] { @@ -110,7 +110,7 @@ const SlotSelectorModal: React.FC = ({ if (!classDefinition.attributes) { return []; } - return groupClassSlots( + return groupTemplateSlots( Object.values(classDefinition.attributes), templateName, ); From 0c923b7d387cee7521d756526b802cac839ab79d Mon Sep 17 00:00:00 2001 From: Patrick Kalita Date: Tue, 3 Dec 2024 09:47:06 -0800 Subject: [PATCH 12/16] Add TS const assertion to TEMPLATES --- src/api.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/api.ts b/src/api.ts index 0028b59..0d005da 100644 --- a/src/api.ts +++ b/src/api.ts @@ -148,7 +148,7 @@ export const TEMPLATES = { schemaClass: "WaterInterface", sampleDataSlot: "water_data", }, -}; +} as const; export type TemplateName = keyof typeof TEMPLATES; export interface MetadataSubmission { From fabc44b9c2c47cc3cd4f1a950dd9e13bfc9c337d Mon Sep 17 00:00:00 2001 From: Patrick Kalita Date: Mon, 9 Dec 2024 14:22:29 -0800 Subject: [PATCH 13/16] Add SlotName type as a semantic hint --- src/api.ts | 4 +++- src/components/SampleView/SampleView.tsx | 3 ++- src/components/SlotSelectorModal/SlotSelectorModal.tsx | 8 ++++---- src/components/SlotSelectorModal/slotVisibilities.ts | 4 ++-- src/components/StudyView/StudyView.tsx | 4 ++-- 5 files changed, 13 insertions(+), 10 deletions(-) diff --git a/src/api.ts b/src/api.ts index 7e1338b..cddff5f 100644 --- a/src/api.ts +++ b/src/api.ts @@ -151,6 +151,8 @@ export const TEMPLATES = { } as const; export type TemplateName = keyof typeof TEMPLATES; +export type SlotName = string; + export interface MetadataSubmission { packageName: TemplateName | ""; contextForm: ContextForm; @@ -187,7 +189,7 @@ export interface SubmissionMetadataUpdate extends SubmissionMetadataBase { } export interface FieldNotesMetadata { - fieldVisibility?: Partial>; + fieldVisibility?: Partial>; } export interface SubmissionMetadata extends SubmissionMetadataCreate { diff --git a/src/components/SampleView/SampleView.tsx b/src/components/SampleView/SampleView.tsx index 8b49766..5d76d1f 100644 --- a/src/components/SampleView/SampleView.tsx +++ b/src/components/SampleView/SampleView.tsx @@ -2,6 +2,7 @@ import React, { useMemo } from "react"; import { SampleData, SampleDataValue, + SlotName, TemplateName, TEMPLATES, } from "../../api"; @@ -26,7 +27,7 @@ interface SampleViewProps { sample?: SampleData; schema: SchemaDefinition; validationResults?: Record; - visibleSlots?: string[]; + visibleSlots?: SlotName[]; } const SampleView: React.FC = ({ onSlotClick, diff --git a/src/components/SlotSelectorModal/SlotSelectorModal.tsx b/src/components/SlotSelectorModal/SlotSelectorModal.tsx index 0afe90a..c0f01b7 100644 --- a/src/components/SlotSelectorModal/SlotSelectorModal.tsx +++ b/src/components/SlotSelectorModal/SlotSelectorModal.tsx @@ -13,7 +13,7 @@ import { closeOutline } from "ionicons/icons"; import SlotSelector from "../SlotSelector/SlotSelector"; import { useSubmissionSchema } from "../../queries"; import { SlotGroup, sortSlots } from "../../utils"; -import { TemplateName, TEMPLATES } from "../../api"; +import { SlotName, TemplateName, TEMPLATES } from "../../api"; import { SlotDefinition } from "../../linkml-metamodel"; import slotVisibilities from "./slotVisibilities"; @@ -74,10 +74,10 @@ function groupTemplateSlots( export interface SlotSelectorModalProps { allowDismiss?: boolean; - defaultSelectedSlots?: string[]; + defaultSelectedSlots?: SlotName[]; isOpen: boolean; onDismiss: () => void; - onSave: (selectedSlots: string[]) => void; + onSave: (selectedSlots: SlotName[]) => void; templateName?: TemplateName; } const SlotSelectorModal: React.FC = ({ @@ -88,7 +88,7 @@ const SlotSelectorModal: React.FC = ({ onSave, templateName, }) => { - const [selectedSlots, setSelectedSlots] = React.useState([]); + const [selectedSlots, setSelectedSlots] = React.useState([]); const schema = useSubmissionSchema(); const schemaClassName = templateName && TEMPLATES[templateName].schemaClass; diff --git a/src/components/SlotSelectorModal/slotVisibilities.ts b/src/components/SlotSelectorModal/slotVisibilities.ts index 192cbfe..169a2ce 100644 --- a/src/components/SlotSelectorModal/slotVisibilities.ts +++ b/src/components/SlotSelectorModal/slotVisibilities.ts @@ -1,4 +1,4 @@ -import { TemplateName } from "../../api"; +import { SlotName, TemplateName } from "../../api"; type VisibilityLevel = "common" | "occasional"; @@ -25,7 +25,7 @@ interface SlotVisibilities * either "common" or "occasional". The setting in the "_default" key is used if there is no specific * setting for a given template. */ -const slotVisibilities: Record = { +const slotVisibilities: Record = { agrochem_addition: { _default: "occasional", }, diff --git a/src/components/StudyView/StudyView.tsx b/src/components/StudyView/StudyView.tsx index a100473..4dd511c 100644 --- a/src/components/StudyView/StudyView.tsx +++ b/src/components/StudyView/StudyView.tsx @@ -20,13 +20,13 @@ import { useNetworkStatus } from "../../NetworkStatus"; import QueryErrorBanner from "../QueryErrorBanner/QueryErrorBanner"; import MutationErrorBanner from "../MutationErrorBanner/MutationErrorBanner"; import SlotSelectorModal from "../SlotSelectorModal/SlotSelectorModal"; -import { TemplateName, TEMPLATES } from "../../api"; +import { SlotName, TemplateName, TEMPLATES } from "../../api"; import Pluralize from "../Pluralize/Pluralize"; interface TemplateVisibleSlots { template: TemplateName; templateDisplay: string; - visibleSlots: string[] | undefined; + visibleSlots: SlotName[] | undefined; } interface StudyViewProps { From f0f8887e3c9f0c649b39bd7740d9c27259549e10 Mon Sep 17 00:00:00 2001 From: Patrick Kalita Date: Mon, 9 Dec 2024 14:45:49 -0800 Subject: [PATCH 14/16] Use ReactNode type for slot group description to support markup --- src/components/SlotSelector/SlotSelector.tsx | 7 +++---- .../SlotSelectorModal/SlotSelectorModal.tsx | 19 ++++++++++++++----- src/utils.ts | 3 ++- 3 files changed, 19 insertions(+), 10 deletions(-) diff --git a/src/components/SlotSelector/SlotSelector.tsx b/src/components/SlotSelector/SlotSelector.tsx index d371059..3deba96 100644 --- a/src/components/SlotSelector/SlotSelector.tsx +++ b/src/components/SlotSelector/SlotSelector.tsx @@ -105,10 +105,9 @@ const SlotSelector: React.FC = ({ {group.description && ( -
+
+ {group.description} +
)} {group.slots.map((slot) => ( diff --git a/src/components/SlotSelectorModal/SlotSelectorModal.tsx b/src/components/SlotSelectorModal/SlotSelectorModal.tsx index c0f01b7..0308d50 100644 --- a/src/components/SlotSelectorModal/SlotSelectorModal.tsx +++ b/src/components/SlotSelectorModal/SlotSelectorModal.tsx @@ -41,11 +41,20 @@ function groupTemplateSlots( }; const uncommonGroup: SlotGroup = { name: "uncommon", - description: - "These fields are rarely measured at the time of sample collection. These field are more " + - "often entered after sampling via the " + - 'NMDC ' + - "Submission Portal.", + description: ( + <> + These fields are rarely measured at the time of sample collection. They + are more often entered after sampling via the{" "} + + NMDC Submission Portal + + . + + ), title: "Uncommon", slots: [], }; diff --git a/src/utils.ts b/src/utils.ts index c951982..8daa35b 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,3 +1,4 @@ +import React from "react"; import { SubmissionMetadata, TEMPLATES } from "./api"; import { SlotDefinition } from "./linkml-metamodel"; @@ -39,7 +40,7 @@ export function getSubmissionSample( export interface SlotGroup { name: string; - description?: string; + description?: React.ReactNode; title?: string; rank?: number; slots: SlotDefinition[]; From 050d0ccb6ccd0d3cad2b311a135e6d409d2354d7 Mon Sep 17 00:00:00 2001 From: Patrick Kalita Date: Mon, 9 Dec 2024 15:25:59 -0800 Subject: [PATCH 15/16] Clarify comment --- src/components/SlotSelectorModal/slotVisibilities.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/SlotSelectorModal/slotVisibilities.ts b/src/components/SlotSelectorModal/slotVisibilities.ts index 169a2ce..e973a76 100644 --- a/src/components/SlotSelectorModal/slotVisibilities.ts +++ b/src/components/SlotSelectorModal/slotVisibilities.ts @@ -8,8 +8,8 @@ interface SlotVisibilities } /** - * This object encodes which slots are commonly, occasionally, or uncommonly measured at sample - * collection time. This was the output of a manual curation process. At some point we might + * This object encodes which slots' values are commonly, occasionally, or uncommonly measured at + * sample collection time. This was the output of a manual curation process. At some point we might * consider encoding this directly in the submission schema somehow. But for now, this is a simple * way to get the job done. The structure of the object is: * { From afb23766f9d67f1b1594eb60862f099eb831a1ca Mon Sep 17 00:00:00 2001 From: Patrick Kalita Date: Mon, 9 Dec 2024 15:26:50 -0800 Subject: [PATCH 16/16] Use consistent select/selected terminology --- src/components/StudyView/StudyView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/StudyView/StudyView.tsx b/src/components/StudyView/StudyView.tsx index 4dd511c..b833752 100644 --- a/src/components/StudyView/StudyView.tsx +++ b/src/components/StudyView/StudyView.tsx @@ -232,7 +232,7 @@ const StudyView: React.FC = ({ singular={"field"} showCount />{" "} - chosen + selected )}