Skip to content

Commit

Permalink
search-profile: add search profile button
Browse files Browse the repository at this point in the history
  • Loading branch information
sevfurneaux committed Jan 15, 2025
1 parent 4710e3a commit b3826a4
Show file tree
Hide file tree
Showing 13 changed files with 550 additions and 46 deletions.
3 changes: 3 additions & 0 deletions changelog/_8661.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
### Added

- Added `SaveSearchProfile` component for saving search profiles
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}"
Expand All @@ -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 }}"></div>
{% endblock content %}

Expand Down
63 changes: 45 additions & 18 deletions meinberlin/apps/plans/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 (
Expand All @@ -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)

Expand All @@ -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")
Expand All @@ -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

Expand Down
20 changes: 13 additions & 7 deletions meinberlin/assets/scss/components_user_facing/_control-bar.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -149,4 +149,26 @@

.search-profile__toggle-switch .toggle-switch__label {
margin-left: -1.25rem;
}
}

.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
}
12 changes: 10 additions & 2 deletions meinberlin/react/kiezradar/SearchProfile.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)

Expand All @@ -80,7 +88,7 @@ export default function SearchProfile ({ apiUrl, planListUrl, profile: profile_,
<div>
<h3 className="search-profile__title">{profile.name}</h3>
<ul className="search-profile__filters">
{filters.map((filter) => (
{selection.map((filter) => (
<li key={filter} className="search-profile__filter">{filter}</li>
))}
</ul>
Expand Down
141 changes: 141 additions & 0 deletions meinberlin/react/kiezradar/use-create-search-profile.jest.js
Original file line number Diff line number Diff line change
@@ -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)
})
})
Loading

0 comments on commit b3826a4

Please sign in to comment.