Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[#8661] Add 'Save Profile' button #5940

Merged
merged 1 commit into from
Jan 20, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@goapunk any idea if the district polygons are used anywhere outside the plans, anything to do with bplans? Since @sevfurneaux is removing these methods, would be good to double check with you as well.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sevfurneaux the map on the project list will not be highlighting the district borders any more? If that's the case I think removing this is fine

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:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AFAIK no need for this check, as the view is allowed only to registered users. Same in the get_search_profile method above

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yup, verified, we don't need these authentication checks, they are inherited from adhocracy4 as a mixin in this view class declaration above.

Copy link
Collaborator Author

@sevfurneaux sevfurneaux Jan 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great, thanks. But this view is for the plans map (/projects) page, so users can be unauthenticated here, so we'll still need it?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

agreed, as this page can be viewed by anyone I think we need the check

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