diff --git a/changelog/_8661.md b/changelog/_8661.md new file mode 100644 index 0000000000..ec56bc3ba7 --- /dev/null +++ b/changelog/_8661.md @@ -0,0 +1,3 @@ +### Added + +- Added `SaveSearchProfile` component for saving search profiles diff --git a/meinberlin/apps/plans/templates/meinberlin_plans/plan_list.html b/meinberlin/apps/plans/templates/meinberlin_plans/plan_list.html index 6c51d1d0cb..6f4d0c08a7 100644 --- a/meinberlin/apps/plans/templates/meinberlin_plans/plan_list.html +++ b/meinberlin/apps/plans/templates/meinberlin_plans/plan_list.html @@ -25,7 +25,6 @@ data-projects-url="{{ projects_api_url }}" data-districts="{{ districts }}" data-organisations="{{ organisations }}" - data-district-names="{{ district_names }}" data-topic-choices="{{ topic_choices }}" data-attribution="{{ attribution }}" data-mapbox-token="{{ mapbox_token }}" @@ -34,6 +33,10 @@ data-baseurl="{{ baseurl }}" data-bounds="{{ bounds }}" data-search-profile="{{ search_profile|default:"" }}" + data-search-profiles-url="{{ search_profiles_url }}" + data-search-profiles-count="{{ search_profiles_count }}" + data-is-authenticated="{{ is_authenticated }}" + data-project-status="{{ project_status }}" data-participation-choices="{{ participation_choices }}"> {% endblock content %} diff --git a/meinberlin/apps/plans/views.py b/meinberlin/apps/plans/views.py index a367dcac5f..2558e6891a 100644 --- a/meinberlin/apps/plans/views.py +++ b/meinberlin/apps/plans/views.py @@ -18,10 +18,13 @@ from adhocracy4.filters import widgets as filter_widgets from adhocracy4.filters.filters import DefaultsFilterSet from adhocracy4.filters.filters import FreeTextFilter +from adhocracy4.projects.models import Topic from adhocracy4.rules import mixins as rules_mixins from meinberlin.apps.contrib.enums import TopicEnum from meinberlin.apps.contrib.views import CanonicalURLDetailView from meinberlin.apps.dashboard.mixins import DashboardProjectListGroupMixin +from meinberlin.apps.kiezradar.models import ProjectStatus +from meinberlin.apps.kiezradar.models import ProjectType from meinberlin.apps.kiezradar.models import SearchProfile from meinberlin.apps.kiezradar.serializers import SearchProfileSerializer from meinberlin.apps.maps.models import MapPreset @@ -77,28 +80,43 @@ def districts(self): return [] def get_organisations(self): - organisations = Organisation.objects.values_list("name", flat=True).order_by( - "name" - ) + organisations = Organisation.objects.values("id", "name").order_by("name") return json.dumps(list(organisations)) - def get_district_polygons(self): - districts = self.districts - return json.dumps([district.polygon for district in districts]) - - def get_district_names(self): - city_wide = _("City wide") - districts = AdministrativeDistrict.objects.all() - district_names_list = [district.name for district in districts] - district_names_list.append(str(city_wide)) - return json.dumps(district_names_list) + def get_districts(self): + districts = AdministrativeDistrict.objects.values("id", "name") + districts_list = [district for district in districts] + districts_list.append({"id": -1, "name": "City Wide"}) + return json.dumps(districts_list) def get_topics(self): - return json.dumps({topic: str(topic.label) for topic in TopicEnum}) + topics = [ + { + "id": topic.id, + "code": topic.code, + "name": str(TopicEnum(topic.code).label), + } + for topic in Topic.objects.all() + ] + return json.dumps(topics) def get_participation_choices(self): - choices = [str(choice[1]) for choice in Plan.participation.field.choices] - return json.dumps(choices) + project_types = [ + {"id": project_type.id, "name": project_type.get_participation_display()} + for project_type in ProjectType.objects.all() + ] + return json.dumps(project_types) + + def get_project_status(self): + statuses = [ + { + "id": project_status.id, + "status": project_status.status, + "name": project_status.get_status_display(), + } + for project_status in ProjectStatus.objects.all() + ] + return json.dumps(statuses) def get_search_profile(self): if ( @@ -119,6 +137,12 @@ def get_search_profile(self): pass return None + def get_search_profiles_count(self): + if self.request.user.is_authenticated: + return SearchProfile.objects.filter(user=self.request.user).count() + else: + return 0 + def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) @@ -136,9 +160,8 @@ def get_context_data(self, **kwargs): omt_token = settings.A4_OPENMAPTILES_TOKEN context["search_profile"] = self.get_search_profile() - context["districts"] = self.get_district_polygons() + context["districts"] = self.get_districts() context["organisations"] = self.get_organisations() - context["district_names"] = self.get_district_names() context["topic_choices"] = self.get_topics() context["extprojects_api_url"] = reverse("extprojects-list") context["privateprojects_api_url"] = reverse("privateprojects-list") @@ -156,6 +179,10 @@ def get_context_data(self, **kwargs): context["district"] = self.request.GET.get("district", -1) context["topic"] = self.request.GET.get("topic", -1) context["participation_choices"] = self.get_participation_choices() + context["search_profiles_url"] = reverse("searchprofiles-list") + context["search_profiles_count"] = self.get_search_profiles_count() + context["is_authenticated"] = json.dumps(self.request.user.is_authenticated) + context["project_status"] = self.get_project_status() return context diff --git a/meinberlin/assets/scss/components_user_facing/_control-bar.scss b/meinberlin/assets/scss/components_user_facing/_control-bar.scss index 1142e4aef6..eba026a62d 100644 --- a/meinberlin/assets/scss/components_user_facing/_control-bar.scss +++ b/meinberlin/assets/scss/components_user_facing/_control-bar.scss @@ -28,15 +28,21 @@ } .control-bar__bottom--projects { - padding: 0; + display: flex; + flex-direction: column-reverse; + padding: 1em 0.875em; - @media screen and (min-width: $breakpoint-tablet) { - padding: 1em 0 0; + @media screen and (min-width: $breakpoint-palm) { + display: block; + flex-direction: row; } +} + +.control-bar__bottom--projects div:last-child { + text-align: right; + margin-bottom: 1rem; - :not(.container) > & { - @media screen and (min-width: $breakpoint-tablet) { - padding: 1em 0.875em 0; - } + @media screen and (min-width: $breakpoint-palm) { + margin-bottom: 0; } } diff --git a/meinberlin/assets/scss/components_user_facing/_search-profiles.scss b/meinberlin/assets/scss/components_user_facing/_search-profiles.scss index e1360d8d2e..21e37c2623 100644 --- a/meinberlin/assets/scss/components_user_facing/_search-profiles.scss +++ b/meinberlin/assets/scss/components_user_facing/_search-profiles.scss @@ -31,7 +31,7 @@ } .search-profile__filters { - color: var(--color-grey-darkest); + color: $gray-darkest; font-size: 0.9rem; margin-bottom: 0; padding-left: 0; @@ -149,4 +149,26 @@ .search-profile__toggle-switch .toggle-switch__label { margin-left: -1.25rem; -} \ No newline at end of file +} + +.save-search-profile__action { + color: $gray-darkest; + line-height: 1.5rem; +} + +.save-search-profile__action--link, +.save-search-profile__action--button { + color: $link-color; +} + +.save-search-profile__action--button { + padding: 0; + + &:hover { + text-decoration: underline; + } +} + +.save-search-profile__error { + color: $text-error +} diff --git a/meinberlin/react/kiezradar/SearchProfile.jsx b/meinberlin/react/kiezradar/SearchProfile.jsx index 240c9b39bc..ed3e70dd05 100644 --- a/meinberlin/react/kiezradar/SearchProfile.jsx +++ b/meinberlin/react/kiezradar/SearchProfile.jsx @@ -16,6 +16,11 @@ const errorDeleteSearchProfilesText = django.gettext( const errorUpdateSearchProfilesText = django.gettext( 'Failed to update search profile' ) +const statusNames = { + running: django.gettext('ongoing'), + future: django.gettext('upcoming'), + done: django.gettext('done') +} export default function SearchProfile ({ apiUrl, planListUrl, profile: profile_, onDelete }) { const [isEditing, setIsEditing] = useState(false) @@ -66,11 +71,14 @@ export default function SearchProfile ({ apiUrl, planListUrl, profile: profile_, const filters = [ profile.districts, - profile.project_types, profile.topics, + profile.project_types, + profile.status.map((status) => ({ name: statusNames[status.name] })), profile.organisations ] .map((filter) => filter.map(({ name }) => name)) + + const selection = [[profile.query_text], ...filters] .map((names) => names.join(', ')) .filter(Boolean) @@ -80,7 +88,7 @@ export default function SearchProfile ({ apiUrl, planListUrl, profile: profile_,

