Skip to content

Commit

Permalink
feat: add cellxgene collection id to atlas forms (#484) (#486)
Browse files Browse the repository at this point in the history
  • Loading branch information
hunterckx authored Dec 17, 2024
1 parent 73bd56a commit a36d125
Show file tree
Hide file tree
Showing 17 changed files with 227 additions and 37 deletions.
19 changes: 19 additions & 0 deletions app/common/constants.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,22 @@
import { escapeRegExp } from "@databiosphere/findable-ui/lib/common/utils";
import {
ATLAS_ECOSYSTEM_PATHS,
ATLAS_ECOSYSTEM_URLS,
} from "../../site-config/common/constants";

export const DEFAULT_HEADERS = {
"content-type": "application/json",
};

export const CELLXGENE_COLLECTION_ID_REGEX = new RegExp(
`^$|^(?:${escapeRegExp(
ATLAS_ECOSYSTEM_URLS.CELLXGENE_PORTAL +
ATLAS_ECOSYSTEM_PATHS.CELLXGENE_COLLECTION
)}/)?[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$`
);

export const HCA_PROJECT_ID_REGEX = new RegExp(
`^$|^(?:${escapeRegExp(
ATLAS_ECOSYSTEM_URLS.HCA_EXPLORER + ATLAS_ECOSYSTEM_PATHS.HCA_PROJECT
)}/)?[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$`
);
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,11 @@ export const Identifiers = ({
const {
formStatus: { isReadOnly },
} = formManager;
const { control, watch } = formMethod;
const {
control,
formState: { errors },
watch,
} = formMethod;
const watchedFields = watch([FIELD_NAME.PUBLICATION_STATUS]);
const [publicationStatus] = watchedFields;
const isPublishedPreprint =
Expand All @@ -48,6 +52,8 @@ export const Identifiers = ({
<Input
{...field}
{...DEFAULT_INPUT_PROPS.HCA_PROJECT_ID}
error={Boolean(errors[FIELD_NAME.HCA_PROJECT_ID])}
helperText={errors[FIELD_NAME.HCA_PROJECT_ID]?.message}
isFilled={Boolean(field.value)}
label={
<Fragment>
Expand All @@ -68,6 +74,8 @@ export const Identifiers = ({
<Input
{...field}
{...DEFAULT_INPUT_PROPS.CELLXGENE_COLLECTION_ID}
error={Boolean(errors[FIELD_NAME.CELLXGENE_COLLECTION_ID])}
helperText={errors[FIELD_NAME.CELLXGENE_COLLECTION_ID]?.message}
isFilled={Boolean(field.value)}
label={
<Fragment>
Expand Down
1 change: 1 addition & 0 deletions app/components/Forms/common/constants.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export const SECTION_TITLES = {
GENERAL_INFORMATION: "General Information",
IDENTIFIERS: "Identifiers",
INTEGRATION_LEAD: "Integration Lead",
};
15 changes: 15 additions & 0 deletions app/components/Forms/components/Atlas/common/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,23 @@ const WAVE: CommonControllerConfig = {
},
};

const CELLXGENE_COLLECTION_ID: CommonControllerConfig = {
inputProps: {
isFullWidth: true,
label: "CELLxGENE collection ID",
},
labelLink: true,
name: FIELD_NAME.CELLXGENE_ATLAS_COLLECTION,
};

export const GENERAL_INFO_NEW_ATLAS_CONTROLLERS: ControllerConfig<NewAtlasData>[] =
[SHORT_NAME, VERSION, BIO_NETWORK, WAVE];

export const IDENTIFIERS_NEW_ATLAS_CONTROLLERS: ControllerConfig<NewAtlasData>[] =
[CELLXGENE_COLLECTION_ID];

export const GENERAL_INFO_VIEW_ATLAS_CONTROLLERS: ControllerConfig<AtlasEditData>[] =
[...GENERAL_INFO_NEW_ATLAS_CONTROLLERS, TARGET_COMPLETION];

export const IDENTIFIERS_VIEW_ATLAS_CONTROLLERS: ControllerConfig<AtlasEditData>[] =
[...IDENTIFIERS_NEW_ATLAS_CONTROLLERS];
8 changes: 8 additions & 0 deletions app/components/Forms/components/Atlas/common/sections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ export const ADD_ATLAS_SECTION_CONFIGS: SectionConfig<
sectionTitle: SECTION_TITLES.GENERAL_INFORMATION,
showDivider: true,
},
{
controllerConfigs: C.IDENTIFIERS_NEW_ATLAS_CONTROLLERS,
sectionTitle: SECTION_TITLES.IDENTIFIERS,
},
{
SectionCard: NewAtlasIntegrationLeadSection,
sectionTitle: SECTION_TITLES.INTEGRATION_LEAD,
Expand All @@ -30,6 +34,10 @@ export const VIEW_ATLAS_SECTION_CONFIGS: SectionConfig<
controllerConfigs: C.GENERAL_INFO_VIEW_ATLAS_CONTROLLERS,
sectionTitle: SECTION_TITLES.GENERAL_INFORMATION,
},
{
controllerConfigs: C.IDENTIFIERS_VIEW_ATLAS_CONTROLLERS,
sectionTitle: SECTION_TITLES.IDENTIFIERS,
},
{
SectionCard: ViewAtlasIntegrationLeadSection,
sectionTitle: SECTION_TITLES.INTEGRATION_LEAD,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { FieldValues, Path } from "react-hook-form";
import { YupValidatedFormValues } from "../../../../../../hooks/useForm/common/entities";
import { InputProps } from "../../Input/input";
import { SelectProps } from "../../Select/select";
import { LabelLinkConfig } from "../components/InputController/inputController";
import { SelectControllerProps } from "../components/SelectController/selectController";

export type ControllerInputConfig = Pick<InputProps, PickedInputProps>;
Expand All @@ -16,6 +17,7 @@ type PickedSelectProps = "displayEmpty" | "label";

export interface ControllerConfig<T extends FieldValues> {
inputProps?: ControllerInputConfig;
labelLink?: LabelLinkConfig | true;
name: Path<YupValidatedFormValues<T>>;
selectProps?: ControllerSelectConfig<T>;
}
Original file line number Diff line number Diff line change
@@ -1,25 +1,34 @@
import { OutlinedInputProps as MOutlinedInputProps } from "@mui/material/OutlinedInput/OutlinedInput";
import { Link } from "@databiosphere/findable-ui/lib/components/Links/components/Link/link";
import { TypographyNoWrap } from "app/components/common/Typography/components/TypographyNoWrap/typographyNoWrap";
import { Fragment, ReactNode } from "react";
import { Controller, FieldValues, UseControllerProps } from "react-hook-form";
import {
FormMethod,
YupValidatedFormValues,
} from "../../../../../../../hooks/useForm/common/entities";
import { FormManager } from "../../../../../../../hooks/useFormManager/common/entities";
import { Input } from "../../../Input/input";
import { Input, InputProps } from "../../../Input/input";

export interface LabelLinkConfig {
getUrl?: (v: string | null) => string | null;
label?: string;
}

export interface InputControllerProps<T extends FieldValues, R = undefined>
extends UseControllerProps<YupValidatedFormValues<T>> {
className?: string;
formManager: FormManager;
formMethod: FormMethod<T, R>;
inputProps?: Partial<Omit<MOutlinedInputProps, "ref">>;
inputProps?: Partial<Omit<InputProps, "ref">>;
labelLink?: LabelLinkConfig | true;
}

export const InputController = <T extends FieldValues, R = undefined>({
className,
formManager,
formMethod,
inputProps,
inputProps: { label, ...inputProps } = {},
labelLink,
name,
...props
}: InputControllerProps<T, R>): JSX.Element => {
Expand All @@ -38,6 +47,15 @@ export const InputController = <T extends FieldValues, R = undefined>({
error={invalid}
helperText={error?.message}
isFilled={Boolean(field.value)}
label={
labelLink
? getLabelWithLink(
label,
labelLink === true ? {} : labelLink,
field.value
)
: label
}
readOnly={isReadOnly}
{...inputProps}
{...props}
Expand All @@ -46,3 +64,31 @@ export const InputController = <T extends FieldValues, R = undefined>({
/>
);
};

/**
* Get input label including a link derived from the input value.
* @param label - Primary label.
* @param param1 - Link config.
* @param param1.getUrl - Function that takes the input value (cast to string or null) and returns the link URL, or null if the link shouldn't be displayed.
* @param param1.label - Link label.
* @param value - Input value.
* @returns Input label with link.
*/
function getLabelWithLink(
label: ReactNode,
{
getUrl = (v): string | null => (v ? v : null),
label: linkLabel = "Visit link",
}: LabelLinkConfig,
value: unknown
): JSX.Element {
const url = getUrl(
value === null || value === undefined ? null : String(value)
);
return (
<Fragment>
<TypographyNoWrap>{label}</TypographyNoWrap>
{url !== null && <Link label={linkLabel} url={url} />}
</Fragment>
);
}
45 changes: 24 additions & 21 deletions app/components/common/Form/components/Controllers/controllers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,27 +19,30 @@ export const Controllers = <T extends FieldValues, R = undefined>({
}: ControllersProps<T, R>): JSX.Element => {
return (
<Fragment>
{controllerConfigs.map(({ inputProps, name, selectProps }, i) => {
const { SelectComponent } = selectProps || {};
return SelectComponent ? (
<SelectController
key={i}
{...selectProps}
name={name}
formManager={formManager}
formMethod={formMethod}
SelectComponent={SelectComponent}
/>
) : (
<InputController
key={i}
{...inputProps}
name={name}
formManager={formManager}
formMethod={formMethod}
/>
);
})}
{controllerConfigs.map(
({ inputProps, labelLink, name, selectProps }, i) => {
const { SelectComponent } = selectProps || {};
return SelectComponent ? (
<SelectController
key={i}
{...selectProps}
name={name}
formManager={formManager}
formMethod={formMethod}
SelectComponent={SelectComponent}
/>
) : (
<InputController
key={i}
inputProps={inputProps}
name={name}
formManager={formManager}
formMethod={formMethod}
labelLink={labelLink}
/>
);
}
)}
</Fragment>
);
};
1 change: 1 addition & 0 deletions app/views/AddNewAtlasView/common/constants.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export const FIELD_NAME = {
BIO_NETWORK: "network",
CELLXGENE_ATLAS_COLLECTION: "cellxgeneAtlasCollection",
INTEGRATION_LEAD: "integrationLead",
SHORT_NAME: "shortName",
VERSION: "version",
Expand Down
8 changes: 8 additions & 0 deletions app/views/AddNewAtlasView/common/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
NETWORK_KEYS,
WAVES,
} from "../../../apis/catalog/hca-atlas-tracker/common/constants";
import { CELLXGENE_COLLECTION_ID_REGEX } from "../../../common/constants";
import { FIELD_NAME } from "./constants";

export const newAtlasSchema = object({
Expand All @@ -24,6 +25,13 @@ export const newAtlasSchema = object({
.required("Network is required")
.notOneOf([""], "Network is required")
.oneOf(NETWORK_KEYS, `Network must be one of: ${NETWORK_KEYS.join(", ")}`),
[FIELD_NAME.CELLXGENE_ATLAS_COLLECTION]: string()
.default("")
.notRequired()
.matches(
CELLXGENE_COLLECTION_ID_REGEX,
"CELLxGENE collection ID must be a UUID or CELLxGENE collection URL"
),
[FIELD_NAME.SHORT_NAME]: string()
.default("")
.required("Short name is required"),
Expand Down
11 changes: 11 additions & 0 deletions app/views/AddNewAtlasView/common/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,14 @@ import { getAtlasesBreadcrumb } from "../../../components/Detail/components/Trac
export function getBreadcrumbs(): Breadcrumb[] {
return [getAtlasesBreadcrumb()];
}

/**
* Returns the identifier URL's ID.
* @param identifierUrl - Identifier URL.
* @returns identifier ID.
*/
export function getIdentifierId(identifierUrl: string | null): string | null {
if (!identifierUrl) return null;
const paths = identifierUrl.split("/");
return paths.pop() || "";
}
16 changes: 15 additions & 1 deletion app/views/AddNewAtlasView/hooks/useAddAtlasFormManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { FormManager } from "../../../hooks/useFormManager/common/entities";
import { useFormManager } from "../../../hooks/useFormManager/useFormManager";
import { ROUTE } from "../../../routes/constants";
import { NewAtlasData } from "../common/entities";
import { getIdentifierId } from "../common/utils";

export const useAddAtlasFormManager = (
formMethod: FormMethod<NewAtlasData, HCAAtlasTrackerAtlas>
Expand All @@ -21,7 +22,7 @@ export const useAddAtlasFormManager = (

const onSave = useCallback(
(payload: NewAtlasData, url?: string) => {
onSubmit(API.CREATE_ATLAS, METHOD.POST, payload, {
onSubmit(API.CREATE_ATLAS, METHOD.POST, mapPayload(payload), {
onSuccess: (data) => onSuccess(data.id, url),
});
},
Expand All @@ -31,6 +32,19 @@ export const useAddAtlasFormManager = (
return useFormManager(formMethod, { onDiscard, onSave });
};

/**
* Maps the payload.
* Strips ID from identifier CELLxGENE collection.
* @param payload - Payload.
* @returns payload.
*/
function mapPayload(payload: NewAtlasData): NewAtlasData {
return {
...payload,
cellxgeneAtlasCollection: getIdentifierId(payload.cellxgeneAtlasCollection),
};
}

/**
* Side effect "onSuccess"; redirects to the atlas page, or to the specified URL.
* @param atlasId - Atlas ID.
Expand Down
19 changes: 19 additions & 0 deletions app/views/AtlasView/hooks/useEditAtlasForm.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import {
ATLAS_ECOSYSTEM_PATHS,
ATLAS_ECOSYSTEM_URLS,
} from "../../../../site-config/common/constants";
import { HCAAtlasTrackerAtlas } from "../../../apis/catalog/hca-atlas-tracker/common/entities";
import { AtlasEditData as APIAtlasEditData } from "../../../apis/catalog/hca-atlas-tracker/common/schema";
import { PathParameter } from "../../../common/entities";
Expand Down Expand Up @@ -45,6 +49,9 @@ function mapSchemaValues(atlas?: HCAAtlasTrackerAtlas): Partial<AtlasEditData> {
return {
[FIELD_NAME.INTEGRATION_LEAD]: sortedIntegrationLead,
[FIELD_NAME.BIO_NETWORK]: atlas.bioNetwork,
[FIELD_NAME.CELLXGENE_ATLAS_COLLECTION]: mapCELLxGENECollectionId(
atlas.cellxgeneAtlasCollection
),
[FIELD_NAME.SHORT_NAME]: atlas.shortName,
[FIELD_NAME.TARGET_COMPLETION]:
atlas.targetCompletion ?? TARGET_COMPLETION_NULL,
Expand All @@ -53,6 +60,18 @@ function mapSchemaValues(atlas?: HCAAtlasTrackerAtlas): Partial<AtlasEditData> {
};
}

/**
* Maps CELLxGENE collection ID to URL.
* @param cellxgeneCollectionId - CELLxGENE collection ID.
* @returns URL.
*/
function mapCELLxGENECollectionId(
cellxgeneCollectionId: string | null
): string {
if (!cellxgeneCollectionId) return "";
return `${ATLAS_ECOSYSTEM_URLS.CELLXGENE_PORTAL}${ATLAS_ECOSYSTEM_PATHS.CELLXGENE_COLLECTION}/${cellxgeneCollectionId}`;
}

/**
* Returns API payload mapped from data.
* @param data - Form data.
Expand Down
Loading

0 comments on commit a36d125

Please sign in to comment.