From a6470d2ec9e2583ee853619ff0a277e0c2dd2eba Mon Sep 17 00:00:00 2001 From: Vidir Valberg Gudmundsson Date: Sun, 16 Jul 2017 01:31:00 +0200 Subject: [PATCH] Initial work on the schedule written in elm. --- .gitignore | 2 +- schedule/Main.elm | 355 ++++++++++++++++++++++++++++++++ schedule/Makefile | 2 + schedule/elm-package.json | 17 ++ src/program/consumers.py | 60 +++--- src/program/models.py | 15 ++ src/program/views.py | 5 +- src/static_src/css/bornhack.css | 28 +-- 8 files changed, 432 insertions(+), 52 deletions(-) create mode 100644 schedule/Main.elm create mode 100644 schedule/Makefile create mode 100644 schedule/elm-package.json diff --git a/.gitignore b/.gitignore index 8841523a..976b5854 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,4 @@ db.sqlite3 *.pyc venv/ environment_settings.py - +elm-stuff/ diff --git a/schedule/Main.elm b/schedule/Main.elm new file mode 100644 index 00000000..b0c92ba6 --- /dev/null +++ b/schedule/Main.elm @@ -0,0 +1,355 @@ +module Main exposing (..) + +import Html exposing (Html, Attribute, div, input, text, li, ul, a, h4, label, i, span) +import Html.Attributes exposing (class, classList, id, type_, for) +import Html.Events exposing (onClick) +import WebSocket exposing (listen) +import Json.Decode exposing (int, string, float, list, bool, Decoder) +import Json.Encode +import Json.Decode.Pipeline exposing (decode, required, optional, hardcoded) + + +main : Program Flags Model Msg +main = + Html.programWithFlags + { init = init + , view = view + , update = update + , subscriptions = subscriptions + } + + +scheduleServer : String +scheduleServer = + "ws://localhost:8000/schedule/" + + + +-- MODEL + + +type alias Model = + { days : List Day + , eventInstances : List EventInstance + , eventLocations : List EventLocation + , eventTypes : List EventType + , flags : Flags + , activeDay : Day + , filter : Filter + } + + +type alias Filter = + { eventTypes : List EventType + , eventLocations : List EventLocation + } + + +type alias Day = + { day_name : String + , iso : String + , repr : String + } + + +type alias Speaker = + { name : String + , url : String + } + + +type alias EventInstance = + { title : String + , id : Int + , url : String + , abstract : String + , eventSlug : String + , eventType : String + , backgroundColor : String + , forgroundColor : String + , from : String + , to : String + , timeslots : Float + , location : String + , locationIcon : String + , speakers : List Speaker + , videoRecording : Bool + , videoUrl : String + } + + +type alias EventLocation = + { name : String + , slug : String + , icon : String + } + + +type alias EventType = + { name : String + , slug : String + , color : String + , lightText : Bool + } + + +type alias Flags = + { schedule_timeslot_length_minutes : Int + , schedule_midnight_offset_hours : Int + , ics_button_href : String + , camp_slug : String + } + + +allDaysDay = + Day "All Days" "" "" + + +init : Flags -> ( Model, Cmd Msg ) +init flags = + ( Model [] [] [] [] flags allDaysDay (Filter [] []), sendInitMessage flags.camp_slug ) + + +sendInitMessage : String -> Cmd Msg +sendInitMessage camp_slug = + WebSocket.send scheduleServer + (Json.Encode.encode 0 + (Json.Encode.object + [ ( "action", Json.Encode.string "init" ) + , ( "camp_slug", Json.Encode.string camp_slug ) + ] + ) + ) + + + +-- UPDATE + + +type Msg + = NoOp + | WebSocketPayload String + | MakeActiveday Day + | ToggleEventTypeFilter EventType + | ToggleEventLocationFilter EventLocation + + +update : Msg -> Model -> ( Model, Cmd Msg ) +update msg model = + case msg of + NoOp -> + ( model, Cmd.none ) + + WebSocketPayload str -> + let + newModel = + case Json.Decode.decodeString initDataDecoder str of + Ok m -> + m model.flags allDaysDay (Filter [] []) + + Err error -> + model + in + newModel ! [] + + MakeActiveday day -> + { model | activeDay = day } ! [] + + ToggleEventTypeFilter eventType -> + let + eventTypesFilter = + if List.member eventType model.filter.eventTypes then + List.filter (\x -> x /= eventType) model.filter.eventTypes + else + eventType :: model.filter.eventTypes + + currentFilter = + model.filter + + newFilter = + { currentFilter | eventTypes = eventTypesFilter } + in + { model | filter = newFilter } ! [] + + ToggleEventLocationFilter eventLocation -> + let + eventLocationsFilter = + if List.member eventLocation model.filter.eventLocations then + List.filter (\x -> x /= eventLocation) model.filter.eventLocations + else + eventLocation :: model.filter.eventLocations + + currentFilter = + model.filter + + newFilter = + { currentFilter | eventLocations = eventLocationsFilter } + in + { model | filter = newFilter } ! [] + + + +-- SUBSCRIPTIONS + + +subscriptions : Model -> Sub Msg +subscriptions model = + WebSocket.listen scheduleServer WebSocketPayload + + + +-- DECODERS + + +dayDecoder : Decoder Day +dayDecoder = + decode Day + |> required "day_name" string + |> required "iso" string + |> required "repr" string + + +speakerDecoder : Decoder Speaker +speakerDecoder = + decode Speaker + |> required "name" string + |> required "url" string + + +eventInstanceDecoder : Decoder EventInstance +eventInstanceDecoder = + decode EventInstance + |> required "title" string + |> required "id" int + |> required "url" string + |> required "abstract" string + |> required "event_slug" string + |> required "event_type" string + |> required "bg-color" string + |> required "fg-color" string + |> required "from" string + |> required "to" string + |> required "timeslots" float + |> required "location" string + |> required "location_icon" string + |> required "speakers" (list speakerDecoder) + |> required "video_recording" bool + |> optional "video_url" string "" + + +eventLocationDecoder : Decoder EventLocation +eventLocationDecoder = + decode EventLocation + |> required "name" string + |> required "slug" string + |> required "icon" string + + +eventTypeDecoder : Decoder EventType +eventTypeDecoder = + decode EventType + |> required "name" string + |> required "slug" string + |> required "color" string + |> required "light_text" bool + + +initDataDecoder : Decoder (Flags -> Day -> Filter -> Model) +initDataDecoder = + decode Model + |> required "days" (list dayDecoder) + |> required "event_instances" (list eventInstanceDecoder) + |> required "event_locations" (list eventLocationDecoder) + |> required "event_types" (list eventTypeDecoder) + + + +-- VIEW + + +dayButton : Day -> Day -> Html Msg +dayButton day activeDay = + a + [ classList + [ ( "btn", True ) + , ( "btn-default", day /= activeDay ) + , ( "btn-primary", day == activeDay ) + ] + , onClick (MakeActiveday day) + ] + [ text day.day_name + ] + + +view : Model -> Html Msg +view model = + div [] + [ div [ class "row" ] + [ div [ id "schedule-days", class "btn-group" ] + (List.map (\day -> dayButton day model.activeDay) (allDaysDay :: model.days)) + ] + , div [ class "row" ] + [ div + [ classList + [ ( "col-sm-3", True ) + , ( "col-sm-9", True ) + , ( "schedule-filter", True ) + ] + ] + [ h4 [] [ text "Filter" ] + , div [ class "form-group" ] + [ filterView "Type" model.eventTypes model.filter.eventTypes ToggleEventTypeFilter + , filterView "Location" model.eventLocations model.filter.eventLocations ToggleEventLocationFilter + ] + ] + , div [] [] + ] + ] + + +filterView : + String + -> List { a | name : String } + -> List { a | name : String } + -> ({ a | name : String } -> Msg) + -> Html Msg +filterView name possibleFilters currentFilters action = + div [] + [ text (name ++ ":") + , ul [] (List.map (\filter -> filterChoiceView filter currentFilters action) possibleFilters) + ] + + +filterChoiceView : + { a | name : String } + -> List { a | name : String } + -> ({ a | name : String } -> Msg) + -> Html Msg +filterChoiceView filter currentFilters action = + let + active = + List.member filter currentFilters + + notActive = + not active + in + li [] + [ div + [ classList + [ ( "btn", True ) + , ( "btn-default", True ) + , ( "filter-choice-active", active ) + ] + , onClick (action filter) + ] + [ span [] + [ i [ classList [ ( "fa", True ), ( "fa-minus", active ), ( "fa-plus", notActive ) ] ] [] + , text (" " ++ filter.name) + ] + ] + ] + + +locationFilter : List EventLocation -> Html Msg +locationFilter eventLocations = + div [] [ text "Location:" ] diff --git a/schedule/Makefile b/schedule/Makefile new file mode 100644 index 00000000..1727673c --- /dev/null +++ b/schedule/Makefile @@ -0,0 +1,2 @@ +all: + elm-make Main.elm --debug --output ../src/program/static/js/elm_based_schedule.js diff --git a/schedule/elm-package.json b/schedule/elm-package.json new file mode 100644 index 00000000..d17058e1 --- /dev/null +++ b/schedule/elm-package.json @@ -0,0 +1,17 @@ +{ + "version": "1.0.0", + "summary": "helpful summary of your project, less than 80 characters", + "repository": "https://github.com/user/project.git", + "license": "BSD3", + "source-directories": [ + "." + ], + "exposed-modules": [], + "dependencies": { + "NoRedInk/elm-decode-pipeline": "3.0.0 <= v < 4.0.0", + "elm-lang/core": "5.1.1 <= v < 6.0.0", + "elm-lang/html": "2.0.0 <= v < 3.0.0", + "elm-lang/websocket": "1.0.2 <= v < 2.0.0" + }, + "elm-version": "0.18.0 <= v < 0.19.0" +} diff --git a/src/program/consumers.py b/src/program/consumers.py index 9d141e87..725a849e 100644 --- a/src/program/consumers.py +++ b/src/program/consumers.py @@ -1,7 +1,7 @@ from channels.generic.websockets import JsonWebsocketConsumer from camps.models import Camp -from .models import EventInstance, Favorite +from .models import EventInstance, Favorite, EventLocation, EventType class ScheduleConsumer(JsonWebsocketConsumer): @@ -10,34 +10,43 @@ class ScheduleConsumer(JsonWebsocketConsumer): def connection_groups(self, **kwargs): return ['schedule_users'] - def connect(self, message, **kwargs): - camp_slug = message.http_session['campslug'] - try: - camp = Camp.objects.get(slug=camp_slug) - days = list(map( - lambda day: - { 'repr': day.lower.strftime('%A %Y-%m-%d') - , 'iso': day.lower.strftime('%Y-%m-%d') - , 'day_name': day.lower.strftime('%A') - }, - camp.get_days('camp') - )) - event_instances_query_set = EventInstance.objects.filter(event__camp=camp) - event_instances = list(map(lambda x: x.to_json(user=message.user), event_instances_query_set)) - self.send({ - "accept": True, - "event_instances": event_instances, - "days": days, - "action": "init" - }) - except Camp.DoesNotExist: - pass - def raw_receive(self, message, **kwargs): content = self.decode_json(message['text']) action = content.get('action') data = {} + if action == 'init': + camp_slug = content.get('camp_slug') + try: + camp = Camp.objects.get(slug=camp_slug) + days = list(map( + lambda day: + { 'repr': day.lower.strftime('%A %Y-%m-%d') + , 'iso': day.lower.strftime('%Y-%m-%d') + , 'day_name': day.lower.strftime('%A') + }, + camp.get_days('camp') + )) + event_instances_query_set = EventInstance.objects.filter(event__camp=camp) + event_instances = list([x.to_json(user=message.user) for x in event_instances_query_set]) + + event_locations_query_set = EventLocation.objects.filter(camp=camp) + event_locations = list([x.to_json() for x in event_locations_query_set]) + + event_types_query_set = EventType.objects.filter() + event_types = list([x.to_json() for x in event_types_query_set]) + + data = { + "event_locations": event_locations, + "event_types": event_types, + "accept": True, + "event_instances": event_instances, + "days": days, + "action": "init" + } + except Camp.DoesNotExist: + pass + if action == 'favorite': event_instance_id = content.get('event_instance_id') event_instance = EventInstance.objects.get(id=event_instance_id) @@ -52,7 +61,8 @@ class ScheduleConsumer(JsonWebsocketConsumer): favorite = Favorite.objects.get(event_instance=event_instance, user=message.user) favorite.delete() - self.send(data) + if data: + self.send(data) def disconnect(self, message, **kwargs): pass diff --git a/src/program/models.py b/src/program/models.py index 9eb9c176..7b3dbb9e 100644 --- a/src/program/models.py +++ b/src/program/models.py @@ -311,6 +311,13 @@ class EventLocation(CampRelatedModel): class Meta: unique_together = (('camp', 'slug'), ('camp', 'name')) + def to_json(self): + return { + "name": self.name, + "slug": self.slug, + "icon": self.icon, + } + class EventType(CreatedUpdatedModel): """ Every event needs to have a type. """ @@ -350,6 +357,14 @@ class EventType(CreatedUpdatedModel): def __str__(self): return self.name + def to_json(self): + return { + "name": self.name, + "slug": self.slug, + "color": self.color, + "light_text": self.light_text, + } + class Event(CampRelatedModel): """ Something that is on the program one or more times. """ diff --git a/src/program/views.py b/src/program/views.py index 2f5f40fa..a866afc4 100644 --- a/src/program/views.py +++ b/src/program/views.py @@ -264,10 +264,7 @@ class EventDetailView(CampViewMixin, DetailView): class ScheduleView(CampViewMixin, TemplateView): - def get_template_names(self): - if 'day' in self.kwargs: - return 'schedule_day.html' - return 'schedule_overview.html' + template_name = 'schedule_overview_elm.html' def get_context_data(self, *args, **kwargs): context = super(ScheduleView, self).get_context_data(**kwargs) diff --git a/src/static_src/css/bornhack.css b/src/static_src/css/bornhack.css index 4effe2f4..2132be61 100644 --- a/src/static_src/css/bornhack.css +++ b/src/static_src/css/bornhack.css @@ -208,6 +208,11 @@ footer { padding: 0; } +.schedule-filter .btn { + min-width: 200px; + text-align: left; +} + @media (min-width: 520px) { .schedule-filter { @@ -231,28 +236,7 @@ footer { flex-wrap: wrap; } -.form-group .schedule-checkbox { - display: none; - } - -.form-group input[type="checkbox"] + .btn-group > label span { - width: 20px; -} - -.form-group input[type="checkbox"] + .btn-group > label span i:first-child { - display: none; -} -.form-group input[type="checkbox"] + .btn-group > label span i:last-child { - display: inline-block; -} - -.form-group input[type="checkbox"]:checked + .btn-group > label span i:first-child { - display: inline-block; -} -.form-group input[type="checkbox"]:checked + .btn-group > label span i:last-child { - display: none; -} -.form-group input[type="checkbox"]:checked + .btn-group > label { +.filter-choice-active { color: #333; background-color: #e6e6e6; border-color: #adadad;