{profile.name}

diff --git a/meinberlin/react/kiezradar/use-create-search-profile.jest.js b/meinberlin/react/kiezradar/use-create-search-profile.jest.js new file mode 100644 index 0000000000..734eaa2514 --- /dev/null +++ b/meinberlin/react/kiezradar/use-create-search-profile.jest.js @@ -0,0 +1,141 @@ +import { renderHook, act } from '@testing-library/react' +import { updateItem } from '../contrib/helpers' +import { useCreateSearchProfile } from './use-create-search-profile' + +jest.mock('../contrib/helpers', () => ({ + updateItem: jest.fn() +})) + +describe('useCreateSearchProfile', () => { + const searchProfilesApiUrl = '/api/search-profiles' + const districts = [ + { id: 1, name: 'Charlottenburg-Wilmersdorf' }, + { id: 2, name: 'Friedrichshain-Kreuzberg' } + ] + const organisations = [{ id: 1, name: 'liqd' }] + const topicChoices = [ + { id: 1, name: 'Anti-discrimination, Work & economy', code: 'ANT' } + ] + const participationChoices = [ + { id: 1, name: 'information (no participation)' }, + { id: 2, name: 'consultation' }, + { id: 3, name: 'cooperation' }, + { id: 4, name: 'decision-making' } + ] + const projectStatus = [ + { + id: 1, + status: 0, + name: 'running' + }, + { + id: 2, + status: 1, + name: 'done' + }, + { + id: 3, + status: 2, + name: 'future' + } + ] + + beforeEach(() => { + jest.clearAllMocks() + }) + + it('handles submission of a search profile', async () => { + const appliedFilters = { + districts: ['Charlottenburg-Wilmersdorf', 'Friedrichshain-Kreuzberg'], + organisation: ['liqd'], + topics: ['ANT'], + participations: [0, 1, 2], + projectState: ['active', 'past', 'future'], + search: '' + } + + const mockedData = { + districts: [1, 2], + organisations: [1], + topics: [1], + project_types: [1, 2, 3], + status: [1, 2, 3] + } + + const { result } = renderHook(() => + useCreateSearchProfile({ + searchProfilesApiUrl, + appliedFilters, + districts, + organisations, + topicChoices, + participationChoices, + projectStatus, + onSearchProfileCreate: () => {} + }) + ) + + await act(async () => { + await result.current.createSearchProfile() + }) + + expect(updateItem).toHaveBeenCalledWith( + expect.objectContaining(mockedData), + searchProfilesApiUrl, + 'POST' + ) + }) + + it('calls onSearchProfileCreate with searchProfile from updateItem', async () => { + const appliedFilters = { + districts: [], + organisation: [], + topics: [], + participations: [], + projectState: [], + search: '' + } + + const mockedSearchProfile = { + id: 1, + user: 1, + name: 'Searchprofile 1', + description: null, + disabled: false, + notification: false, + status: [], + query: 15, + organisations: [], + districts: [], + project_types: [], + topics: [], + query_text: '' + } + + jest.mocked(updateItem).mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce(mockedSearchProfile) + }) + + const mockOnSearchProfileCreate = jest.fn() + + const { result } = renderHook(() => + useCreateSearchProfile({ + searchProfilesApiUrl, + appliedFilters, + districts, + organisations, + topicChoices, + participationChoices, + projectStatus, + onSearchProfileCreate: mockOnSearchProfileCreate + }) + ) + + await act(async () => { + await result.current.createSearchProfile() + }) + + expect(mockOnSearchProfileCreate).toHaveBeenCalledWith(mockedSearchProfile) + }) +}) diff --git a/meinberlin/react/kiezradar/use-create-search-profile.js b/meinberlin/react/kiezradar/use-create-search-profile.js new file mode 100644 index 0000000000..de4555c36c --- /dev/null +++ b/meinberlin/react/kiezradar/use-create-search-profile.js @@ -0,0 +1,127 @@ +import { useState } from 'react' +import django from 'django' +import { updateItem } from '../contrib/helpers' + +const errorCreatingSearchProfile = django.gettext( + 'Error creating search profile' +) + +const STATUS_MAPPING = { + running: 'active', + done: 'past', + future: 'future' +} + +export function useCreateSearchProfile ({ + searchProfilesApiUrl, + appliedFilters, + districts, + organisations, + topicChoices, + participationChoices, + projectStatus, + onSearchProfileCreate +}) { + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + const createSearchProfile = async () => { + setLoading(true) + setError(null) + + const results = getFilteredResults({ + appliedFilters, + districts, + organisations, + topicChoices, + participationChoices, + projectStatus + }) + + const { + districtIds, + organisationIds, + topicIds, + participationIds, + projectStatusIds + } = getFilteredIds(results) + + const params = { + districts: districtIds, + organisations: organisationIds, + topics: topicIds, + project_types: participationIds, + status: projectStatusIds + } + + if (appliedFilters.search.length > 0) { + params.query_text = appliedFilters.search + } + + try { + const response = await updateItem(params, searchProfilesApiUrl, 'POST') + const searchProfile = await response.json() + + onSearchProfileCreate(searchProfile) + } catch (err) { + setError(errorCreatingSearchProfile) + } finally { + setLoading(false) + } + } + + return { loading, error, createSearchProfile } +} + +function getFilteredResults ({ + appliedFilters, + districts, + organisations, + topicChoices, + participationChoices, + projectStatus +}) { + return { + filteredDistricts: districts.filter(district => + appliedFilters.districts.includes(district.name) + ), + filteredOrganisations: organisations.filter(organisation => + appliedFilters.organisation.includes(organisation.name) + ), + filteredTopics: topicChoices.filter(topic => + appliedFilters.topics.includes(topic.code) + ), + filteredParticipationChoices: appliedFilters.participations.map(index => + participationChoices[index] + ), + filteredProjectStatus: projectStatus.filter(status => + appliedFilters.projectState.includes(STATUS_MAPPING[status.name]) + ) + } +} + +function getFilteredIds (results) { + const filters = [ + results.filteredDistricts, + results.filteredOrganisations, + results.filteredTopics, + results.filteredParticipationChoices, + results.filteredProjectStatus + ] + + const [ + districtIds, + organisationIds, + topicIds, + participationIds, + projectStatusIds + ] = filters.map(items => items.map(item => item.id)) + + return { + districtIds, + organisationIds, + topicIds, + participationIds, + projectStatusIds + } +} diff --git a/meinberlin/react/plans/SaveSearchProfile.jsx b/meinberlin/react/plans/SaveSearchProfile.jsx new file mode 100644 index 0000000000..ec4fe960d3 --- /dev/null +++ b/meinberlin/react/plans/SaveSearchProfile.jsx @@ -0,0 +1,87 @@ +import React from 'react' +import django from 'django' +import { useCreateSearchProfile } from '../kiezradar/use-create-search-profile' + +const loginText = django.gettext('Login to save search profiles') +const viewText = django.gettext('View search profiles') +const limitText = django.gettext('You can only create 10 search profiles.') +const saveText = django.gettext('Save search profile') +const savingText = django.gettext('Saving') + +export default function SaveSearchProfile ({ + isAuthenticated, + searchProfile, + searchProfilesCount, + ...props +}) { + if (!isAuthenticated) { + return ( + {saveText}} + + ) +} + +function Icon () { + return