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}
- {filters.map((filter) => (
+ {selection.map((filter) => (
- {filter}
))}
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 (
+
+
+ {loginText}
+
+ )
+ }
+
+ if (searchProfilesCount > 10) {
+ return
{limitText}
+ }
+
+ if (searchProfile) {
+ return (
+
+ {viewText}
+
+ )
+ }
+
+ return
+}
+
+function CreateSearchProfileButton ({
+ districts,
+ organisations,
+ topicChoices,
+ participationChoices,
+ projectStatus,
+ searchProfilesApiUrl,
+ appliedFilters,
+ onSearchProfileCreate
+}) {
+ const { loading, error, createSearchProfile } = useCreateSearchProfile({
+ searchProfilesApiUrl,
+ appliedFilters,
+ districts,
+ organisations,
+ topicChoices,
+ participationChoices,
+ projectStatus,
+ onSearchProfileCreate
+ })
+
+ if (error) {
+ return
{error}
+ }
+
+ return (
+
+ )
+}
+
+function Icon () {
+ return
+}
diff --git a/meinberlin/react/plans/react_plans_map.jsx b/meinberlin/react/plans/react_plans_map.jsx
index 6928c2cbe4..06757d26d1 100644
--- a/meinberlin/react/plans/react_plans_map.jsx
+++ b/meinberlin/react/plans/react_plans_map.jsx
@@ -17,12 +17,15 @@ function init () {
const selectedTopic = el.getAttribute('data-selected-topic')
const districts = JSON.parse(el.getAttribute('data-districts'))
const organisations = JSON.parse(el.getAttribute('data-organisations'))
- const districtNames = JSON.parse(el.getAttribute('data-district-names'))
const topicChoices = JSON.parse(el.getAttribute('data-topic-choices'))
const mapboxToken = el.getAttribute('data-mapbox-token')
const omtToken = el.getAttribute('data-omt-token')
const useVectorMap = el.getAttribute('data-use_vector_map')
const participationChoices = JSON.parse(el.getAttribute('data-participation-choices'))
+ const searchProfilesApiUrl = el.getAttribute('data-search-profiles-url')
+ const searchProfilesCount = JSON.parse(el.getAttribute('data-search-profiles-count'))
+ const isAuthenticated = JSON.parse(el.getAttribute('data-is-authenticated'))
+ const projectStatus = JSON.parse(el.getAttribute('data-project-status'))
const root = createRoot(el)
root.render(
@@ -41,10 +44,13 @@ function init () {
bounds={bounds}
organisations={organisations}
districts={districts}
- districtNames={districtNames}
topicChoices={topicChoices}
participationChoices={participationChoices}
searchProfile={searchProfile}
+ searchProfilesApiUrl={searchProfilesApiUrl}
+ searchProfilesCount={searchProfilesCount}
+ isAuthenticated={isAuthenticated}
+ projectStatus={projectStatus}
/>
)
})
diff --git a/meinberlin/react/projects/ProjectTopics.jsx b/meinberlin/react/projects/ProjectTopics.jsx
index 2a25ed509f..754a305134 100644
--- a/meinberlin/react/projects/ProjectTopics.jsx
+++ b/meinberlin/react/projects/ProjectTopics.jsx
@@ -17,8 +17,8 @@ const ProjectTilePills = ({ project, topicChoices }) => {
return (
{project.type === 'plan' && - {planStr}
}
- {topicList.map(topic =>
- - {topic}
+ {topicChoices.filter(topic => project.topics.includes(topic.code)).map(topic =>
+ - {topic.name}
)}
)
diff --git a/meinberlin/react/projects/ProjectsControlBar.jsx b/meinberlin/react/projects/ProjectsControlBar.jsx
index 0629083a59..d827926707 100644
--- a/meinberlin/react/projects/ProjectsControlBar.jsx
+++ b/meinberlin/react/projects/ProjectsControlBar.jsx
@@ -4,6 +4,7 @@ import { TypeaheadField } from '../contrib/TypeaheadField'
import { MultiSelect } from '../contrib/forms/MultiSelect'
import { classNames } from '../contrib/helpers'
import { ControlBarFilterPills } from '../contrib/ControlBarFilterPills'
+import SaveSearchProfile from '../plans/SaveSearchProfile'
const translated = {
search: django.gettext('Search'),
@@ -30,6 +31,12 @@ const statusNames = {
past: django.gettext('done')
}
+const STATUS_MAPPING = {
+ active: 'running',
+ past: 'done',
+ future: 'future'
+}
+
const initialState = {
search: '',
districts: [],
@@ -45,23 +52,38 @@ const getAlteredFilters = ({ search, districts, topics, projectState, organisati
filters.push({ label: search, type: 'search', value: search })
}
districts.forEach(d => filters.push({ label: d, type: 'districts', value: d }))
- topics.forEach(t => filters.push({ label: topicChoices[t], type: 'topics', value: t }))
+ topics.forEach(topicCode => {
+ const choice = topicChoices.find(choice => choice.code === topicCode)
+ if (choice) {
+ filters.push({ label: choice.name, type: 'topics', value: topicCode })
+ }
+ })
projectState.forEach(s => filters.push({ label: statusNames[s], type: 'projectState', value: s }))
organisation.forEach(o => filters.push({ label: o, type: 'organisation', value: o }))
- participations.forEach(p => filters.push({ label: participationChoices[p], type: 'participations', value: p }))
+ participations.forEach(participationId => {
+ const choice = participationChoices.find(choice => choice.id === participationId)
+ if (choice) {
+ filters.push({ label: choice.name, type: 'participations', value: participationId })
+ }
+ })
return filters
}
export const ProjectsControlBar = ({
- districtNames,
+ districts,
organisations,
participationChoices,
topicChoices,
appliedFilters,
onFiltered,
onResetClick,
- hasContainer
+ hasContainer,
+ searchProfile: initialSearchProfile,
+ searchProfilesApiUrl,
+ searchProfilesCount: initialSearchProfilesCount,
+ isAuthenticated,
+ projectStatus
}) => {
const [expandFilters, setExpandFilters] = useState(false)
const [filters, setFilters] = useState(appliedFilters)
@@ -69,6 +91,22 @@ export const ProjectsControlBar = ({
setFilters({ ...filters, [type]: choice })
}
const alteredFilters = getAlteredFilters(appliedFilters, topicChoices, participationChoices)
+ const [searchProfile, setSearchProfile] = useState(initialSearchProfile)
+ const [searchProfilesCount, setSearchProfilesCount] = useState(initialSearchProfilesCount)
+
+ const isFiltersInitialState = JSON.stringify(appliedFilters) === JSON.stringify(initialState)
+
+ const removeSearchProfile = () => {
+ setSearchProfile(null)
+ window.history.replaceState({}, '', window.location.pathname)
+ }
+
+ const createSearchProfile = (searchProfile) => {
+ setSearchProfile(searchProfile)
+ setSearchProfilesCount(searchProfilesCount + 1)
+ window.history.replaceState({}, '', window.location.pathname + '?search-profile=' + searchProfile.id
+ )
+ }
return (