From f52b59597268f74c7c965b0678447246f1b67e4a Mon Sep 17 00:00:00 2001 From: Derrick Reimer Date: Thu, 1 Nov 2018 17:19:18 -0500 Subject: [PATCH 1/8] Add DELETED state to group state --- ...1101221239_add_deleted_to_group_states.exs | 19 +++++++++++++++++++ priv/repo/structure.sql | 5 +++-- 2 files changed, 22 insertions(+), 2 deletions(-) create mode 100644 priv/repo/migrations/20181101221239_add_deleted_to_group_states.exs diff --git a/priv/repo/migrations/20181101221239_add_deleted_to_group_states.exs b/priv/repo/migrations/20181101221239_add_deleted_to_group_states.exs new file mode 100644 index 00000000..8251fb03 --- /dev/null +++ b/priv/repo/migrations/20181101221239_add_deleted_to_group_states.exs @@ -0,0 +1,19 @@ +defmodule Level.Repo.Migrations.AddDeletedToGroupStates do + use Ecto.Migration + + @disable_ddl_transaction true + + def up do + execute "ALTER TYPE group_state ADD VALUE IF NOT EXISTS 'DELETED'" + end + + def down do + execute "UPDATE groups SET state = 'CLOSED' WHERE state = 'DELETED'" + execute "ALTER TYPE group_state RENAME TO group_state_old" + execute "CREATE TYPE group_state AS ENUM('OPEN', 'CLOSED')" + + execute "ALTER TABLE groups ALTER COLUMN state TYPE group_state USING state::text::group_state" + + execute "DROP TYPE group_state_old" + end +end diff --git a/priv/repo/structure.sql b/priv/repo/structure.sql index 9155348a..773b6103 100644 --- a/priv/repo/structure.sql +++ b/priv/repo/structure.sql @@ -59,7 +59,8 @@ CREATE TYPE public.bot_state AS ENUM ( CREATE TYPE public.group_state AS ENUM ( 'OPEN', - 'CLOSED' + 'CLOSED', + 'DELETED' ); @@ -1724,5 +1725,5 @@ ALTER TABLE ONLY public.user_mentions -- PostgreSQL database dump complete -- -INSERT INTO public."schema_migrations" (version) VALUES (20170527220454), (20170528000152), (20170619214118), (20180403181445), (20180404204544), (20180413214033), (20180509143149), (20180510211015), (20180515174533), (20180518203612), (20180531200436), (20180627000743), (20180627231041), (20180724162650), (20180725135511), (20180731205027), (20180803151120), (20180807173948), (20180809201313), (20180810141122), (20180903213417), (20180903215930), (20180903220826), (20180908173406), (20180918182427), (20181003182443), (20181005154158), (20181009210537), (20181010174443), (20181011172259), (20181012200233), (20181012223338), (20181014144651), (20181018210912), (20181019194025), (20181022151255), (20181023175556), (20181029191737), (20181029220713); +INSERT INTO public."schema_migrations" (version) VALUES (20170527220454), (20170528000152), (20170619214118), (20180403181445), (20180404204544), (20180413214033), (20180509143149), (20180510211015), (20180515174533), (20180518203612), (20180531200436), (20180627000743), (20180627231041), (20180724162650), (20180725135511), (20180731205027), (20180803151120), (20180807173948), (20180809201313), (20180810141122), (20180903213417), (20180903215930), (20180903220826), (20180908173406), (20180918182427), (20181003182443), (20181005154158), (20181009210537), (20181010174443), (20181011172259), (20181012200233), (20181012223338), (20181014144651), (20181018210912), (20181019194025), (20181022151255), (20181023175556), (20181029191737), (20181029220713), (20181101221239); From 4c30f39f5f9eadc5836b45a3d622bc5be29780f9 Mon Sep 17 00:00:00 2001 From: Derrick Reimer Date: Thu, 1 Nov 2018 17:33:38 -0500 Subject: [PATCH 2/8] Add graphql mutations for group state --- lib/level/groups.ex | 33 +++++++++- lib/level/mutations.ex | 54 +++++++++++++++ lib/level_web/schema.ex | 24 +++++++ lib/level_web/schema/mutations.ex | 57 ++++++++++++++++ test/level/groups_test.exs | 33 ++++++++++ .../graphql/mutations/close_group_test.exs | 65 ++++++++++++++++++ .../graphql/mutations/delete_group_test.exs | 58 ++++++++++++++++ .../graphql/mutations/reopen_group_test.exs | 66 +++++++++++++++++++ 8 files changed, 388 insertions(+), 2 deletions(-) create mode 100644 test/level_web/graphql/mutations/close_group_test.exs create mode 100644 test/level_web/graphql/mutations/delete_group_test.exs create mode 100644 test/level_web/graphql/mutations/reopen_group_test.exs diff --git a/lib/level/groups.ex b/lib/level/groups.ex index 8ee249af..cc830fe4 100644 --- a/lib/level/groups.ex +++ b/lib/level/groups.ex @@ -27,7 +27,8 @@ defmodule Level.Groups do where: g.space_id == ^space_id, left_join: gu in GroupUser, on: gu.group_id == g.id and gu.space_user_id == ^space_user_id, - where: g.is_private == false or (g.is_private == true and not is_nil(gu.id)) + where: g.is_private == false or (g.is_private == true and not is_nil(gu.id)), + where: g.state != "DELETED" end def groups_base_query(%User{id: user_id}) do @@ -36,7 +37,8 @@ defmodule Level.Groups do on: su.space_id == g.space_id and su.user_id == ^user_id, left_join: gu in GroupUser, on: gu.group_id == g.id and gu.space_user_id == su.id, - where: g.is_private == false or (g.is_private == true and not is_nil(gu.id)) + where: g.is_private == false or (g.is_private == true and not is_nil(gu.id)), + where: g.state != "DELETED" end @doc """ @@ -227,6 +229,33 @@ defmodule Level.Groups do |> Repo.update() end + @doc """ + Reopens a group. + """ + @spec reopen_group(Group.t()) :: {:ok, Group.t()} | {:error, Changeset.t()} + def reopen_group(group) do + group + |> Changeset.change(state: "OPEN") + |> Repo.update() + end + + @doc """ + Deletes a group. + """ + @spec delete_group(SpaceUser.t(), Group.t()) :: + {:ok, Group.t()} | {:error, Changeset.t() | String.t()} + def delete_group(current_user, group) do + case get_user_role(group, current_user) do + :owner -> + group + |> Changeset.change(state: "DELETED") + |> Repo.update() + + _ -> + {:error, dgettext("errors", "You are not authorized to perform this action.")} + end + end + @doc """ Subscribes a user to a group. """ diff --git a/lib/level/mutations.ex b/lib/level/mutations.ex index e5641fa3..1a826854 100644 --- a/lib/level/mutations.ex +++ b/lib/level/mutations.ex @@ -228,6 +228,60 @@ defmodule Level.Mutations do end end + @doc """ + Closes a group. + """ + @spec close_group(map(), info()) :: group_mutation_result() + def close_group(args, %{context: %{current_user: user}}) do + with {:ok, %{space_user: space_user}} <- Spaces.get_space(user, args.space_id), + {:ok, group} <- Groups.get_group(space_user, args.group_id), + {:ok, updated_group} <- Groups.close_group(group) do + {:ok, %{success: true, group: updated_group, errors: []}} + else + {:error, %Ecto.Changeset{} = changeset} -> + {:ok, %{success: false, group: nil, errors: format_errors(changeset)}} + + err -> + err + end + end + + @doc """ + Reopens a group. + """ + @spec reopen_group(map(), info()) :: group_mutation_result() + def reopen_group(args, %{context: %{current_user: user}}) do + with {:ok, %{space_user: space_user}} <- Spaces.get_space(user, args.space_id), + {:ok, group} <- Groups.get_group(space_user, args.group_id), + {:ok, updated_group} <- Groups.reopen_group(group) do + {:ok, %{success: true, group: updated_group, errors: []}} + else + {:error, %Ecto.Changeset{} = changeset} -> + {:ok, %{success: false, group: nil, errors: format_errors(changeset)}} + + err -> + err + end + end + + @doc """ + Deletes a group. + """ + @spec delete_group(map(), info()) :: group_mutation_result() + def delete_group(args, %{context: %{current_user: user}}) do + with {:ok, %{space_user: space_user}} <- Spaces.get_space(user, args.space_id), + {:ok, group} <- Groups.get_group(space_user, args.group_id), + {:ok, _} <- Groups.delete_group(space_user, group) do + {:ok, %{success: true, errors: []}} + else + {:error, %Ecto.Changeset{} = changeset} -> + {:ok, %{success: false, errors: format_errors(changeset)}} + + err -> + err + end + end + @doc """ Create multiple groups. """ diff --git a/lib/level_web/schema.ex b/lib/level_web/schema.ex index 6d14137e..e3ec6581 100644 --- a/lib/level_web/schema.ex +++ b/lib/level_web/schema.ex @@ -137,6 +137,30 @@ defmodule LevelWeb.Schema do resolve &Level.Mutations.update_group/2 end + @desc "Closes a group." + field :close_group, type: :close_group_payload do + arg :space_id, non_null(:id) + arg :group_id, non_null(:id) + + resolve &Level.Mutations.close_group/2 + end + + @desc "Reopens a group." + field :reopen_group, type: :reopen_group_payload do + arg :space_id, non_null(:id) + arg :group_id, non_null(:id) + + resolve &Level.Mutations.reopen_group/2 + end + + @desc "Deletes a group." + field :delete_group, type: :delete_group_payload do + arg :space_id, non_null(:id) + arg :group_id, non_null(:id) + + resolve &Level.Mutations.delete_group/2 + end + @desc "Subscribes to a group." field :subscribe_to_group, type: :subscribe_to_group_payload do arg :space_id, non_null(:id) diff --git a/lib/level_web/schema/mutations.ex b/lib/level_web/schema/mutations.ex index 40df07d9..2076ea9f 100644 --- a/lib/level_web/schema/mutations.ex +++ b/lib/level_web/schema/mutations.ex @@ -139,6 +139,63 @@ defmodule LevelWeb.Schema.Mutations do interface :validatable end + @desc "The response to closing a group." + object :close_group_payload do + @desc """ + A boolean indicating if the mutation was successful. If true, the errors + list will be empty. Otherwise, errors may contain objects describing why + the mutation failed. + """ + field :success, non_null(:boolean) + + @desc "A list of validation errors." + field :errors, list_of(:error) + + @desc """ + The mutated object. If the mutation was not successful, + this field may be null. + """ + field :group, :group + + interface :validatable + end + + @desc "The response to reopening a group." + object :reopen_group_payload do + @desc """ + A boolean indicating if the mutation was successful. If true, the errors + list will be empty. Otherwise, errors may contain objects describing why + the mutation failed. + """ + field :success, non_null(:boolean) + + @desc "A list of validation errors." + field :errors, list_of(:error) + + @desc """ + The mutated object. If the mutation was not successful, + this field may be null. + """ + field :group, :group + + interface :validatable + end + + @desc "The response to deleting a group." + object :delete_group_payload do + @desc """ + A boolean indicating if the mutation was successful. If true, the errors + list will be empty. Otherwise, errors may contain objects describing why + the mutation failed. + """ + field :success, non_null(:boolean) + + @desc "A list of validation errors." + field :errors, list_of(:error) + + interface :validatable + end + @desc "The response to bulk creating groups." object :bulk_create_groups_payload do @desc "A list of result payloads for each group." diff --git a/test/level/groups_test.exs b/test/level/groups_test.exs index 43c392e0..e4e54de5 100644 --- a/test/level/groups_test.exs +++ b/test/level/groups_test.exs @@ -240,6 +240,39 @@ defmodule Level.GroupsTest do end end + describe "reopen_group/1" do + setup do + create_user_and_space() + end + + test "transitions closed groups to open", %{space_user: space_user} do + {:ok, %{group: group}} = create_group(space_user) + {:ok, closed_group} = Groups.close_group(group) + {:ok, reopened_group} = Groups.reopen_group(closed_group) + assert reopened_group.state == "OPEN" + end + end + + describe "delete_group/1" do + setup do + create_user_and_space() + end + + test "transitions groups to deleted if space user is an owner", %{space_user: space_user} do + {:ok, %{group: group}} = create_group(space_user) + {:ok, deleted_group} = Groups.delete_group(space_user, group) + assert deleted_group.state == "DELETED" + end + + test "errors out if user is not authorized", %{space: space, space_user: space_user} do + {:ok, %{space_user: another_user}} = create_space_member(space) + {:ok, %{group: group}} = create_group(space_user) + + assert {:error, "You are not authorized to perform this action."} = + Groups.delete_group(another_user, group) + end + end + describe "is_bookmarked/2" do setup do create_user_and_space() diff --git a/test/level_web/graphql/mutations/close_group_test.exs b/test/level_web/graphql/mutations/close_group_test.exs new file mode 100644 index 00000000..c3eaaf72 --- /dev/null +++ b/test/level_web/graphql/mutations/close_group_test.exs @@ -0,0 +1,65 @@ +defmodule LevelWeb.GraphQL.CloseGroupTest do + use LevelWeb.ConnCase, async: true + import LevelWeb.GraphQL.TestHelpers + + alias Level.Groups + alias Level.Schemas.Group + + @query """ + mutation CloseGroup( + $space_id: ID!, + $group_id: ID! + ) { + closeGroup( + spaceId: $space_id, + groupId: $group_id + ) { + success + group { + state + } + errors { + attribute + message + } + } + } + """ + + setup %{conn: conn} do + {:ok, %{user: user} = result} = create_user_and_space() + conn = authenticate_with_jwt(conn, user) + {:ok, Map.put(result, :conn, conn)} + end + + test "closes the group", %{ + conn: conn, + space: space, + space_user: space_user + } do + {:ok, %{group: group}} = create_group(space_user) + + assert group.state == "OPEN" + + variables = %{space_id: space.id, group_id: group.id} + + conn = + conn + |> put_graphql_headers() + |> post("/graphql", %{query: @query, variables: variables}) + + assert json_response(conn, 200) == %{ + "data" => %{ + "closeGroup" => %{ + "success" => true, + "group" => %{ + "state" => "CLOSED" + }, + "errors" => [] + } + } + } + + assert {:ok, %Group{state: "CLOSED"}} = Groups.get_group(space_user, group.id) + end +end diff --git a/test/level_web/graphql/mutations/delete_group_test.exs b/test/level_web/graphql/mutations/delete_group_test.exs new file mode 100644 index 00000000..6aa50032 --- /dev/null +++ b/test/level_web/graphql/mutations/delete_group_test.exs @@ -0,0 +1,58 @@ +defmodule LevelWeb.GraphQL.DeleteGroupTest do + use LevelWeb.ConnCase, async: true + import LevelWeb.GraphQL.TestHelpers + + alias Level.Groups + + @query """ + mutation DeleteGroup( + $space_id: ID!, + $group_id: ID! + ) { + deleteGroup( + spaceId: $space_id, + groupId: $group_id + ) { + success + errors { + attribute + message + } + } + } + """ + + setup %{conn: conn} do + {:ok, %{user: user} = result} = create_user_and_space() + conn = authenticate_with_jwt(conn, user) + {:ok, Map.put(result, :conn, conn)} + end + + test "deletes the group if user is an owner", %{ + conn: conn, + space: space, + space_user: space_user + } do + {:ok, %{group: group}} = create_group(space_user) + + assert group.state == "OPEN" + + variables = %{space_id: space.id, group_id: group.id} + + conn = + conn + |> put_graphql_headers() + |> post("/graphql", %{query: @query, variables: variables}) + + assert json_response(conn, 200) == %{ + "data" => %{ + "deleteGroup" => %{ + "success" => true, + "errors" => [] + } + } + } + + assert {:error, _} = Groups.get_group(space_user, group.id) + end +end diff --git a/test/level_web/graphql/mutations/reopen_group_test.exs b/test/level_web/graphql/mutations/reopen_group_test.exs new file mode 100644 index 00000000..9f9510a7 --- /dev/null +++ b/test/level_web/graphql/mutations/reopen_group_test.exs @@ -0,0 +1,66 @@ +defmodule LevelWeb.GraphQL.ReopenGroupTest do + use LevelWeb.ConnCase, async: true + import LevelWeb.GraphQL.TestHelpers + + alias Level.Groups + alias Level.Schemas.Group + + @query """ + mutation ReopenGroup( + $space_id: ID!, + $group_id: ID! + ) { + reopenGroup( + spaceId: $space_id, + groupId: $group_id + ) { + success + group { + state + } + errors { + attribute + message + } + } + } + """ + + setup %{conn: conn} do + {:ok, %{user: user} = result} = create_user_and_space() + conn = authenticate_with_jwt(conn, user) + {:ok, Map.put(result, :conn, conn)} + end + + test "reopens the group", %{ + conn: conn, + space: space, + space_user: space_user + } do + {:ok, %{group: group}} = create_group(space_user) + {:ok, group} = Groups.close_group(group) + + assert group.state == "CLOSED" + + variables = %{space_id: space.id, group_id: group.id} + + conn = + conn + |> put_graphql_headers() + |> post("/graphql", %{query: @query, variables: variables}) + + assert json_response(conn, 200) == %{ + "data" => %{ + "reopenGroup" => %{ + "success" => true, + "group" => %{ + "state" => "OPEN" + }, + "errors" => [] + } + } + } + + assert {:ok, %Group{state: "OPEN"}} = Groups.get_group(space_user, group.id) + end +end From 288c88b5882a7255c4040d6a1ddce52f3596b594 Mon Sep 17 00:00:00 2001 From: Derrick Reimer Date: Thu, 1 Nov 2018 17:51:09 -0500 Subject: [PATCH 3/8] Add close/reopen toggle to groups --- assets/elm/src/Group.elm | 41 ++++++++++++-- assets/elm/src/Mutation/CloseGroup.elm | 56 +++++++++++++++++++ assets/elm/src/Mutation/ReopenGroup.elm | 56 +++++++++++++++++++ assets/elm/src/Page/Group.elm | 72 +++++++++++++++++++++++++ 4 files changed, 220 insertions(+), 5 deletions(-) create mode 100644 assets/elm/src/Mutation/CloseGroup.elm create mode 100644 assets/elm/src/Mutation/ReopenGroup.elm diff --git a/assets/elm/src/Group.elm b/assets/elm/src/Group.elm index 04731527..70dbce7e 100644 --- a/assets/elm/src/Group.elm +++ b/assets/elm/src/Group.elm @@ -1,9 +1,9 @@ -module Group exposing (Group, decoder, fragment, id, isBookmarked, isPrivate, membershipState, name) +module Group exposing (Group, State(..), decoder, fragment, id, isBookmarked, isPrivate, membershipState, name, state) import GraphQL exposing (Fragment) import GroupMembership exposing (GroupMembershipState(..)) import Id exposing (Id) -import Json.Decode as Decode exposing (Decoder, bool, field, int, string) +import Json.Decode as Decode exposing (Decoder, bool, fail, field, int, string, succeed) @@ -14,8 +14,14 @@ type Group = Group Data +type State + = Open + | Closed + + type alias Data = { id : Id + , state : State , name : String , isPrivate : Bool , isBookmarked : Bool @@ -30,6 +36,7 @@ fragment = """ fragment GroupFields on Group { id + state name isPrivate isBookmarked @@ -51,6 +58,11 @@ id (Group data) = data.id +state : Group -> State +state (Group data) = + data.state + + name : Group -> String name (Group data) = data.name @@ -78,17 +90,36 @@ membershipState (Group data) = decoder : Decoder Group decoder = Decode.map Group <| - Decode.map6 Data + Decode.map7 Data (field "id" Id.decoder) + (field "state" stateDecoder) (field "name" string) (field "isPrivate" bool) (field "isBookmarked" bool) - stateDecoder + membershipStateDecoder (field "fetchedAt" int) -stateDecoder : Decoder GroupMembershipState +stateDecoder : Decoder State stateDecoder = + let + convert : String -> Decoder State + convert raw = + case raw of + "OPEN" -> + succeed Open + + "CLOSED" -> + succeed Closed + + _ -> + fail "State not valid" + in + Decode.andThen convert string + + +membershipStateDecoder : Decoder GroupMembershipState +membershipStateDecoder = Decode.oneOf [ Decode.at [ "membership", "state" ] GroupMembership.stateDecoder , Decode.succeed NotSubscribed diff --git a/assets/elm/src/Mutation/CloseGroup.elm b/assets/elm/src/Mutation/CloseGroup.elm new file mode 100644 index 00000000..6d8c2af9 --- /dev/null +++ b/assets/elm/src/Mutation/CloseGroup.elm @@ -0,0 +1,56 @@ +module Mutation.CloseGroup exposing (Response(..), request) + +import GraphQL exposing (Document) +import Group exposing (Group) +import Json.Decode as Decode exposing (Decoder) +import Json.Encode as Encode +import Session exposing (Session) +import Task exposing (Task) + + +type Response + = Success Group + + +document : Document +document = + GraphQL.toDocument + """ + mutation CloseGroup( + $spaceId: ID!, + $groupId: ID! + ) { + closeGroup( + spaceId: $spaceId, + groupId: $groupId + ) { + group { + ...GroupFields + } + } + } + """ + [ Group.fragment + ] + + +variables : String -> String -> Maybe Encode.Value +variables spaceId groupId = + Just <| + Encode.object + [ ( "spaceId", Encode.string spaceId ) + , ( "groupId", Encode.string groupId ) + ] + + +decoder : Decoder Response +decoder = + Decode.map Success <| + Decode.at [ "data", "closeGroup", "group" ] + Group.decoder + + +request : String -> String -> Session -> Task Session.Error ( Session, Response ) +request spaceId groupId session = + Session.request session <| + GraphQL.request document (variables spaceId groupId) decoder diff --git a/assets/elm/src/Mutation/ReopenGroup.elm b/assets/elm/src/Mutation/ReopenGroup.elm new file mode 100644 index 00000000..c1b2c914 --- /dev/null +++ b/assets/elm/src/Mutation/ReopenGroup.elm @@ -0,0 +1,56 @@ +module Mutation.ReopenGroup exposing (Response(..), request) + +import GraphQL exposing (Document) +import Group exposing (Group) +import Json.Decode as Decode exposing (Decoder) +import Json.Encode as Encode +import Session exposing (Session) +import Task exposing (Task) + + +type Response + = Success Group + + +document : Document +document = + GraphQL.toDocument + """ + mutation ReopenGroup( + $spaceId: ID!, + $groupId: ID! + ) { + reopenGroup( + spaceId: $spaceId, + groupId: $groupId + ) { + group { + ...GroupFields + } + } + } + """ + [ Group.fragment + ] + + +variables : String -> String -> Maybe Encode.Value +variables spaceId groupId = + Just <| + Encode.object + [ ( "spaceId", Encode.string spaceId ) + , ( "groupId", Encode.string groupId ) + ] + + +decoder : Decoder Response +decoder = + Decode.map Success <| + Decode.at [ "data", "reopenGroup", "group" ] + Group.decoder + + +request : String -> String -> Session -> Task Session.Error ( Session, Response ) +request spaceId groupId session = + Session.request session <| + GraphQL.request document (variables spaceId groupId) decoder diff --git a/assets/elm/src/Page/Group.elm b/assets/elm/src/Page/Group.elm index 0aca34aa..af41ce2e 100644 --- a/assets/elm/src/Page/Group.elm +++ b/assets/elm/src/Page/Group.elm @@ -18,7 +18,9 @@ import Json.Decode as Decode import KeyboardShortcuts import ListHelpers exposing (insertUniqueBy, removeBy) import Mutation.BookmarkGroup as BookmarkGroup +import Mutation.CloseGroup as CloseGroup import Mutation.CreatePost as CreatePost +import Mutation.ReopenGroup as ReopenGroup import Mutation.SubscribeToGroup as SubscribeToGroup import Mutation.UnbookmarkGroup as UnbookmarkGroup import Mutation.UnsubscribeFromGroup as UnsubscribeFromGroup @@ -228,6 +230,10 @@ type Msg | CollapseSearchEditor | SearchEditorChanged String | SearchSubmitted + | CloseClicked + | Closed (Result Session.Error ( Session, CloseGroup.Response )) + | ReopenClicked + | Reopened (Result Session.Error ( Session, ReopenGroup.Response )) update : Msg -> Globals -> Model -> ( ( Model, Cmd Msg ), Globals ) @@ -596,6 +602,52 @@ update msg globals model = in ( ( { model | searchEditor = newSearchEditor }, cmd ), globals ) + CloseClicked -> + let + cmd = + globals.session + |> CloseGroup.request model.spaceId model.groupId + |> Task.attempt Closed + in + ( ( model, cmd ), globals ) + + Closed (Ok ( newSession, CloseGroup.Success newGroup )) -> + let + newRepo = + globals.repo + |> Repo.setGroup newGroup + in + noCmd { globals | session = newSession, repo = newRepo } model + + Closed (Err Session.Expired) -> + redirectToLogin globals model + + Closed (Err _) -> + noCmd globals model + + ReopenClicked -> + let + cmd = + globals.session + |> ReopenGroup.request model.spaceId model.groupId + |> Task.attempt Reopened + in + ( ( model, cmd ), globals ) + + Reopened (Ok ( newSession, ReopenGroup.Success newGroup )) -> + let + newRepo = + globals.repo + |> Repo.setGroup newGroup + in + noCmd { globals | session = newSession, repo = newRepo } model + + Reopened (Err Session.Expired) -> + redirectToLogin globals model + + Reopened (Err _) -> + noCmd globals model + noCmd : Globals -> Model -> ( ( Model, Cmd Msg ), Globals ) noCmd globals model = @@ -961,6 +1013,8 @@ sidebarView params group featuredMembers = , li [] [ subscribeButtonView (Group.membershipState group) ] + , li [] + [ stateButtonView (Group.state group) ] ] ] @@ -1000,6 +1054,24 @@ subscribeButtonView state = [ text "Leave this group" ] +stateButtonView : Group.State -> Html Msg +stateButtonView state = + case state of + Group.Open -> + button + [ class "text-md text-dusty-blue no-underline font-bold" + , onClick CloseClicked + ] + [ text "Close this group" ] + + Group.Closed -> + button + [ class "text-md text-dusty-blue no-underline font-bold" + , onClick ReopenClicked + ] + [ text "Reopen this group" ] + + -- INTERNAL From c4971d240d56c1b170c6c4536c44b16bf13e8f8f Mon Sep 17 00:00:00 2001 From: Derrick Reimer Date: Thu, 1 Nov 2018 20:38:40 -0500 Subject: [PATCH 4/8] Hide the composer when a group is closed --- assets/elm/src/Page/Group.elm | 38 ++++++++++++++++------------------- 1 file changed, 17 insertions(+), 21 deletions(-) diff --git a/assets/elm/src/Page/Group.elm b/assets/elm/src/Page/Group.elm index af41ce2e..95c11786 100644 --- a/assets/elm/src/Page/Group.elm +++ b/assets/elm/src/Page/Group.elm @@ -785,7 +785,15 @@ resolvedView repo maybeCurrentRoute spaceUsers model data = , controlsView model ] ] - , newPostView model.spaceId model.postComposer data.viewer spaceUsers + , viewIf (Group.state data.group == Group.Open) <| + newPostView model.spaceId model.postComposer data.viewer spaceUsers + , viewIf (Group.state data.group == Group.Closed) <| + p [ class "flex items-center p-4 mb-4 bg-grey-light rounded-lg text-dusty-blue-dark" ] + [ div [ class "flex-grow" ] [ text "This group is closed." ] + , div [ class "flex-no-shrink" ] + [ button [ class "btn btn-blue btn-sm", onClick ReopenClicked ] [ text "Reopen this group" ] + ] + ] , div [ class "sticky flex items-baseline mx-4 mb-4 border-b" ] [ filterTab "Open" Route.Group.Open (openParams model.params) model.params , filterTab "Closed" Route.Group.Closed (closedParams model.params) model.params @@ -1013,8 +1021,14 @@ sidebarView params group featuredMembers = , li [] [ subscribeButtonView (Group.membershipState group) ] - , li [] - [ stateButtonView (Group.state group) ] + , viewIf (Group.state group == Group.Open) <| + li [] + [ button + [ class "text-md text-dusty-blue no-underline font-bold" + , onClick CloseClicked + ] + [ text "Close this group" ] + ] ] ] @@ -1054,24 +1068,6 @@ subscribeButtonView state = [ text "Leave this group" ] -stateButtonView : Group.State -> Html Msg -stateButtonView state = - case state of - Group.Open -> - button - [ class "text-md text-dusty-blue no-underline font-bold" - , onClick CloseClicked - ] - [ text "Close this group" ] - - Group.Closed -> - button - [ class "text-md text-dusty-blue no-underline font-bold" - , onClick ReopenClicked - ] - [ text "Reopen this group" ] - - -- INTERNAL From 0e995212bf5aced34d76d7b9f336f2f03e10ac17 Mon Sep 17 00:00:00 2001 From: Derrick Reimer Date: Sat, 3 Nov 2018 16:50:03 -0500 Subject: [PATCH 5/8] Add state filtering to groups page --- assets/elm/src/Page/Groups.elm | 43 ++++++++++++++++---- assets/elm/src/Query/GroupsInit.elm | 17 +++++++- assets/elm/src/Route/Groups.elm | 52 ++++++++++++++++++++++--- lib/level/resolvers/group_connection.ex | 19 +++++++-- lib/level_web/schema/enums.ex | 6 +++ lib/level_web/schema/objects.ex | 2 +- test/level/resolvers_test.exs | 2 +- 7 files changed, 120 insertions(+), 21 deletions(-) diff --git a/assets/elm/src/Page/Groups.elm b/assets/elm/src/Page/Groups.elm index 13aa75a1..3b60fc2f 100644 --- a/assets/elm/src/Page/Groups.elm +++ b/assets/elm/src/Page/Groups.elm @@ -159,23 +159,38 @@ resolvedView repo maybeCurrentRoute model data = data.bookmarks maybeCurrentRoute [ div [ class "mx-auto max-w-sm leading-normal p-8" ] - [ div [ class "flex items-center pb-5" ] - [ h1 [ class "flex-1 ml-4 mr-4 font-extrabold text-3xl" ] [ text "Groups" ] + [ div [ class "flex items-center pb-4" ] + [ h1 [ class "flex-1 mx-4 font-extrabold text-3xl" ] [ text "Groups" ] , div [ class "flex-0 flex-no-shrink" ] [ a [ Route.href (Route.NewGroup (Space.slug data.space)), class "btn btn-blue btn-md no-underline" ] [ text "New group" ] ] ] - , div [ class "pb-8" ] - [ label [ class "flex items-center p-4 w-full rounded bg-grey-light" ] - [ div [ class "flex-0 flex-no-shrink pr-3" ] [ Icons.search ] - , input [ id "search-input", type_ "text", class "flex-1 bg-transparent no-outline", placeholder "Type to search" ] [] - ] + , div [ class "flex items-baseline mx-4 mb-4 border-b" ] + [ filterTab "Open" Route.Groups.Open (openParams model.params) model.params + , filterTab "Closed" Route.Groups.Closed (closedParams model.params) model.params ] , groupsView repo model.params data.space model.groupIds ] ] +filterTab : String -> Route.Groups.State -> Params -> Params -> Html Msg +filterTab label state linkParams currentParams = + let + isCurrent = + Route.Groups.getState currentParams == state + in + a + [ Route.href (Route.Groups linkParams) + , classList + [ ( "block text-sm mr-4 py-2 border-b-3 border-transparent no-underline font-bold", True ) + , ( "text-dusty-blue", not isCurrent ) + , ( "border-turquoise text-dusty-blue-darker", isCurrent ) + ] + ] + [ text label ] + + groupsView : Repo -> Params -> Space -> Connection Id -> Html Msg groupsView repo params space groupIds = let @@ -268,3 +283,17 @@ startsWith letter ( _, group ) = isEven : Int -> Bool isEven number = remainderBy 2 number == 0 + + +openParams : Params -> Params +openParams params = + params + |> Route.Groups.setCursors Nothing Nothing + |> Route.Groups.setState Route.Groups.Open + + +closedParams : Params -> Params +closedParams params = + params + |> Route.Groups.setCursors Nothing Nothing + |> Route.Groups.setState Route.Groups.Closed diff --git a/assets/elm/src/Query/GroupsInit.elm b/assets/elm/src/Query/GroupsInit.elm index ce0393b5..a1be32d8 100644 --- a/assets/elm/src/Query/GroupsInit.elm +++ b/assets/elm/src/Query/GroupsInit.elm @@ -40,7 +40,8 @@ document params = $first: Int, $last: Int, $before: Cursor, - $after: Cursor + $after: Cursor, + $state: GroupStateFilter ) { spaceUser(spaceSlug: $spaceSlug) { ...SpaceUserFields @@ -50,7 +51,8 @@ document params = first: $first, last: $last, before: $before, - after: $after + after: $after, + state: $state ) { ...GroupConnectionFields } @@ -74,6 +76,14 @@ variables params limit = spaceSlug = Encode.string (Route.Groups.getSpaceSlug params) + state = + case Route.Groups.getState params of + Route.Groups.Open -> + "OPEN" + + Route.Groups.Closed -> + "CLOSED" + values = case ( Route.Groups.getBefore params @@ -84,17 +94,20 @@ variables params limit = [ ( "spaceSlug", spaceSlug ) , ( "last", Encode.int limit ) , ( "before", Encode.string before ) + , ( "state", Encode.string state ) ] ( Nothing, Just after ) -> [ ( "spaceSlug", spaceSlug ) , ( "first", Encode.int limit ) , ( "after", Encode.string after ) + , ( "state", Encode.string state ) ] ( _, _ ) -> [ ( "spaceSlug", spaceSlug ) , ( "first", Encode.int limit ) + , ( "state", Encode.string state ) ] in Just (Encode.object values) diff --git a/assets/elm/src/Route/Groups.elm b/assets/elm/src/Route/Groups.elm index d2138a88..db568545 100644 --- a/assets/elm/src/Route/Groups.elm +++ b/assets/elm/src/Route/Groups.elm @@ -1,6 +1,6 @@ module Route.Groups exposing - ( Params(..) - , init, getSpaceSlug, getAfter, getBefore, setCursors + ( Params(..), State(..) + , init, getSpaceSlug, getAfter, getBefore, setCursors, getState, setState , parser , toString ) @@ -10,12 +10,12 @@ module Route.Groups exposing # Types -@docs Params +@docs Params, State # API -@docs init, getSpaceSlug, getAfter, getBefore, setCursors +@docs init, getSpaceSlug, getAfter, getBefore, setCursors, getState, setState # Parsing @@ -42,16 +42,22 @@ type alias Internal = { spaceSlug : String , after : Maybe String , before : Maybe String + , state : State } +type State + = Open + | Closed + + -- API init : String -> Params init spaceSlug = - Params (Internal spaceSlug Nothing Nothing) + Params (Internal spaceSlug Nothing Nothing Open) getSpaceSlug : Params -> String @@ -69,11 +75,21 @@ getBefore (Params internal) = internal.before +getState : Params -> State +getState (Params internal) = + internal.state + + setCursors : Maybe String -> Maybe String -> Params -> Params setCursors before after (Params internal) = Params { internal | before = before, after = after } +setState : State -> Params -> Params +setState newState (Params internal) = + Params { internal | state = newState } + + -- PARSING @@ -81,7 +97,7 @@ setCursors before after (Params internal) = parser : Parser (Params -> a) a parser = map Params <| - map Internal (string s "groups" Query.string "after" Query.string "before") + map Internal (string s "groups" Query.string "after" Query.string "before" Query.map parseState (Query.string "state")) @@ -97,11 +113,35 @@ toString (Params internal) = -- PRIVATE +parseState : Maybe String -> State +parseState value = + case value of + Just "closed" -> + Closed + + Just "open" -> + Open + + _ -> + Open + + +castState : State -> Maybe String +castState state = + case state of + Open -> + Nothing + + Closed -> + Just "closed" + + buildQuery : Internal -> List QueryParameter buildQuery internal = buildStringParams [ ( "after", internal.after ) , ( "before", internal.before ) + , ( "state", castState internal.state ) ] diff --git a/lib/level/resolvers/group_connection.ex b/lib/level/resolvers/group_connection.ex index 2940d681..2bc2e1dd 100644 --- a/lib/level/resolvers/group_connection.ex +++ b/lib/level/resolvers/group_connection.ex @@ -14,7 +14,7 @@ defmodule Level.Resolvers.GroupConnection do last: nil, before: nil, after: nil, - state: "OPEN", + state: :open, order_by: %{ field: :name, direction: :asc @@ -25,17 +25,28 @@ defmodule Level.Resolvers.GroupConnection do last: integer() | nil, before: String.t() | nil, after: String.t() | nil, - state: String.t(), + state: :open | :closed | :all, order_by: %{field: :name, direction: :asc | :desc} } @doc """ Executes a paginated query for groups belonging to a given space. """ - def get(%Space{id: space_id}, %{state: state} = args, %{context: %{current_user: user}}) do + def get(%Space{id: space_id}, args, %{context: %{current_user: user}}) do user |> Groups.groups_base_query() - |> where(space_id: ^space_id, state: ^state) + |> where(space_id: ^space_id) + |> apply_state_filter(args) |> Pagination.fetch_result(Args.build(args)) end + + defp apply_state_filter(query, %{state: :open}) do + where(query, state: "OPEN") + end + + defp apply_state_filter(query, %{state: :closed}) do + where(query, state: "CLOSED") + end + + defp apply_state_filter(query, _), do: query end diff --git a/lib/level_web/schema/enums.ex b/lib/level_web/schema/enums.ex index 4fd9247b..dcc5de80 100644 --- a/lib/level_web/schema/enums.ex +++ b/lib/level_web/schema/enums.ex @@ -112,4 +112,10 @@ defmodule LevelWeb.Schema.Enums do value :closed value :all end + + enum :group_state_filter do + value :open + value :closed + value :all + end end diff --git a/lib/level_web/schema/objects.ex b/lib/level_web/schema/objects.ex index 1dcf9d04..a8586d3f 100644 --- a/lib/level_web/schema/objects.ex +++ b/lib/level_web/schema/objects.ex @@ -185,7 +185,7 @@ defmodule LevelWeb.Schema.Objects do arg :before, :cursor arg :after, :cursor arg :order_by, :group_order - arg :state, :group_state + arg :state, :group_state_filter, default_value: :open resolve &Resolvers.groups/3 end diff --git a/test/level/resolvers_test.exs b/test/level/resolvers_test.exs index db731807..5dc45602 100644 --- a/test/level/resolvers_test.exs +++ b/test/level/resolvers_test.exs @@ -34,7 +34,7 @@ defmodule Level.ResolversTest do {:ok, closed_group} = Groups.close_group(closed_group) {:ok, %{edges: edges}} = - Resolvers.groups(space, %{first: 10, state: "CLOSED"}, build_context(user)) + Resolvers.groups(space, %{first: 10, state: :closed}, build_context(user)) assert edges_include?(edges, closed_group.id) refute edges_include?(edges, open_group.id) From 587d36ddf130b5495d4faa03c2f3b7c4f1439faa Mon Sep 17 00:00:00 2001 From: Derrick Reimer Date: Sat, 3 Nov 2018 16:57:24 -0500 Subject: [PATCH 6/8] Require unique group names across open and closed groups --- ...15151_update_group_state_partial_index.exs | 29 +++++++++++++++++++ priv/repo/structure.sql | 6 ++-- 2 files changed, 32 insertions(+), 3 deletions(-) create mode 100644 priv/repo/migrations/20181103215151_update_group_state_partial_index.exs diff --git a/priv/repo/migrations/20181103215151_update_group_state_partial_index.exs b/priv/repo/migrations/20181103215151_update_group_state_partial_index.exs new file mode 100644 index 00000000..99cd8568 --- /dev/null +++ b/priv/repo/migrations/20181103215151_update_group_state_partial_index.exs @@ -0,0 +1,29 @@ +defmodule Level.Repo.Migrations.UpdateGroupStatePartialIndex do + use Ecto.Migration + + def up do + drop index(:groups, [:space_id, "lower(name)"], name: :groups_unique_names_when_open) + + create( + unique_index( + :groups, + [:space_id, "lower(name)"], + where: "not state = 'DELETED'", + name: :groups_unique_names_when_undeleted + ) + ) + end + + def down do + drop index(:groups, [:space_id, "lower(name)"], name: :groups_unique_names_when_undeleted) + + create( + unique_index( + :groups, + [:space_id, "lower(name)"], + where: "state = 'OPEN'", + name: :groups_unique_names_when_open + ) + ) + end +end diff --git a/priv/repo/structure.sql b/priv/repo/structure.sql index 773b6103..8307ffd4 100644 --- a/priv/repo/structure.sql +++ b/priv/repo/structure.sql @@ -990,10 +990,10 @@ CREATE INDEX groups_space_id_index ON public.groups USING btree (space_id); -- --- Name: groups_unique_names_when_open; Type: INDEX; Schema: public; Owner: - +-- Name: groups_unique_names_when_undeleted; Type: INDEX; Schema: public; Owner: - -- -CREATE UNIQUE INDEX groups_unique_names_when_open ON public.groups USING btree (space_id, lower(name)) WHERE (state = 'OPEN'::public.group_state); +CREATE UNIQUE INDEX groups_unique_names_when_undeleted ON public.groups USING btree (space_id, lower(name)) WHERE (NOT (state = 'DELETED'::public.group_state)); -- @@ -1725,5 +1725,5 @@ ALTER TABLE ONLY public.user_mentions -- PostgreSQL database dump complete -- -INSERT INTO public."schema_migrations" (version) VALUES (20170527220454), (20170528000152), (20170619214118), (20180403181445), (20180404204544), (20180413214033), (20180509143149), (20180510211015), (20180515174533), (20180518203612), (20180531200436), (20180627000743), (20180627231041), (20180724162650), (20180725135511), (20180731205027), (20180803151120), (20180807173948), (20180809201313), (20180810141122), (20180903213417), (20180903215930), (20180903220826), (20180908173406), (20180918182427), (20181003182443), (20181005154158), (20181009210537), (20181010174443), (20181011172259), (20181012200233), (20181012223338), (20181014144651), (20181018210912), (20181019194025), (20181022151255), (20181023175556), (20181029191737), (20181029220713), (20181101221239); +INSERT INTO public."schema_migrations" (version) VALUES (20170527220454), (20170528000152), (20170619214118), (20180403181445), (20180404204544), (20180413214033), (20180509143149), (20180510211015), (20180515174533), (20180518203612), (20180531200436), (20180627000743), (20180627231041), (20180724162650), (20180725135511), (20180731205027), (20180803151120), (20180807173948), (20180809201313), (20180810141122), (20180903213417), (20180903215930), (20180903220826), (20180908173406), (20180918182427), (20181003182443), (20181005154158), (20181009210537), (20181010174443), (20181011172259), (20181012200233), (20181012223338), (20181014144651), (20181018210912), (20181019194025), (20181022151255), (20181023175556), (20181029191737), (20181029220713), (20181101221239), (20181103215151); From 6e192a4a71e5b5f1a0267a53b99282575485eac3 Mon Sep 17 00:00:00 2001 From: Derrick Reimer Date: Sat, 3 Nov 2018 17:00:55 -0500 Subject: [PATCH 7/8] Bug fix --- lib/level/schemas/group.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/level/schemas/group.ex b/lib/level/schemas/group.ex index 51b5e3f4..c131d213 100644 --- a/lib/level/schemas/group.ex +++ b/lib/level/schemas/group.ex @@ -50,6 +50,6 @@ defmodule Level.Schemas.Group do def validate(changeset) do changeset |> validate_required([:name]) - |> unique_constraint(:name, name: :groups_unique_names_when_open) + |> unique_constraint(:name, name: :groups_unique_names_when_undeleted) end end From 4eddfe22f7c2c60b96c9441e29bcafcde03a96b9 Mon Sep 17 00:00:00 2001 From: Derrick Reimer Date: Sat, 3 Nov 2018 17:03:41 -0500 Subject: [PATCH 8/8] Make closed state more prominent --- assets/elm/src/Page/Group.elm | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/elm/src/Page/Group.elm b/assets/elm/src/Page/Group.elm index 95c11786..e5447122 100644 --- a/assets/elm/src/Page/Group.elm +++ b/assets/elm/src/Page/Group.elm @@ -788,7 +788,7 @@ resolvedView repo maybeCurrentRoute spaceUsers model data = , viewIf (Group.state data.group == Group.Open) <| newPostView model.spaceId model.postComposer data.viewer spaceUsers , viewIf (Group.state data.group == Group.Closed) <| - p [ class "flex items-center p-4 mb-4 bg-grey-light rounded-lg text-dusty-blue-dark" ] + p [ class "flex items-center px-4 py-3 mb-4 bg-red-lightest border-b-2 border-red text-red font-extrabold" ] [ div [ class "flex-grow" ] [ text "This group is closed." ] , div [ class "flex-no-shrink" ] [ button [ class "btn btn-blue btn-sm", onClick ReopenClicked ] [ text "Reopen this group" ]