Skip to content

Commit

Permalink
support custom fields on meetings (#1123)
Browse files Browse the repository at this point in the history
* support custom fields on meetings

* add openapi annotation and regenerate doc

* Pj/custom fields UI (#1127)

* add tests for partial update

* simplify mapWithKeys

* remove isVisible

---------

Co-authored-by: pjaudiomv <34245618+pjaudiomv@users.noreply.github.com>
  • Loading branch information
jbraswell and pjaudiomv authored Nov 9, 2024
1 parent 650a62b commit 703914c
Show file tree
Hide file tree
Showing 19 changed files with 621 additions and 160 deletions.
28 changes: 28 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

42 changes: 38 additions & 4 deletions src/app/Http/Controllers/Admin/MeetingController.php
Original file line number Diff line number Diff line change
Expand Up @@ -88,13 +88,18 @@ public function partialUpdate(Request $request, Meeting $meeting)
->toBase()
->merge($meeting->longdata->mapWithKeys(fn ($data, $_) => [$data->key => $data->data_blob])->toBase());

$customFields = $this->getCustomFields();
$stockDataFields = $this->getDataTemplates()
->reject(fn ($_, $key) => $customFields->contains($key))
->map(fn ($_, $key) => $key);

// Since a patch is only a partial representation of the meeting, we fill in all of the gaps
// with data from the actual meeting. This allows us to share code with the store and update
// (POST and PUT) handlers.
$request->merge(
collect(Meeting::$mainFields)
->merge($this->getDataTemplates()->map(fn ($_, $key) => $key))
->mapWithKeys(function ($fieldName, $_) use ($request, $meeting, $meetingData) {
->merge($stockDataFields)
->mapWithKeys(function ($fieldName) use ($request, $meeting, $meetingData) {
if ($fieldName == 'service_body_bigint') {
return ['serviceBodyId' => $request->has('serviceBodyId') ? $request->input('serviceBodyId') : $meeting->service_body_bigint];
} elseif ($fieldName == 'formats') {
Expand Down Expand Up @@ -123,6 +128,14 @@ public function partialUpdate(Request $request, Meeting $meeting)
return [$fieldName => $request->has($fieldName) ? $request->input($fieldName) : $meetingData->get($fieldName)];
}
})
->merge([
'customFields' => collect($request->input('customFields', []))
->keys()
->concat($customFields->toArray())
->unique()
->mapWithKeys(fn ($fieldName) => [$fieldName => $request->input('customFields', [])[$fieldName] ?? $meetingData->get($fieldName)])
->toArray()
])
->toArray()
);

Expand All @@ -147,6 +160,15 @@ private function getDataTemplates(): Collection
return $dataTemplates;
}

private function getCustomFields(): Collection
{
static $customFields = null;
if (is_null($customFields) || App::runningUnitTests()) {
$customFields = $this->meetingRepository->getCustomFields();
}
return $customFields;
}

private function validateInputs(Request $request)
{
return collect($request->validate(
Expand All @@ -172,15 +194,20 @@ private function validateInputs(Request $request)

private function getDataFieldValidators(): array
{
$customFields = $this->getCustomFields();
return $this->getDataTemplates()
->reject(fn ($_, $fieldName) => $fieldName == 'meeting_name')
->reject(fn ($_, $fieldName) => $fieldName == 'meeting_name' || $customFields->contains($fieldName))
->mapWithKeys(function ($_, $fieldName) {
if (in_array($fieldName, VenueTypeLocation::FIELDS)) {
return [$fieldName => ['max:512', new VenueTypeLocation]];
} else {
return [$fieldName => 'nullable|string|max:512'];
}
})
->merge([
'customFields' => 'array:' . $this->getCustomFields()->join(','),
'customFields.*' => 'nullable|string|max:512',
])
->toArray();
}

Expand Down Expand Up @@ -245,13 +272,20 @@ private function buildValuesArray(Collection $validated): array
'meeting_name' => $validated['name'],
];

$customFields = $this->getCustomFields();

return collect($values)
->merge(
$this->getDataTemplates()
->reject(fn ($_, $fieldName) => $fieldName == 'meeting_name')
->reject(fn ($_, $fieldName) => $fieldName == 'meeting_name' || $customFields->contains($fieldName))
->mapWithKeys(fn ($_, $fieldName) => $validated->has($fieldName) ? [$fieldName => $validated[$fieldName]] : [null => null])
->reject(fn ($value, $_) => is_null($value))
)
->merge(
$customFields
->mapWithKeys(fn ($fieldName) => [$fieldName => $validated->get('customFields')[$fieldName] ?? null])
->reject(fn ($value, $_) => is_null($value))
)
->toArray();
}
}
3 changes: 3 additions & 0 deletions src/app/Http/Controllers/Admin/Swagger/MeetingController.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@
* @OA\Property(property="bus_lines", type="string", example="string"),
* @OA\Property(property="train_lines", type="string", example="string"),
* @OA\Property(property="comments", type="string", example="string"),
* @OA\Property(property="customFields", type="object", example={"key1": "value1", "key2": "value2"},
* @OA\AdditionalProperties(type="string")
* ),
* ),
* @OA\Schema(schema="Meeting", required={"id", "serviceBodyId", "formatIds", "venueType", "temporarilyVirtual", "day", "startTime", "duration", "timeZone", "latitude", "longitude", "published", "email", "worldId", "name"},
* @OA\Property(property="id", type="integer", example="0"),
Expand Down
17 changes: 17 additions & 0 deletions src/app/Http/Controllers/CatchAllController.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@

use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Collection;
use App\Http\Controllers\Legacy\LegacyPathInfo;
use App\Interfaces\MeetingRepositoryInterface;

class CatchAllController extends Controller
{
Expand Down Expand Up @@ -42,6 +44,7 @@ public static function handle(Request $request): Response
'centerLatitude' => legacy_config('search_spec_map_center_latitude'),
'centerZoom' => legacy_config('search_spec_map_center_zoom'),
'countyAutoGeocodingEnabled' => legacy_config('county_auto_geocoding_enabled'),
'customFields' => self::getCustomFields(),
'defaultClosedStatus' => legacy_config('default_closed_status'),
'defaultDuration' => legacy_config('default_duration_time'),
'defaultLanguage' => legacy_config('language'),
Expand Down Expand Up @@ -79,6 +82,20 @@ private static function getLanguageMapping(): array
->toArray();
}

private static function getCustomFields(): Collection
{
$meetingRepository = resolve(MeetingRepositoryInterface::class);
$customFields = $meetingRepository->getCustomFields();
return $meetingRepository->getDataTemplates()
->reject(fn ($t) => !$customFields->contains($t->key))
->map(fn ($t) => [
'name' => $t->key,
'displayName' => $t->field_prompt,
'language' => $t->lang_enum
])
->values();
}

private static function isAllowedLegacyPath(LegacyPathInfo $pathInfo): bool
{
foreach (self::$allowedLegacyPathEndings as $allowedPathEnding) {
Expand Down
49 changes: 32 additions & 17 deletions src/app/Http/Resources/Admin/MeetingResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ class MeetingResource extends JsonResource
{
private static bool $isRequestInitialized = false;
private static ?Collection $dataTemplates = null;
private static ?Collection $customFields = null;
private static ?Collection $formatsById = null;
private static ?int $virtualFormatId = null;
private static ?int $hybridFormatId = null;
Expand All @@ -22,6 +23,7 @@ public static function resetStaticVariables()
{
self::$isRequestInitialized = false;
self::$dataTemplates = null;
self::$customFields = null;
self::$formatsById = null;
self::$virtualFormatId = null;
self::$hybridFormatId = null;
Expand All @@ -36,6 +38,7 @@ public function toArray($request)
self::$dataTemplates = $meetingRepository
->getDataTemplates()
->reject(fn ($template, $_) => $template->key == 'meeting_name');
self::$customFields = $meetingRepository->getCustomFields();
$formatRepository = new FormatRepository();
self::$formatsById = $formatRepository->getAsTranslations()->mapWithKeys(fn ($fmt) => [$fmt->shared_id_bigint => $fmt]);
self::$virtualFormatId = $formatRepository->getVirtualFormat()->shared_id_bigint;
Expand All @@ -59,22 +62,34 @@ public function toArray($request)
->reject(fn ($id) => !self::$formatsById->has($id))
->sort();

return array_merge([
'id' => $this->id_bigint,
'serviceBodyId' => $this->service_body_bigint,
'formatIds' => $formatIds->reject(fn ($id) => self::$hiddenFormatIds->contains($id))->toArray(),
'venueType' => $this->venue_type,
'temporarilyVirtual' => $this->venue_type == Meeting::VENUE_TYPE_VIRTUAL && $formatIds->contains(self::$temporarilyClosedFormatId),
'day' => $this->weekday_tinyint,
'startTime' => is_null($this->start_time) ? null : (\DateTime::createFromFormat('H:i:s', $this->start_time) ?: \DateTime::createFromFormat('H:i', $this->start_time))->format('H:i'),
'duration' => is_null($this->duration_time) ? null : (\DateTime::createFromFormat('H:i:s', $this->duration_time) ?: \DateTime::createFromFormat('H:i', $this->duration_time))->format('H:i'),
'timeZone' => $this->time_zone ?: null,
'latitude' => $this->latitude ?? null,
'longitude' => $this->longitude ?? null,
'published' => $this->published === 1,
'email' => $this->email_contact ?: null,
'worldId' => $this->worldid_mixed ?: null,
'name' => $meetingData->get('meeting_name') ?: null,
], self::$dataTemplates->mapWithKeys(fn ($t, $_) => [$t->key => $meetingData->get($t->key) ?: null])->toArray());
return array_merge(
[
'id' => $this->id_bigint,
'serviceBodyId' => $this->service_body_bigint,
'formatIds' => $formatIds->reject(fn ($id) => self::$hiddenFormatIds->contains($id))->toArray(),
'venueType' => $this->venue_type,
'temporarilyVirtual' => $this->venue_type == Meeting::VENUE_TYPE_VIRTUAL && $formatIds->contains(self::$temporarilyClosedFormatId),
'day' => $this->weekday_tinyint,
'startTime' => is_null($this->start_time) ? null : (\DateTime::createFromFormat('H:i:s', $this->start_time) ?: \DateTime::createFromFormat('H:i', $this->start_time))->format('H:i'),
'duration' => is_null($this->duration_time) ? null : (\DateTime::createFromFormat('H:i:s', $this->duration_time) ?: \DateTime::createFromFormat('H:i', $this->duration_time))->format('H:i'),
'timeZone' => $this->time_zone ?: null,
'latitude' => $this->latitude ?? null,
'longitude' => $this->longitude ?? null,
'published' => $this->published === 1,
'email' => $this->email_contact ?: null,
'worldId' => $this->worldid_mixed ?: null,
'name' => $meetingData->get('meeting_name') ?: null,
],
self::$dataTemplates
->reject(fn ($t, $_) => self::$customFields->contains($t->key))
->mapWithKeys(fn ($t, $_) => [$t->key => $meetingData->get($t->key) ?: null])
->toArray(),
[
'customFields' => self::$dataTemplates
->reject(fn ($t, $_) => !self::$customFields->contains($t->key))
->mapWithKeys(fn ($t, $_) => [$t->key => $meetingData->get($t->key) ?: null])
->toArray()
],
);
}
}
1 change: 1 addition & 0 deletions src/app/Interfaces/MeetingRepositoryInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ public function getSearchResults(
public function getFieldKeys(): Collection;
public function getFieldValues(string $fieldName, array $specificFormats = [], bool $allFormats = false): Collection;
public function getMainFields(): Collection;
public function getCustomFields(): Collection;
public function getDataTemplates(): Collection;
public function getBoundingBox(): array;
public function create(array $values): Meeting;
Expand Down
10 changes: 10 additions & 0 deletions src/app/Repositories/MeetingRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -511,6 +511,16 @@ public function getMainFields(): Collection
return collect(Meeting::$mainFields);
}

public function getCustomFields(): Collection
{
return MeetingData::query()
->select('key')
->distinct()
->where('meetingid_bigint', 0)
->whereNotIn('key', MeetingData::STOCK_FIELDS)
->pluck('key');
}

public function getDataTemplates(): Collection
{
$serverLanguage = App::currentLocale();
Expand Down
8 changes: 4 additions & 4 deletions src/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@
"@googlemaps/js-api-loader": "^1.16.8",
"@turf/boolean-point-in-polygon": "^7.1.0",
"@turf/helpers": "^7.1.0",
"bmlt-root-server-client": "^1.1.9",
"bmlt-root-server-client": "^1.2.5",
"felte": "^1.2.14",
"geobuf": "^3.0.2",
"leaflet": "^1.9.4",
Expand Down
22 changes: 19 additions & 3 deletions src/resources/js/components/MeetingEditForm.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,10 @@
contactEmail2: selectedMeeting?.contactEmail2 ?? '',
busLines: selectedMeeting?.busLines ?? '',
trainLines: selectedMeeting?.trainLines ?? '',
comments: selectedMeeting?.comments ?? ''
comments: selectedMeeting?.comments ?? '',
customFields: selectedMeeting?.customFields
? { ...Object.fromEntries(globalSettings.customFields.map((field) => [field.name, ''])), ...selectedMeeting.customFields }
: Object.fromEntries(globalSettings.customFields.map((field) => [field.name, '']))
};
let latitude = initialValues.latitude;
let longitude = initialValues.longitude;
Expand Down Expand Up @@ -196,7 +199,6 @@
initialValues: initialValues,
onSubmit: async (values) => {
spinner.show();
const isNewMeeting = !selectedMeeting;
if (shouldGeocode(initialValues, values, isNewMeeting)) {
if (globalSettings.autoGeocodingEnabled && !manualDrag) {
Expand Down Expand Up @@ -258,7 +260,8 @@
contactEmail2: (error?.errors?.contact_email_2 ?? []).join(' '),
busLines: (error?.errors?.bus_lines ?? []).join(' '),
trainLines: (error?.errors?.train_lines ?? []).join(' '),
comments: (error?.errors?.comments ?? []).join(' ')
comments: (error?.errors?.comments ?? []).join(' '),
customFields: error?.errors?.customFields ? Object.fromEntries(Object.entries(error.errors.customFields).map(([key, value]) => [key, Array.isArray(value) ? value.join(' ') : value])) : {}
});
}
});
Expand Down Expand Up @@ -984,6 +987,19 @@
{/if}
</div>
</div>
{#each globalSettings.customFields as { name, displayName }}
<div class="grid gap-4 md:grid-cols-2">
<div class="md:col-span-2">
<Label for={name} class="mb-2">{displayName}</Label>
<Input type="text" id={name} name={$data.customFields[name]} bind:value={$data.customFields[name]} />
{#if $errors.customFields?.[name]}
<Helper class="mt-2" color="red">
{$errors.customFields[name]}
</Helper>
{/if}
</div>
</div>
{/each}
</div>
<div slot="tab-content-3">
{#if changesLoaded && changes.length > 0}
Expand Down
1 change: 1 addition & 0 deletions src/resources/js/globals.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ declare const settings: {
centerLatitude: number;
centerZoom: number;
countyAutoGeocodingEnabled: boolean;
customFields: { name: string; displayName: string; language: string }[];
defaultClosedStatus: boolean;
defaultDuration: string;
defaultLanguage: string;
Expand Down
1 change: 1 addition & 0 deletions src/resources/js/tests/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ global.settings = {
centerLatitude: 34.235918,
centerZoom: 6,
countyAutoGeocodingEnabled: false,
customFields: [{ name: 'zone', displayName: 'zone', language: 'en' }],
defaultClosedStatus: true,
defaultDuration: '01:00:00',
defaultLanguage: 'en',
Expand Down
1 change: 1 addition & 0 deletions src/resources/views/frontend.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
centerLatitude: '{{ $centerLatitude }}',
centerZoom: '{{ $centerZoom }}',
countyAutoGeocodingEnabled: {{ $countyAutoGeocodingEnabled ? 'true' : 'false' }},
customFields: {!! json_encode($customFields) !!},
defaultClosedStatus: {{ $defaultClosedStatus ? 'true' : 'false' }},
defaultDuration: '{{ $defaultDuration }}',
defaultLanguage: '{{ $defaultLanguage }}',
Expand Down
10 changes: 10 additions & 0 deletions src/storage/api-docs/api-docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -2478,6 +2478,16 @@
"comments": {
"type": "string",
"example": "string"
},
"customFields": {
"type": "object",
"example": {
"key1": "value1",
"key2": "value2"
},
"additionalProperties": {
"type": "string"
}
}
},
"type": "object"
Expand Down
Loading

0 comments on commit 703914c

Please sign in to comment.