diff --git a/app/Filament/Forms/Resources/DataBindingMappingResource.php b/app/Filament/Forms/Resources/DataBindingMappingResource.php new file mode 100644 index 00000000..dab83d81 --- /dev/null +++ b/app/Filament/Forms/Resources/DataBindingMappingResource.php @@ -0,0 +1,182 @@ +user()?->hasRole('admin') ?? false; } + public static function canCreate(): bool { return self::canViewAny(); } + public static function canEdit(Model $record): bool { return self::canViewAny(); } + public static function canDelete(Model $record): bool { return self::canViewAny(); } + public static function canDeleteAny(): bool { return self::canViewAny(); } + + public static function form(Form $form): Form + { + return $form->schema([ + Section::make('Details')->columns(2)->schema([ + TextInput::make('label') + ->label('Label') + ->required() + ->maxLength(255), + + TextInput::make('endpoint') + ->label('Endpoint') + ->helperText('ICM Endpoint'), + + Textarea::make('description') + ->label('Description') + ->rows(3) + ->columnSpanFull(), + ]), + + Section::make('Binding')->columns(2)->schema([ + + // Data source dropdown which comes from Databinding Sources + Select::make('data_source') + ->label('Data source') + ->required() + ->searchable() + ->preload() + // options from form_data_sources.name + ->options(fn () => + FormDataSource::query() + ->orderBy('name') + ->pluck('name', 'name') + ->all() + ) + // keep JSONPath preview synced + ->live(debounce: 400) + ->afterStateUpdated(function (Set $set, $state, Get $get) { + $set('data_path', self::composeJsonPath( + (string) $state, + (string) $get('path_label') + )); + }) + // ensure chosen value exists in form_data_sources.name + ->rule(function () { + $table = (new FormDataSource)->getTable(); + return Rule::exists($table, 'name'); + }), + + // Path label: free text + dynamic datalist scoped by current data_source; preview updates on blur + DataBindingsHelper::pathLabelField( + sourceField: 'data_source', + sourceIsId: false, + targetPathField: 'data_path', + ), + + Grid::make(2)->schema([ + TextInput::make('data_path') + ->label('Data path') + ->disabled() + ->dehydrated(false) + ->helperText("Composed as: \$['{Data source}']['{Path label}']") + ->afterStateHydrated(function (Set $set, Get $get) { + $set('data_path', self::composeJsonPath( + (string) $get('data_source'), + (string) $get('path_label') + )); + }), + + TextInput::make('repeating_path') + ->label('Repeating path') + ->helperText("Repeating path for container element type e.g. $.['{Data source}'].[*]"), + ])->columnSpanFull(), + ]), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + TextColumn::make('label')->label('Label')->searchable()->sortable(), + TextColumn::make('description')->label('Description')->limit(40)->toggleable(), + TextColumn::make('data_source')->label('Data source')->sortable()->searchable(), + TextColumn::make('endpoint')->label('End point')->limit(40)->toggleable(), + TextColumn::make('path_label')->label('Path label')->searchable(), + TextColumn::make('data_path')->label('Data path')->wrap(), + TextColumn::make('repeating_path')->label('Repeating path')->wrap(), + TextColumn::make('updated_at')->label('Updated')->dateTime()->sortable()->toggleable(isToggledHiddenByDefault: true), + ]) + ->defaultSort('updated_at', 'desc') + ->filters([ + SelectFilter::make('data_source') + ->label('Data source') + ->multiple() + ->options( + DataBindingMapping::query() + ->whereNotNull('data_source') + ->distinct() + ->orderBy('data_source') + ->pluck('data_source', 'data_source') + ->all() + ), + ]) + ->actions([ + Tables\Actions\ViewAction::make(), + Tables\Actions\EditAction::make(), + ]) + ->bulkActions([ + Tables\Actions\DeleteBulkAction::make(), + ]); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListDataBindingMappings::route('/'), + 'create' => Pages\CreateDataBindingMapping::route('/create'), + 'view' => Pages\ViewDataBindingMapping::route('/{record}'), + 'edit' => Pages\EditDataBindingMapping::route('/{record}/edit'), + ]; + } + + // build JSONPath from source + label without mutating inputs + public static function composeJsonPath(?string $source, ?string $label): string + { + $s = trim((string) $source); + $l = trim((string) $label); + + if ($s === '' || $l === '') { + return ''; + } + + // normalise minimally for preview safety + $s = str_replace(['"', "\r", "\n"], ["'", ' ', ' '], $s); + $l = str_replace(['"', "\r", "\n"], ["'", ' ', ' '], $l); + + return "$.['{$s}'].['{$l}']"; + } +} diff --git a/app/Filament/Forms/Resources/DataBindingMappingResource/Pages/CreateDataBindingMapping.php b/app/Filament/Forms/Resources/DataBindingMappingResource/Pages/CreateDataBindingMapping.php new file mode 100644 index 00000000..9da0d306 --- /dev/null +++ b/app/Filament/Forms/Resources/DataBindingMappingResource/Pages/CreateDataBindingMapping.php @@ -0,0 +1,22 @@ +label('New Databinding Mapping') + ->icon('heroicon-m-plus') + ->authorize(fn () => DataBindingMappingResource::canCreate()), + ]; + } +} diff --git a/app/Filament/Forms/Resources/DataBindingMappingResource/Pages/ViewDataBindingMapping.php b/app/Filament/Forms/Resources/DataBindingMappingResource/Pages/ViewDataBindingMapping.php new file mode 100644 index 00000000..8afe935b --- /dev/null +++ b/app/Filament/Forms/Resources/DataBindingMappingResource/Pages/ViewDataBindingMapping.php @@ -0,0 +1,19 @@ +name ?? '') : ''; + }; + $compose = static function (string $src, string $label): string { + return ($src !== '' && $label !== '') + ? "$.['{$src}'].['{$label}']" + : ''; + }; + + $findRepeating = static function (string $src, string $label): ?string { + if ($src === '' || $label === '') { + return null; + } + /** @var ?string $value */ + $value = DataBindingMapping::query() + ->where('data_source', $src) + ->where('path_label', $label) + ->value('repeating_path'); + + return $value ?: null; + }; + // Build the repeater schema $repeater = Repeater::make('dataBindings') ->label('Data Bindings') @@ -76,16 +103,34 @@ public static function getDataBindingsSchema( ->required(!$disabled) ->disabled($disabled) ->live(onBlur: true), + // Path label + self::pathLabelField( + sourceField: 'form_data_source_id', + sourceIsId: true, + targetPathField: 'path', + targetRepeatingField: 'repeating_path', + ), TextInput::make('path') - ->label('Data Path') - ->when($shouldShowTooltipsCallback && $shouldShowTooltipsCallback(), function ($component) { - return $component->hintIcon('heroicon-m-question-mark-circle', tooltip: 'The full string referencing the ICM data'); + ->label('Data path') + ->disabled() + ->dehydrated(false) + ->helperText("Composed as: $.['{Data source}'].['{Path label}']") + // When the form loads (view/edit), compose once so users see the preview + ->afterStateHydrated(function (Set $set, Get $get) use ($resolveSource, $compose) { + $src = $resolveSource($get); + $label = (string) ($get('path_label') ?? ''); + $set('path', $compose($src, $label)); }) - ->required(!$disabled) - ->disabled($disabled) - ->autocomplete(false) - ->placeholder("$.['Contact'].['Birth Date']") - ->helperText('The path to the data field in the selected data source'), + ->reactive(), + // Repeating path current set up as a preview only, shown only when it exists + // possible todo: add a checking on form builder to check if the field is a repeatable container element + TextInput::make('repeating_path') + ->label('Repeating path') + ->disabled() + ->dehydrated(false) + ->reactive() + ->visible(fn (Get $get) => filled($get('repeating_path'))) + ->helperText("Repeating path for container element type e.g. $.['{Data source}'].[*]"), \Filament\Forms\Components\Textarea::make('condition') ->label('Condition') ->when($shouldShowTooltipsCallback && $shouldShowTooltipsCallback(), function ($component) { @@ -181,4 +226,121 @@ public static function getViewSchema( useRelationship: true ); } + + /** + * Reusable Path Label field. + * + * @param string $sourceField State key holding the source (id or name) + * @param bool $sourceIsId True if $sourceField stores form_data_sources.id + * @param string $targetPathField State key to receive composed JSONPath (e.g. 'path') + * @param string|null $targetRepeatingField Optional state key to receive repeating path (if any) + */ + public static function pathLabelField( + string $sourceField, + bool $sourceIsId = false, + string $targetPathField = 'path', + ?string $targetRepeatingField = null, + ): TextInput { + $resolveSource = function (Get $get) use ($sourceField, $sourceIsId): string { + $raw = $get($sourceField); + + if ($sourceIsId) { + $id = (int) $raw; + if (!$id) { + return ''; + } + + return (string) (FormDataSource::find($id)->name ?? ''); + } + + return (string) $raw; + }; + + $compose = static function (string $src, string $label): string { + return $src !== '' && $label !== '' ? "$.['{$src}'].['{$label}']" : ''; + }; + + $lookupRepeating = static function (string $src, string $label): ?string { + if ($src === '' || $label === '') { + return null; + } + $value = DataBindingMapping::query() + ->where('data_source', $src) + ->where('path_label', $label) + ->value('repeating_path'); + + return $value ?: null; + }; + + return TextInput::make('path_label') + ->label('Path label') + ->placeholder('e.g. First Name') + ->autocomplete(false) + ->required() + ->datalist(function (Get $get) use ($resolveSource): array { + $src = $resolveSource($get); + + if ($src === '') { + return DataBindingMapping::query() + ->whereNotNull('path_label') + ->select('path_label') + ->groupBy('path_label') + ->orderBy('path_label') + ->limit(30) + ->pluck('path_label') + ->all(); + } + + return DataBindingMapping::query() + ->where('data_source', $src) + ->whereNotNull('path_label') + ->select('path_label') + ->groupBy('path_label') + ->orderBy('path_label') + ->limit(50) + ->pluck('path_label') + ->all(); + }) + ->live(onBlur: true) + ->afterStateUpdated(function (Set $set, $state, Get $get) use ( + $resolveSource, + $compose, + $targetPathField, + $lookupRepeating, + $targetRepeatingField + ) { + $src = $resolveSource($get); + $label = (string) $state; + + // Compose preview path + if ($src !== '' && $label !== '') { + $set($targetPathField, $compose($src, $label)); + } else { + $set($targetPathField, ''); + } + + // Fill/clear repeating path if a target is provided + if ($targetRepeatingField !== null) { + $set($targetRepeatingField, $lookupRepeating($src, $label)); + } + }) + ->afterStateHydrated(function (Set $set, $state, Get $get) use ( + $resolveSource, + $compose, + $targetPathField, + $lookupRepeating, + $targetRepeatingField + ) { + $src = $resolveSource($get); + $label = (string) ($state ?? ''); + + if ($src !== '' && $label !== '') { + $set($targetPathField, $compose($src, $label)); + } + + if ($targetRepeatingField !== null) { + $set($targetRepeatingField, $lookupRepeating($src, $label)); + } + }); + } } diff --git a/app/Models/DataBindingMapping.php b/app/Models/DataBindingMapping.php new file mode 100644 index 00000000..2aa289c9 --- /dev/null +++ b/app/Models/DataBindingMapping.php @@ -0,0 +1,71 @@ + 'array', + ]; + + // Compose JSONPath from source + label + public static function composePath(string $source, string $label): string + { + $source = trim($source); + $label = trim($label); + + if ($source === '' || $label === '') { + return ''; + } + + return "$.['{$source}'].['{$label}']"; + } + + // Ensure data_path is always consistent before save + protected static function booted(): void + { + static::saving(function (self $model) { + $model->data_path = self::composePath( + (string) $model->data_source, + (string) $model->path_label + ); + }); + } + + /** + * Suggest distinct path labels for a given data source and search term. + * Uses LOWER(..) LIKE for portability & to let PostgreSQL use an index on LOWER(path_label). + */ + public function scopeSuggestPathLabels(Builder $q, ?string $source, string $term): Builder + { + $term = trim($term); + + if ($source !== null && $source !== '') { + $q->where('data_source', $source); + } + + if ($term !== '') { + $q->whereRaw('LOWER(path_label) LIKE ?', ['%' . mb_strtolower($term, 'UTF-8') . '%']); + } + + return $q->selectRaw('path_label, COUNT(*) as uses') + ->groupBy('path_label') + ->orderByDesc('uses') + ->orderBy('path_label'); + } +} diff --git a/app/Models/UserType.php b/app/Models/UserType.php new file mode 100644 index 00000000..e28b1eda --- /dev/null +++ b/app/Models/UserType.php @@ -0,0 +1,11 @@ +id(); + + // Required fields + $table->string('label'); // human readable title + $table->string('data_source'); // e.g. Contact + $table->string('path_label'); // e.g. First Name + + // Optional fields + $table->text('description')->nullable(); + $table->string('endpoint')->nullable(); // ICM endpoint (free text) + $table->string('repeating_path')->nullable(); // JSONPath for repeaters + + // Convenience (read-only in UI) + $table->string('data_path'); // $.['{data_source}'].['{path_label}'] + + $table->json('meta')->nullable(); + $table->timestamps(); + + // Helpful indexes + $table->index('data_source'); + + if (DB::getDriverName() !== 'pgsql') { + $table->unique(['data_source', 'path_label'], 'dbm_source_label_unique_fallback'); + } + }); + + // Postgres: create a functional unique index for case-insensitive uniqueness. + if (DB::getDriverName() === 'pgsql') { + DB::statement(<<<'SQL' + CREATE UNIQUE INDEX data_binding_mappings_source_label_lower_unique + ON data_binding_mappings (LOWER(data_source), LOWER(path_label)); + SQL); + } + } + + public function down(): void + { + if (Schema::hasTable('data_binding_mappings')) { + // Drop the functional index in Postgres if it exists + if (DB::getDriverName() === 'pgsql') { + DB::statement('DROP INDEX IF EXISTS data_binding_mappings_source_label_lower_unique;'); + } + + Schema::dropIfExists('data_binding_mappings'); + } + } +}; diff --git a/database/migrations/2025_08_18_230000_optimize_data_binding_mappings_indexes.php b/database/migrations/2025_08_18_230000_optimize_data_binding_mappings_indexes.php new file mode 100644 index 00000000..7e51c4fd --- /dev/null +++ b/database/migrations/2025_08_18_230000_optimize_data_binding_mappings_indexes.php @@ -0,0 +1,42 @@ +