From 683c2298dcdbda96d44e23d5f4db3f3c959d1161 Mon Sep 17 00:00:00 2001 From: Yorhel Date: Wed, 30 Oct 2019 12:39:07 +0100 Subject: ulist: Inline assigning labels to VNs I'm really unhappy with the workarounds to deal with the global onClick subscription doing the right thing, but I wasn't able to find a good alternative. --- data/style.css | 28 ++++++++--- elm/Lib/Html.elm | 4 ++ elm/ULists/LabelEdit.elm | 117 ++++++++++++++++++++++++++++++++++++++++++++ elm/ULists/ManageLabels.elm | 2 +- elm/ULists/ManageLabels.js | 4 +- lib/VNWeb/User/Lists.pm | 53 +++++++++++++++++--- 6 files changed, 191 insertions(+), 17 deletions(-) create mode 100644 elm/ULists/LabelEdit.elm diff --git a/data/style.css b/data/style.css index ce37590d..0cd56c7a 100644 --- a/data/style.css +++ b/data/style.css @@ -152,14 +152,15 @@ table.formtable td { padding: 0; } table.formtable tr.newfield td { padding-top: 5px; } table.formtable tr.newpart td { padding-top: 20px; font-weight: bold; } -.linkradio { padding: 2px } +p.linkradio { padding: 2px } .linkradio label { color: $link$; cursor: pointer } .linkradio label:before { content: '✗' } .linkradio input:checked + label { color: $maintext$ } .linkradio input:checked + label:before { content: '✓' } .linkradio em { font-weight: normal; font-style: normal; color: $grayedout$ } -div.spinner { content: ''; border: 3px solid #9eaebd; border-bottom-color: transparent; border-radius: 100%; animation: spin 1s infinite linear; width: 14px; height: 14px; display: inline-block; margin: auto } +.spinner { content: ''; border: 3px solid #9eaebd; border-bottom-color: transparent; border-radius: 100%; animation: spin 1s infinite linear; width: 14px; height: 14px; display: inline-block; margin: auto } +span.spinner { width: 1ex; height: 1ex } @keyframes spin { from { transform:rotate(0deg); } to { transform:rotate(360deg); } } .textpreview .head { width: 100%; text-align: right } @@ -782,12 +783,12 @@ div.votelist td.tc2 { width: 50px; text-align: right; padding-right: 10px } .labelfilters { text-align: center } .labelfilters input.submit { margin-top: 5px } -.labeledit > div { width: 600px; margin: 10px auto } -.labeledit table { margin: 0 auto } -.labeledit tbody td:nth-child(1) { text-align: right } -.labeledit tbody td:nth-child(4) { padding-left: 10px; width: 300px} -.labeledit select { width: 100% } -.labeledit tfoot div { float: right; text-align: right } +.managelabels > div { width: 600px; margin: 10px auto } +.managelabels table { margin: 0 auto } +.managelabels tbody td:nth-child(1) { text-align: right } +.managelabels tbody td:nth-child(4) { padding-left: 10px; width: 300px} +.managelabels select { width: 100% } +.managelabels tfoot div { float: right; text-align: right } .ulist .tc1 label { cursor: pointer } .ulist .tc1 input { display: none } @@ -799,6 +800,17 @@ div.votelist td.tc2 { width: 50px; text-align: right; padding-right: 10px } .ulist .tc4 input { width: 55px; text-align: right } .ulist .tc5, .ulist .tc6, .ulist .tc7 { white-space: nowrap; width: 100px } +.labeledit > a { color: $maintext$; display: block; border: none!important } +.labeledit > a > span { float: right; width: 16px; text-align: right; display: inline-block } +.labeledit > a > span i { visibility: hidden; font-style: normal } +.labeledit > a:hover > span > i, +.labeledit > a:focus > span > i { visibility: visible } +.labeledit > div { position: relative; float: right; width: 0; height: 0 } +.labeledit > div > ul { position: absolute; right: -10px; top: 0; border: 1px solid $border$; background-color: $secbg$; z-index: 1000; list-style-type: none; margin: 0; padding: 0 } +.labeledit > div > ul li { white-space: nowrap } +.labeledit > div > ul li label { display: block; padding-left: 10px; border: 0; padding: 3px 5px 3px 3px } +.labeledit > div > ul li label:hover { background: $boxbg$ } + /***** User VN list browser ******/ diff --git a/elm/Lib/Html.elm b/elm/Lib/Html.elm index d9c0594a..770e9142 100644 --- a/elm/Lib/Html.elm +++ b/elm/Lib/Html.elm @@ -12,6 +12,10 @@ import Lib.Api as Api onClickN : m -> Attribute m onClickN action = custom "click" (JD.succeed { message = action, stopPropagation = True, preventDefault = True}) +-- onClick with preventDefault +onClickD : m -> Attribute m +onClickD action = custom "click" (JD.succeed { message = action, stopPropagation = False, preventDefault = True}) + -- onInput that also tells us whether the input is valid onInputValidation : (String -> Bool -> msg) -> Attribute msg onInputValidation msg = custom "input" <| diff --git a/elm/ULists/LabelEdit.elm b/elm/ULists/LabelEdit.elm new file mode 100644 index 00000000..eb59c823 --- /dev/null +++ b/elm/ULists/LabelEdit.elm @@ -0,0 +1,117 @@ +module ULists.LabelEdit exposing (main) + +import Html exposing (..) +import Html.Attributes exposing (..) +import Html.Events exposing (..) +import Task +import Process +import Browser +import Browser.Events as E +import Json.Decode as JD +import Set exposing (Set) +import Dict exposing (Dict) +import Lib.Html exposing (..) +import Lib.Api as Api +import Gen.Api as GApi +import Gen.LabelEdit as GLE + + +main : Program GLE.Recv Model Msg +main = Browser.element + { init = \f -> (init f, Cmd.none) + , subscriptions = \model -> if model.opened then E.onClick (JD.succeed (Open False)) else Sub.none + , view = view + , update = update + } + +type alias Model = + { uid : Int + , vid : Int + , labels : List GLE.RecvLabels + , sel : Set Int -- Set of label IDs applied on the server + , tsel : Set Int -- Set of label IDs applied on the client + , state : Dict Int Api.State -- Only for labels that are being changed + , opened : Bool + } + +init : GLE.Recv -> Model +init f = + { uid = f.uid + , vid = f.vid + , labels = f.labels + , sel = Set.fromList f.selected + , tsel = Set.fromList f.selected + , state = Dict.empty + , opened = False + } + +type Msg + = Redo Msg + | Open Bool + | Toggle Int Bool + | Saved Int Bool GApi.Response + + +update : Msg -> Model -> (Model, Cmd Msg) +update msg model = + case msg of + -- 'Redo' will process the same message again after a very short timeout, + -- this is used to overrule an 'Open False' triggered by the onClick + -- subscription. + Redo m -> (model, Cmd.batch [ Task.perform (\_ -> m) (Task.succeed ()), Task.perform (\_ -> m) (Process.sleep 0) ]) + Open b -> ({ model | opened = b }, Cmd.none) + + -- The 'opened = True' counters the onClick subscription that would have + -- closed the dropdown, this works because that subscription triggers + -- before the Toggle (I just hope this is well-defined, otherwise we need + -- to use Redo for this one as well). + Toggle l b -> + ( { model + | opened = True + , tsel = if b then Set.insert l model.tsel else Set.remove l model.tsel + , state = Dict.insert l Api.Loading model.state + } + , Api.post "/u/ulist/setlabel.json" (GLE.encode { uid = model.uid, vid = model.vid, label = l, applied = b }) (Saved l b) + ) + + Saved l b (GApi.Success) -> ({ model | sel = if b then Set.insert l model.sel else Set.remove l model.sel, state = Dict.remove l model.state }, Cmd.none) + Saved l b e -> ({ model | state = Dict.insert l (Api.Error e) model.state }, Cmd.none) + + +view : Model -> Html Msg +view model = + let + str = String.join ", " <| List.filterMap (\l -> if Set.member l.id model.sel then Just l.label else Nothing) model.labels + + item l = + let lid = "label_edit_" ++ String.fromInt model.vid ++ "_" ++ String.fromInt l.id + in + li [ class "linkradio" ] + [ inputCheck lid (Set.member l.id model.tsel) (Toggle l.id) + , label [ for lid ] + [ text l.label + , text " " + , span [ class "spinner", classList [("invisible", Dict.get l.id model.state /= Just Api.Loading)] ] [] + , case Dict.get l.id model.state of + Just (Api.Error _) -> b [ class "standout" ] [ text "error" ] -- Need something better + _ -> text "" + ] + ] + + loading = List.any (\s -> s == Api.Loading) <| Dict.values model.state + + in + div [ class "labeledit" ] + [ a [ href "#", onClickD (Redo (Open (not model.opened))) ] + [ text <| if str == "" then "-" else str + , span [] + [ if loading && not model.opened + then span [ class "spinner" ] [] + else i [] [ text "▾" ] + ] + ] + , div [] + [ ul [ classList [("hidden", not model.opened)] ] + <| List.map item model.labels + ] + ] diff --git a/elm/ULists/ManageLabels.elm b/elm/ULists/ManageLabels.elm index f6e1d5e5..1e953b0a 100644 --- a/elm/ULists/ManageLabels.elm +++ b/elm/ULists/ManageLabels.elm @@ -86,7 +86,7 @@ view model = ] ] in - Html.form [ onSubmit Submit, class "labeledit hidden" ] + Html.form [ onSubmit Submit, class "managelabels hidden" ] [ div [ ] [ b [] [ text "How to use labels" ] , ul [] diff --git a/elm/ULists/ManageLabels.js b/elm/ULists/ManageLabels.js index 85312d21..76ee960c 100644 --- a/elm/ULists/ManageLabels.js +++ b/elm/ULists/ManageLabels.js @@ -1,6 +1,6 @@ -document.querySelectorAll('#labeledit').forEach(function(b) { +document.querySelectorAll('#managelabels').forEach(function(b) { b.onclick = function() { - document.querySelectorAll('.labeledit').forEach(function(e) { e.classList.toggle('hidden') }) + document.querySelectorAll('.managelabels').forEach(function(e) { e.classList.toggle('hidden') }) }; return false; }) diff --git a/lib/VNWeb/User/Lists.pm b/lib/VNWeb/User/Lists.pm index db55e74b..033c3722 100644 --- a/lib/VNWeb/User/Lists.pm +++ b/lib/VNWeb/User/Lists.pm @@ -26,6 +26,21 @@ my $VNVOTE = form_compile any => { elm_form 'VoteEdit', undef, $VNVOTE; +my $VNLABELS = { + uid => { id => 1 }, + vid => { id => 1 }, + label => { _when => 'in', id => 1 }, + applied => { _when => 'in', anybool => 1 }, + labels => { _when => 'out', aoh => { id => { int => 1 }, label => {} } }, + selected => { _when => 'out', type => 'array', values => { id => 1 } }, +}; + +my $VNLABELS_OUT = form_compile out => $VNLABELS; +my $VNLABELS_IN = form_compile in => $VNLABELS; + +elm_form 'LabelEdit', $VNLABELS_OUT, $VNLABELS_IN; + + # TODO: Filters to find unlabeled VNs or VNs with notes? sub filters_ { my($own, $labels) = @_; @@ -69,7 +84,7 @@ sub filters_ { } br_; input_ type => 'submit', class => 'submit', value => 'Update filters'; - input_ type => 'button', class => 'submit', id => 'labeledit', value => 'Manage labels' if $own; + input_ type => 'button', class => 'submit', id => 'managelabels', value => 'Manage labels' if $own; }; }; $opt; @@ -95,14 +110,21 @@ sub vn_ { b_ class => 'grayedout', $v->{notes} if $v->{notes}; }; td_ class => 'tc3', sub { - my %l = map +($_,1), $v->{labels}->@*; - my @l = grep $l{$_->{id}} && $_->{id} != 7, @$labels; - join_ ', ', sub { txt_ $_->{label} }, @l if @l; - txt_ '-' if !@l; + if($own) { + # XXX: Copying the entire $labels list for each entry is rather inefficient, would be nice if we could store that globally. + my @labels = grep $_->{id} != 7, @$labels; + elm_ 'ULists.LabelEdit' => $VNLABELS_OUT, + { uid => $uid, vid => $v->{id}, labels => \@labels, selected => [ grep $_ != 7, $v->{labels}->@* ] }; + } else { + my %l = map +($_,1), $v->{labels}->@*; + my @l = grep $l{$_->{id}} && $_->{id} != 7, @$labels; + join_ ', ', sub { txt_ $_->{label} }, @l if @l; + txt_ '-' if !@l; + } }; td_ mkclass(tc4 => 1, compact => $own, stealth => $own), sub { txt_ fmtvote $v->{vote} if !$own; - elm_ 'ULists.VoteEdit' => $VNVOTE, { uid => $uid*1, vid => $v->{id}*1, vote => fmtvote($v->{vote}) } if $own; + elm_ 'ULists.VoteEdit' => $VNVOTE, { uid => $uid, vid => $v->{id}, vote => fmtvote($v->{vote}) } if $own; }; td_ class => 'tc5', fmtdate $v->{added}, 'compact'; td_ class => 'tc6', $v->{started}||''; @@ -279,4 +301,23 @@ json_api qr{/u/ulist/setvote.json}, $VNVOTE, sub { elm_Success }; + +json_api qr{/u/ulist/setlabel.json}, $VNLABELS_IN, sub { + my($data) = @_; + return elm_Unauth if !auth || auth->uid != $data->{uid}; + die "Attempt to set vote label" if $data->{label} == 7; + + tuwf->dbExeci( + 'DELETE FROM ulists_vn_labels + WHERE uid =', \$data->{uid}, 'AND vid =', \$data->{vid}, 'AND lbl =', \$data->{label} + ) if !$data->{applied}; + tuwf->dbExeci( + 'INSERT INTO ulists_vn_labels (uid, vid, lbl) + VALUES (', sql_comma(\$data->{uid}, \$data->{vid}, \$data->{label}), ') + ON CONFLICT (uid, vid, lbl) DO NOTHING' + ) if $data->{applied}; + + elm_Success +}; + 1; -- cgit v1.2.3