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

[V2] Search & filtering #55

Draft
wants to merge 18 commits into
base: version-2
Choose a base branch
from
Draft
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
2 changes: 2 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -99,3 +99,5 @@ gem "ruby-lsp-rspec", "~> 0.1.12", group: :development, require: false
gem "bullet", "~> 7.2", group: [:development, :test]

gem "friendly_id", "~> 5.5"

gem "filterameter", "~> 1.0"
3 changes: 3 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,8 @@ GEM
railties (>= 5.0.0)
faker (3.4.2)
i18n (>= 1.8.11, < 2)
filterameter (1.0.0)
rails (>= 6.1)
friendly_id (5.5.1)
activerecord (>= 4.0.0)
globalid (1.2.1)
Expand Down Expand Up @@ -379,6 +381,7 @@ DEPENDENCIES
debug
factory_bot_rails (~> 6.4)
faker (~> 3.3)
filterameter (~> 1.0)
friendly_id (~> 5.5)
inertia_rails (~> 3.1)
jbuilder
Expand Down
22 changes: 17 additions & 5 deletions app/controllers/companies_controller.rb
Original file line number Diff line number Diff line change
@@ -1,23 +1,35 @@
# frozen_string_literal: true

class CompaniesController < InertiaController
include Filterameter::DeclarativeFilters
include Pagy::Backend

default_sort name: :asc

filter :name, partial: true
filter :continent, name: :slug, association: :continent
filter :country, name: :slug, association: :country
filter :region, name: :slug, association: :region
filter :city, name: :slug, association: :city

def index
pagy, companies = pagy(company_scope.all)
companies = build_query_from_filters(Company.with_all_associations)
pagy, companies = pagy(companies)
@companies = CompanySerializer.many(companies)
@pagination = inertia_pagination(pagy)

paginate(pagy)
@filters = filter_params.slice(:name, :continent, :country, :region, :city).compact_blank
@options = CompanyOptionsSerializer.render(filter_params)
end

def show
company = company_scope.friendly.find(params[:id])
company = Company.with_all_associations.friendly.find(params[:id])
@company = CompanySerializer.one(company)
end

private

def company_scope
Company.includes(:technologies, :continent, :country, :region, :city)
def filter_params
params.fetch(:filter, {}).permit(:name, :continent, :country, :region, :city)
end
end
4 changes: 2 additions & 2 deletions app/controllers/inertia_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ def set_default_inertia_rendering
inertia_render(inertia: true) unless performed?
end

def paginate(pagy)
def inertia_pagination(pagy)
pagination = pagy_metadata(pagy)
@pagination = PaginationSerializer.render(pagination)
PaginationSerializer.render(pagination)
end
end
95 changes: 95 additions & 0 deletions app/frontend/components/combobox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
"use client";

import * as React from "react";
import { Check, ChevronsUpDown } from "lucide-react";

import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";

import { cn } from "@/utils/ui";

import type { Option } from "@/types/fragments/option";

interface ComboboxProps {
options: Option[];
value?: string;
onChange: (value: string) => void;
placeholder?: string;
emptyMessage?: string;
searchPlaceholder?: string;
disabled?: boolean;
}

function Combobox({
options,
value,
onChange,
placeholder = "Select option...",
emptyMessage = "No option found.",
searchPlaceholder = "Search option...",
disabled = false,
}: ComboboxProps) {
const [open, setOpen] = React.useState(false);

const selectedOption = options.find((option) => option.value === value);
const displayValue = selectedOption ? selectedOption.label : placeholder;

return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-[200px] justify-between"
disabled={disabled}
>
{displayValue}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0">
<Command>
<CommandInput placeholder={searchPlaceholder} />
<CommandList>
<CommandEmpty>{emptyMessage}</CommandEmpty>
<CommandGroup>
{options.map((option) => (
<CommandItem
key={option.value}
value={option.value}
onSelect={(currentValue) => {
onChange(currentValue === value ? "" : currentValue);
setOpen(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
value === option.value ? "opacity-100" : "opacity-0",
)}
/>
{option.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

export default Combobox;
6 changes: 5 additions & 1 deletion app/frontend/components/data_table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,20 @@ import {
TableRow,
} from "@/components/ui/table";

import { cn } from "@/utils/ui";

interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
pagination: PaginatorProps;
className?: string;
}

export function DataTable<TData, TValue>({
columns,
data,
pagination,
className,
}: DataTableProps<TData, TValue>) {
const table = useReactTable({
data,
Expand All @@ -38,7 +42,7 @@ export function DataTable<TData, TValue>({

return (
<>
<div className="rounded-md border">
<div className={cn(className)}>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
Expand Down
28 changes: 19 additions & 9 deletions app/frontend/components/external_link.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,32 @@
import { Link, type LucideProps } from "lucide-react";
import { Link as LinkIcon, type LucideProps } from "lucide-react";

import { Button } from "@/components/ui/button";

import { cn } from "@/utils/ui";

interface Props {
href: string;
children: React.ReactNode;
icon?: React.ForwardRefExoticComponent<
Omit<LucideProps, "ref"> & React.RefAttributes<SVGSVGElement>
>;
> | null;
className?: string;
}

function ExternalLink({ href, children, icon }: Props) {
const Icon = icon || Link;
function ExternalLink({ href, children, icon, className }: Props) {
const Icon = icon || LinkIcon;

return (
<div className="flex items-center">
<Icon className="mr-1.5 h-5 w-5 flex-shrink-0 text-gray-400" />
<a href={href} target="_blank" rel="noopener noreferrer">
{children}
</a>
<div className={cn("inline-flex items-center", className)}>
{icon !== null && (
<Icon className="mr-0.5 h-5 w-5 flex-shrink-0 text-gray-400" />
)}

<Button variant="link" asChild className="px-0 py-0">
<a href={href} target="_blank" rel="noopener noreferrer">
{children}
</a>
</Button>
</div>
);
}
Expand Down
17 changes: 17 additions & 0 deletions app/frontend/components/link.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { Link as InertiaLink, InertiaLinkProps } from "@inertiajs/react";

import { Button } from "@/components/ui/button";

import { cn } from "@/utils/ui";

interface Props extends InertiaLinkProps {}

function Link({ children, className, ...props }: Props) {
return (
<Button variant="link" asChild className={cn("px-0", "py-0", className)}>
<InertiaLink {...props}>{children}</InertiaLink>
</Button>
);
}

export default Link;
26 changes: 13 additions & 13 deletions app/frontend/components/ui/button.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { type VariantProps, cva } from "class-variance-authority";

import { cn } from "@/utils/ui"
import { cn } from "@/utils/ui";

const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
Expand Down Expand Up @@ -30,27 +30,27 @@ const buttonVariants = cva(
variant: "default",
size: "default",
},
}
)
},
);

export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
asChild?: boolean;
}

const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
);
},
);
Button.displayName = "Button";

export { Button, buttonVariants }
export { Button, buttonVariants };
88 changes: 88 additions & 0 deletions app/frontend/components/ui/card.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import * as React from "react";

import { cn } from "@/utils/ui";

const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className,
)}
{...props}
/>
));
Card.displayName = "Card";

const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
));
CardHeader.displayName = "CardHeader";

const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, children, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className,
)}
{...props}
>
{children}
</h3>
));
CardTitle.displayName = "CardTitle";

const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
CardDescription.displayName = "CardDescription";

const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
));
CardContent.displayName = "CardContent";

const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
));
CardFooter.displayName = "CardFooter";

export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardDescription,
CardContent,
};
Loading