Skip to content

Commit

Permalink
Merge pull request #137 from microbiomedata/issue-90-submission-locking
Browse files Browse the repository at this point in the history
Obtain editing lock on submissions
  • Loading branch information
pkalita-lbl authored Jul 26, 2024
2 parents c89f03d + 27c7bb5 commit e5a8828
Show file tree
Hide file tree
Showing 17 changed files with 493 additions and 56 deletions.
12 changes: 12 additions & 0 deletions src/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,18 @@ test("deleteSubmission", async () => {
// Nothing to explicitly test here, just that it doesn't throw an error
});

test("acquireSubmissionLock", async () => {
const submissionId = "00000000-0000-0000-0000-000000000001";
const lock = await nmdcServerClient.acquireSubmissionLock(submissionId);
expect(lock.success).toBeTruthy();
});

test("releaseSubmissionLock", async () => {
const submissionId = "00000000-0000-0000-0000-000000000001";
const lock = await nmdcServerClient.releaseSubmissionLock(submissionId);
expect(lock.success).toBeTruthy();
});

test("getCurrentUser", async () => {
const user = await nmdcServerClient.getCurrentUser();
expect(user.name).toEqual("Test Testerson");
Expand Down
41 changes: 38 additions & 3 deletions src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,11 +167,20 @@ export interface User {
is_admin: boolean;
}

export interface SubmissionMetadataCreate {
interface SubmissionMetadataBase {
metadata_submission: MetadataSubmission;
}

export interface SubmissionMetadataCreate extends SubmissionMetadataBase {
status?: string;
source_client: "submission_portal" | "field_notes" | null;
}

export interface SubmissionMetadataUpdate extends SubmissionMetadataBase {
// Map of ORCID iD to permission level
permissions?: Record<string, string>;
}

export interface SubmissionMetadata extends SubmissionMetadataCreate {
status: string;
id: string;
Expand All @@ -180,6 +189,7 @@ export interface SubmissionMetadata extends SubmissionMetadataCreate {
author: User;
lock_updated?: string;
locked_by: Nullable<User>;
permission_level?: string;
}

export interface PaginationOptions {
Expand All @@ -201,7 +211,14 @@ export interface TokenResponse {
expires: number;
}

class ApiError extends Error {
export interface LockOperationResult {
success: boolean;
message: string;
locked_by?: User | null;
lock_updated?: string | null; // ISO 8601 datetime string
}

export class ApiError extends Error {
public readonly response: Response;

constructor(message: string, response: Response) {
Expand Down Expand Up @@ -325,7 +342,7 @@ class NmdcServerClient extends FetchClient {
);
}

async updateSubmission(id: string, data: Partial<SubmissionMetadata>) {
async updateSubmission(id: string, data: SubmissionMetadataUpdate) {
return this.fetchJson<SubmissionMetadata>(
`/api/metadata_submission/${id}`,
{
Expand All @@ -348,6 +365,24 @@ class NmdcServerClient extends FetchClient {
});
}

async acquireSubmissionLock(id: string) {
return this.fetchJson<LockOperationResult>(
`/api/metadata_submission/${id}/lock`,
{
method: "PUT",
},
);
}

async releaseSubmissionLock(id: string) {
return this.fetchJson<LockOperationResult>(
`/api/metadata_submission/${id}/unlock`,
{
method: "PUT",
},
);
}

async getCurrentUser() {
return this.fetchJson<User>("/api/me");
}
Expand Down
13 changes: 13 additions & 0 deletions src/components/Banner/Banner.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
ion-list.banner {
padding: 0;

& ion-item {
--min-height: auto;
font-size: 0.875rem;
}

& ion-button {
--color: initial;
font-weight: 500;
}
}
20 changes: 20 additions & 0 deletions src/components/Banner/Banner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import React, { PropsWithChildren } from "react";
import { Color } from "@ionic/core";
import { IonItem, IonList } from "@ionic/react";

import styles from "./Banner.module.css";

export interface BannerProps extends PropsWithChildren {
color?: Color | string;
}
const Banner: React.FC<BannerProps> = ({ children, color }) => {
return (
<IonList className={styles.banner}>
<IonItem lines="none" color={color}>
{children}
</IonItem>
</IonList>
);
};

export default Banner;
9 changes: 9 additions & 0 deletions src/components/SampleList/SampleList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,17 +22,20 @@ import { IonSearchbarCustomEvent } from "@ionic/core/dist/types/components";
import { useMiniSearch } from "react-minisearch";
import { getSubmissionSamples } from "../../utils";
import { produce } from "immer";
import Banner from "../Banner/Banner";

interface SampleListProps {
submission: SubmissionMetadata;
collapsedSize?: number;
onSampleCreate: () => void;
sampleCreateFailureMessage?: string;
}

const SampleList: React.FC<SampleListProps> = ({
submission,
collapsedSize = 5,
onSampleCreate,
sampleCreateFailureMessage,
}) => {
const searchElement = React.useRef<HTMLIonSearchbarElement>(null);

Expand Down Expand Up @@ -115,6 +118,12 @@ const SampleList: React.FC<SampleListProps> = ({
<IonButton onClick={onSampleCreate}>New</IonButton>
</IonListHeader>

{sampleCreateFailureMessage && (
<Banner color="warning">
<IonLabel>{sampleCreateFailureMessage}</IonLabel>
</Banner>
)}

<IonGrid className={isSearchVisible ? "ion-hide" : ""}>
<IonRow class="ion-justify-content-between">
<IonCol size="auto">
Expand Down
4 changes: 3 additions & 1 deletion src/components/SampleSlotEditModal/SampleSlotEditModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ function getSelectState(

interface SampleSlotEditModalProps {
defaultValue: SampleDataValue;
disabled?: boolean;
getSlotValue: (slot: SlotDefinitionName) => SampleDataValue;
goldEcosystemTree: GoldEcosystemTreeNode;
onCancel: () => void;
Expand All @@ -126,6 +127,7 @@ interface SampleSlotEditModalProps {
}
const SampleSlotEditModal: React.FC<SampleSlotEditModalProps> = ({
defaultValue,
disabled,
getSlotValue,
goldEcosystemTree,
onCancel,
Expand Down Expand Up @@ -304,7 +306,7 @@ const SampleSlotEditModal: React.FC<SampleSlotEditModalProps> = ({
color="primary"
expand="block"
onClick={handleSave}
disabled={saving}
disabled={disabled || saving}
>
{saving ? "Saving..." : "Save"}
</IonButton>
Expand Down
15 changes: 0 additions & 15 deletions src/components/SampleView/SampleView.module.css

This file was deleted.

31 changes: 14 additions & 17 deletions src/components/SampleView/SampleView.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
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 { SchemaDefinition, SlotDefinition } from "../../linkml-metamodel";
import { warningOutline } from "ionicons/icons";
import { useStore } from "../../Store";
import SlotSelectorModal from "../SlotSelectorModal/SlotSelectorModal";

import styles from "./SampleView.module.css";

function formatSlotValue(value: SampleDataValue) {
if (value == null) {
return null;
Expand Down Expand Up @@ -53,21 +52,19 @@ const SampleView: React.FC<SampleViewProps> = ({
return (
<>
{hiddenSlots === undefined && (
<IonList className={styles.slotSelectorBanner}>
<IonItem lines="none">
<IonLabel>Too many fields?</IonLabel>
<IonButton
slot="end"
fill="clear"
onClick={() => setIsModalOpen(true)}
>
Customize List
</IonButton>
<IonButton slot="end" fill="clear" onClick={handleDismiss}>
Dismiss
</IonButton>
</IonItem>
</IonList>
<Banner color="primary">
<IonLabel>Too many fields?</IonLabel>
<IonButton
slot="end"
fill="clear"
onClick={() => setIsModalOpen(true)}
>
Customize List
</IonButton>
<IonButton slot="end" fill="clear" onClick={handleDismiss}>
Dismiss
</IonButton>
</Banner>
)}

{slotGroups.map((group) => (
Expand Down
10 changes: 8 additions & 2 deletions src/components/StudyForm/StudyForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { colorWand as autoFill } from "ionicons/icons";
import styles from "./StudyForm.module.css";

interface StudyFormProps {
disabled?: boolean;
submission: SubmissionMetadataCreate;
onSave: (submission: SubmissionMetadataCreate) => Promise<unknown>;
}
Expand All @@ -36,14 +37,19 @@ interface StudyFormProps {
const EMAIL_REGEX =
/^(?!\.)(?!.*\.\.)([A-Z0-9_+-.]*)[A-Z0-9_+-]@([A-Z0-9][A-Z0-9-]*\.)+[A-Z]{2,}$/i;

const StudyForm: React.FC<StudyFormProps> = ({ submission, onSave }) => {
const StudyForm: React.FC<StudyFormProps> = ({
disabled,
submission,
onSave,
}) => {
const { loggedInUser } = useStore();

const { handleSubmit, control, formState, setValue } =
useForm<SubmissionMetadataCreate>({
defaultValues: submission,
mode: "onTouched",
reValidateMode: "onChange",
disabled,
});

return (
Expand Down Expand Up @@ -257,7 +263,7 @@ const StudyForm: React.FC<StudyFormProps> = ({ submission, onSave }) => {
expand="block"
className="ion-margin-top"
type="submit"
disabled={formState.isDirty && !formState.isValid}
disabled={disabled || (formState.isDirty && !formState.isValid)}
>
{formState.isSubmitting ? "Saving" : "Save"}
</IonButton>
Expand Down
20 changes: 18 additions & 2 deletions src/components/StudyView/StudyView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,23 @@ interface StudyViewProps {

const StudyView: React.FC<StudyViewProps> = ({ submissionId }) => {
const router = useIonRouter();
const { query: submission, updateMutation } = useSubmission(submissionId);
const {
query: submission,
updateMutation,
lockMutation,
} = useSubmission(submissionId);

const handleSampleCreate = () => {
const handleSampleCreate = async () => {
if (!submission.data) {
return;
}

try {
await lockMutation.mutateAsync(submissionId);
} catch {
return;
}

const updatedSubmission = produce(submission.data, (draft) => {
const samples = getSubmissionSamples(draft, {
createSampleDataFieldIfMissing: true,
Expand Down Expand Up @@ -128,6 +139,11 @@ const StudyView: React.FC<StudyViewProps> = ({ submissionId }) => {
<SampleList
submission={submission.data}
onSampleCreate={handleSampleCreate}
sampleCreateFailureMessage={
lockMutation.isError
? `Cannot create new sample because this study is currently being edited by ${submission.data.locked_by?.name || "an unknown user"}`
: undefined
}
/>
</>
)}
Expand Down
7 changes: 6 additions & 1 deletion src/mocks/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,12 @@ export const submissions: SubmissionMetadata[] = [
is_admin: true,
},
lock_updated: "2024-01-02T00:00:00.000000",
locked_by: null,
locked_by: {
id: "00000000-0000-0000-0001-000000000002",
orcid: "0000-0000-0000-0002",
name: "Test Tester 2",
is_admin: true,
},
source_client: "field_notes",
},
];
Loading

0 comments on commit e5a8828

Please sign in to comment.