diff --git a/src/Client/Helper.fs b/src/Client/Helper.fs index 804a2bf7..4d54a50a 100644 --- a/src/Client/Helper.fs +++ b/src/Client/Helper.fs @@ -11,12 +11,10 @@ let logf a b = open System.Collections.Generic -let mutable memoizations = Dictionary(HashIdentity.Structural) - -let debounce<'T> (key: string) (timeout: int) (fn: 'T -> unit) value = +let debounce<'T> (storage:Dictionary) (key: string) (timeout: int) (fn: 'T -> unit) value = let key = key // fn.ToString() // Cancel previous debouncer - match memoizations.TryGetValue(key) with + match storage.TryGetValue(key) with | true, timeoutId -> printfn "CLEAR"; Fable.Core.JS.clearTimeout timeoutId | _ -> printfn "Not clear";() @@ -24,16 +22,16 @@ let debounce<'T> (key: string) (timeout: int) (fn: 'T -> unit) value = let timeoutId = Fable.Core.JS.setTimeout (fun () -> - memoizations.Remove(key) |> ignore + storage.Remove(key) |> ignore fn value ) timeout - memoizations.[key] <- timeoutId + storage.[key] <- timeoutId -let debouncel<'T> (key: string) (timeout: int) (setLoading: bool -> unit) (fn: 'T -> unit) value = +let debouncel<'T> (storage:Dictionary) (key: string) (timeout: int) (setLoading: bool -> unit) (fn: 'T -> unit) value = let key = key // fn.ToString() // Cancel previous debouncer - match memoizations.TryGetValue(key) with + match storage.TryGetValue(key) with | true, timeoutId -> Fable.Core.JS.clearTimeout timeoutId | _ -> setLoading true; () @@ -41,9 +39,11 @@ let debouncel<'T> (key: string) (timeout: int) (setLoading: bool -> unit) (fn: ' let timeoutId = Fable.Core.JS.setTimeout (fun () -> - memoizations.Remove(key) |> ignore + storage.Remove(key) |> ignore setLoading false fn value ) timeout - memoizations.[key] <- timeoutId \ No newline at end of file + storage.[key] <- timeoutId + +let newDebounceStorage = fun () -> Dictionary(HashIdentity.Structural) \ No newline at end of file diff --git a/src/Client/MainComponents/Metadata/Assay.fs b/src/Client/MainComponents/Metadata/Assay.fs index fd9f21fd..bf2f4ab3 100644 --- a/src/Client/MainComponents/Metadata/Assay.fs +++ b/src/Client/MainComponents/Metadata/Assay.fs @@ -2,11 +2,7 @@ open Feliz open Feliz.Bulma - -open Spreadsheet open Messages -open Browser.Types -open Fable.Core.JsInterop open ARCtrl.ISA open Shared @@ -43,11 +39,18 @@ let Main(assay: ArcAssay, model: Messages.Model, dispatch: Msg -> unit) = assay.TechnologyPlatform <- oa assay |> Assay |> Spreadsheet.UpdateArcFile |> SpreadsheetMsg |> dispatch ) - FormComponents.PersonInput( + FormComponents.PersonsInput( assay.Performers, "Performers", fun persons -> assay.Performers <- persons assay |> Assay |> Spreadsheet.UpdateArcFile |> SpreadsheetMsg |> dispatch ) + FormComponents.CommentsInput( + assay.Comments, + "Comments", + fun comments -> + assay.Comments <- comments + assay |> Assay |> Spreadsheet.UpdateArcFile |> SpreadsheetMsg |> dispatch + ) ] \ No newline at end of file diff --git a/src/Client/MainComponents/Metadata/Forms.fs b/src/Client/MainComponents/Metadata/Forms.fs index f57c246c..2c59c10f 100644 --- a/src/Client/MainComponents/Metadata/Forms.fs +++ b/src/Client/MainComponents/Metadata/Forms.fs @@ -11,82 +11,342 @@ open ARCtrl.ISA open Shared +module Helper = + type PersonMutable(?firstname, ?lastname, ?midinitials, ?orcid, ?address, ?affiliation, ?email, ?phone, ?fax, ?roles) = + member val FirstName : string option = firstname with get, set + member val LastName : string option = lastname with get, set + member val MidInitials : string option = midinitials with get, set + member val ORCID : string option = orcid with get, set + member val Address : string option = address with get, set + member val Affiliation : string option = affiliation with get, set + member val EMail : string option = email with get, set + member val Phone : string option = phone with get, set + member val Fax : string option = fax with get, set + member val Roles : OntologyAnnotation [] option = roles with get, set + + static member fromPerson(person:Person) = + PersonMutable( + ?firstname=person.FirstName, + ?lastname=person.LastName, + ?midinitials=person.MidInitials, + ?orcid=person.ORCID, + ?address=person.Address, + ?affiliation=person.Affiliation, + ?email=person.EMail, + ?phone=person.Phone, + ?fax=person.Fax, + ?roles=person.Roles + ) + + member this.ToPerson() = + Person.create( + ?FirstName=this.FirstName, + ?LastName=this.LastName, + ?MidInitials=this.MidInitials, + ?ORCID=this.ORCID, + ?Address=this.Address, + ?Affiliation=this.Affiliation, + ?Email=this.EMail, + ?Phone=this.Phone, + ?Fax=this.Fax, + ?Roles=this.Roles + ) + + let addButton (clickEvent: MouseEvent -> unit) = + Html.div [ + prop.classes ["is-flex"; "is-justify-content-center"] + prop.children [ + Bulma.button.button [ + Bulma.button.isOutlined + Bulma.color.isInfo + prop.text "+" + prop.onClick clickEvent + ] + ] + ] + + let deleteButton (clickEvent: MouseEvent -> unit) = + Html.div [ + prop.classes ["is-flex"; "is-justify-content-flex-end"] + prop.children [ + Bulma.button.button [ + Bulma.button.isOutlined + Bulma.color.isDanger + prop.text "Delete" + prop.onClick clickEvent + ] + ] + ] + type FormComponents = [] - static member TextInput (input: string, label: string, setter: string -> unit, ?placeholder: string) = + static member TextInput (input: string, label: string, setter: string -> unit, ?placeholder: string, ?fullwidth: bool) = + let fullwidth = defaultArg fullwidth false let loading, setLoading = React.useState(false) let state, setState = React.useState(input) + let debounceStorage, setdebounceStorage = React.useState(newDebounceStorage) React.useEffect((fun () -> setState input), dependencies=[|box input|]) Bulma.field.div [ - if label <> "" then Bulma.label label - Bulma.control.div [ - if loading then Bulma.control.isLoading - prop.children [ - Bulma.input.text [ - if placeholder.IsSome then prop.placeholder placeholder.Value - prop.valueOrDefault state - prop.onChange(fun (e: string) -> - setState e - debouncel "t-field" 1000 setLoading setter e - ) + prop.style [if fullwidth then style.flexGrow 1] + prop.children [ + if label <> "" then Bulma.label label + Bulma.control.div [ + if loading then Bulma.control.isLoading + prop.children [ + Bulma.input.text [ + if placeholder.IsSome then prop.placeholder placeholder.Value + prop.valueOrDefault state + prop.onChange(fun (e: string) -> + setState e + debouncel debounceStorage label 1000 setLoading setter e + ) + ] ] ] ] ] [] - static member OntologyAnnotationInput (input: OntologyAnnotation, label: string, setter: OntologyAnnotation -> unit) = + static member OntologyAnnotationInput (oa: OntologyAnnotation, label: string, setter: OntologyAnnotation -> unit, ?showTextLabels: bool, ?removebutton: MouseEvent -> unit) = + let showTextLabels = defaultArg showTextLabels true Bulma.field.div [ - Bulma.label label + if label <> "" then Bulma.label label Html.div [ - prop.classes ["is-flex-direction-row"; "is-flex"; "is-justify-content-space-between"; "is-flex-wrap-wrap"] + prop.classes ["form-container"] prop.children [ FormComponents.TextInput( - input.NameText, - $"Term Name", - fun s -> OntologyAnnotation.fromString(s, ?tsr=input.TermSourceREF, ?tan=input.TermAccessionNumber) |> setter + oa.NameText, + (if showTextLabels then $"Term Name" else ""), + (fun s -> { oa with Name = AnnotationValue.Text s |> Some } |> setter), + fullwidth = true ) FormComponents.TextInput( - input.TermSourceREFString, - $"TSR", - fun s -> + oa.TermSourceREFString, + (if showTextLabels then $"TSR" else ""), + (fun s -> let s2 = s |> fun s -> if s = "" then None else Some s - OntologyAnnotation.fromString(input.NameText, ?tsr=s2, ?tan=input.TermAccessionNumber) |> setter + { oa with TermSourceREF = s2 } |> setter), + fullwidth = true ) FormComponents.TextInput( - input.TermAccessionShort, - $"TAN", - fun s -> + oa.TermAccessionShort, + (if showTextLabels then $"TAN" else ""), + (fun s -> let s2 = s |> fun s -> if s = "" then None else Some s - OntologyAnnotation.fromString(input.NameText, ?tsr=input.TermSourceREF, ?tan=s2) |> setter + { oa with TermAccessionNumber = s2 } |> setter), + fullwidth = true ) + if removebutton.IsSome then + Bulma.button.button [ + prop.text "X" + prop.onClick removebutton.Value + Bulma.color.isDanger + Bulma.button.isOutlined + ] ] ] ] - static member PersonInput (persons: Person [], label: string, setter: Person [] -> unit) = - let createPersonTextInput (inputOpt: string option) (index: int) (innerSetter:(string option -> Person)) = - FormComponents.TextInput(Option.defaultValue "" inputOpt, "", - fun s -> + [] + static member PersonInput(person: Person, setter: Person -> unit, ?deletebutton: MouseEvent -> unit) = + let isExtended, setIsExtended = React.useState(false) + let fn = Option.defaultValue "" person.FirstName + let ln = Option.defaultValue "" person.LastName + let mi = Option.defaultValue "" person.MidInitials + // Must use `React.useRef` do this. Otherwise simultanios updates will overwrite each other + let person = React.useRef(Helper.PersonMutable.fromPerson person) + let nameStr = + let x = $"{fn} {mi} {ln}".Trim() + if x = "" then "" else x + let orcid = Option.defaultValue "" person.current.ORCID + let createPersonFieldTextInput(field: string option, label, personSetter: string option -> unit) = + FormComponents.TextInput( + field |> Option.defaultValue "", + label, + (fun s -> let s = if s = "" then None else Some s - persons.[index] <- innerSetter s - setter persons + personSetter s + person.current.ToPerson() |> setter), + fullwidth=true ) + let countFilledFieldsString (person:Fable.React.IRefValue) = + let fields = [ + person.current.FirstName + person.current.LastName + person.current.MidInitials + person.current.ORCID + person.current.Address + person.current.Affiliation + person.current.EMail + person.current.Phone + person.current.Fax + person.current.Roles |> Option.map (fun _ -> "") + ] + let all = fields.Length + let filled = fields |> List.choose id |> _.Length + $"{filled}/{all}" + Bulma.card [ + Bulma.cardHeader [ + Bulma.cardHeaderTitle.div [ + //prop.classes ["is-align-items-flex-start"] + prop.children [ + Html.div [ + Bulma.title.h5 nameStr + Bulma.subtitle.h6 orcid + ] + Html.div [ + prop.style [style.custom("marginLeft", "auto")] + prop.text (countFilledFieldsString person) + ] + ] + ] + Bulma.cardHeaderIcon.a [ + prop.onClick (fun _ -> not isExtended |> setIsExtended) + prop.children [ + Bulma.icon [Html.i [prop.classes ["fas"; "fa-angle-down"]]] + ] + ] + ] + Bulma.cardContent [ + prop.classes [if not isExtended then "is-hidden"] + prop.children [ + Bulma.field.div [ + Html.div [ + prop.classes ["form-container"] + prop.children [ + createPersonFieldTextInput(person.current.FirstName, "First Name", fun s -> person.current.FirstName <- s) + createPersonFieldTextInput(person.current.LastName, "Last Name", fun s -> person.current.LastName <- s) + ] + ] + ] + Bulma.field.div [ + Html.div [ + prop.classes ["form-container"] + prop.children [ + createPersonFieldTextInput(person.current.MidInitials, "Mid Initials", fun s -> person.current.MidInitials <- s) + createPersonFieldTextInput(person.current.ORCID, "ORCID", fun s -> person.current.ORCID <- s) + ] + ] + ] + Bulma.field.div [ + Html.div [ + prop.classes ["form-container"] + prop.children [ + createPersonFieldTextInput(person.current.Affiliation, "Affiliation", fun s -> person.current.Affiliation <- s) + createPersonFieldTextInput(person.current.Address, "Address", fun s -> person.current.Address <- s) + ] + ] + ] + Bulma.field.div [ + Html.div [ + prop.classes ["form-container"] + prop.children [ + createPersonFieldTextInput(person.current.EMail, "Email", fun s -> person.current.EMail <- s) + createPersonFieldTextInput(person.current.Phone, "Phone", fun s -> person.current.Phone <- s) + createPersonFieldTextInput(person.current.Fax, "Fax", fun s -> person.current.Fax <- s) + ] + ] + ] + Bulma.field.div [ + Bulma.label "Roles" + Html.orderedList [ + yield! Option.defaultValue [||] person.current.Roles + |> Array.mapi (fun i role -> + Html.li [ + FormComponents.OntologyAnnotationInput( + role, "", + (fun oa -> + let nextRoles = + person.current.Roles.Value.[i] <- oa + person.current.Roles.Value + setter {person.current.ToPerson() with Roles = Some nextRoles} + ), + showTextLabels = false, + removebutton=(fun e -> + let nextRoles = person.current.Roles.Value |> Array.removeAt i + setter {person.current.ToPerson() with Roles = if nextRoles.Length = 0 then None else Some nextRoles} + )) + ] + ) + ] + Helper.addButton (fun _ -> + let roles = Option.defaultValue [||] person.current.Roles + let newRoles = Array.append roles [|OntologyAnnotation.empty|] + setter {person.current.ToPerson() with Roles = Some newRoles} + ) + ] + if deletebutton.IsSome then + Helper.deleteButton deletebutton.Value + ] + ] + ] + + static member PersonsInput (persons: Person [], label: string, setter: Person [] -> unit) = Bulma.field.div [ Bulma.label label - //yield! persons - //|> Array.mapi (fun i person -> - - //) - //Html.th "First Name" - //Html.th "Mid Initilias" - //Html.th "Last Name" - //Html.th "ORCID" - //Html.th [] - ////Html.th "Affiliation" - ////Html.th "Email" - ////Html.th "Address" - ////Html.th "Phone" - ////Html.th "Fax" + Bulma.field.div [ + yield! persons + |> Array.mapi (fun i person -> + let personsSetter = fun p -> + persons.[i] <- p + setter persons + let rmv = fun _ -> + persons |> Array.removeAt i |> setter + FormComponents.PersonInput (person, personsSetter, rmv) + ) + ] + Helper.addButton (fun _ -> + let newPersons = Array.append persons [|Person.create()|] + setter newPersons + ) + ] + + static member CommentInput (comment: Comment, label: string, setter: Comment -> unit, ?showTextLabels: bool, ?removebutton: MouseEvent -> unit) = + let showTextLabels = defaultArg showTextLabels true + Bulma.field.div [ + if label <> "" then Bulma.label label + Html.div [ + prop.classes ["form-container"] + prop.children [ + FormComponents.TextInput( + comment.Name |> Option.defaultValue "", + (if showTextLabels then $"Term Name" else ""), + (fun s -> {comment with Name = if s = "" then None else Some s} |> setter), + fullwidth = true + ) + FormComponents.TextInput( + comment.Value |> Option.defaultValue "", + (if showTextLabels then $"TSR" else ""), + (fun s -> {comment with Value = if s = "" then None else Some s} |> setter), + fullwidth = true + ) + if removebutton.IsSome then + Bulma.button.button [ + prop.text "X" + prop.onClick removebutton.Value + Bulma.color.isDanger + Bulma.button.isOutlined + ] + ] + ] + ] + + static member CommentsInput(comments: Comment [], label, setter: Comment [] -> unit) = + Bulma.field.div [ + if label <> "" then Bulma.label label + Bulma.field.div [ + yield! comments + |> Array.mapi (fun i comment -> + let commentSetter = fun c -> + comments.[i] <- c + setter comments + let rmv = fun _ -> comments |> Array.removeAt i |> setter + FormComponents.CommentInput (comment,"", commentSetter, false, rmv) + ) + ] + Helper.addButton (fun _ -> + let newComment = Array.append comments [|Comment.create()|] + setter newComment + ) ] \ No newline at end of file diff --git a/src/Client/MainComponents/Metadata/Study.fs b/src/Client/MainComponents/Metadata/Study.fs index b6294877..4e3a9c0a 100644 --- a/src/Client/MainComponents/Metadata/Study.fs +++ b/src/Client/MainComponents/Metadata/Study.fs @@ -2,12 +2,39 @@ open Feliz open Feliz.Bulma - -open Spreadsheet open Messages -open Browser.Types -open Fable.Core.JsInterop open ARCtrl.ISA open Shared -let Main() = Html.div "Study - Metadata" \ No newline at end of file +let Main(study: ArcStudy, assignedAssays: ArcAssay list, model: Messages.Model, dispatch: Msg -> unit) = + Bulma.section [ + FormComponents.TextInput ( + study.Identifier, + "Identifier", + fun s -> + let nextAssay = IdentifierSetters.setStudyIdentifier s study + (nextAssay, assignedAssays) |> Study |> Spreadsheet.UpdateArcFile |> SpreadsheetMsg |> dispatch + ) + FormComponents.TextInput ( + Option.defaultValue "" study.Description, + "Description", + fun s -> + let s = if s = "" then None else Some s + study.Description <- s + (study, assignedAssays) |> Study |> Spreadsheet.UpdateArcFile |> SpreadsheetMsg |> dispatch + ) + FormComponents.PersonsInput( + study.Contacts, + "Contacts", + fun persons -> + study.Contacts <- persons + (study, assignedAssays) |> Study |> Spreadsheet.UpdateArcFile |> SpreadsheetMsg |> dispatch + ) + FormComponents.CommentsInput( + study.Comments, + "Comments", + fun comments -> + study.Comments <- comments + (study, assignedAssays) |> Study |> Spreadsheet.UpdateArcFile |> SpreadsheetMsg |> dispatch + ) + ] \ No newline at end of file diff --git a/src/Client/Views/XlsxFileView.fs b/src/Client/Views/XlsxFileView.fs index e1a79f91..acb31f5a 100644 --- a/src/Client/Views/XlsxFileView.fs +++ b/src/Client/Views/XlsxFileView.fs @@ -16,8 +16,8 @@ let Main(x: {| model: Messages.Model; dispatch: Messages.Msg -> unit |}) = match model.SpreadsheetModel.ArcFile with | Some (ArcFiles.Assay a) -> MainComponents.Metadata.Assay.Main(a, model, dispatch) - | Some (ArcFiles.Study _) -> - MainComponents.Metadata.Study.Main() + | Some (ArcFiles.Study (s,aArr)) -> + MainComponents.Metadata.Study.Main(s, aArr, model, dispatch) | Some (ArcFiles.Investigation _) -> MainComponents.Metadata.Investigation.Main() | Some (ArcFiles.Template _) -> diff --git a/src/Client/style.scss b/src/Client/style.scss index 6bd57b90..799c73e7 100644 --- a/src/Client/style.scss +++ b/src/Client/style.scss @@ -73,6 +73,14 @@ $colors: map-merge($colors, $addColors); @import "../../node_modules/bulma/bulma.sass"; /*@import "../../node_modules/bulma-checkradio/src/sass/index.sass";*/ +.form-container { + display: flex; + flex-direction: row; + justify-content: space-between; + flex-wrap: wrap; + column-gap: 10px; +} + .modal-background { opacity: 0.5 } diff --git a/src/Client/vite.config.mts b/src/Client/vite.config.mts index fa10a470..b0e86bc2 100644 --- a/src/Client/vite.config.mts +++ b/src/Client/vite.config.mts @@ -28,6 +28,6 @@ export default defineConfig({ target: proxyTarget, ws: true, }, - }, + } }, }); \ No newline at end of file