├── .gitignore ├── packages ├── base │ ├── constants.ts │ ├── makeStyles.ts │ ├── FilterBuilder │ │ ├── components │ │ │ ├── FilterBuilder.tsx │ │ │ ├── FilterGroup.tsx │ │ │ ├── FilterCondition.tsx │ │ │ ├── FilterRoot.tsx │ │ │ └── FilterInputs.tsx │ │ ├── state.ts │ │ ├── translation.ts │ │ ├── constants.ts │ │ ├── utils.ts │ │ ├── types.ts │ │ └── hooks.ts │ ├── hooks.ts │ ├── types.ts │ ├── utils.ts │ └── components │ │ └── ODataGridBase.tsx ├── o-data-grid-pro │ ├── tsconfig.json │ ├── .eslintrc.json │ ├── .npmignore │ ├── dev │ │ ├── index.tsx │ │ ├── index.html │ │ └── App.tsx │ ├── src │ │ ├── ODataGridProProps.ts │ │ ├── ODataGridPro.tsx │ │ └── index.ts │ ├── package.json │ └── pnpm-lock.yaml ├── o-data-grid │ ├── tsconfig.json │ ├── .eslintrc.json │ ├── .npmignore │ ├── dev │ │ ├── index.tsx │ │ ├── index.html │ │ └── App.tsx │ ├── src │ │ ├── ODataGridProps.ts │ │ ├── ODataGrid.tsx │ │ └── index.ts │ ├── .pnpm-debug.log │ ├── package.json │ └── pnpm-lock.yaml ├── webpack.o-data-grid-pro.config.js ├── tsconfig.json ├── webpack.o-data-grid.config.js ├── .eslintrc.json ├── rollup.o-data-grid.config.js ├── rollup.o-data-grid-pro.config.js └── package.json ├── images └── o-data-grid.png ├── docs └── api │ ├── appsettings.Development.json │ ├── Models │ ├── Product.cs │ ├── OrderProduct.cs │ ├── Order.cs │ ├── Customer.cs │ └── Address.cs │ ├── Controllers │ ├── OrderController.cs │ ├── CustomerController.cs │ ├── ProductController.cs │ └── ODataBaseController.cs │ ├── Data │ ├── FakerExtensions.cs │ ├── ODataModelBuilder.cs │ ├── SeederService.cs │ ├── ApiContext.cs │ └── Seeder.cs │ ├── appsettings.json │ ├── Dockerfile.dev │ ├── Dockerfile │ ├── Directory.Build.props │ ├── Migrations │ ├── 20230325170658_OrderDate.cs │ ├── 20230325165107_InitialCreate.cs │ ├── 20230325165107_InitialCreate.Designer.cs │ ├── ApiContextModelSnapshot.cs │ └── 20230325170658_OrderDate.Designer.cs │ ├── api.csproj │ ├── Properties │ └── launchSettings.json │ ├── docker-compose-prod.yml │ ├── docker-compose.yml │ ├── .vscode │ ├── tasks.json │ └── launch.json │ ├── Program.cs │ ├── init-data │ └── initdb.d │ │ └── 10-schema.sql │ └── .gitignore └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | dev-build -------------------------------------------------------------------------------- /packages/base/constants.ts: -------------------------------------------------------------------------------- 1 | export const defaultPageSize = 10; -------------------------------------------------------------------------------- /packages/o-data-grid-pro/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json" 3 | } -------------------------------------------------------------------------------- /packages/o-data-grid/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json" 3 | } 4 | -------------------------------------------------------------------------------- /images/o-data-grid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamerst/o-data-grid/HEAD/images/o-data-grid.png -------------------------------------------------------------------------------- /packages/o-data-grid/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../.eslintrc.json", 3 | "ignorePatterns": ["build/*", "dev-build/*"] 4 | } -------------------------------------------------------------------------------- /packages/o-data-grid/.npmignore: -------------------------------------------------------------------------------- 1 | dev/ 2 | dev-build/ 3 | src/ 4 | .eslintrc.json 5 | pnpm-lock.yaml 6 | .pnpm-debug.log 7 | tsconfig.json -------------------------------------------------------------------------------- /packages/o-data-grid-pro/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../.eslintrc.json", 3 | "ignorePatterns": ["build/*", "dev-build/*"] 4 | } -------------------------------------------------------------------------------- /packages/o-data-grid-pro/.npmignore: -------------------------------------------------------------------------------- 1 | dev/ 2 | dev-build/ 3 | src/ 4 | .eslintrc.json 5 | pnpm-lock.yaml 6 | .pnpm-debug.log 7 | tsconfig.json -------------------------------------------------------------------------------- /docs/api/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /packages/base/makeStyles.ts: -------------------------------------------------------------------------------- 1 | import { createMakeStyles } from "tss-react"; 2 | import { useTheme } from "@mui/material"; 3 | 4 | export const { makeStyles } = createMakeStyles({ useTheme }); 5 | 6 | export default makeStyles; -------------------------------------------------------------------------------- /packages/o-data-grid/dev/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import ReactDOM from "react-dom" 3 | import App from "./App" 4 | 5 | ReactDOM.render( 6 | 7 | 8 | , 9 | document.getElementById("root") 10 | ) -------------------------------------------------------------------------------- /docs/api/Models/Product.cs: -------------------------------------------------------------------------------- 1 | namespace Api.Models; 2 | public class Product 3 | { 4 | [DatabaseGenerated(DatabaseGeneratedOption.Identity)] 5 | public int Id { get; set; } 6 | public required string Name { get; set; } 7 | public decimal Price { get; set; } 8 | } -------------------------------------------------------------------------------- /packages/o-data-grid-pro/dev/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import ReactDOM from "react-dom" 3 | import App from "./App" 4 | 5 | ReactDOM.render( 6 | 7 | 8 | , 9 | document.getElementById("root") 10 | ) -------------------------------------------------------------------------------- /docs/api/Controllers/OrderController.cs: -------------------------------------------------------------------------------- 1 | using Api.Data; 2 | using Api.Models; 3 | 4 | namespace Api.Controllers; 5 | 6 | public class OrderController : ODataBaseController 7 | { 8 | public OrderController(ApiContext context) : base(context) { } 9 | } 10 | -------------------------------------------------------------------------------- /docs/api/Controllers/CustomerController.cs: -------------------------------------------------------------------------------- 1 | using Api.Data; 2 | using Api.Models; 3 | 4 | namespace Api.Controllers; 5 | 6 | public class CustomerController : ODataBaseController 7 | { 8 | public CustomerController(ApiContext context) : base(context) { } 9 | } 10 | -------------------------------------------------------------------------------- /docs/api/Controllers/ProductController.cs: -------------------------------------------------------------------------------- 1 | using Api.Data; 2 | using Api.Models; 3 | 4 | namespace Api.Controllers; 5 | 6 | public class ProductController : ODataBaseController 7 | { 8 | public ProductController(ApiContext context) : base(context) { } 9 | } 10 | -------------------------------------------------------------------------------- /docs/api/Data/FakerExtensions.cs: -------------------------------------------------------------------------------- 1 | using Bogus; 2 | 3 | namespace Api.Data; 4 | 5 | public static class FakerExtensions 6 | { 7 | public static T PickRandomParam(this Faker faker, out int index, params T[] items) 8 | { 9 | index = faker.Random.Int(0, items.Length - 1); 10 | return items[index]; 11 | } 12 | } -------------------------------------------------------------------------------- /packages/o-data-grid-pro/dev/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ODataGrid 6 | 7 | 8 | 9 |
10 | 11 | 12 | -------------------------------------------------------------------------------- /packages/o-data-grid/dev/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ODataGrid 6 | 7 | 8 | 9 |
10 | 11 | 12 | -------------------------------------------------------------------------------- /docs/api/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "ConnectionStrings": { 3 | "DefaultConnection": "Host=db; Database=o-data-grid-demo_db; Username=o-data-grid; Password=o-data-grid" 4 | }, 5 | "Logging": { 6 | "LogLevel": { 7 | "Default": "Information", 8 | "Microsoft.AspNetCore": "Warning" 9 | } 10 | }, 11 | "AllowedHosts": "*" 12 | } 13 | -------------------------------------------------------------------------------- /docs/api/Dockerfile.dev: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/dotnet/sdk:7.0 AS build-env 2 | WORKDIR /app 3 | 4 | # install Visual Studio debugger 5 | RUN curl -sSL https://aka.ms/getvsdbgsh | /bin/sh /dev/stdin -v latest -l /vsdbg 6 | 7 | # Copy csproj and restore as distinct layers 8 | COPY *.csproj ./ 9 | RUN dotnet restore 10 | 11 | ENTRYPOINT dotnet watch run --urls=https://+:5000 -------------------------------------------------------------------------------- /packages/o-data-grid/src/ODataGridProps.ts: -------------------------------------------------------------------------------- 1 | import { ODataGridBaseProps, ODataRowModel } from "../../base/types"; 2 | import { DataGridProps, GridColDef, GridSortModel } from "@mui/x-data-grid"; 3 | 4 | export type ODataGridProps = Omit< 5 | ODataGridBaseProps>, GridSortModel, GridColDef>, TRow, TDate>, 6 | "component" 7 | >; -------------------------------------------------------------------------------- /packages/o-data-grid/src/ODataGrid.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { ODataGridProps } from "./ODataGridProps" 3 | import ODataGridBase from "../../base/components/ODataGridBase" 4 | import { DataGrid } from "@mui/x-data-grid" 5 | 6 | const ODataGrid = (props: ODataGridProps) => ( 7 | 11 | ) 12 | 13 | export default ODataGrid; -------------------------------------------------------------------------------- /docs/api/Models/OrderProduct.cs: -------------------------------------------------------------------------------- 1 | namespace Api.Models; 2 | public class OrderProduct 3 | { 4 | [DatabaseGenerated(DatabaseGeneratedOption.Identity)] 5 | public int Id { get; set; } 6 | public int OrderId { get; set; } 7 | public required Order Order { get; set; } 8 | public int ProductId { get; set; } 9 | public required Product Product { get; set; } 10 | public int Quantity { get; set; } 11 | } -------------------------------------------------------------------------------- /packages/o-data-grid-pro/src/ODataGridProProps.ts: -------------------------------------------------------------------------------- 1 | import { ODataGridBaseProps, ODataRowModel } from "../../base/types"; 2 | import { DataGridProProps, GridColDef, GridSortModel } from "@mui/x-data-grid-pro"; 3 | 4 | export type ODataGridProProps = Omit< 5 | ODataGridBaseProps>, GridSortModel, GridColDef>, TRow, TDate>, 6 | "component" 7 | >; -------------------------------------------------------------------------------- /packages/o-data-grid-pro/src/ODataGridPro.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { ODataGridProProps } from "./ODataGridProProps" 3 | import ODataGridBase from "../../base/components/ODataGridBase" 4 | import { DataGridPro } from "@mui/x-data-grid-pro" 5 | 6 | const ODataGridPro = (props: ODataGridProProps) => ( 7 | 11 | ) 12 | 13 | export default ODataGridPro; -------------------------------------------------------------------------------- /docs/api/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/dotnet/sdk:7.0 AS build-env 2 | WORKDIR /app 3 | 4 | # Copy csproj and restore as distinct layers 5 | COPY *.csproj ./ 6 | RUN dotnet restore 7 | 8 | # Copy everything else and build 9 | ADD . ./ 10 | 11 | RUN dotnet publish -c Release -o out 12 | 13 | # Build runtime image 14 | FROM mcr.microsoft.com/dotnet/aspnet:7.0 15 | WORKDIR /app 16 | COPY --from=build-env /app/out . 17 | ENTRYPOINT ["dotnet", "api.dll"] -------------------------------------------------------------------------------- /docs/api/Models/Order.cs: -------------------------------------------------------------------------------- 1 | namespace Api.Models; 2 | public class Order 3 | { 4 | [DatabaseGenerated(DatabaseGeneratedOption.Identity)] 5 | public int Id { get; set; } 6 | public int CustomerId { get; set; } 7 | public required Customer Customer { get; set; } 8 | public DateTimeOffset Date { get; set; } 9 | public int DeliveryAddressId { get; set; } 10 | public required Address DeliveryAddress { get; set; } 11 | public decimal Total { get; set; } 12 | public required List OrderProducts { get; set; } 13 | } -------------------------------------------------------------------------------- /docs/api/Data/ODataModelBuilder.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.OData.ModelBuilder; 2 | using Microsoft.OData.Edm; 3 | 4 | using Api.Models; 5 | 6 | namespace Api.Data; 7 | public static class ODataModelBuilder 8 | { 9 | public static IEdmModel Build() 10 | { 11 | var builder = new ODataConventionModelBuilder(); 12 | 13 | builder.EntitySet(nameof(Customer)); 14 | builder.EntitySet(nameof(Order)); 15 | builder.EntitySet(nameof(Product)); 16 | 17 | return builder.GetEdmModel(); 18 | } 19 | } -------------------------------------------------------------------------------- /docs/api/Models/Customer.cs: -------------------------------------------------------------------------------- 1 | namespace Api.Models; 2 | public class Customer 3 | { 4 | [DatabaseGenerated(DatabaseGeneratedOption.Identity)] 5 | public int Id { get; set; } 6 | public required string FirstName { get; set; } 7 | public string? MiddleNames { get; set; } 8 | public required string Surname { get; set; } 9 | public required string EmailAddress { get; set; } 10 | public DateTimeOffset CreatedDate { get; set; } 11 | public required List
Addresses { get; set; } 12 | public required List Orders { get; set; } 13 | } -------------------------------------------------------------------------------- /packages/base/FilterBuilder/components/FilterBuilder.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { RecoilRoot } from "recoil"; 3 | 4 | import FilterRoot from "./FilterRoot"; 5 | 6 | import { ExternalBuilderProps, FieldDef } from "../types" 7 | 8 | export type FilterBuilderProps = ExternalBuilderProps & { 9 | schema: FieldDef[] 10 | } 11 | 12 | const FilterBuilder = (props: FilterBuilderProps) => { 13 | return ( 14 | 15 | 16 | 17 | ); 18 | } 19 | 20 | export default FilterBuilder; -------------------------------------------------------------------------------- /docs/api/Directory.Build.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | $(DefaultItemExcludes);$(MSBuildProjectDirectory)/obj/**/* 5 | $(DefaultItemExcludes);$(MSBuildProjectDirectory)/bin/**/* 6 | 7 | 8 | 9 | $(MSBuildProjectDirectory)/obj/container/ 10 | $(MSBuildProjectDirectory)/bin/container/ 11 | 12 | 13 | -------------------------------------------------------------------------------- /docs/api/Models/Address.cs: -------------------------------------------------------------------------------- 1 | namespace Api.Models; 2 | public class Address 3 | { 4 | [DatabaseGenerated(DatabaseGeneratedOption.Identity)] 5 | public int Id { get; set; } 6 | public int CustomerId { get; set; } 7 | public required Customer Customer { get; set; } 8 | public required string Line1 { get; set; } 9 | public string? Line2 { get; set; } 10 | public string? Line3 { get; set; } 11 | public required string Town { get; set; } 12 | public string? County { get; set; } 13 | public required string Country { get; set; } 14 | public required string PostCode { get; set; } 15 | } -------------------------------------------------------------------------------- /packages/base/FilterBuilder/state.ts: -------------------------------------------------------------------------------- 1 | import { atom } from "recoil" 2 | 3 | import { ExternalBuilderProps, FieldDef } from "./types" 4 | import { initialTree, initialClauses } from "./constants"; 5 | 6 | export const schemaState = atom[]>({ 7 | key: "schema", 8 | default: [] 9 | }); 10 | 11 | export const clauseState = atom({ 12 | key: "filterClauses", 13 | default: initialClauses 14 | }); 15 | 16 | export const treeState = atom({ 17 | key: "filterTree", 18 | default: initialTree 19 | }); 20 | 21 | export const propsState = atom>({ 22 | key: "props", 23 | default: {} 24 | }); -------------------------------------------------------------------------------- /docs/api/Data/SeederService.cs: -------------------------------------------------------------------------------- 1 | namespace Api.Data; 2 | 3 | public class SeederService : IHostedService 4 | { 5 | public IServiceProvider _provider; 6 | 7 | public SeederService(IServiceProvider provider) 8 | { 9 | _provider = provider; 10 | } 11 | 12 | public async Task StartAsync(CancellationToken token) 13 | { 14 | await using (var scope =_provider.CreateAsyncScope()) 15 | { 16 | var seeder = scope.ServiceProvider.GetRequiredService(); 17 | await seeder.Seed(token); 18 | } 19 | } 20 | 21 | public Task StopAsync(CancellationToken token) => Task.CompletedTask; 22 | } -------------------------------------------------------------------------------- /packages/webpack.o-data-grid-pro.config.js: -------------------------------------------------------------------------------- 1 | const HtmlWebPackPlugin = require("html-webpack-plugin"); 2 | 3 | module.exports = { 4 | mode: "development", 5 | entry: "./o-data-grid-pro/dev/index.tsx", 6 | output: { 7 | path: __dirname + "/o-data-grid-pro/dev-build", 8 | filename: "bundle.js" 9 | }, 10 | module: { 11 | rules: [ 12 | { 13 | test: /\.tsx?$/, 14 | use: "ts-loader", 15 | } 16 | ] 17 | }, 18 | resolve: { 19 | extensions: [".js", ".ts", ".tsx"] 20 | }, 21 | plugins: [ 22 | new HtmlWebPackPlugin({ 23 | template: "./o-data-grid-pro/dev/index.html" 24 | }) 25 | ] 26 | } -------------------------------------------------------------------------------- /packages/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "src", 4 | "target": "es6", 5 | "lib": [ 6 | "dom", 7 | "dom.iterable", 8 | "esnext" 9 | ], 10 | "allowJs": true, 11 | "skipLibCheck": true, 12 | "esModuleInterop": true, 13 | "allowSyntheticDefaultImports": true, 14 | "strict": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "noFallthroughCasesInSwitch": true, 17 | "module": "esnext", 18 | "moduleResolution": "node", 19 | "resolveJsonModule": true, 20 | "isolatedModules": true, 21 | "jsx": "react-jsx", 22 | "outDir": "./build", 23 | "declaration": true 24 | }, 25 | "include": [ 26 | "./o-data-grid/src", 27 | "./base" 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /docs/api/Controllers/ODataBaseController.cs: -------------------------------------------------------------------------------- 1 | using Api.Data; 2 | using Microsoft.AspNetCore.Mvc; 3 | using Microsoft.AspNetCore.OData.Query; 4 | using Microsoft.AspNetCore.OData.Routing.Controllers; 5 | using Microsoft.EntityFrameworkCore; 6 | 7 | namespace Api.Controllers; 8 | 9 | public abstract class ODataBaseController : ODataController 10 | where T : class 11 | { 12 | private readonly ApiContext _context; 13 | 14 | public ODataBaseController(ApiContext context) 15 | { 16 | _context = context; 17 | } 18 | 19 | [HttpGet] 20 | [EnableQuery(MaxAnyAllExpressionDepth = 5, MaxExpansionDepth = 5, PageSize = 250)] 21 | public IActionResult Get() 22 | { 23 | return Ok(_context.Set().AsNoTracking()); 24 | } 25 | } -------------------------------------------------------------------------------- /packages/webpack.o-data-grid.config.js: -------------------------------------------------------------------------------- 1 | const ESLintPlugin = require('eslint-webpack-plugin'); 2 | const HtmlWebPackPlugin = require("html-webpack-plugin"); 3 | 4 | module.exports = { 5 | mode: "development", 6 | entry: "./o-data-grid/dev/index.tsx", 7 | output: { 8 | path: __dirname + "/o-data-grid/dev-build", 9 | filename: "bundle.js" 10 | }, 11 | module: { 12 | rules: [ 13 | { 14 | test: /\.tsx?$/, 15 | use: "ts-loader", 16 | } 17 | ] 18 | }, 19 | resolve: { 20 | extensions: [".js", ".ts", ".tsx"] 21 | }, 22 | plugins: [ 23 | new HtmlWebPackPlugin({ 24 | template: "./o-data-grid/dev/index.html" 25 | }), 26 | new ESLintPlugin({ context: "./o-data-grid/" }) 27 | ] 28 | } -------------------------------------------------------------------------------- /docs/api/Migrations/20230325170658_OrderDate.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Microsoft.EntityFrameworkCore.Migrations; 3 | 4 | #nullable disable 5 | 6 | namespace api.Migrations 7 | { 8 | public partial class OrderDate : Migration 9 | { 10 | protected override void Up(MigrationBuilder migrationBuilder) 11 | { 12 | migrationBuilder.AddColumn( 13 | name: "Date", 14 | table: "Orders", 15 | type: "timestamp with time zone", 16 | nullable: false, 17 | defaultValue: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0))); 18 | } 19 | 20 | protected override void Down(MigrationBuilder migrationBuilder) 21 | { 22 | migrationBuilder.DropColumn( 23 | name: "Date", 24 | table: "Orders"); 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /docs/api/api.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net7.0 5 | enable 6 | enable 7 | 8 | 9 | 10 | 11 | 12 | 13 | runtime; build; native; contentfiles; analyzers; buildtransitive 14 | all 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /docs/api/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/launchsettings.json", 3 | "iisSettings": { 4 | "windowsAuthentication": false, 5 | "anonymousAuthentication": true, 6 | "iisExpress": { 7 | "applicationUrl": "http://localhost:58843", 8 | "sslPort": 44380 9 | } 10 | }, 11 | "profiles": { 12 | "api": { 13 | "commandName": "Project", 14 | "dotnetRunMessages": true, 15 | "launchBrowser": true, 16 | "launchUrl": "swagger", 17 | "applicationUrl": "https://localhost:7230;http://localhost:5129", 18 | "environmentVariables": { 19 | "ASPNETCORE_ENVIRONMENT": "Development" 20 | } 21 | }, 22 | "IIS Express": { 23 | "commandName": "IISExpress", 24 | "launchBrowser": true, 25 | "launchUrl": "swagger", 26 | "environmentVariables": { 27 | "ASPNETCORE_ENVIRONMENT": "Development" 28 | } 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /docs/api/docker-compose-prod.yml: -------------------------------------------------------------------------------- 1 | version: "3.3" 2 | 3 | services: 4 | web-prod: 5 | image: "jamerst/o-data-grid.api:latest" 6 | ports: 7 | - "80:5000" 8 | depends_on: 9 | - db 10 | volumes: 11 | - /etc/localtime:/etc/localtime:ro 12 | environment: 13 | ASPNETCORE_ENVIRONMENT: Production 14 | TZ: "Europe/London" 15 | CultureName: "en-GB" 16 | networks: 17 | - o-data-grid.api 18 | restart: always 19 | db: 20 | image: "postgres:14.2" 21 | environment: 22 | POSTGRES_DB: o-data-grid-demo_db 23 | POSTGRES_USER: o-data-grid 24 | POSTGRES_PASSWORD: o-data-grid 25 | ports: 26 | - "5432:5432" 27 | volumes: 28 | - postgres-data:/var/lib/postgresql/data 29 | - ./init-data/initdb.d:/docker-entrypoint-initdb.d/ 30 | networks: 31 | - o-data-grid.api 32 | restart: always 33 | 34 | volumes: 35 | postgres-data: 36 | 37 | networks: 38 | o-data-grid-demo: 39 | driver: bridge -------------------------------------------------------------------------------- /docs/api/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.3" 2 | 3 | services: 4 | web: 5 | build: 6 | context: . 7 | dockerfile: Dockerfile.dev 8 | ports: 9 | - "5000:5000" 10 | - "5001:5001" 11 | depends_on: 12 | - db 13 | volumes: 14 | - ./:/app:Z 15 | - ~/.microsoft/usersecrets:/root/.microsoft/usersecrets:ro,Z 16 | - /etc/localtime:/etc/localtime:ro 17 | environment: 18 | TZ: "Europe/London" 19 | CultureName: "en-GB" 20 | networks: 21 | - o-data-grid-demo 22 | db: 23 | image: "postgres:14.2" 24 | environment: 25 | POSTGRES_DB: o-data-grid-demo_db 26 | POSTGRES_USER: o-data-grid 27 | POSTGRES_PASSWORD: o-data-grid 28 | ports: 29 | - "5432:5432" 30 | volumes: 31 | - postgres-data:/var/lib/postgresql/data 32 | - ./init-data/initdb.d:/docker-entrypoint-initdb.d/:Z 33 | networks: 34 | - o-data-grid-demo 35 | 36 | volumes: 37 | postgres-data: 38 | 39 | networks: 40 | o-data-grid-demo: 41 | driver: bridge -------------------------------------------------------------------------------- /packages/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true 5 | }, 6 | "extends": [ 7 | "eslint:recommended", 8 | "plugin:react/recommended", 9 | "plugin:@typescript-eslint/recommended" 10 | ], 11 | "parser": "@typescript-eslint/parser", 12 | "parserOptions": { 13 | "ecmaFeatures": { 14 | "jsx": true 15 | }, 16 | "ecmaVersion": 13, 17 | "sourceType": "module" 18 | }, 19 | "plugins": [ 20 | "react", 21 | "react-hooks", 22 | "@typescript-eslint" 23 | ], 24 | "rules": { 25 | "react-hooks/rules-of-hooks": "error", 26 | "react-hooks/exhaustive-deps": "warn", 27 | "react/display-name": "off", 28 | "react/prop-types": "off", 29 | "@typescript-eslint/no-non-null-assertion": "off", 30 | "@typescript-eslint/no-explicit-any": "off" 31 | }, 32 | "settings": { 33 | "react": { 34 | "version": "detect" 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /packages/o-data-grid/.pnpm-debug.log: -------------------------------------------------------------------------------- 1 | { 2 | "0 debug pnpm:scope": { 3 | "selected": 1 4 | }, 5 | "1 error pnpm": { 6 | "code": "ELIFECYCLE", 7 | "errno": "ENOENT", 8 | "syscall": "spawn", 9 | "file": "sh", 10 | "pkgid": "o-data-grid@0.1.0", 11 | "stage": "build", 12 | "script": "rollup -c ./rollup.config.js", 13 | "pkgname": "o-data-grid", 14 | "err": { 15 | "name": "pnpm", 16 | "message": "o-data-grid@0.1.0 build: `rollup -c ./rollup.config.js`\nspawn ENOENT", 17 | "code": "ELIFECYCLE", 18 | "stack": "pnpm: o-data-grid@0.1.0 build: `rollup -c ./rollup.config.js`\nspawn ENOENT\n at ChildProcess. (/usr/local/pnpm-global/5/node_modules/.pnpm/pnpm@6.24.2/node_modules/pnpm/dist/pnpm.cjs:91775:22)\n at ChildProcess.emit (node:events:390:28)\n at maybeClose (node:internal/child_process:1064:16)\n at Process.ChildProcess._handle.onexit (node:internal/child_process:301:5)" 19 | } 20 | }, 21 | "2 warn pnpm:global": " Local package.json exists, but node_modules missing, did you mean to install?" 22 | } -------------------------------------------------------------------------------- /packages/rollup.o-data-grid.config.js: -------------------------------------------------------------------------------- 1 | import dts from "rollup-plugin-dts" 2 | import pkg from "./o-data-grid/package.json" 3 | import typescript from "rollup-plugin-typescript2"; 4 | 5 | export default [ 6 | { 7 | input: "./o-data-grid/src/index.ts", 8 | output: [ 9 | { 10 | file: "./o-data-grid/build/o-data-grid-esm.js", 11 | format: "esm" 12 | }, 13 | { 14 | file: "./o-data-grid/build/o-data-grid-cjs.js", 15 | format: "cjs" 16 | } 17 | ], 18 | plugins: [ 19 | typescript({ clean: true }) 20 | ], 21 | external: Object.keys({ ...pkg.peerDependencies, ...pkg.dependencies }).map((packageName) => { 22 | // Make sure that e.g. `react` as well as `react/jsx-runtime` is considered an external 23 | return new RegExp(`(${packageName}|${packageName}\\/.*)`); 24 | }), 25 | }, 26 | { 27 | input: "./o-data-grid/build/o-data-grid/src/index.d.ts", 28 | output: [ 29 | { 30 | file: "./o-data-grid/build/o-data-grid.d.ts", 31 | format: "es" 32 | } 33 | ], 34 | plugins: [ 35 | dts() 36 | ] 37 | } 38 | ] -------------------------------------------------------------------------------- /docs/api/.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "build", 6 | "command": "dotnet", 7 | "type": "process", 8 | "args": [ 9 | "build", 10 | "${workspaceFolder}/api.csproj", 11 | "/property:GenerateFullPaths=true", 12 | "/consoleloggerparameters:NoSummary" 13 | ], 14 | "problemMatcher": "$msCompile" 15 | }, 16 | { 17 | "label": "publish", 18 | "command": "dotnet", 19 | "type": "process", 20 | "args": [ 21 | "publish", 22 | "${workspaceFolder}/api.csproj", 23 | "/property:GenerateFullPaths=true", 24 | "/consoleloggerparameters:NoSummary" 25 | ], 26 | "problemMatcher": "$msCompile" 27 | }, 28 | { 29 | "label": "watch", 30 | "command": "dotnet", 31 | "type": "process", 32 | "args": [ 33 | "watch", 34 | "run", 35 | "--project", 36 | "${workspaceFolder}/api.csproj" 37 | ], 38 | "problemMatcher": "$msCompile" 39 | } 40 | ] 41 | } -------------------------------------------------------------------------------- /packages/rollup.o-data-grid-pro.config.js: -------------------------------------------------------------------------------- 1 | import dts from "rollup-plugin-dts" 2 | import pkg from "./o-data-grid-pro/package.json" 3 | import typescript from "rollup-plugin-typescript2"; 4 | 5 | export default [ 6 | { 7 | input: "./o-data-grid-pro/src/index.ts", 8 | output: [ 9 | { 10 | file: "./o-data-grid-pro/build/o-data-grid-pro-esm.js", 11 | format: "esm" 12 | }, 13 | { 14 | file: "./o-data-grid-pro/build/o-data-grid-pro-cjs.js", 15 | format: "cjs" 16 | } 17 | ], 18 | plugins: [ 19 | typescript({ clean: true }) 20 | ], 21 | external: Object.keys({ ...pkg.peerDependencies, ...pkg.dependencies }).map((packageName) => { 22 | // Make sure that e.g. `react` as well as `react/jsx-runtime` is considered an external 23 | return new RegExp(`(${packageName}|${packageName}\\/.*)`); 24 | }), 25 | }, 26 | { 27 | input: "./o-data-grid-pro/build/o-data-grid-pro/src/index.d.ts", 28 | output: [ 29 | { 30 | file: "./o-data-grid-pro/build/o-data-grid-pro.d.ts", 31 | format: "es" 32 | } 33 | ], 34 | plugins: [ 35 | dts() 36 | ] 37 | } 38 | ] -------------------------------------------------------------------------------- /docs/api/Data/ApiContext.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | 3 | using Api.Models; 4 | 5 | namespace Api.Data; 6 | public class ApiContext : DbContext 7 | { 8 | public ApiContext(DbContextOptions options) : base(options) { } 9 | 10 | protected override void OnModelCreating(ModelBuilder builder) 11 | { 12 | base.OnModelCreating(builder); 13 | 14 | builder.Entity
() 15 | .HasOne(a => a.Customer) 16 | .WithMany(c => c.Addresses) 17 | .OnDelete(DeleteBehavior.Cascade); 18 | 19 | builder.Entity() 20 | .HasMany(c => c.Orders) 21 | .WithOne(o => o.Customer) 22 | .OnDelete(DeleteBehavior.Cascade); 23 | 24 | builder.Entity() 25 | .HasOne(o => o.DeliveryAddress) 26 | .WithMany() 27 | .OnDelete(DeleteBehavior.Cascade); 28 | 29 | builder.Entity() 30 | .HasMany(o => o.OrderProducts) 31 | .WithOne(op => op.Order) 32 | .OnDelete(DeleteBehavior.Cascade); 33 | 34 | builder.Entity() 35 | .HasOne(op => op.Product) 36 | .WithMany() 37 | .OnDelete(DeleteBehavior.Cascade); 38 | 39 | } 40 | 41 | public DbSet
Addresses => Set
(); 42 | public DbSet Customers => Set(); 43 | public DbSet Orders => Set(); 44 | 45 | } -------------------------------------------------------------------------------- /docs/api/.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | // Use IntelliSense to find out which attributes exist for C# debugging 6 | // Use hover for the description of the existing attributes 7 | // For further information visit https://github.com/OmniSharp/omnisharp-vscode/blob/master/debugger-launchjson.md 8 | "name": ".NET Core Launch (web)", 9 | "type": "coreclr", 10 | "request": "launch", 11 | "preLaunchTask": "build", 12 | // If you have changed target frameworks, make sure to update the program path. 13 | "program": "${workspaceFolder}/bin/Debug/net6.0/api.dll", 14 | "args": [], 15 | "cwd": "${workspaceFolder}", 16 | "stopAtEntry": false, 17 | // Enable launching a web browser when ASP.NET Core starts. For more information: https://aka.ms/VSCode-CS-LaunchJson-WebBrowser 18 | "serverReadyAction": { 19 | "action": "openExternally", 20 | "pattern": "\\bNow listening on:\\s+(https?://\\S+)" 21 | }, 22 | "env": { 23 | "ASPNETCORE_ENVIRONMENT": "Development" 24 | }, 25 | "sourceFileMap": { 26 | "/Views": "${workspaceFolder}/Views" 27 | } 28 | }, 29 | { 30 | "name": ".NET Core Attach", 31 | "type": "coreclr", 32 | "request": "attach" 33 | } 34 | ] 35 | } -------------------------------------------------------------------------------- /packages/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0.0", 3 | "private": true, 4 | "scripts": { 5 | "build": "rollup -c ./rollup.o-data-grid.config.js", 6 | "lint": "eslint -c ./o-data-grid/.eslintrc.json ./base" 7 | }, 8 | "devDependencies": { 9 | "@emotion/cache": "11.10.1", 10 | "@emotion/react": "11.10.0", 11 | "@emotion/styled": "11.10.0", 12 | "@mui/base": "^5.0.0-alpha.62", 13 | "@mui/icons-material": "5.8.4", 14 | "@mui/material": "5.10.0", 15 | "@mui/system": "5.10.0", 16 | "@mui/x-data-grid": "5.15.2", 17 | "@mui/x-data-grid-pro": "5.15.2", 18 | "@mui/x-date-pickers": "^5.0.0-beta.2", 19 | "@types/react": "^17.0.38", 20 | "@types/react-dom": "^17.0.11", 21 | "@types/uuid": "^8.3.3", 22 | "@typescript-eslint/eslint-plugin": "5.33.0", 23 | "@typescript-eslint/parser": "5.33.0", 24 | "dayjs": "1.11.5", 25 | "eslint": "8.21.0", 26 | "eslint-plugin-react": "7.30.1", 27 | "eslint-plugin-react-hooks": "4.6.0", 28 | "eslint-webpack-plugin": "3.2.0", 29 | "html-webpack-plugin": "^5.5.0", 30 | "immutable": "4.1.0", 31 | "react": "^17.0.2", 32 | "react-dom": "^17.0.2", 33 | "recoil": "0.7.5", 34 | "rollup": "2.77.3", 35 | "rollup-plugin-dts": "4.2.2", 36 | "rollup-plugin-typescript2": "0.32.1", 37 | "source-map-explorer": "^2.5.2", 38 | "ts-loader": "9.3.1", 39 | "tslib": "2.4.0", 40 | "tss-react": "3.7.1", 41 | "typescript": "4.7.4", 42 | "uuid": "^8.3.2", 43 | "webpack": "5.74.0", 44 | "webpack-cli": "4.10.0", 45 | "webpack-dev-server": "4.10.0" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /packages/base/FilterBuilder/translation.ts: -------------------------------------------------------------------------------- 1 | import { FilterTranslatorCollection } from "./types"; 2 | import { escapeODataString } from "./utils"; 3 | 4 | export const defaultTranslators: FilterTranslatorCollection = { 5 | "contains": ({ schema, field, value }) => { 6 | if ((schema.type && schema.type !== "string") || typeof value !== "string") { 7 | console.warn(`Warning: operation "contains" is only supported for fields of type "string"`); 8 | return false; 9 | } 10 | if (schema.caseSensitive === true) { 11 | return `contains(${field}, '${escapeODataString(value)}')`; 12 | } else { 13 | return `contains(tolower(${field}), tolower('${escapeODataString(value)}'))`; 14 | } 15 | }, 16 | 17 | "null": ({ field }) => { 18 | return `${field} eq null`; 19 | }, 20 | 21 | "notnull": ({ field }) => { 22 | return `${field} ne null`; 23 | }, 24 | 25 | "default": ({ schema, field, op, value }) => { 26 | if (schema.type === "date") { 27 | return `date(${field}) ${op} ${value}`; 28 | } else if (schema.type === "datetime") { 29 | return `${field} ${op} ${value}`; 30 | } else if (schema.type === "boolean") { 31 | return `${field} ${op} ${value}`; 32 | } else if (!schema.type || schema.type === "string" || typeof value === "string") { 33 | if (schema.caseSensitive === true) { 34 | return `${field} ${op} '${escapeODataString(value)}'`; 35 | } else { 36 | return `tolower(${field}) ${op} tolower('${escapeODataString(value)}')`; 37 | } 38 | } else { 39 | return `${field} ${op} ${value}`; 40 | } 41 | } 42 | } -------------------------------------------------------------------------------- /packages/base/FilterBuilder/constants.ts: -------------------------------------------------------------------------------- 1 | import Immutable from "immutable"; 2 | 3 | import { ConditionClause, FilterBuilderLocaleText, GroupClause, Operation, TreeGroup } from "./types" 4 | 5 | export const rootGroupUuid = "17c63a07-397b-4f03-a74b-2f935dcc6c8a"; 6 | export const rootConditionUuid = "18c1713a-2480-40c0-b60f-220a3fd4b117"; 7 | 8 | export const allOperators: Operation[] = ["eq", "ne", "gt", "lt", "ge", "le", "contains", "null", "notnull"]; 9 | export const numericOperators: Operation[] = ["eq", "ne", "gt", "lt", "ge", "le"]; 10 | 11 | export const initialClauses = Immutable.Map({ 12 | [rootGroupUuid]: { 13 | id: rootGroupUuid, 14 | connective: "and" 15 | }, 16 | [rootConditionUuid]: { 17 | id: rootConditionUuid, 18 | field: "", 19 | op: "eq", 20 | value: null, 21 | default: true 22 | } 23 | }) 24 | 25 | export const initialTree = Immutable.Map({ 26 | [rootGroupUuid]: { 27 | id: rootGroupUuid, 28 | children: Immutable.Map({ [rootConditionUuid]: rootConditionUuid }) 29 | } 30 | }) 31 | 32 | export const defaultLocale: Required = { 33 | and: "And", 34 | or: "Or", 35 | 36 | addCondition: "Add Condition", 37 | addGroup: "Add Group", 38 | 39 | field: "Field", 40 | operation: "Operation", 41 | value: "Value", 42 | collectionOperation: "Operation", 43 | collectionField: "Field", 44 | 45 | search: "Search", 46 | reset: "Reset", 47 | 48 | opAny: "Has at least one", 49 | opAll: "All have", 50 | opCount: "Count", 51 | 52 | opEq: "=", 53 | opNe: "≠", 54 | opGt: ">", 55 | opLt: "<", 56 | opGe: "≥", 57 | opLe: "≤", 58 | opContains: "Contains", 59 | opNull: "Is Blank", 60 | opNotNull: "Is Not Blank" 61 | } -------------------------------------------------------------------------------- /packages/o-data-grid/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "o-data-grid", 3 | "version": "1.3.1", 4 | "description": "A React Data Grid and Query Builder for OData APIs. Based on the Material-UI DataGrid.", 5 | "main": "build/o-data-grid-cjs.js", 6 | "module": "build/o-data-grid-esm.js", 7 | "types": "build/o-data-grid.d.ts", 8 | "sideEffects": false, 9 | "scripts": { 10 | "build": "cd ../ && node_modules/.bin/rollup -c ./rollup.o-data-grid.config.js", 11 | "start": "cd ../ && node_modules/.bin/webpack-dev-server --mode development --open --hot -c ./webpack.o-data-grid.config.js", 12 | "build-dev": "cd ../ && node_modules/.bin/webpack -c ./webpack.o-data-grid.config.js", 13 | "lint": "cd ../ && node_modules/.bin/eslint ./o-data-grid" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/jamerst/o-data-grid.git" 18 | }, 19 | "keywords": [ 20 | "react", 21 | "react-component", 22 | "odata", 23 | "material-ui", 24 | "mui", 25 | "datagrid", 26 | "data-grid", 27 | "table", 28 | "datatable", 29 | "data-table", 30 | "filter", 31 | "query" 32 | ], 33 | "author": "James Tattersall", 34 | "license": "GPL-3.0-or-later", 35 | "bugs": { 36 | "url": "https://github.com/jamerst/o-data-grid/issues" 37 | }, 38 | "homepage": "https://github.com/jamerst/o-data-grid#readme", 39 | "peerDependencies": { 40 | "@mui/icons-material": "^5.2.5", 41 | "@mui/material": "^5.2.8", 42 | "@mui/system": "^5.2.8", 43 | "@mui/x-data-grid": "^5.8.0", 44 | "@mui/x-date-pickers": "^5.0.0-beta.2", 45 | "react": "^17.0.2" 46 | }, 47 | "dependencies": { 48 | "immutable": "^4.0.0", 49 | "recoil": "0.7.2", 50 | "tss-react": "3.6.2", 51 | "uuid": "^8.3.2" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /packages/o-data-grid/src/index.ts: -------------------------------------------------------------------------------- 1 | import { GridActionsColDef, GridColDef, GridEnrichedColDef } from "@mui/x-data-grid"; 2 | 3 | import ODataGrid from "./ODataGrid"; 4 | import { ODataBaseGridColumns, ODataGridBaseColDef, ODataGridBaseEnrichedColDef, ODataRowModel } from "../../base/types"; 5 | import FilterBuilder from "../../base/FilterBuilder/components/FilterBuilder"; 6 | import { allOperators, numericOperators } from "../../base/FilterBuilder/constants"; 7 | 8 | export { 9 | ODataGrid, 10 | FilterBuilder, 11 | allOperators, 12 | numericOperators 13 | } 14 | 15 | export type { ODataGridProps } from "./ODataGridProps"; 16 | export type ODataGridColDef = ODataGridBaseColDef>, TDate>; 17 | 18 | export type ODataGridEnrichedColDef = ODataGridBaseEnrichedColDef, V, F>, GridActionsColDef>, TDate>; 19 | export type ODataGridColumns = ODataBaseGridColumns>, GridActionsColDef>, TDate>; 20 | 21 | export type { SelectOption, ValueOption, ODataColumnVisibilityModel } from "../../base/types"; 22 | export type { 23 | CollectionFieldDef, 24 | CollectionOperation, 25 | ComputeSelect, 26 | Connective, 27 | ExternalBuilderProps, 28 | FilterBuilderLocaleText, 29 | FilterCompute, 30 | FilterParameters, 31 | FieldDef, 32 | QueryStringCollection, 33 | SerialisedGroup, 34 | SerialisedCondition, 35 | } from "../../base/FilterBuilder/types"; 36 | export type { FilterBuilderProps } from "../../base/FilterBuilder/components/FilterBuilder"; 37 | 38 | export { escapeODataString } from "../../base/FilterBuilder/utils"; 39 | export { defaultTranslators } from "../../base/FilterBuilder/translation"; -------------------------------------------------------------------------------- /packages/o-data-grid-pro/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "o-data-grid-pro", 3 | "version": "1.3.1", 4 | "description": "A React Data Grid and Query Builder for OData APIs. Based on the Material-UI DataGridPro.", 5 | "main": "build/o-data-grid-pro-cjs.js", 6 | "module": "build/o-data-grid-pro-esm.js", 7 | "types": "build/o-data-grid-pro.d.ts", 8 | "sideEffects": false, 9 | "scripts": { 10 | "build": "cd ../ && node_modules/.bin/rollup -c ./rollup.o-data-grid-pro.config.js", 11 | "start": "cd ../ && node_modules/.bin/webpack-dev-server --mode development --open --hot -c ./webpack.o-data-grid-pro.config.js", 12 | "build-dev": "cd ../ && node_modules/.bin/webpack -c ./webpack.o-data-grid-pro.config.js", 13 | "lint": "cd ../ && node_modules/.bin/eslint ./o-data-grid" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/jamerst/o-data-grid.git" 18 | }, 19 | "keywords": [ 20 | "react", 21 | "react-component", 22 | "odata", 23 | "material-ui", 24 | "mui", 25 | "datagrid", 26 | "data-grid", 27 | "table", 28 | "datatable", 29 | "data-table", 30 | "filter", 31 | "query" 32 | ], 33 | "author": "James Tattersall", 34 | "license": "GPL-3.0-or-later", 35 | "bugs": { 36 | "url": "https://github.com/jamerst/o-data-grid/issues" 37 | }, 38 | "homepage": "https://github.com/jamerst/o-data-grid#readme", 39 | "peerDependencies": { 40 | "@mui/icons-material": "^5.2.5", 41 | "@mui/material": "^5.2.8", 42 | "@mui/system": "^5.2.8", 43 | "@mui/x-data-grid-pro": "^5.8.0", 44 | "@mui/x-date-pickers": "^5.0.0-beta.2", 45 | "react": "^17.0.2" 46 | }, 47 | "dependencies": { 48 | "immutable": "^4.0.0", 49 | "recoil": "0.7.2", 50 | "tss-react": "3.6.2", 51 | "uuid": "^8.3.2" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /packages/o-data-grid-pro/src/index.ts: -------------------------------------------------------------------------------- 1 | import { GridActionsColDef, GridColDef, GridEnrichedColDef } from "@mui/x-data-grid-pro"; 2 | 3 | import ODataGridPro from "./ODataGridPro"; 4 | import { ODataBaseGridColumns, ODataGridBaseColDef, ODataGridBaseEnrichedColDef, ODataRowModel } from "../../base/types"; 5 | import FilterBuilder from "../../base/FilterBuilder/components/FilterBuilder"; 6 | import { allOperators, numericOperators } from "../../base/FilterBuilder/constants"; 7 | 8 | export { 9 | ODataGridPro, 10 | FilterBuilder, 11 | allOperators, 12 | numericOperators 13 | } 14 | 15 | export type { ODataGridProProps } from "./ODataGridProProps" 16 | export type ODataGridColDef = ODataGridBaseColDef>, TDate> 17 | 18 | export type ODataGridEnrichedColDef = ODataGridBaseEnrichedColDef, V, F>, GridActionsColDef>, TDate>; 19 | export type ODataGridColumns = ODataBaseGridColumns>, GridActionsColDef>, TDate>; 20 | 21 | export type { SelectOption, ValueOption, ODataColumnVisibilityModel } from "../../base/types"; 22 | export type { 23 | CollectionFieldDef, 24 | CollectionOperation, 25 | ComputeSelect, 26 | Connective, 27 | ExternalBuilderProps, 28 | FilterBuilderLocaleText, 29 | FilterCompute, 30 | FilterParameters, 31 | FieldDef, 32 | QueryStringCollection, 33 | SerialisedGroup, 34 | SerialisedCondition, 35 | } from "../../base/FilterBuilder/types"; 36 | export type { FilterBuilderProps } from "../../base/FilterBuilder/components/FilterBuilder"; 37 | 38 | export { escapeODataString } from "../../base/FilterBuilder/utils"; 39 | export { defaultTranslators } from "../../base/FilterBuilder/translation"; -------------------------------------------------------------------------------- /docs/api/Program.cs: -------------------------------------------------------------------------------- 1 | global using System.ComponentModel.DataAnnotations.Schema; 2 | 3 | using System.Text.Json.Serialization; 4 | 5 | using Microsoft.AspNetCore.OData; 6 | using Microsoft.EntityFrameworkCore; 7 | 8 | using Api.Data; 9 | 10 | var builder = WebApplication.CreateBuilder(args); 11 | 12 | builder.WebHost.UseKestrel(options => options.ListenAnyIP(5000)); 13 | 14 | // Add services to the container. 15 | builder.Services.AddDbContext(options => 16 | options.UseNpgsql( 17 | builder.Configuration.GetConnectionString("DefaultConnection")!, 18 | o => o.UseQuerySplittingBehavior(QuerySplittingBehavior.SingleQuery) 19 | ) 20 | ); 21 | 22 | builder.Services 23 | .AddControllers() 24 | .AddOData(options => options 25 | .AddRouteComponents(ODataModelBuilder.Build()) 26 | .Filter() 27 | .Select() 28 | .Expand() 29 | .Count() 30 | .OrderBy() 31 | .SetMaxTop(250) 32 | ) 33 | .AddJsonOptions(options => 34 | { 35 | options.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles; 36 | }); 37 | 38 | builder.Services.AddScoped(); 39 | builder.Services.AddHostedService(); 40 | 41 | var app = builder.Build(); 42 | 43 | app.MapControllers(); 44 | 45 | if (app.Environment.IsDevelopment()) 46 | { 47 | app.UseDeveloperExceptionPage(); 48 | app.UseODataRouteDebug(); 49 | app.UseCors(options => options.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod()); 50 | } 51 | else 52 | { 53 | app.UseCors(options => options 54 | .SetIsOriginAllowed(origin => 55 | Uri.TryCreate(origin, UriKind.Absolute, out Uri? uri) 56 | && (uri?.Host == "localhost" || uri?.Host == "o-data-grid.jtattersall.net") 57 | ) 58 | .AllowAnyHeader() 59 | .AllowAnyMethod() 60 | ); 61 | } 62 | 63 | 64 | app.Run(); 65 | -------------------------------------------------------------------------------- /packages/base/hooks.ts: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react" 2 | import { useTheme, Breakpoint, Theme } from "@mui/material/styles" 3 | 4 | export type ResponsiveValues

= Partial> 5 | 6 | export const useResponsive = () => { 7 | const theme = useTheme() 8 | 9 | const matches = useBreakpoints(); 10 | 11 | return function

(responsiveValues: ResponsiveValues

) { 12 | let match: Breakpoint | undefined; 13 | theme.breakpoints.keys.forEach((breakpoint) => { 14 | if (matches[breakpoint] && responsiveValues[breakpoint] != null) { 15 | match = breakpoint; 16 | } 17 | }) 18 | 19 | return match && responsiveValues[match] 20 | } 21 | } 22 | 23 | // eslint-disable-next-line react-hooks/exhaustive-deps 24 | export const useMountEffect = (func: React.EffectCallback) => useEffect(func, []); 25 | 26 | export const useBreakpoints = ():Partial> => { 27 | const theme = useTheme(); 28 | const [matches, setMatches] = useState>>(getMatches(theme.breakpoints.keys, theme)); 29 | 30 | useEffect(() => { 31 | const queries: Partial> = getQueries(theme.breakpoints.keys, theme); 32 | const listeners: Partial void>> = {}; 33 | 34 | const updateMatch = (b: Breakpoint) => { 35 | setMatches((oldMatches) => ({...oldMatches, [b]: queries[b]?.matches ?? false })); 36 | } 37 | 38 | theme.breakpoints.keys.forEach(b => { 39 | listeners[b] = () => updateMatch(b); 40 | queries[b]!.addEventListener("change", listeners[b]!); 41 | }); 42 | 43 | return () => { 44 | theme.breakpoints.keys.forEach(b => { 45 | queries[b]!.removeEventListener("change", listeners[b]!) 46 | }) 47 | } 48 | }, [theme]); 49 | 50 | return matches; 51 | } 52 | 53 | const getQueries = (breakpoints: Breakpoint[], theme: Theme) => breakpoints.reduce((acc: Partial>, b) => 54 | ({ 55 | ...acc, 56 | [b]: window.matchMedia(theme.breakpoints.up(b).replace(/^@media( ?)/m, '')) 57 | }), 58 | {} 59 | ); 60 | 61 | const getMatches = (breakpoints: Breakpoint[], theme: Theme) => breakpoints.reduce((acc: Partial>, b) => 62 | ({ 63 | ...acc, 64 | [b]: window.matchMedia(theme.breakpoints.up(b).replace(/^@media( ?)/m, '')).matches 65 | }), 66 | {} 67 | ); -------------------------------------------------------------------------------- /packages/base/FilterBuilder/utils.ts: -------------------------------------------------------------------------------- 1 | import Immutable from "immutable"; 2 | import { SelectOption, ValueOption } from "../types"; 3 | import { v4 as uuid } from "uuid"; 4 | import { defaultLocale, rootGroupUuid } from "./constants"; 5 | import { SerialisedGroup, StateTree, StateClause, TreeGroup } from "./types"; 6 | 7 | import { GroupClause, ConditionClause, FilterBuilderLocaleText } from "./types" 8 | 9 | export const getDefaultCondition = (field: string): ConditionClause => ({ 10 | field: field, 11 | op: "eq", 12 | value: null, 13 | id: uuid() 14 | }) 15 | 16 | export const getDefaultGroup = (): GroupClause => ({ 17 | connective: "and", 18 | id: uuid() 19 | }); 20 | 21 | export const getSelectOption = (option: ValueOption): SelectOption => { 22 | if (typeof option === "string") { 23 | return { value: option, label: option }; 24 | } else if (typeof option === "number") { 25 | return { value: option.toString(), label: option.toString() } 26 | } else { 27 | return option; 28 | } 29 | } 30 | 31 | export const getLocaleText = (key: keyof FilterBuilderLocaleText, locale: FilterBuilderLocaleText | undefined) => 32 | locale !== undefined && locale[key] ? locale[key]! : defaultLocale[key]; 33 | 34 | export const deserialise = (obj: SerialisedGroup): [StateTree, StateClause] => { 35 | const [treeGroup, clauses] = groupObjToMap(obj, rootGroupUuid); 36 | 37 | return [ 38 | Immutable.Map({ 39 | [rootGroupUuid]: treeGroup 40 | }), 41 | clauses 42 | ]; 43 | } 44 | 45 | const groupObjToMap = (obj: SerialisedGroup, id: string, clauses?: StateClause): [TreeGroup, StateClause] => { 46 | let children = Immutable.Map(); 47 | 48 | if (!clauses) { 49 | clauses = Immutable.Map(); 50 | } 51 | 52 | clauses = clauses.set(id, { 53 | id: id, 54 | ...obj 55 | }); 56 | 57 | obj.children.forEach((child) => { 58 | const childId = uuid(); 59 | clauses = clauses!.set(childId, { 60 | id: childId, 61 | ...child 62 | }); 63 | 64 | const g = child as SerialisedGroup; 65 | if (g.connective) { 66 | const result = groupObjToMap(g, childId, clauses); 67 | 68 | children = children.set(childId, result[0]); 69 | clauses = clauses.merge(result[1]); 70 | } else { 71 | children = children.set(childId, childId); 72 | } 73 | }); 74 | 75 | return [{ id: id, children: children }, clauses] 76 | } 77 | 78 | export const escapeODataString = (val: string) => val.replace("'", "''"); -------------------------------------------------------------------------------- /docs/api/init-data/initdb.d/10-schema.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS "__EFMigrationsHistory" ( 2 | "MigrationId" character varying(150) NOT NULL, 3 | "ProductVersion" character varying(32) NOT NULL, 4 | CONSTRAINT "PK___EFMigrationsHistory" PRIMARY KEY ("MigrationId") 5 | ); 6 | 7 | START TRANSACTION; 8 | 9 | CREATE TABLE "Customers" ( 10 | "Id" integer GENERATED BY DEFAULT AS IDENTITY, 11 | "FirstName" text NOT NULL, 12 | "MiddleNames" text NULL, 13 | "Surname" text NOT NULL, 14 | "EmailAddress" text NOT NULL, 15 | "CreatedDate" timestamp with time zone NOT NULL, 16 | CONSTRAINT "PK_Customers" PRIMARY KEY ("Id") 17 | ); 18 | 19 | CREATE TABLE "Product" ( 20 | "Id" integer GENERATED BY DEFAULT AS IDENTITY, 21 | "Name" text NOT NULL, 22 | "Price" numeric NOT NULL, 23 | CONSTRAINT "PK_Product" PRIMARY KEY ("Id") 24 | ); 25 | 26 | CREATE TABLE "Addresses" ( 27 | "Id" integer GENERATED BY DEFAULT AS IDENTITY, 28 | "CustomerId" integer NOT NULL, 29 | "Line1" text NOT NULL, 30 | "Line2" text NULL, 31 | "Line3" text NULL, 32 | "Town" text NOT NULL, 33 | "County" text NULL, 34 | "Country" text NOT NULL, 35 | "PostCode" text NOT NULL, 36 | CONSTRAINT "PK_Addresses" PRIMARY KEY ("Id"), 37 | CONSTRAINT "FK_Addresses_Customers_CustomerId" FOREIGN KEY ("CustomerId") REFERENCES "Customers" ("Id") ON DELETE CASCADE 38 | ); 39 | 40 | CREATE TABLE "Orders" ( 41 | "Id" integer GENERATED BY DEFAULT AS IDENTITY, 42 | "CustomerId" integer NOT NULL, 43 | "DeliveryAddressId" integer NOT NULL, 44 | "Total" numeric NOT NULL, 45 | CONSTRAINT "PK_Orders" PRIMARY KEY ("Id"), 46 | CONSTRAINT "FK_Orders_Addresses_DeliveryAddressId" FOREIGN KEY ("DeliveryAddressId") REFERENCES "Addresses" ("Id") ON DELETE CASCADE, 47 | CONSTRAINT "FK_Orders_Customers_CustomerId" FOREIGN KEY ("CustomerId") REFERENCES "Customers" ("Id") ON DELETE CASCADE 48 | ); 49 | 50 | CREATE TABLE "OrderProduct" ( 51 | "Id" integer GENERATED BY DEFAULT AS IDENTITY, 52 | "OrderId" integer NOT NULL, 53 | "ProductId" integer NOT NULL, 54 | "Quantity" integer NOT NULL, 55 | CONSTRAINT "PK_OrderProduct" PRIMARY KEY ("Id"), 56 | CONSTRAINT "FK_OrderProduct_Orders_OrderId" FOREIGN KEY ("OrderId") REFERENCES "Orders" ("Id") ON DELETE CASCADE, 57 | CONSTRAINT "FK_OrderProduct_Product_ProductId" FOREIGN KEY ("ProductId") REFERENCES "Product" ("Id") ON DELETE CASCADE 58 | ); 59 | 60 | CREATE INDEX "IX_Addresses_CustomerId" ON "Addresses" ("CustomerId"); 61 | 62 | CREATE INDEX "IX_OrderProduct_OrderId" ON "OrderProduct" ("OrderId"); 63 | 64 | CREATE INDEX "IX_OrderProduct_ProductId" ON "OrderProduct" ("ProductId"); 65 | 66 | CREATE INDEX "IX_Orders_CustomerId" ON "Orders" ("CustomerId"); 67 | 68 | CREATE INDEX "IX_Orders_DeliveryAddressId" ON "Orders" ("DeliveryAddressId"); 69 | 70 | INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") 71 | VALUES ('20230325165107_InitialCreate', '6.0.4'); 72 | 73 | COMMIT; 74 | -------------------------------------------------------------------------------- /packages/base/types.ts: -------------------------------------------------------------------------------- 1 | 2 | import { ResponsiveValues } from "./hooks" 3 | import { ExternalBuilderProps, FieldDef } from "./FilterBuilder/types" 4 | import React from "react"; 5 | 6 | export type ODataGridBaseProps< 7 | ComponentProps extends IGridProps, 8 | SortModel extends IGridSortModel, 9 | ColDef, 10 | TRow, 11 | TDate 12 | > = 13 | OmitGridProps 14 | & 15 | { 16 | url: string, 17 | alwaysSelect?: string[], 18 | columns: ODataGridBaseColDef[], 19 | columnVisibilityModel?: ODataColumnVisibilityModel, 20 | component: React.ElementType, 21 | defaultPageSize?: number, 22 | defaultSortModel?: SortModel, 23 | disableFilterBuilder?: boolean, 24 | disableHistory?: boolean, 25 | $filter?: string, 26 | filterBuilderProps?: ExternalBuilderProps, 27 | requestOptions?: RequestInit 28 | }; 29 | 30 | // remove properties which should not be used - these are handled internally or overridden 31 | type OmitGridProps = Omit 50 | 51 | type ODataColumn = Omit & FieldDef & { 52 | select?: string, 53 | expand?: Expand | Expand[], 54 | hide?: ResponsiveValues | boolean, 55 | filterOnly?: boolean 56 | } 57 | 58 | // type for rows when displayed in datagrid 59 | // allows object to be flattened for convenience, but still allows strong typing through "result" property 60 | export type ODataRowModel = { 61 | result: T, 62 | [key: string]: any 63 | } 64 | 65 | export type ODataGridBaseColDef = ODataColumn 66 | export type ODataGridBaseEnrichedColDef = 67 | | ODataColumn 68 | | ODataColumn; 69 | 70 | export type ODataBaseGridColumns = ODataGridBaseEnrichedColDef[] 71 | 72 | export type ODataResponse = { 73 | "@odata.count"?: number, 74 | value: T[] 75 | } 76 | 77 | export type Expand = { 78 | navigationField: string, 79 | select?: string, 80 | expand?: Expand[] | Expand, 81 | orderBy?: string, 82 | top?: number, 83 | count?: boolean 84 | } 85 | 86 | export type ValueOption = string | number | SelectOption; 87 | 88 | export type SelectOption = { 89 | value: any, 90 | label: string 91 | } 92 | 93 | export type ODataColumnVisibilityModel = Record>; 94 | 95 | export type ColumnVisibilityModel = Record; 96 | 97 | export type IGridSortModel = ({ field: string, sort: 'asc' | 'desc' | null | undefined })[]; 98 | 99 | export type IGridRowModel = T; 100 | 101 | export type IGridProps = { 102 | onColumnVisibilityModelChange?: any, 103 | columnVisibilityModel?: ColumnVisibilityModel, 104 | onSortModelChange?: any 105 | } -------------------------------------------------------------------------------- /docs/api/Data/Seeder.cs: -------------------------------------------------------------------------------- 1 | using Api.Models; 2 | using Bogus; 3 | using Microsoft.EntityFrameworkCore; 4 | 5 | namespace Api.Data; 6 | 7 | public interface ISeeder 8 | { 9 | Task Seed(CancellationToken token); 10 | } 11 | 12 | public class Seeder : ISeeder 13 | { 14 | private readonly ApiContext _context; 15 | 16 | public Seeder(ApiContext context) 17 | { 18 | _context = context; 19 | } 20 | 21 | public async Task Seed(CancellationToken token) 22 | { 23 | if (await _context.Customers.AnyAsync(token)) 24 | { 25 | return; 26 | } 27 | 28 | var productFaker = new Faker() 29 | .RuleFor(p => p.Name, f => f.Commerce.ProductName()) 30 | .RuleFor(p => p.Price, f => Math.Round(f.Random.Decimal(0, 500), 2)) 31 | .FinishWith((_, _) => token.ThrowIfCancellationRequested()); 32 | 33 | List products = productFaker.GenerateBetween(100, 200); 34 | 35 | int line1Index = 0; 36 | var addressFaker = new Faker

() 37 | .RuleFor(a => a.Line1, f => f.PickRandomParam(out line1Index, f.Address.StreetAddress(), f.Address.SecondaryAddress())) 38 | .RuleFor(a => a.Line2, f => line1Index == 1 ? f.Address.StreetAddress() : null) 39 | .RuleFor(a => a.Line3, f => f.Address.City().OrNull(f, .9f)) 40 | .RuleFor(a => a.Town, f => f.Address.City()) 41 | .RuleFor(a => a.Country, f => f.Address.Country()) 42 | .RuleFor(a => a.County, (f, a) => a.Country == "United Kingdom" 43 | ? f.Address.County().OrNull(f) 44 | : null) 45 | .RuleFor(a => a.PostCode, f => f.Address.ZipCode()) 46 | .FinishWith((_, _) => token.ThrowIfCancellationRequested()); 47 | 48 | var orderProductFaker = new Faker() 49 | .RuleFor(op => op.Product, f => f.PickRandom(products)) 50 | .RuleFor(op => op.Quantity, f => f.Random.Int(1, 5)) 51 | .FinishWith((_, _) => token.ThrowIfCancellationRequested()); 52 | 53 | var orderFaker = new Faker() 54 | .RuleFor(o => o.Date, f => f.Date.PastOffset(5).ToUniversalTime()) 55 | .RuleFor(o => o.OrderProducts, _ => orderProductFaker.GenerateBetween(1, 5)) 56 | .FinishWith((_, o) => 57 | { 58 | token.ThrowIfCancellationRequested(); 59 | o.Total = o.OrderProducts.Sum(op => op.Product.Price * op.Quantity); 60 | }); 61 | 62 | var customerFaker = new Faker() 63 | .RuleFor(c => c.FirstName, f => f.Name.FirstName()) 64 | .RuleFor(c => c.MiddleNames, f => f.Name.FirstName().OrNull(f)) 65 | .RuleFor(c => c.Surname, f => f.Name.LastName()) 66 | .RuleFor(c => c.EmailAddress, (f, c) => f.Internet.ExampleEmail(c.FirstName, c.Surname)) 67 | .RuleFor(c => c.Addresses, _ => addressFaker.GenerateBetween(1, 3)) 68 | .RuleFor(c => c.Orders, _ => orderFaker.GenerateBetween(0, 10)) 69 | .FinishWith((f, c) => 70 | { 71 | token.ThrowIfCancellationRequested(); 72 | foreach (var order in c.Orders) 73 | { 74 | order.DeliveryAddress = f.PickRandom(c.Addresses); 75 | } 76 | 77 | c.CreatedDate = c.Orders.Select(o => o.Date).DefaultIfEmpty(f.Date.PastOffset(5).ToUniversalTime()).Min(); 78 | }); 79 | 80 | List customers = customerFaker.GenerateBetween(800, 1200); 81 | 82 | _context.Customers.AddRange(customers); 83 | await _context.SaveChangesAsync(token); 84 | } 85 | } -------------------------------------------------------------------------------- /packages/o-data-grid/pnpm-lock.yaml: -------------------------------------------------------------------------------- 1 | lockfileVersion: 5.3 2 | 3 | specifiers: 4 | immutable: ^4.0.0 5 | recoil: 0.7.2 6 | tss-react: 3.6.2 7 | uuid: ^8.3.2 8 | 9 | dependencies: 10 | immutable: 4.0.0 11 | recoil: 0.7.2 12 | tss-react: 3.6.2 13 | uuid: 8.3.2 14 | 15 | packages: 16 | 17 | /@emotion/cache/11.7.1: 18 | resolution: {integrity: sha512-r65Zy4Iljb8oyjtLeCuBH8Qjiy107dOYC6SJq7g7GV5UCQWMObY4SJDPGFjiiVpPrOJ2hmJOoBiYTC7hwx9E2A==} 19 | dependencies: 20 | '@emotion/memoize': 0.7.5 21 | '@emotion/sheet': 1.1.0 22 | '@emotion/utils': 1.0.0 23 | '@emotion/weak-memoize': 0.2.5 24 | stylis: 4.0.13 25 | dev: false 26 | 27 | /@emotion/hash/0.8.0: 28 | resolution: {integrity: sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==} 29 | dev: false 30 | 31 | /@emotion/memoize/0.7.5: 32 | resolution: {integrity: sha512-igX9a37DR2ZPGYtV6suZ6whr8pTFtyHL3K/oLUotxpSVO2ASaprmAe2Dkq7tBo7CRY7MMDrAa9nuQP9/YG8FxQ==} 33 | dev: false 34 | 35 | /@emotion/serialize/1.0.2: 36 | resolution: {integrity: sha512-95MgNJ9+/ajxU7QIAruiOAdYNjxZX7G2mhgrtDWswA21VviYIRP1R5QilZ/bDY42xiKsaktP4egJb3QdYQZi1A==} 37 | dependencies: 38 | '@emotion/hash': 0.8.0 39 | '@emotion/memoize': 0.7.5 40 | '@emotion/unitless': 0.7.5 41 | '@emotion/utils': 1.0.0 42 | csstype: 3.0.10 43 | dev: false 44 | 45 | /@emotion/sheet/1.1.0: 46 | resolution: {integrity: sha512-u0AX4aSo25sMAygCuQTzS+HsImZFuS8llY8O7b9MDRzbJM0kVJlAz6KNDqcG7pOuQZJmj/8X/rAW+66kMnMW+g==} 47 | dev: false 48 | 49 | /@emotion/unitless/0.7.5: 50 | resolution: {integrity: sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==} 51 | dev: false 52 | 53 | /@emotion/utils/1.0.0: 54 | resolution: {integrity: sha512-mQC2b3XLDs6QCW+pDQDiyO/EdGZYOygE8s5N5rrzjSI4M3IejPE/JPndCBwRT9z982aqQNi6beWs1UeayrQxxA==} 55 | dev: false 56 | 57 | /@emotion/weak-memoize/0.2.5: 58 | resolution: {integrity: sha512-6U71C2Wp7r5XtFtQzYrW5iKFT67OixrSxjI4MptCHzdSVlgabczzqLe0ZSgnub/5Kp4hSbpDB1tMytZY9pwxxA==} 59 | dev: false 60 | 61 | /csstype/3.0.10: 62 | resolution: {integrity: sha512-2u44ZG2OcNUO9HDp/Jl8C07x6pU/eTR3ncV91SiK3dhG9TWvRVsCoJw14Ckx5DgWkzGA3waZWO3d7pgqpUI/XA==} 63 | dev: false 64 | 65 | /hamt_plus/1.0.2: 66 | resolution: {integrity: sha1-4hwlKWjH4zsg9qGwlM2FeHomVgE=} 67 | dev: false 68 | 69 | /immutable/4.0.0: 70 | resolution: {integrity: sha512-zIE9hX70qew5qTUjSS7wi1iwj/l7+m54KWU247nhM3v806UdGj1yDndXj+IOYxxtW9zyLI+xqFNZjTuDaLUqFw==} 71 | dev: false 72 | 73 | /recoil/0.7.2: 74 | resolution: {integrity: sha512-OT4pI7FOUHcIoRtjsL5Lqq+lFFzQfir4MIbUkqyJ3nqv3WfBP1pHepyurqTsK5gw+T+I2R8+uOD28yH+Lg5o4g==} 75 | peerDependencies: 76 | react: '>=16.13.1' 77 | react-dom: '*' 78 | react-native: '*' 79 | peerDependenciesMeta: 80 | react-dom: 81 | optional: true 82 | react-native: 83 | optional: true 84 | dependencies: 85 | hamt_plus: 1.0.2 86 | dev: false 87 | 88 | /stylis/4.0.13: 89 | resolution: {integrity: sha512-xGPXiFVl4YED9Jh7Euv2V220mriG9u4B2TA6Ybjc1catrstKD2PpIdU3U0RKpkVBC2EhmL/F0sPCr9vrFTNRag==} 90 | dev: false 91 | 92 | /tss-react/3.6.2: 93 | resolution: {integrity: sha512-+ecQIqCNFVlVJVk3NiCWZY2+5DhVKMVUVxIbEwv9nYsTRQA/KxyKCU8g+KMj5ByqFv9LW76+TUYzbWck/IHkfA==} 94 | peerDependencies: 95 | '@emotion/react': ^11.4.1 96 | '@emotion/server': ^11.4.0 97 | react: ^16.8.0 || ^17.0.2 || ^18.0.0 98 | peerDependenciesMeta: 99 | '@emotion/server': 100 | optional: true 101 | dependencies: 102 | '@emotion/cache': 11.7.1 103 | '@emotion/serialize': 1.0.2 104 | '@emotion/utils': 1.0.0 105 | dev: false 106 | 107 | /uuid/8.3.2: 108 | resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} 109 | hasBin: true 110 | dev: false 111 | -------------------------------------------------------------------------------- /packages/o-data-grid-pro/pnpm-lock.yaml: -------------------------------------------------------------------------------- 1 | lockfileVersion: 5.3 2 | 3 | specifiers: 4 | immutable: ^4.0.0 5 | recoil: 0.7.2 6 | tss-react: 3.6.2 7 | uuid: ^8.3.2 8 | 9 | dependencies: 10 | immutable: 4.0.0 11 | recoil: 0.7.2 12 | tss-react: 3.6.2 13 | uuid: 8.3.2 14 | 15 | packages: 16 | 17 | /@emotion/cache/11.7.1: 18 | resolution: {integrity: sha512-r65Zy4Iljb8oyjtLeCuBH8Qjiy107dOYC6SJq7g7GV5UCQWMObY4SJDPGFjiiVpPrOJ2hmJOoBiYTC7hwx9E2A==} 19 | dependencies: 20 | '@emotion/memoize': 0.7.5 21 | '@emotion/sheet': 1.1.0 22 | '@emotion/utils': 1.0.0 23 | '@emotion/weak-memoize': 0.2.5 24 | stylis: 4.0.13 25 | dev: false 26 | 27 | /@emotion/hash/0.8.0: 28 | resolution: {integrity: sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==} 29 | dev: false 30 | 31 | /@emotion/memoize/0.7.5: 32 | resolution: {integrity: sha512-igX9a37DR2ZPGYtV6suZ6whr8pTFtyHL3K/oLUotxpSVO2ASaprmAe2Dkq7tBo7CRY7MMDrAa9nuQP9/YG8FxQ==} 33 | dev: false 34 | 35 | /@emotion/serialize/1.0.2: 36 | resolution: {integrity: sha512-95MgNJ9+/ajxU7QIAruiOAdYNjxZX7G2mhgrtDWswA21VviYIRP1R5QilZ/bDY42xiKsaktP4egJb3QdYQZi1A==} 37 | dependencies: 38 | '@emotion/hash': 0.8.0 39 | '@emotion/memoize': 0.7.5 40 | '@emotion/unitless': 0.7.5 41 | '@emotion/utils': 1.0.0 42 | csstype: 3.0.10 43 | dev: false 44 | 45 | /@emotion/sheet/1.1.0: 46 | resolution: {integrity: sha512-u0AX4aSo25sMAygCuQTzS+HsImZFuS8llY8O7b9MDRzbJM0kVJlAz6KNDqcG7pOuQZJmj/8X/rAW+66kMnMW+g==} 47 | dev: false 48 | 49 | /@emotion/unitless/0.7.5: 50 | resolution: {integrity: sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==} 51 | dev: false 52 | 53 | /@emotion/utils/1.0.0: 54 | resolution: {integrity: sha512-mQC2b3XLDs6QCW+pDQDiyO/EdGZYOygE8s5N5rrzjSI4M3IejPE/JPndCBwRT9z982aqQNi6beWs1UeayrQxxA==} 55 | dev: false 56 | 57 | /@emotion/weak-memoize/0.2.5: 58 | resolution: {integrity: sha512-6U71C2Wp7r5XtFtQzYrW5iKFT67OixrSxjI4MptCHzdSVlgabczzqLe0ZSgnub/5Kp4hSbpDB1tMytZY9pwxxA==} 59 | dev: false 60 | 61 | /csstype/3.0.10: 62 | resolution: {integrity: sha512-2u44ZG2OcNUO9HDp/Jl8C07x6pU/eTR3ncV91SiK3dhG9TWvRVsCoJw14Ckx5DgWkzGA3waZWO3d7pgqpUI/XA==} 63 | dev: false 64 | 65 | /hamt_plus/1.0.2: 66 | resolution: {integrity: sha1-4hwlKWjH4zsg9qGwlM2FeHomVgE=} 67 | dev: false 68 | 69 | /immutable/4.0.0: 70 | resolution: {integrity: sha512-zIE9hX70qew5qTUjSS7wi1iwj/l7+m54KWU247nhM3v806UdGj1yDndXj+IOYxxtW9zyLI+xqFNZjTuDaLUqFw==} 71 | dev: false 72 | 73 | /recoil/0.7.2: 74 | resolution: {integrity: sha512-OT4pI7FOUHcIoRtjsL5Lqq+lFFzQfir4MIbUkqyJ3nqv3WfBP1pHepyurqTsK5gw+T+I2R8+uOD28yH+Lg5o4g==} 75 | peerDependencies: 76 | react: '>=16.13.1' 77 | react-dom: '*' 78 | react-native: '*' 79 | peerDependenciesMeta: 80 | react-dom: 81 | optional: true 82 | react-native: 83 | optional: true 84 | dependencies: 85 | hamt_plus: 1.0.2 86 | dev: false 87 | 88 | /stylis/4.0.13: 89 | resolution: {integrity: sha512-xGPXiFVl4YED9Jh7Euv2V220mriG9u4B2TA6Ybjc1catrstKD2PpIdU3U0RKpkVBC2EhmL/F0sPCr9vrFTNRag==} 90 | dev: false 91 | 92 | /tss-react/3.6.2: 93 | resolution: {integrity: sha512-+ecQIqCNFVlVJVk3NiCWZY2+5DhVKMVUVxIbEwv9nYsTRQA/KxyKCU8g+KMj5ByqFv9LW76+TUYzbWck/IHkfA==} 94 | peerDependencies: 95 | '@emotion/react': ^11.4.1 96 | '@emotion/server': ^11.4.0 97 | react: ^16.8.0 || ^17.0.2 || ^18.0.0 98 | peerDependenciesMeta: 99 | '@emotion/server': 100 | optional: true 101 | dependencies: 102 | '@emotion/cache': 11.7.1 103 | '@emotion/serialize': 1.0.2 104 | '@emotion/utils': 1.0.0 105 | dev: false 106 | 107 | /uuid/8.3.2: 108 | resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} 109 | hasBin: true 110 | dev: false 111 | -------------------------------------------------------------------------------- /packages/base/utils.ts: -------------------------------------------------------------------------------- 1 | import { Expand } from "./types"; 2 | 3 | import { defaultPageSize } from "./constants"; 4 | 5 | /** 6 | * Convert an Expand object (or array of objects) to a clause to use in an OData $expand query parameter 7 | * @param e Expand(s) to convert 8 | * @returns OData expand clause string 9 | */ 10 | export const ExpandToQuery = (expand?: Expand[] | Expand): string => { 11 | if (expand === undefined) { 12 | return ""; 13 | } 14 | 15 | if (!Array.isArray(expand)) { 16 | return ExpandToQuery([expand]); 17 | } 18 | 19 | // group all expands by the navigation field 20 | const groupedExpands = GroupArrayBy(expand, (e) => e.navigationField); 21 | 22 | // construct a single expand for each navigation field, combining nested query options (where possible) 23 | const expands: Expand[] = []; 24 | groupedExpands.forEach((e, k) => { 25 | expands.push({ 26 | navigationField: k, 27 | top: e.find(e2 => e2.top)?.top, 28 | orderBy: e.find(e2 => e2.orderBy)?.orderBy, 29 | count: e.some(e2 => e2.count), 30 | select: Array.from(new Set(e.filter(e2 => e2.select).map(e2 => e2.select))).join(","), 31 | expand: e.filter(e2 => e2.expand) 32 | .map(e2 => e2.expand!) 33 | .reduce((a: Expand[], b) => Array.isArray(b) ? a.concat(b) : [...a, b], []) 34 | }); 35 | }); 36 | 37 | return expands.map(e => { 38 | let result = `${e.navigationField}`; 39 | 40 | const options = [ 41 | { type: "select", value: e.select }, 42 | { type: "expand", value: ExpandToQuery(e.expand) }, 43 | { type: "orderby", value: e.orderBy }, 44 | { type: "top", value: e.top }, 45 | { type: "count", value: e.count } 46 | ]; 47 | 48 | if (options.some(o => o.value)) { 49 | result += `(${options.filter(o => o.value).map(o => `$${o.type}=${o.value}`).join(";")})` 50 | } 51 | 52 | return result; 53 | 54 | }).join(",") 55 | } 56 | 57 | /** 58 | * Group an array into multiple arrays linked by a common key value 59 | * @param arr Array to group 60 | * @param keySelector Function to select property to group by 61 | * @returns ES6 Map of keys to arrays of values 62 | */ 63 | export const GroupArrayBy = (arr: T[], keySelector: (e: T) => TKey) => arr 64 | .reduce((m, e) => m.set(keySelector(e), [...m.get(keySelector(e)) || [], e]), new Map()); 65 | 66 | /** 67 | * Flatten an object to a single level, i.e. { Person: { Name: "John" } } becomes { "Person.Name": "John" }. 68 | * Arrays are kept as arrays, with their elements flattened. 69 | * @param obj Object to flatten 70 | * @param sep Level separator (default ".") 71 | * @returns Flattened object 72 | */ 73 | 74 | export const Flatten = (obj: any, sep = ".") => _flatten(obj, sep, ""); 75 | 76 | const _flatten = (obj: any, sep: string, prefix: string) => 77 | Object.keys(obj).reduce((x: { [key: string]: any }, k) => { 78 | if (obj[k] !== null) { 79 | const pre = prefix.length ? prefix + sep : ""; 80 | if (Array.isArray(obj[k])) { 81 | x[pre + k] = (obj[k] as Array).map(i => Flatten(i, sep)); 82 | } else if (typeof obj[k] === "object") { 83 | Object.assign(x, _flatten(obj[k], sep, pre + k)); 84 | } else { 85 | x[pre + k] = obj[k]; 86 | } 87 | } 88 | return x; 89 | }, {}); 90 | 91 | export const GetPageNumber = () => { 92 | const params = new URLSearchParams(window.location.search); 93 | if (params.has("page")) { 94 | const pageVal = params.get("page"); 95 | if (pageVal) { 96 | return parseInt(pageVal, 10) - 1; 97 | } 98 | } 99 | 100 | return 0; 101 | } 102 | 103 | export const GetPageSizeOrDefault = (defaultSize?: number) => { 104 | const params = new URLSearchParams(window.location.search); 105 | if (params.has("page-size")) { 106 | const sizeVal = params.get("page-size"); 107 | if (sizeVal) { 108 | return parseInt(sizeVal, 10); 109 | } 110 | } 111 | 112 | return defaultSize ?? defaultPageSize; 113 | } -------------------------------------------------------------------------------- /packages/base/FilterBuilder/types.ts: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { DatePickerProps, DateTimePickerProps, LocalizationProviderProps } from "@mui/x-date-pickers"; 3 | import { AutocompleteProps, FormControlProps, SelectProps, TextFieldProps } from "@mui/material"; 4 | import { GridValueOptionsParams } from "@mui/x-data-grid"; 5 | import { ValueOption } from "../types"; 6 | 7 | export type ExternalBuilderProps = { 8 | searchMenuItems?: ({ label: string, onClick: () => void })[], 9 | onSubmit?: (params: FilterParameters) => (void | any), 10 | onRestoreState?: (params: FilterParameters, state?: any) => void, 11 | localeText?: FilterBuilderLocaleText, 12 | 13 | autocompleteGroups?: string[], 14 | 15 | autocompleteProps?: AutocompleteProps, 16 | datePickerProps?: DatePickerProps, 17 | dateTimePickerProps?: DateTimePickerProps, 18 | localizationProviderProps?: LocalizationProviderProps, 19 | selectProps?: SelectProps, 20 | textFieldProps?: TextFieldProps, 21 | 22 | disableHistory?: boolean, 23 | 24 | filter?: SerialisedGroup 25 | } 26 | 27 | export type FilterParameters = { 28 | compute?: string, 29 | filter: string, 30 | queryString?: QueryStringCollection, 31 | select?: string[], 32 | serialised?: SerialisedGroup, 33 | } 34 | 35 | export type FilterBuilderLocaleText = { 36 | and?: string, 37 | or?: string, 38 | 39 | addCondition?: string, 40 | addGroup?: string, 41 | 42 | field?: string, 43 | operation?: string, 44 | value?: string, 45 | collectionOperation?: string, 46 | collectionField?: string, 47 | 48 | search?: string, 49 | reset?: string 50 | 51 | opAny?: string, 52 | opAll?: string, 53 | opCount?: string, 54 | 55 | opEq?: string, 56 | opNe?: string, 57 | opGt?: string, 58 | opLt?: string, 59 | opGe?: string, 60 | opLe?: string, 61 | opContains?: string, 62 | opNull?: string, 63 | opNotNull?: string 64 | } 65 | 66 | export type BaseFieldDef = { 67 | field: string, 68 | autocompleteGroup?: string, 69 | caseSensitive?: boolean, 70 | 71 | datePickerProps?: DatePickerProps, 72 | dateTimePickerProps?: DateTimePickerProps, 73 | 74 | filterable?: boolean, 75 | filterField?: string, 76 | filterOperators?: Operation[], 77 | filterType?: string, 78 | 79 | getCustomFilterString?: (op: Operation, value: any) => string | FilterCompute | boolean, 80 | getCustomQueryString?: (op: Operation, value: any) => QueryStringCollection, 81 | 82 | label?: string, 83 | nullable?: boolean, 84 | 85 | selectProps?: { selectProps?: SelectProps, formControlProps?: FormControlProps, label?: string }, 86 | sortField?: string, 87 | textFieldProps?: TextFieldProps, 88 | 89 | renderCustomInput?: (value: any, setValue: (v: any) => void) => React.ReactNode, 90 | renderCustomFilter?: (value: any, setValue: (v: any) => void) => React.ReactNode, 91 | 92 | type?: string, 93 | valueOptions?: ValueOption[] | ((params: GridValueOptionsParams) => ValueOption[]), 94 | } 95 | 96 | export type FieldDef = BaseFieldDef & { 97 | headerName?: string, 98 | collection?: boolean, 99 | collectionFields?: CollectionFieldDef[], 100 | } 101 | 102 | export type CollectionFieldDef = BaseFieldDef; 103 | 104 | export type FilterCompute = { 105 | filter: string, 106 | compute: string | ComputeSelect 107 | } 108 | 109 | export type ComputeSelect = { 110 | compute: string, 111 | select: string[] 112 | } 113 | 114 | export type QueryStringCollection = { 115 | [key: string]: string 116 | } 117 | 118 | export type FilterTranslatorCollection = { 119 | [key in Operation | "default"]?: FilterTranslator 120 | } 121 | 122 | export type FilterTranslator = (params: FilterTranslatorParams) => string | boolean; 123 | 124 | export type FilterTranslatorParams = { 125 | schema: BaseFieldDef, 126 | field: string, 127 | op: Operation, 128 | value: any 129 | } 130 | 131 | export type Connective = "and" | "or" 132 | 133 | export type Operation = "eq" | "ne" | "gt" | "lt" | "ge" | "le" | "contains" | "null" | "notnull" 134 | 135 | export type CollectionOperation = "any" | "all" | "count" 136 | 137 | type Clause = { 138 | id: string 139 | } 140 | 141 | export type GroupClause = Clause & { 142 | connective: Connective 143 | } 144 | 145 | export type ConditionClause = Clause & { 146 | field: string, 147 | op: Operation; 148 | collectionOp?: CollectionOperation, 149 | collectionField?: string, 150 | value: any, 151 | default?: boolean 152 | } 153 | 154 | export type TreeGroup = Clause & { 155 | children: TreeChildren 156 | } 157 | 158 | export type TreeChildren = Immutable.Map; 159 | 160 | export type StateClause = Immutable.Map; 161 | export type StateTree = Immutable.Map; 162 | 163 | export type SerialisedGroup = Omit & { 164 | children: (SerialisedGroup | SerialisedCondition)[] 165 | } 166 | 167 | export type SerialisedCondition = Omit; -------------------------------------------------------------------------------- /packages/base/FilterBuilder/hooks.ts: -------------------------------------------------------------------------------- 1 | import { useCallback } from "react"; 2 | import { useRecoilValue, waitForAll } from "recoil" 3 | import { rootGroupUuid } from "./constants"; 4 | import { clauseState, schemaState, treeState } from "./state" 5 | import { defaultTranslators } from "./translation"; 6 | import { BaseFieldDef, SerialisedCondition, ConditionClause, FieldDef, SerialisedGroup, GroupClause, Operation, QueryStringCollection, StateClause, StateTree, TreeGroup, FilterTranslator } from "./types"; 7 | 8 | export const UseODataFilter = () => { 9 | const schema = useRecoilValue(schemaState); 10 | const [clauses, tree] = useRecoilValue(waitForAll([clauseState, treeState])); 11 | 12 | return useCallback(() => { 13 | return buildGroup(schema, clauses, tree, rootGroupUuid, []) as BuiltQuery; 14 | }, [schema, clauses, tree]); 15 | } 16 | 17 | export const UseODataFilterWithState = () => { 18 | const schema = useRecoilValue(schemaState); 19 | 20 | return useCallback((clauses: StateClause, tree: StateTree) => { 21 | return buildGroup(schema, clauses, tree, rootGroupUuid, []) as BuiltQuery; 22 | }, [schema]) 23 | } 24 | 25 | type BuiltInnerQuery = { 26 | filter?: string, 27 | compute?: string, 28 | select?: string[], 29 | queryString?: QueryStringCollection 30 | } 31 | 32 | type BuiltQuery = BuiltInnerQuery & { 33 | serialised: T 34 | } 35 | 36 | const buildGroup = (schema: FieldDef[], clauses: StateClause, tree: StateTree, id: string, path: string[]): (BuiltQuery | boolean) => { 37 | const clause = clauses.get(id) as GroupClause; 38 | const treeNode = tree.getIn([...path, id]) as TreeGroup; 39 | 40 | if (!treeNode) { 41 | console.error(`Tree node ${[...path, id].join("->")} not found`); 42 | return false; 43 | } 44 | 45 | const childClauses = treeNode.children 46 | .toArray() 47 | .map((c) => { 48 | if (typeof c[1] === "string") { 49 | return buildCondition(schema, clauses, c[0]); 50 | } else { 51 | return buildGroup(schema, clauses, tree, c[0], [...path, id, "children"]); 52 | } 53 | }) 54 | .filter(c => c !== false) as (BuiltQuery | BuiltQuery)[]; 55 | 56 | if (childClauses.length > 1) { 57 | return { 58 | filter: `(${childClauses.filter(c => c.filter).map(c => c.filter).join(` ${clause.connective} `)})`, 59 | compute: `${childClauses.filter(c => c.compute).map(c => c.compute).join(",")}`, 60 | select: childClauses.filter(c => c.select).flatMap(c => c.select!), 61 | serialised: { connective: clause.connective, children: childClauses.map(c => c.serialised) }, 62 | queryString: childClauses.reduce((x, c) => ({ ...x, ...c.queryString }), {}) 63 | }; 64 | } else if (childClauses.length === 1) { 65 | return { 66 | filter: childClauses[0].filter, 67 | compute: childClauses[0].compute, 68 | select: childClauses[0].select, 69 | serialised: { connective: clause.connective, children: [childClauses[0].serialised] }, 70 | queryString: childClauses[0].queryString 71 | } 72 | } else { 73 | console.error("Group has no children"); 74 | return false; 75 | } 76 | } 77 | 78 | const buildCondition = (schema: FieldDef[], clauses: StateClause, id: string): (BuiltQuery | boolean) => { 79 | const clause = clauses.get(id) as ConditionClause; 80 | 81 | let condition: SerialisedCondition | undefined = undefined; 82 | if (!clause || clause.default === true) { 83 | console.error(`Clause not found: ${id}`); 84 | return false; 85 | } else { 86 | condition = { 87 | field: clause.field, 88 | op: clause.op, 89 | collectionOp: clause.collectionOp, 90 | collectionField: clause.collectionField, 91 | value: clause.value 92 | } 93 | } 94 | 95 | const def = schema.find(d => d.field === clause.field); 96 | 97 | if (!def) { 98 | console.error(`Schema entry not found for field "${clause.field}"`); 99 | return false; 100 | } 101 | 102 | const filterField = def.filterField ?? def.field; 103 | 104 | let innerResult; 105 | if (clause.collectionOp) { 106 | if (clause.collectionOp === "count") { 107 | innerResult = { 108 | filter: `${filterField}/$count ${clause.op} ${clause.value}` 109 | }; 110 | } else { 111 | const collectionDef = def.collectionFields!.find(d => d.field === clause.collectionField!); 112 | innerResult = buildInnerCondition(collectionDef!, "x/" + clause.collectionField!, clause.op, clause.value); 113 | } 114 | } else { 115 | innerResult = buildInnerCondition(def, filterField, clause.op, clause.value); 116 | } 117 | 118 | if (typeof innerResult !== "boolean") { 119 | if (innerResult.filter) { 120 | return { 121 | filter: innerResult.filter, 122 | compute: innerResult.compute, 123 | select: innerResult.select, 124 | serialised: condition 125 | } 126 | } else { 127 | return { 128 | serialised: condition, 129 | queryString: innerResult.queryString 130 | }; 131 | } 132 | } else { 133 | return false; 134 | } 135 | } 136 | 137 | const buildInnerCondition = (schema: BaseFieldDef, field: string, op: Operation, value: any): BuiltInnerQuery | boolean => { 138 | if (schema.getCustomQueryString) { 139 | return { 140 | queryString: schema.getCustomQueryString(op, value) 141 | }; 142 | } 143 | 144 | if (schema.getCustomFilterString) { 145 | const result = schema.getCustomFilterString(op, value); 146 | 147 | if (typeof result === "string") { 148 | return { 149 | filter: result 150 | } 151 | } else if (typeof result !== "boolean") { 152 | const compute = result.compute; 153 | if (typeof compute === "string") { 154 | return { 155 | filter: result.filter, 156 | compute: compute 157 | }; 158 | } else { 159 | return { 160 | filter: result.filter, 161 | compute: compute.compute, 162 | select: compute.select 163 | }; 164 | } 165 | } else { 166 | return result; 167 | } 168 | } 169 | 170 | let translator: FilterTranslator; 171 | if (op in defaultTranslators) { 172 | translator = defaultTranslators[op]!; 173 | } else { 174 | translator = defaultTranslators["default"]!; 175 | } 176 | 177 | const result = translator({ schema, field, op, value }); 178 | 179 | if (typeof result === "string") { 180 | return { 181 | filter: result 182 | }; 183 | } else { 184 | return result; 185 | } 186 | } -------------------------------------------------------------------------------- /packages/base/FilterBuilder/components/FilterGroup.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useMemo } from "react" 2 | import { useRecoilValue, useSetRecoilState, waitForAll } from "recoil"; 3 | import Immutable from "immutable"; 4 | import { Button, ButtonGroup, Grid, ToggleButton, ToggleButtonGroup } from "@mui/material"; 5 | import { Add } from "@mui/icons-material"; 6 | 7 | import FilterCondition from "./FilterCondition"; 8 | 9 | import { Connective, GroupClause, TreeChildren, TreeGroup } from "../types" 10 | 11 | import { clauseState, propsState, schemaState, treeState } from "../state" 12 | 13 | import { getDefaultCondition, getDefaultGroup, getLocaleText } from "../utils"; 14 | 15 | import makeStyles from "../../makeStyles"; 16 | import { useResponsive } from "../../hooks"; 17 | 18 | 19 | const useStyles = makeStyles()((theme) => ({ 20 | group: { 21 | borderWidth: 1, 22 | borderColor: theme.palette.mode === "dark" ? "rgb(81,81,81)" : "rgb(224,224,224)", 23 | borderRadius: theme.shape.borderRadius, 24 | borderStyle: "solid", 25 | padding: theme.spacing(2), 26 | }, 27 | child: { 28 | position: "relative", 29 | "&:not(:last-of-type)::before": { 30 | content: "''", 31 | display: "block", 32 | position: "absolute", 33 | width: 2, 34 | height: "100%", 35 | background: theme.palette.primary.main, 36 | left: theme.spacing(-1), 37 | }, 38 | "&:first-of-type::before": { 39 | height: `calc(100% + ${theme.spacing(2)})`, 40 | top: 0 41 | }, 42 | "&::after": { 43 | content: "''", 44 | display: "block", 45 | position: "absolute", 46 | left: theme.spacing(-1), 47 | top: `calc(${theme.spacing(1)} + 1px)`, 48 | width: theme.spacing(2), 49 | height: "50%", 50 | borderWidth: 2, 51 | borderStyle: "solid", 52 | borderColor: theme.palette.primary.main, 53 | borderRight: "none", 54 | borderTop: "none", 55 | borderBottomLeftRadius: theme.shape.borderRadius 56 | } 57 | } 58 | })); 59 | 60 | 61 | type FilterGroupProps = { 62 | clauseId: string, 63 | path: string[], 64 | root?: boolean 65 | } 66 | 67 | const FilterGroup = ({ clauseId, path, root }: FilterGroupProps) => { 68 | const { classes } = useStyles(); 69 | const r = useResponsive(); 70 | 71 | const [tree, clauses] = useRecoilValue(waitForAll([treeState, clauseState])); 72 | const setTree = useSetRecoilState(treeState); 73 | const setClauses = useSetRecoilState(clauseState); 74 | const schema = useRecoilValue(schemaState); 75 | const builderProps = useRecoilValue(propsState); 76 | 77 | const group = useMemo(() => clauses.get(clauseId) as GroupClause, [clauses, clauseId]); 78 | const treeGroup = useMemo(() => tree.getIn([...path, clauseId]) as TreeGroup, [tree, path, clauseId]); 79 | 80 | const childrenPath = useMemo(() => [...path, clauseId, "children"], [path, clauseId]); 81 | 82 | const multiple = useMemo(() => treeGroup.children.count() > 1, [treeGroup]); 83 | 84 | const setConnective = useCallback((con: Connective) => { 85 | setClauses(clauses.update(clauseId, c => ({...c as GroupClause, connective: con}))) 86 | }, [clauses, setClauses, clauseId]); 87 | 88 | const addGroup = useCallback(() => { 89 | const group = getDefaultGroup(); 90 | const condition = getDefaultCondition(schema[0].field); 91 | 92 | setClauses(clauses 93 | .set(group.id, group) 94 | .set(condition.id, condition) 95 | ); 96 | 97 | setTree(tree 98 | .updateIn( 99 | childrenPath, 100 | (list) => (list as TreeChildren).set(group.id, { id: group.id, children: Immutable.Map({ [condition.id]: condition.id }) }) 101 | ) 102 | ); 103 | }, [clauses, setClauses, tree, setTree, childrenPath, schema]); 104 | 105 | const addCondition = useCallback(() => { 106 | const condition = getDefaultCondition(schema[0].field); 107 | 108 | setClauses(clauses.set(condition.id, condition)); 109 | 110 | setTree(tree 111 | .updateIn( 112 | childrenPath, 113 | (list) => (list as TreeChildren).set(condition.id, condition.id) 114 | ) 115 | ); 116 | }, [clauses, setClauses, tree, setTree, childrenPath, schema]); 117 | 118 | const handleConnective = useCallback((event, val: Connective | null) => { 119 | if (val) { 120 | setConnective(val); 121 | } 122 | }, [setConnective]); 123 | 124 | return ( 125 | 126 | 127 | {multiple && ( 128 | 129 | 137 | {getLocaleText("and", builderProps.localeText)} 138 | {getLocaleText("or", builderProps.localeText)} 139 | 140 | 141 | )} 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | {treeGroup.children.toArray().map((c) => { 151 | if (typeof c[1] === "string") { 152 | return ( 153 | 154 | 158 | 159 | ); 160 | } else { 161 | return ( 162 | 163 | 167 | 168 | ); 169 | } 170 | })} 171 | 172 | 173 | ) 174 | } 175 | 176 | export default FilterGroup; -------------------------------------------------------------------------------- /packages/base/FilterBuilder/components/FilterCondition.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useMemo } from "react" 2 | import { useRecoilState, useRecoilValue, useSetRecoilState } from "recoil"; 3 | import { Grid, IconButton } from "@mui/material"; 4 | import { Remove } from "@mui/icons-material"; 5 | 6 | import FilterInputs from "./FilterInputs"; 7 | 8 | import { CollectionOperation, ConditionClause, Operation, TreeGroup } from "../types" 9 | 10 | import { clauseState, schemaState, treeState } from "../state" 11 | 12 | import { numericOperators } from "../constants"; 13 | 14 | 15 | type FilterConditionProps = { 16 | clauseId: string, 17 | path: string[], 18 | } 19 | 20 | const FilterCondition = ({ clauseId, path }: FilterConditionProps) => { 21 | const [clauses, setClauses] = useRecoilState(clauseState); 22 | const setTree = useSetRecoilState(treeState); 23 | 24 | const schema = useRecoilValue(schemaState); 25 | 26 | const condition = useMemo(() => clauses.get(clauseId) as ConditionClause, [clauses, clauseId]); 27 | 28 | const changeField = useCallback((oldField: string, currentOp: Operation, newField: string) => { 29 | const oldFieldDef = schema.find(c => c.field === oldField); 30 | const newFieldDef = schema.find(c => c.field === newField); 31 | 32 | setClauses(old => old.update(clauseId, c => { 33 | const condition = { ...c as ConditionClause }; 34 | condition.field = newField; 35 | condition.default = false; 36 | 37 | if (oldFieldDef && newFieldDef) { 38 | // reset value if fields have different types 39 | if (oldFieldDef.type !== newFieldDef.type) { 40 | condition.value = ""; 41 | } 42 | 43 | // reset operator if new field doesn't support current operator 44 | if (newFieldDef.filterOperators && !newFieldDef.filterOperators.includes(currentOp)) { 45 | condition.op = newFieldDef.filterOperators[0] ?? "eq"; 46 | } 47 | 48 | // set collection field if new field is a collection 49 | if (newFieldDef.collection === true && newFieldDef.collectionFields) { 50 | condition.collectionField = newFieldDef.collectionFields[0].field; 51 | condition.collectionOp = "any"; 52 | 53 | if (newFieldDef.collectionFields[0].filterOperators) { 54 | condition.op = newFieldDef.collectionFields[0].filterOperators[0]; 55 | } 56 | else { 57 | condition.op = "eq"; 58 | } 59 | } else { // clear collection fields if new field is not a collection 60 | condition.collectionField = undefined; 61 | condition.collectionOp = undefined; 62 | } 63 | } 64 | 65 | return condition; 66 | })); 67 | }, [schema, setClauses, clauseId]); 68 | 69 | const changeOp = useCallback((o: Operation) => { 70 | setClauses(old => old.update(clauseId, c => ({ ...c as ConditionClause, op: o, default: false }))); 71 | }, [setClauses, clauseId]); 72 | 73 | const changeValue = useCallback((v: any) => { 74 | setClauses(old => old.update(clauseId, c => ({ ...c as ConditionClause, value: v, default: false }))); 75 | }, [setClauses, clauseId]); 76 | 77 | const changeCollectionOp = useCallback((o: CollectionOperation) => { 78 | setClauses(old => old.update(clauseId, c => { 79 | const condition = { ...c as ConditionClause, collectionOp: o, default: false }; 80 | 81 | // reset field operator if switching to count operator and current op is not valid 82 | if (o === "count" && !numericOperators.includes(condition.op)) { 83 | condition.op = "eq"; 84 | } 85 | 86 | return condition; 87 | })); 88 | }, [setClauses, clauseId]); 89 | 90 | const changeCollectionField = useCallback((field: string, oldColField: string | undefined, currentOp: Operation, newColField: string | undefined) => { 91 | const fieldDef = schema.find(c => c.field === field); 92 | 93 | setClauses(old => old.update(clauseId, c => { 94 | const condition = { ...c as ConditionClause }; 95 | condition.collectionField = newColField; 96 | condition.default = false; 97 | 98 | if (fieldDef && fieldDef.collectionFields && oldColField && newColField) { 99 | const oldColFieldDef = fieldDef.collectionFields.find(c => c.field === oldColField); 100 | const newColFieldDef = fieldDef.collectionFields.find(c => c.field === newColField); 101 | 102 | // reset value if fields have different types 103 | if (oldColFieldDef!.type !== newColFieldDef!.type) { 104 | condition.value = ""; 105 | } 106 | 107 | // reset operator if new field doesn't support current operator 108 | if (newColFieldDef!.filterOperators && !newColFieldDef!.filterOperators.includes(currentOp)) { 109 | condition.op = newColFieldDef!.filterOperators[0] ?? "eq"; 110 | } 111 | } 112 | 113 | return condition; 114 | })); 115 | }, [schema, setClauses, clauseId]); 116 | 117 | const remove = useCallback(() => { 118 | // if not root group 119 | if (path.length > 2) { 120 | setTree(oldTree => oldTree.withMutations((old) => { 121 | // delete self 122 | old.deleteIn([...path, clauseId]); 123 | 124 | // get path to parent node (i.e. remove "children" from end of path) 125 | const parentPath = [...path]; 126 | parentPath.splice(-1, 1); 127 | 128 | do { 129 | const node = old.getIn(parentPath) as TreeGroup; 130 | // delete parent if now empty 131 | if (node && node.children.count() < 1) { 132 | old.deleteIn(parentPath); 133 | } else { // not the only child, so only remove self and stop 134 | old.deleteIn([...path, clauseId]); 135 | break; 136 | } 137 | 138 | parentPath.splice(-2, 2); // move up in path to next parent 139 | } while (parentPath.length > 2) // keep removing empty groups until root is reached 140 | })) 141 | } else { 142 | setTree(old => old.deleteIn([...path, clauseId])); 143 | } 144 | 145 | setClauses(old => old.remove(clauseId)); 146 | }, [setClauses, setTree, clauseId, path]) 147 | 148 | if (!condition) { 149 | return null; 150 | } 151 | 152 | return ( 153 | 154 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | ); 174 | } 175 | 176 | export default FilterCondition; -------------------------------------------------------------------------------- /packages/o-data-grid-pro/dev/App.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { CssBaseline, Typography, Grid, TextField, Slider, Chip } from "@mui/material"; 3 | import { createTheme, ThemeProvider } from "@mui/material/styles"; 4 | import { GridSortModel } from "@mui/x-data-grid" 5 | import { ODataGridColDef, ODataColumnVisibilityModel, escapeODataString } from "../src/index"; 6 | import ODataGridPro from "../src/ODataGridPro"; 7 | import { CacheProvider } from "@emotion/react"; 8 | import createCache from "@emotion/cache"; 9 | import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; 10 | import { Dayjs } from "dayjs"; 11 | import { ExternalBuilderProps } from "../../base/FilterBuilder/types"; 12 | 13 | const theme = createTheme({ 14 | palette: { 15 | mode: "dark" 16 | } 17 | }) 18 | 19 | export const muiCache = createCache({ 20 | key: "mui", 21 | prepend: true 22 | }); 23 | 24 | const App = () => { 25 | return ( 26 | 27 | 28 | 29 | row.Id} 34 | defaultSortModel={defaultSort} 35 | filterBuilderProps={filterBuilderProps} 36 | alwaysSelect={alwaysFetch} 37 | /> 38 | 39 | 40 | ); 41 | } 42 | 43 | type LocationFilter = { 44 | location?: string, 45 | distance?: number 46 | } 47 | 48 | const filterBuilderProps: ExternalBuilderProps = { autocompleteGroups: ["Job", "Company"], localizationProviderProps: { dateAdapter: AdapterDayjs } }; 49 | 50 | const alwaysFetch = ["Id", "Archived"]; 51 | const columns: ODataGridColDef[] = [ 52 | { 53 | field: "Title", 54 | headerName: "Job Title", 55 | flex: 2, 56 | autocompleteGroup: "Job" 57 | }, 58 | { 59 | field: "Location", 60 | headerName: "Location", 61 | flex: 1, 62 | renderCustomFilter: (value, setValue) => ( 63 | 64 | 65 | setValue({ ...value, location: e.target.value })} 68 | size="small" 69 | fullWidth 70 | label="Search Location" 71 | required 72 | /> 73 | 74 | 75 | Distance 76 | setValue({ ...value, distance: val as number })} 79 | step={5} 80 | min={0} 81 | max={50} 82 | valueLabelFormat={(val) => `${val}mi`} 83 | valueLabelDisplay="auto" 84 | size="small" 85 | sx={{padding: 0}} 86 | /> 87 | 88 | 89 | ), 90 | getCustomFilterString: (_, v) => { 91 | const filter = v as LocationFilter; 92 | return { 93 | filter: `Latitude ne null and Longitude ne null and Distance le ${filter.distance ?? 15}`, 94 | compute: { 95 | compute: `geocode('${escapeODataString(filter.location ?? "")}', Latitude, Longitude) as Distance`, 96 | select: ["Distance"] 97 | } 98 | }; 99 | }, 100 | valueGetter: (params) => `${params.row.Location}${params.row.Distance ? ` (${params.row.Distance.toFixed(1)} mi away)` : ""}`, 101 | autocompleteGroup: "Job" 102 | }, 103 | { 104 | field: "Company/Name", 105 | headerName: "Company", 106 | flex: 2, 107 | renderCell: (params) => ( 108 | 109 | 110 | {params.value} 111 | 112 | {params.row["Company/Recruiter"] && } 113 | {params.row["Company/Blacklisted"] && } 114 | 115 | ), 116 | expand: { navigationField: "Company", select: "Id,Name,Recruiter,Blacklisted,Watched" }, 117 | autocompleteGroup: "Company" 118 | }, 119 | { 120 | field: "Salary", 121 | type: "number", 122 | filterField: "AvgYearlySalary", 123 | sortField: "AvgYearlySalary", 124 | label: "Median Annual Salary", 125 | filterType: "number", 126 | filterOperators: ["eq", "ne", "gt", "lt", "ge", "le", "null", "notnull"], 127 | flex: 1, 128 | autocompleteGroup: "Job" 129 | }, 130 | { 131 | field: "Status", 132 | type: "singleSelect", 133 | valueOptions: ["Not Applied", "Awaiting Response", "In Progress", "Rejected", "Dropped Out"], 134 | filterOperators: ["eq", "ne"], 135 | autocompleteGroup: "Job" 136 | }, 137 | { 138 | field: "JobCategories", 139 | headerName: "Categories", 140 | label: "Category", 141 | expand: { 142 | navigationField: "JobCategories/Category", 143 | select: "Name" 144 | }, 145 | sortable: false, 146 | filterable: false, 147 | flex: 1, 148 | renderCell: (params) => params.row.JobCategories.map((c: any) => c["Category/Name"]).join(", "), 149 | autocompleteGroup: "Job" 150 | }, 151 | { 152 | field: "Source/DisplayName", 153 | expand: { navigationField: "Source", select: "DisplayName" }, 154 | headerName: "Source", 155 | filterable: false, 156 | sortable: false, 157 | flex: 1, 158 | valueGetter: (params) => params.row[params.field] ? params.row[params.field] : "Added Manually", 159 | autocompleteGroup: "Job" 160 | }, 161 | { 162 | field: "Posted", 163 | select: "Posted,Seen,Archived", 164 | headerName: "Posted", 165 | type: "date", 166 | flex: .9, 167 | autocompleteGroup: "Job" 168 | }, 169 | 170 | // filter only 171 | { 172 | field: "Company/Recruiter", 173 | label: "Company Type", 174 | filterOnly: true, 175 | filterOperators: ["eq", "ne"], 176 | type: "singleSelect", 177 | valueOptions: [ 178 | { label: "Employer", value: false }, 179 | { label: "Recruiter", value: true } 180 | ], 181 | autocompleteGroup: "Company" 182 | }, 183 | { 184 | field: "Company/Watched", 185 | label: "Company Watched", 186 | filterOnly: true, 187 | filterOperators: ["eq", "ne"], 188 | type: "boolean", 189 | autocompleteGroup: "Company" 190 | }, 191 | { 192 | field: "Company/Blacklisted", 193 | label: "Company Blacklisted", 194 | filterOnly: true, 195 | filterOperators: ["eq", "ne"], 196 | type: "boolean", 197 | autocompleteGroup: "Company" 198 | }, 199 | { 200 | field: "Description", 201 | filterOnly: true, 202 | filterOperators: ["contains"], 203 | autocompleteGroup: "Job" 204 | }, 205 | { 206 | field: "Notes", 207 | filterOnly: true, 208 | filterOperators: ["contains"], 209 | autocompleteGroup: "Job" 210 | } 211 | ]; 212 | 213 | const columnVisibility: ODataColumnVisibilityModel = { 214 | "Company/Name": { xs: false, md: true }, 215 | "Salary": { xs: false, lg: true }, 216 | "Status": false, 217 | "JobCategories": { xs: false, xl: true }, 218 | "Source/DisplayName": true, 219 | "Posted": { xs: false, sm: true }, 220 | } 221 | 222 | const defaultSort: GridSortModel = [{ field: "Posted", sort: "desc" }]; 223 | 224 | export default App; -------------------------------------------------------------------------------- /packages/base/FilterBuilder/components/FilterRoot.tsx: -------------------------------------------------------------------------------- 1 | import React, { Fragment, useCallback, useEffect, useState } from "react" 2 | import { useSetRecoilState } from "recoil"; 3 | import { ArrowDropDown } from "@mui/icons-material"; 4 | import { Button, ButtonGroup, Grid, MenuItem, MenuList, Paper, Popover } from "@mui/material"; 5 | 6 | import FilterGroup from "./FilterGroup"; 7 | 8 | import { clauseState, propsState, schemaState, treeState } from "../state" 9 | 10 | import { initialClauses, initialTree, rootConditionUuid, rootGroupUuid } from "../constants" 11 | import { FilterBuilderProps } from "./FilterBuilder"; 12 | import { UseODataFilter, UseODataFilterWithState } from "../hooks"; 13 | import { useMountEffect } from "../../hooks"; 14 | import { ConditionClause, SerialisedGroup, QueryStringCollection } from "../types"; 15 | import { deserialise } from "../utils"; 16 | 17 | type FilterRootProps = { 18 | props: FilterBuilderProps 19 | } 20 | 21 | const FilterRoot = ({ props }: FilterRootProps) => { 22 | const setClauses = useSetRecoilState(clauseState); 23 | const setProps = useSetRecoilState(propsState); 24 | const setSchema = useSetRecoilState(schemaState); 25 | const setTree = useSetRecoilState(treeState); 26 | 27 | const odataFilter = UseODataFilter(); 28 | const odataFilterWithState = UseODataFilterWithState(); 29 | 30 | const [anchor, setAnchor] = useState(null); 31 | 32 | const { onSubmit, onRestoreState, disableHistory, filter: propsFilter } = props; 33 | const submit = useCallback((e: React.FormEvent) => { 34 | e.preventDefault(); 35 | if (onSubmit) { 36 | const result = odataFilter(); 37 | 38 | if (result.filter) { 39 | const returned = onSubmit({ ...result, filter: result.filter }); 40 | 41 | if (disableHistory !== true) { 42 | window.history.pushState( 43 | { 44 | ...window.history.state, 45 | ...returned, 46 | filterBuilder: { 47 | filter: result.filter, 48 | compute: result.compute, 49 | select: result.select, 50 | serialised: result.serialised, 51 | queryString: result.queryString 52 | } 53 | }, 54 | "" 55 | ); 56 | } 57 | } 58 | } 59 | }, [onSubmit, odataFilter, disableHistory]); 60 | 61 | const reset = useCallback(() => { 62 | setClauses(initialClauses.update(rootConditionUuid, (c) => ({ ...c as ConditionClause, field: props.schema[0].field }))); 63 | setTree(initialTree); 64 | 65 | if (onSubmit) { 66 | onSubmit({ filter: "" }); 67 | } 68 | 69 | if (disableHistory !== true) { 70 | window.history.pushState({ 71 | ...window.history.state, 72 | filterBuilder: { 73 | reset: true 74 | } 75 | }, ""); 76 | } 77 | }, [setClauses, setTree, onSubmit, props.schema, disableHistory]); 78 | 79 | const handleReset = useCallback(() => reset(), [reset]); 80 | 81 | useEffect(() => { 82 | setSchema(props.schema); 83 | }, [props.schema, setSchema]); 84 | 85 | const restoreDefault = useCallback(() => { 86 | setClauses(initialClauses.update(rootConditionUuid, (c) => ({ ...c as ConditionClause, field: props.schema[0].field }))); 87 | setTree(initialTree); 88 | }, [props.schema, setClauses, setTree]); 89 | 90 | const restoreState = useCallback((state: any, isPopstate: boolean) => { 91 | let filter = "", serialised, queryString, compute, select; 92 | 93 | if (state?.filterBuilder) { 94 | if (state.filterBuilder.reset === true && isPopstate === true) { 95 | restoreDefault(); 96 | } 97 | 98 | compute = state.filterBuilder.compute as string; 99 | filter = state.filterBuilder.filter as string; 100 | select = state.filterBuilder.select as string[]; 101 | serialised = state.filterBuilder.serialised as SerialisedGroup; 102 | queryString = state.filterBuilder.queryString as QueryStringCollection; 103 | } else { 104 | restoreDefault(); 105 | } 106 | 107 | if (filter && serialised) { 108 | const [tree, clauses] = deserialise(serialised); 109 | 110 | setClauses(clauses); 111 | setTree(tree); 112 | } 113 | 114 | if (onRestoreState) { 115 | onRestoreState({ compute, filter, queryString, select, serialised}, state); 116 | } 117 | }, [onRestoreState, restoreDefault, setClauses, setTree]); 118 | 119 | const restoreFilter = useCallback((serialised: SerialisedGroup) => { 120 | const [tree, clauses] = deserialise(serialised); 121 | 122 | setClauses(clauses); 123 | setTree(tree); 124 | 125 | if (onRestoreState) { 126 | const result = odataFilterWithState(clauses, tree); 127 | 128 | onRestoreState({ ...result, filter: result.filter ?? "" }); 129 | } 130 | }, [setClauses, setTree, onRestoreState, odataFilterWithState]); 131 | 132 | useEffect(() => { 133 | if (disableHistory !== true) { 134 | const handlePopState = (e: PopStateEvent) => { restoreState(e.state, true); }; 135 | 136 | window.addEventListener("popstate", handlePopState); 137 | return () => window.removeEventListener("popstate", handlePopState); 138 | } 139 | }, [disableHistory, restoreState]); 140 | 141 | useEffect(() => { 142 | if (propsFilter) { 143 | restoreFilter(propsFilter); 144 | } else { 145 | restoreDefault(); 146 | } 147 | }, [propsFilter, restoreFilter, restoreDefault]); 148 | 149 | useMountEffect(() => { 150 | setProps(props); 151 | 152 | // restore query from history state if enabled 153 | if (disableHistory !== true && window.history.state && window.history.state.filterBuilder) { 154 | restoreState(window.history.state, false); 155 | } else if (propsFilter) { 156 | restoreFilter(propsFilter); 157 | } else { 158 | restoreDefault(); 159 | } 160 | }); 161 | 162 | return ( 163 | 164 |
165 | 170 | 171 | 172 | 173 | 174 | 175 | { 176 | props.searchMenuItems && 177 | 186 | } 187 | 188 | 189 | 190 | 191 | 192 | 193 | { 194 | props.searchMenuItems && 195 | setAnchor(null)} 200 | transitionDuration={100} 201 | > 202 | 203 | 204 | {props.searchMenuItems.map((item, i) => ( 205 | 209 | {item.label} 210 | ))} 211 | 212 | 213 | 214 | } 215 | 216 | 217 |
218 | ); 219 | } 220 | 221 | export default FilterRoot; -------------------------------------------------------------------------------- /docs/api/Migrations/20230325165107_InitialCreate.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Microsoft.EntityFrameworkCore.Migrations; 3 | using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; 4 | 5 | #nullable disable 6 | 7 | namespace api.Migrations 8 | { 9 | public partial class InitialCreate : Migration 10 | { 11 | protected override void Up(MigrationBuilder migrationBuilder) 12 | { 13 | migrationBuilder.CreateTable( 14 | name: "Customers", 15 | columns: table => new 16 | { 17 | Id = table.Column(type: "integer", nullable: false) 18 | .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), 19 | FirstName = table.Column(type: "text", nullable: false), 20 | MiddleNames = table.Column(type: "text", nullable: true), 21 | Surname = table.Column(type: "text", nullable: false), 22 | EmailAddress = table.Column(type: "text", nullable: false), 23 | CreatedDate = table.Column(type: "timestamp with time zone", nullable: false) 24 | }, 25 | constraints: table => 26 | { 27 | table.PrimaryKey("PK_Customers", x => x.Id); 28 | }); 29 | 30 | migrationBuilder.CreateTable( 31 | name: "Product", 32 | columns: table => new 33 | { 34 | Id = table.Column(type: "integer", nullable: false) 35 | .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), 36 | Name = table.Column(type: "text", nullable: false), 37 | Price = table.Column(type: "numeric", nullable: false) 38 | }, 39 | constraints: table => 40 | { 41 | table.PrimaryKey("PK_Product", x => x.Id); 42 | }); 43 | 44 | migrationBuilder.CreateTable( 45 | name: "Addresses", 46 | columns: table => new 47 | { 48 | Id = table.Column(type: "integer", nullable: false) 49 | .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), 50 | CustomerId = table.Column(type: "integer", nullable: false), 51 | Line1 = table.Column(type: "text", nullable: false), 52 | Line2 = table.Column(type: "text", nullable: true), 53 | Line3 = table.Column(type: "text", nullable: true), 54 | Town = table.Column(type: "text", nullable: false), 55 | County = table.Column(type: "text", nullable: true), 56 | Country = table.Column(type: "text", nullable: false), 57 | PostCode = table.Column(type: "text", nullable: false) 58 | }, 59 | constraints: table => 60 | { 61 | table.PrimaryKey("PK_Addresses", x => x.Id); 62 | table.ForeignKey( 63 | name: "FK_Addresses_Customers_CustomerId", 64 | column: x => x.CustomerId, 65 | principalTable: "Customers", 66 | principalColumn: "Id", 67 | onDelete: ReferentialAction.Cascade); 68 | }); 69 | 70 | migrationBuilder.CreateTable( 71 | name: "Orders", 72 | columns: table => new 73 | { 74 | Id = table.Column(type: "integer", nullable: false) 75 | .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), 76 | CustomerId = table.Column(type: "integer", nullable: false), 77 | DeliveryAddressId = table.Column(type: "integer", nullable: false), 78 | Total = table.Column(type: "numeric", nullable: false) 79 | }, 80 | constraints: table => 81 | { 82 | table.PrimaryKey("PK_Orders", x => x.Id); 83 | table.ForeignKey( 84 | name: "FK_Orders_Addresses_DeliveryAddressId", 85 | column: x => x.DeliveryAddressId, 86 | principalTable: "Addresses", 87 | principalColumn: "Id", 88 | onDelete: ReferentialAction.Cascade); 89 | table.ForeignKey( 90 | name: "FK_Orders_Customers_CustomerId", 91 | column: x => x.CustomerId, 92 | principalTable: "Customers", 93 | principalColumn: "Id", 94 | onDelete: ReferentialAction.Cascade); 95 | }); 96 | 97 | migrationBuilder.CreateTable( 98 | name: "OrderProduct", 99 | columns: table => new 100 | { 101 | Id = table.Column(type: "integer", nullable: false) 102 | .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), 103 | OrderId = table.Column(type: "integer", nullable: false), 104 | ProductId = table.Column(type: "integer", nullable: false), 105 | Quantity = table.Column(type: "integer", nullable: false) 106 | }, 107 | constraints: table => 108 | { 109 | table.PrimaryKey("PK_OrderProduct", x => x.Id); 110 | table.ForeignKey( 111 | name: "FK_OrderProduct_Orders_OrderId", 112 | column: x => x.OrderId, 113 | principalTable: "Orders", 114 | principalColumn: "Id", 115 | onDelete: ReferentialAction.Cascade); 116 | table.ForeignKey( 117 | name: "FK_OrderProduct_Product_ProductId", 118 | column: x => x.ProductId, 119 | principalTable: "Product", 120 | principalColumn: "Id", 121 | onDelete: ReferentialAction.Cascade); 122 | }); 123 | 124 | migrationBuilder.CreateIndex( 125 | name: "IX_Addresses_CustomerId", 126 | table: "Addresses", 127 | column: "CustomerId"); 128 | 129 | migrationBuilder.CreateIndex( 130 | name: "IX_OrderProduct_OrderId", 131 | table: "OrderProduct", 132 | column: "OrderId"); 133 | 134 | migrationBuilder.CreateIndex( 135 | name: "IX_OrderProduct_ProductId", 136 | table: "OrderProduct", 137 | column: "ProductId"); 138 | 139 | migrationBuilder.CreateIndex( 140 | name: "IX_Orders_CustomerId", 141 | table: "Orders", 142 | column: "CustomerId"); 143 | 144 | migrationBuilder.CreateIndex( 145 | name: "IX_Orders_DeliveryAddressId", 146 | table: "Orders", 147 | column: "DeliveryAddressId"); 148 | } 149 | 150 | protected override void Down(MigrationBuilder migrationBuilder) 151 | { 152 | migrationBuilder.DropTable( 153 | name: "OrderProduct"); 154 | 155 | migrationBuilder.DropTable( 156 | name: "Orders"); 157 | 158 | migrationBuilder.DropTable( 159 | name: "Product"); 160 | 161 | migrationBuilder.DropTable( 162 | name: "Addresses"); 163 | 164 | migrationBuilder.DropTable( 165 | name: "Customers"); 166 | } 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /docs/api/.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore 5 | 6 | # User-specific files 7 | *.rsuser 8 | *.suo 9 | *.user 10 | *.userosscache 11 | *.sln.docstates 12 | 13 | # User-specific files (MonoDevelop/Xamarin Studio) 14 | *.userprefs 15 | 16 | # Mono auto generated files 17 | mono_crash.* 18 | 19 | # Build results 20 | [Dd]ebug/ 21 | [Dd]ebugPublic/ 22 | [Rr]elease/ 23 | [Rr]eleases/ 24 | x64/ 25 | x86/ 26 | [Ww][Ii][Nn]32/ 27 | [Aa][Rr][Mm]/ 28 | [Aa][Rr][Mm]64/ 29 | bld/ 30 | [Bb]in/ 31 | [Oo]bj/ 32 | [Ll]og/ 33 | [Ll]ogs/ 34 | 35 | # Visual Studio 2015/2017 cache/options directory 36 | .vs/ 37 | # Uncomment if you have tasks that create the project's static files in wwwroot 38 | #wwwroot/ 39 | 40 | # Visual Studio 2017 auto generated files 41 | Generated\ Files/ 42 | 43 | # MSTest test Results 44 | [Tt]est[Rr]esult*/ 45 | [Bb]uild[Ll]og.* 46 | 47 | # NUnit 48 | *.VisualState.xml 49 | TestResult.xml 50 | nunit-*.xml 51 | 52 | # Build Results of an ATL Project 53 | [Dd]ebugPS/ 54 | [Rr]eleasePS/ 55 | dlldata.c 56 | 57 | # Benchmark Results 58 | BenchmarkDotNet.Artifacts/ 59 | 60 | # .NET Core 61 | project.lock.json 62 | project.fragment.lock.json 63 | artifacts/ 64 | 65 | # ASP.NET Scaffolding 66 | ScaffoldingReadMe.txt 67 | 68 | # StyleCop 69 | StyleCopReport.xml 70 | 71 | # Files built by Visual Studio 72 | *_i.c 73 | *_p.c 74 | *_h.h 75 | *.ilk 76 | *.meta 77 | *.obj 78 | *.iobj 79 | *.pch 80 | *.pdb 81 | *.ipdb 82 | *.pgc 83 | *.pgd 84 | *.rsp 85 | *.sbr 86 | *.tlb 87 | *.tli 88 | *.tlh 89 | *.tmp 90 | *.tmp_proj 91 | *_wpftmp.csproj 92 | *.log 93 | *.tlog 94 | *.vspscc 95 | *.vssscc 96 | .builds 97 | *.pidb 98 | *.svclog 99 | *.scc 100 | 101 | # Chutzpah Test files 102 | _Chutzpah* 103 | 104 | # Visual C++ cache files 105 | ipch/ 106 | *.aps 107 | *.ncb 108 | *.opendb 109 | *.opensdf 110 | *.sdf 111 | *.cachefile 112 | *.VC.db 113 | *.VC.VC.opendb 114 | 115 | # Visual Studio profiler 116 | *.psess 117 | *.vsp 118 | *.vspx 119 | *.sap 120 | 121 | # Visual Studio Trace Files 122 | *.e2e 123 | 124 | # TFS 2012 Local Workspace 125 | $tf/ 126 | 127 | # Guidance Automation Toolkit 128 | *.gpState 129 | 130 | # ReSharper is a .NET coding add-in 131 | _ReSharper*/ 132 | *.[Rr]e[Ss]harper 133 | *.DotSettings.user 134 | 135 | # TeamCity is a build add-in 136 | _TeamCity* 137 | 138 | # DotCover is a Code Coverage Tool 139 | *.dotCover 140 | 141 | # AxoCover is a Code Coverage Tool 142 | .axoCover/* 143 | !.axoCover/settings.json 144 | 145 | # Coverlet is a free, cross platform Code Coverage Tool 146 | coverage*.json 147 | coverage*.xml 148 | coverage*.info 149 | 150 | # Visual Studio code coverage results 151 | *.coverage 152 | *.coveragexml 153 | 154 | # NCrunch 155 | _NCrunch_* 156 | .*crunch*.local.xml 157 | nCrunchTemp_* 158 | 159 | # MightyMoose 160 | *.mm.* 161 | AutoTest.Net/ 162 | 163 | # Web workbench (sass) 164 | .sass-cache/ 165 | 166 | # Installshield output folder 167 | [Ee]xpress/ 168 | 169 | # DocProject is a documentation generator add-in 170 | DocProject/buildhelp/ 171 | DocProject/Help/*.HxT 172 | DocProject/Help/*.HxC 173 | DocProject/Help/*.hhc 174 | DocProject/Help/*.hhk 175 | DocProject/Help/*.hhp 176 | DocProject/Help/Html2 177 | DocProject/Help/html 178 | 179 | # Click-Once directory 180 | publish/ 181 | 182 | # Publish Web Output 183 | *.[Pp]ublish.xml 184 | *.azurePubxml 185 | # Note: Comment the next line if you want to checkin your web deploy settings, 186 | # but database connection strings (with potential passwords) will be unencrypted 187 | *.pubxml 188 | *.publishproj 189 | 190 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 191 | # checkin your Azure Web App publish settings, but sensitive information contained 192 | # in these scripts will be unencrypted 193 | PublishScripts/ 194 | 195 | # NuGet Packages 196 | *.nupkg 197 | # NuGet Symbol Packages 198 | *.snupkg 199 | # The packages folder can be ignored because of Package Restore 200 | **/[Pp]ackages/* 201 | # except build/, which is used as an MSBuild target. 202 | !**/[Pp]ackages/build/ 203 | # Uncomment if necessary however generally it will be regenerated when needed 204 | #!**/[Pp]ackages/repositories.config 205 | # NuGet v3's project.json files produces more ignorable files 206 | *.nuget.props 207 | *.nuget.targets 208 | 209 | # Microsoft Azure Build Output 210 | csx/ 211 | *.build.csdef 212 | 213 | # Microsoft Azure Emulator 214 | ecf/ 215 | rcf/ 216 | 217 | # Windows Store app package directories and files 218 | AppPackages/ 219 | BundleArtifacts/ 220 | Package.StoreAssociation.xml 221 | _pkginfo.txt 222 | *.appx 223 | *.appxbundle 224 | *.appxupload 225 | 226 | # Visual Studio cache files 227 | # files ending in .cache can be ignored 228 | *.[Cc]ache 229 | # but keep track of directories ending in .cache 230 | !?*.[Cc]ache/ 231 | 232 | # Others 233 | ClientBin/ 234 | ~$* 235 | *~ 236 | *.dbmdl 237 | *.dbproj.schemaview 238 | *.jfm 239 | *.pfx 240 | *.publishsettings 241 | orleans.codegen.cs 242 | 243 | # Including strong name files can present a security risk 244 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 245 | #*.snk 246 | 247 | # Since there are multiple workflows, uncomment next line to ignore bower_components 248 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 249 | #bower_components/ 250 | 251 | # RIA/Silverlight projects 252 | Generated_Code/ 253 | 254 | # Backup & report files from converting an old project file 255 | # to a newer Visual Studio version. Backup files are not needed, 256 | # because we have git ;-) 257 | _UpgradeReport_Files/ 258 | Backup*/ 259 | UpgradeLog*.XML 260 | UpgradeLog*.htm 261 | ServiceFabricBackup/ 262 | *.rptproj.bak 263 | 264 | # SQL Server files 265 | *.mdf 266 | *.ldf 267 | *.ndf 268 | 269 | # Business Intelligence projects 270 | *.rdl.data 271 | *.bim.layout 272 | *.bim_*.settings 273 | *.rptproj.rsuser 274 | *- [Bb]ackup.rdl 275 | *- [Bb]ackup ([0-9]).rdl 276 | *- [Bb]ackup ([0-9][0-9]).rdl 277 | 278 | # Microsoft Fakes 279 | FakesAssemblies/ 280 | 281 | # GhostDoc plugin setting file 282 | *.GhostDoc.xml 283 | 284 | # Node.js Tools for Visual Studio 285 | .ntvs_analysis.dat 286 | node_modules/ 287 | 288 | # Visual Studio 6 build log 289 | *.plg 290 | 291 | # Visual Studio 6 workspace options file 292 | *.opt 293 | 294 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 295 | *.vbw 296 | 297 | # Visual Studio 6 auto-generated project file (contains which files were open etc.) 298 | *.vbp 299 | 300 | # Visual Studio 6 workspace and project file (working project files containing files to include in project) 301 | *.dsw 302 | *.dsp 303 | 304 | # Visual Studio 6 technical files 305 | *.ncb 306 | *.aps 307 | 308 | # Visual Studio LightSwitch build output 309 | **/*.HTMLClient/GeneratedArtifacts 310 | **/*.DesktopClient/GeneratedArtifacts 311 | **/*.DesktopClient/ModelManifest.xml 312 | **/*.Server/GeneratedArtifacts 313 | **/*.Server/ModelManifest.xml 314 | _Pvt_Extensions 315 | 316 | # Paket dependency manager 317 | .paket/paket.exe 318 | paket-files/ 319 | 320 | # FAKE - F# Make 321 | .fake/ 322 | 323 | # CodeRush personal settings 324 | .cr/personal 325 | 326 | # Python Tools for Visual Studio (PTVS) 327 | __pycache__/ 328 | *.pyc 329 | 330 | # Cake - Uncomment if you are using it 331 | # tools/** 332 | # !tools/packages.config 333 | 334 | # Tabs Studio 335 | *.tss 336 | 337 | # Telerik's JustMock configuration file 338 | *.jmconfig 339 | 340 | # BizTalk build output 341 | *.btp.cs 342 | *.btm.cs 343 | *.odx.cs 344 | *.xsd.cs 345 | 346 | # OpenCover UI analysis results 347 | OpenCover/ 348 | 349 | # Azure Stream Analytics local run output 350 | ASALocalRun/ 351 | 352 | # MSBuild Binary and Structured Log 353 | *.binlog 354 | 355 | # NVidia Nsight GPU debugger configuration file 356 | *.nvuser 357 | 358 | # MFractors (Xamarin productivity tool) working folder 359 | .mfractor/ 360 | 361 | # Local History for Visual Studio 362 | .localhistory/ 363 | 364 | # Visual Studio History (VSHistory) files 365 | .vshistory/ 366 | 367 | # BeatPulse healthcheck temp database 368 | healthchecksdb 369 | 370 | # Backup folder for Package Reference Convert tool in Visual Studio 2017 371 | MigrationBackup/ 372 | 373 | # Ionide (cross platform F# VS Code tools) working folder 374 | .ionide/ 375 | 376 | # Fody - auto-generated XML schema 377 | FodyWeavers.xsd 378 | 379 | # VS Code files for those working on multiple tools 380 | .vscode/* 381 | !.vscode/settings.json 382 | !.vscode/tasks.json 383 | !.vscode/launch.json 384 | !.vscode/extensions.json 385 | *.code-workspace 386 | 387 | # Local History for Visual Studio Code 388 | .history/ 389 | 390 | # Windows Installer files from build outputs 391 | *.cab 392 | *.msi 393 | *.msix 394 | *.msm 395 | *.msp 396 | 397 | # JetBrains Rider 398 | *.sln.iml 399 | -------------------------------------------------------------------------------- /docs/api/Migrations/20230325165107_InitialCreate.Designer.cs: -------------------------------------------------------------------------------- 1 | // 2 | using System; 3 | using Api.Data; 4 | using Microsoft.EntityFrameworkCore; 5 | using Microsoft.EntityFrameworkCore.Infrastructure; 6 | using Microsoft.EntityFrameworkCore.Migrations; 7 | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; 8 | using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; 9 | 10 | #nullable disable 11 | 12 | namespace api.Migrations 13 | { 14 | [DbContext(typeof(ApiContext))] 15 | [Migration("20230325165107_InitialCreate")] 16 | partial class InitialCreate 17 | { 18 | protected override void BuildTargetModel(ModelBuilder modelBuilder) 19 | { 20 | #pragma warning disable 612, 618 21 | modelBuilder 22 | .HasAnnotation("ProductVersion", "6.0.4") 23 | .HasAnnotation("Relational:MaxIdentifierLength", 63); 24 | 25 | NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); 26 | 27 | modelBuilder.Entity("Api.Models.Address", b => 28 | { 29 | b.Property("Id") 30 | .ValueGeneratedOnAdd() 31 | .HasColumnType("integer"); 32 | 33 | NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); 34 | 35 | b.Property("Country") 36 | .IsRequired() 37 | .HasColumnType("text"); 38 | 39 | b.Property("County") 40 | .HasColumnType("text"); 41 | 42 | b.Property("CustomerId") 43 | .HasColumnType("integer"); 44 | 45 | b.Property("Line1") 46 | .IsRequired() 47 | .HasColumnType("text"); 48 | 49 | b.Property("Line2") 50 | .HasColumnType("text"); 51 | 52 | b.Property("Line3") 53 | .HasColumnType("text"); 54 | 55 | b.Property("PostCode") 56 | .IsRequired() 57 | .HasColumnType("text"); 58 | 59 | b.Property("Town") 60 | .IsRequired() 61 | .HasColumnType("text"); 62 | 63 | b.HasKey("Id"); 64 | 65 | b.HasIndex("CustomerId"); 66 | 67 | b.ToTable("Addresses"); 68 | }); 69 | 70 | modelBuilder.Entity("Api.Models.Customer", b => 71 | { 72 | b.Property("Id") 73 | .ValueGeneratedOnAdd() 74 | .HasColumnType("integer"); 75 | 76 | NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); 77 | 78 | b.Property("CreatedDate") 79 | .HasColumnType("timestamp with time zone"); 80 | 81 | b.Property("EmailAddress") 82 | .IsRequired() 83 | .HasColumnType("text"); 84 | 85 | b.Property("FirstName") 86 | .IsRequired() 87 | .HasColumnType("text"); 88 | 89 | b.Property("MiddleNames") 90 | .HasColumnType("text"); 91 | 92 | b.Property("Surname") 93 | .IsRequired() 94 | .HasColumnType("text"); 95 | 96 | b.HasKey("Id"); 97 | 98 | b.ToTable("Customers"); 99 | }); 100 | 101 | modelBuilder.Entity("Api.Models.Order", b => 102 | { 103 | b.Property("Id") 104 | .ValueGeneratedOnAdd() 105 | .HasColumnType("integer"); 106 | 107 | NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); 108 | 109 | b.Property("CustomerId") 110 | .HasColumnType("integer"); 111 | 112 | b.Property("DeliveryAddressId") 113 | .HasColumnType("integer"); 114 | 115 | b.Property("Total") 116 | .HasColumnType("numeric"); 117 | 118 | b.HasKey("Id"); 119 | 120 | b.HasIndex("CustomerId"); 121 | 122 | b.HasIndex("DeliveryAddressId"); 123 | 124 | b.ToTable("Orders"); 125 | }); 126 | 127 | modelBuilder.Entity("Api.Models.OrderProduct", b => 128 | { 129 | b.Property("Id") 130 | .ValueGeneratedOnAdd() 131 | .HasColumnType("integer"); 132 | 133 | NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); 134 | 135 | b.Property("OrderId") 136 | .HasColumnType("integer"); 137 | 138 | b.Property("ProductId") 139 | .HasColumnType("integer"); 140 | 141 | b.Property("Quantity") 142 | .HasColumnType("integer"); 143 | 144 | b.HasKey("Id"); 145 | 146 | b.HasIndex("OrderId"); 147 | 148 | b.HasIndex("ProductId"); 149 | 150 | b.ToTable("OrderProduct"); 151 | }); 152 | 153 | modelBuilder.Entity("Api.Models.Product", b => 154 | { 155 | b.Property("Id") 156 | .ValueGeneratedOnAdd() 157 | .HasColumnType("integer"); 158 | 159 | NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); 160 | 161 | b.Property("Name") 162 | .IsRequired() 163 | .HasColumnType("text"); 164 | 165 | b.Property("Price") 166 | .HasColumnType("numeric"); 167 | 168 | b.HasKey("Id"); 169 | 170 | b.ToTable("Product"); 171 | }); 172 | 173 | modelBuilder.Entity("Api.Models.Address", b => 174 | { 175 | b.HasOne("Api.Models.Customer", "Customer") 176 | .WithMany("Addresses") 177 | .HasForeignKey("CustomerId") 178 | .OnDelete(DeleteBehavior.Cascade) 179 | .IsRequired(); 180 | 181 | b.Navigation("Customer"); 182 | }); 183 | 184 | modelBuilder.Entity("Api.Models.Order", b => 185 | { 186 | b.HasOne("Api.Models.Customer", "Customer") 187 | .WithMany("Orders") 188 | .HasForeignKey("CustomerId") 189 | .OnDelete(DeleteBehavior.Cascade) 190 | .IsRequired(); 191 | 192 | b.HasOne("Api.Models.Address", "DeliveryAddress") 193 | .WithMany() 194 | .HasForeignKey("DeliveryAddressId") 195 | .OnDelete(DeleteBehavior.Cascade) 196 | .IsRequired(); 197 | 198 | b.Navigation("Customer"); 199 | 200 | b.Navigation("DeliveryAddress"); 201 | }); 202 | 203 | modelBuilder.Entity("Api.Models.OrderProduct", b => 204 | { 205 | b.HasOne("Api.Models.Order", "Order") 206 | .WithMany("OrderProducts") 207 | .HasForeignKey("OrderId") 208 | .OnDelete(DeleteBehavior.Cascade) 209 | .IsRequired(); 210 | 211 | b.HasOne("Api.Models.Product", "Product") 212 | .WithMany() 213 | .HasForeignKey("ProductId") 214 | .OnDelete(DeleteBehavior.Cascade) 215 | .IsRequired(); 216 | 217 | b.Navigation("Order"); 218 | 219 | b.Navigation("Product"); 220 | }); 221 | 222 | modelBuilder.Entity("Api.Models.Customer", b => 223 | { 224 | b.Navigation("Addresses"); 225 | 226 | b.Navigation("Orders"); 227 | }); 228 | 229 | modelBuilder.Entity("Api.Models.Order", b => 230 | { 231 | b.Navigation("OrderProducts"); 232 | }); 233 | #pragma warning restore 612, 618 234 | } 235 | } 236 | } 237 | -------------------------------------------------------------------------------- /docs/api/Migrations/ApiContextModelSnapshot.cs: -------------------------------------------------------------------------------- 1 | // 2 | using System; 3 | using Api.Data; 4 | using Microsoft.EntityFrameworkCore; 5 | using Microsoft.EntityFrameworkCore.Infrastructure; 6 | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; 7 | using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; 8 | 9 | #nullable disable 10 | 11 | namespace api.Migrations 12 | { 13 | [DbContext(typeof(ApiContext))] 14 | partial class ApiContextModelSnapshot : ModelSnapshot 15 | { 16 | protected override void BuildModel(ModelBuilder modelBuilder) 17 | { 18 | #pragma warning disable 612, 618 19 | modelBuilder 20 | .HasAnnotation("ProductVersion", "6.0.4") 21 | .HasAnnotation("Relational:MaxIdentifierLength", 63); 22 | 23 | NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); 24 | 25 | modelBuilder.Entity("Api.Models.Address", b => 26 | { 27 | b.Property("Id") 28 | .ValueGeneratedOnAdd() 29 | .HasColumnType("integer"); 30 | 31 | NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); 32 | 33 | b.Property("Country") 34 | .IsRequired() 35 | .HasColumnType("text"); 36 | 37 | b.Property("County") 38 | .HasColumnType("text"); 39 | 40 | b.Property("CustomerId") 41 | .HasColumnType("integer"); 42 | 43 | b.Property("Line1") 44 | .IsRequired() 45 | .HasColumnType("text"); 46 | 47 | b.Property("Line2") 48 | .HasColumnType("text"); 49 | 50 | b.Property("Line3") 51 | .HasColumnType("text"); 52 | 53 | b.Property("PostCode") 54 | .IsRequired() 55 | .HasColumnType("text"); 56 | 57 | b.Property("Town") 58 | .IsRequired() 59 | .HasColumnType("text"); 60 | 61 | b.HasKey("Id"); 62 | 63 | b.HasIndex("CustomerId"); 64 | 65 | b.ToTable("Addresses"); 66 | }); 67 | 68 | modelBuilder.Entity("Api.Models.Customer", b => 69 | { 70 | b.Property("Id") 71 | .ValueGeneratedOnAdd() 72 | .HasColumnType("integer"); 73 | 74 | NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); 75 | 76 | b.Property("CreatedDate") 77 | .HasColumnType("timestamp with time zone"); 78 | 79 | b.Property("EmailAddress") 80 | .IsRequired() 81 | .HasColumnType("text"); 82 | 83 | b.Property("FirstName") 84 | .IsRequired() 85 | .HasColumnType("text"); 86 | 87 | b.Property("MiddleNames") 88 | .HasColumnType("text"); 89 | 90 | b.Property("Surname") 91 | .IsRequired() 92 | .HasColumnType("text"); 93 | 94 | b.HasKey("Id"); 95 | 96 | b.ToTable("Customers"); 97 | }); 98 | 99 | modelBuilder.Entity("Api.Models.Order", b => 100 | { 101 | b.Property("Id") 102 | .ValueGeneratedOnAdd() 103 | .HasColumnType("integer"); 104 | 105 | NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); 106 | 107 | b.Property("CustomerId") 108 | .HasColumnType("integer"); 109 | 110 | b.Property("Date") 111 | .HasColumnType("timestamp with time zone"); 112 | 113 | b.Property("DeliveryAddressId") 114 | .HasColumnType("integer"); 115 | 116 | b.Property("Total") 117 | .HasColumnType("numeric"); 118 | 119 | b.HasKey("Id"); 120 | 121 | b.HasIndex("CustomerId"); 122 | 123 | b.HasIndex("DeliveryAddressId"); 124 | 125 | b.ToTable("Orders"); 126 | }); 127 | 128 | modelBuilder.Entity("Api.Models.OrderProduct", b => 129 | { 130 | b.Property("Id") 131 | .ValueGeneratedOnAdd() 132 | .HasColumnType("integer"); 133 | 134 | NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); 135 | 136 | b.Property("OrderId") 137 | .HasColumnType("integer"); 138 | 139 | b.Property("ProductId") 140 | .HasColumnType("integer"); 141 | 142 | b.Property("Quantity") 143 | .HasColumnType("integer"); 144 | 145 | b.HasKey("Id"); 146 | 147 | b.HasIndex("OrderId"); 148 | 149 | b.HasIndex("ProductId"); 150 | 151 | b.ToTable("OrderProduct"); 152 | }); 153 | 154 | modelBuilder.Entity("Api.Models.Product", b => 155 | { 156 | b.Property("Id") 157 | .ValueGeneratedOnAdd() 158 | .HasColumnType("integer"); 159 | 160 | NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); 161 | 162 | b.Property("Name") 163 | .IsRequired() 164 | .HasColumnType("text"); 165 | 166 | b.Property("Price") 167 | .HasColumnType("numeric"); 168 | 169 | b.HasKey("Id"); 170 | 171 | b.ToTable("Product"); 172 | }); 173 | 174 | modelBuilder.Entity("Api.Models.Address", b => 175 | { 176 | b.HasOne("Api.Models.Customer", "Customer") 177 | .WithMany("Addresses") 178 | .HasForeignKey("CustomerId") 179 | .OnDelete(DeleteBehavior.Cascade) 180 | .IsRequired(); 181 | 182 | b.Navigation("Customer"); 183 | }); 184 | 185 | modelBuilder.Entity("Api.Models.Order", b => 186 | { 187 | b.HasOne("Api.Models.Customer", "Customer") 188 | .WithMany("Orders") 189 | .HasForeignKey("CustomerId") 190 | .OnDelete(DeleteBehavior.Cascade) 191 | .IsRequired(); 192 | 193 | b.HasOne("Api.Models.Address", "DeliveryAddress") 194 | .WithMany() 195 | .HasForeignKey("DeliveryAddressId") 196 | .OnDelete(DeleteBehavior.Cascade) 197 | .IsRequired(); 198 | 199 | b.Navigation("Customer"); 200 | 201 | b.Navigation("DeliveryAddress"); 202 | }); 203 | 204 | modelBuilder.Entity("Api.Models.OrderProduct", b => 205 | { 206 | b.HasOne("Api.Models.Order", "Order") 207 | .WithMany("OrderProducts") 208 | .HasForeignKey("OrderId") 209 | .OnDelete(DeleteBehavior.Cascade) 210 | .IsRequired(); 211 | 212 | b.HasOne("Api.Models.Product", "Product") 213 | .WithMany() 214 | .HasForeignKey("ProductId") 215 | .OnDelete(DeleteBehavior.Cascade) 216 | .IsRequired(); 217 | 218 | b.Navigation("Order"); 219 | 220 | b.Navigation("Product"); 221 | }); 222 | 223 | modelBuilder.Entity("Api.Models.Customer", b => 224 | { 225 | b.Navigation("Addresses"); 226 | 227 | b.Navigation("Orders"); 228 | }); 229 | 230 | modelBuilder.Entity("Api.Models.Order", b => 231 | { 232 | b.Navigation("OrderProducts"); 233 | }); 234 | #pragma warning restore 612, 618 235 | } 236 | } 237 | } 238 | -------------------------------------------------------------------------------- /docs/api/Migrations/20230325170658_OrderDate.Designer.cs: -------------------------------------------------------------------------------- 1 | // 2 | using System; 3 | using Api.Data; 4 | using Microsoft.EntityFrameworkCore; 5 | using Microsoft.EntityFrameworkCore.Infrastructure; 6 | using Microsoft.EntityFrameworkCore.Migrations; 7 | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; 8 | using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; 9 | 10 | #nullable disable 11 | 12 | namespace api.Migrations 13 | { 14 | [DbContext(typeof(ApiContext))] 15 | [Migration("20230325170658_OrderDate")] 16 | partial class OrderDate 17 | { 18 | protected override void BuildTargetModel(ModelBuilder modelBuilder) 19 | { 20 | #pragma warning disable 612, 618 21 | modelBuilder 22 | .HasAnnotation("ProductVersion", "6.0.4") 23 | .HasAnnotation("Relational:MaxIdentifierLength", 63); 24 | 25 | NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); 26 | 27 | modelBuilder.Entity("Api.Models.Address", b => 28 | { 29 | b.Property("Id") 30 | .ValueGeneratedOnAdd() 31 | .HasColumnType("integer"); 32 | 33 | NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); 34 | 35 | b.Property("Country") 36 | .IsRequired() 37 | .HasColumnType("text"); 38 | 39 | b.Property("County") 40 | .HasColumnType("text"); 41 | 42 | b.Property("CustomerId") 43 | .HasColumnType("integer"); 44 | 45 | b.Property("Line1") 46 | .IsRequired() 47 | .HasColumnType("text"); 48 | 49 | b.Property("Line2") 50 | .HasColumnType("text"); 51 | 52 | b.Property("Line3") 53 | .HasColumnType("text"); 54 | 55 | b.Property("PostCode") 56 | .IsRequired() 57 | .HasColumnType("text"); 58 | 59 | b.Property("Town") 60 | .IsRequired() 61 | .HasColumnType("text"); 62 | 63 | b.HasKey("Id"); 64 | 65 | b.HasIndex("CustomerId"); 66 | 67 | b.ToTable("Addresses"); 68 | }); 69 | 70 | modelBuilder.Entity("Api.Models.Customer", b => 71 | { 72 | b.Property("Id") 73 | .ValueGeneratedOnAdd() 74 | .HasColumnType("integer"); 75 | 76 | NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); 77 | 78 | b.Property("CreatedDate") 79 | .HasColumnType("timestamp with time zone"); 80 | 81 | b.Property("EmailAddress") 82 | .IsRequired() 83 | .HasColumnType("text"); 84 | 85 | b.Property("FirstName") 86 | .IsRequired() 87 | .HasColumnType("text"); 88 | 89 | b.Property("MiddleNames") 90 | .HasColumnType("text"); 91 | 92 | b.Property("Surname") 93 | .IsRequired() 94 | .HasColumnType("text"); 95 | 96 | b.HasKey("Id"); 97 | 98 | b.ToTable("Customers"); 99 | }); 100 | 101 | modelBuilder.Entity("Api.Models.Order", b => 102 | { 103 | b.Property("Id") 104 | .ValueGeneratedOnAdd() 105 | .HasColumnType("integer"); 106 | 107 | NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); 108 | 109 | b.Property("CustomerId") 110 | .HasColumnType("integer"); 111 | 112 | b.Property("Date") 113 | .HasColumnType("timestamp with time zone"); 114 | 115 | b.Property("DeliveryAddressId") 116 | .HasColumnType("integer"); 117 | 118 | b.Property("Total") 119 | .HasColumnType("numeric"); 120 | 121 | b.HasKey("Id"); 122 | 123 | b.HasIndex("CustomerId"); 124 | 125 | b.HasIndex("DeliveryAddressId"); 126 | 127 | b.ToTable("Orders"); 128 | }); 129 | 130 | modelBuilder.Entity("Api.Models.OrderProduct", b => 131 | { 132 | b.Property("Id") 133 | .ValueGeneratedOnAdd() 134 | .HasColumnType("integer"); 135 | 136 | NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); 137 | 138 | b.Property("OrderId") 139 | .HasColumnType("integer"); 140 | 141 | b.Property("ProductId") 142 | .HasColumnType("integer"); 143 | 144 | b.Property("Quantity") 145 | .HasColumnType("integer"); 146 | 147 | b.HasKey("Id"); 148 | 149 | b.HasIndex("OrderId"); 150 | 151 | b.HasIndex("ProductId"); 152 | 153 | b.ToTable("OrderProduct"); 154 | }); 155 | 156 | modelBuilder.Entity("Api.Models.Product", b => 157 | { 158 | b.Property("Id") 159 | .ValueGeneratedOnAdd() 160 | .HasColumnType("integer"); 161 | 162 | NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); 163 | 164 | b.Property("Name") 165 | .IsRequired() 166 | .HasColumnType("text"); 167 | 168 | b.Property("Price") 169 | .HasColumnType("numeric"); 170 | 171 | b.HasKey("Id"); 172 | 173 | b.ToTable("Product"); 174 | }); 175 | 176 | modelBuilder.Entity("Api.Models.Address", b => 177 | { 178 | b.HasOne("Api.Models.Customer", "Customer") 179 | .WithMany("Addresses") 180 | .HasForeignKey("CustomerId") 181 | .OnDelete(DeleteBehavior.Cascade) 182 | .IsRequired(); 183 | 184 | b.Navigation("Customer"); 185 | }); 186 | 187 | modelBuilder.Entity("Api.Models.Order", b => 188 | { 189 | b.HasOne("Api.Models.Customer", "Customer") 190 | .WithMany("Orders") 191 | .HasForeignKey("CustomerId") 192 | .OnDelete(DeleteBehavior.Cascade) 193 | .IsRequired(); 194 | 195 | b.HasOne("Api.Models.Address", "DeliveryAddress") 196 | .WithMany() 197 | .HasForeignKey("DeliveryAddressId") 198 | .OnDelete(DeleteBehavior.Cascade) 199 | .IsRequired(); 200 | 201 | b.Navigation("Customer"); 202 | 203 | b.Navigation("DeliveryAddress"); 204 | }); 205 | 206 | modelBuilder.Entity("Api.Models.OrderProduct", b => 207 | { 208 | b.HasOne("Api.Models.Order", "Order") 209 | .WithMany("OrderProducts") 210 | .HasForeignKey("OrderId") 211 | .OnDelete(DeleteBehavior.Cascade) 212 | .IsRequired(); 213 | 214 | b.HasOne("Api.Models.Product", "Product") 215 | .WithMany() 216 | .HasForeignKey("ProductId") 217 | .OnDelete(DeleteBehavior.Cascade) 218 | .IsRequired(); 219 | 220 | b.Navigation("Order"); 221 | 222 | b.Navigation("Product"); 223 | }); 224 | 225 | modelBuilder.Entity("Api.Models.Customer", b => 226 | { 227 | b.Navigation("Addresses"); 228 | 229 | b.Navigation("Orders"); 230 | }); 231 | 232 | modelBuilder.Entity("Api.Models.Order", b => 233 | { 234 | b.Navigation("OrderProducts"); 235 | }); 236 | #pragma warning restore 612, 618 237 | } 238 | } 239 | } 240 | -------------------------------------------------------------------------------- /packages/o-data-grid/dev/App.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { CssBaseline, Typography, Grid, TextField, Slider, Chip } from "@mui/material"; 3 | import { createTheme, ThemeProvider } from "@mui/material/styles"; 4 | import { GridActionsCellItem, GridSortModel } from "@mui/x-data-grid" 5 | import { ODataColumnVisibilityModel, escapeODataString, ODataGridColumns } from "../src/index"; 6 | import ODataGrid from "../src/ODataGrid"; 7 | import { CacheProvider } from "@emotion/react"; 8 | import createCache from "@emotion/cache"; 9 | import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; 10 | import { Dayjs } from "dayjs"; 11 | import { ExternalBuilderProps } from "../../base/FilterBuilder/types"; 12 | 13 | const theme = createTheme({ 14 | palette: { 15 | mode: "dark" 16 | } 17 | }) 18 | 19 | export const muiCache = createCache({ 20 | key: "mui", 21 | prepend: true 22 | }); 23 | 24 | const App = () => { 25 | return ( 26 | 27 | 28 | 29 | 37 | 38 | 39 | ); 40 | } 41 | 42 | type LocationFilter = { 43 | location?: string, 44 | distance?: number 45 | } 46 | 47 | 48 | type Job = { 49 | id: number, 50 | title: string, 51 | description: string, 52 | salary?: string, 53 | avgYearlySalary?: number, 54 | location: string, 55 | latitude?: number, 56 | longitude?: number, 57 | url?: string, 58 | companyId?: number, 59 | posted: string, 60 | notes: string, 61 | seen: boolean, 62 | archived: boolean, 63 | status: string, 64 | dateApplied: string, 65 | provider?: string, 66 | providerId?: string, 67 | sourceId?: number, 68 | duplicateJobId?: number, 69 | actualCompanyId?: number, 70 | 71 | company: Company, 72 | jobCategories: JobCategory[], 73 | } 74 | 75 | type Company = { 76 | id: number, 77 | name: string, 78 | location: string, 79 | latitude?: number, 80 | longitude?: number, 81 | notes?: string, 82 | watched: boolean, 83 | blacklisted: boolean, 84 | website?: string, 85 | rating?: number, 86 | glassdoor?: string, 87 | linkedIn?: string, 88 | endole?: string, 89 | recruiter: boolean 90 | } 91 | 92 | type JobCategory = { 93 | jobId: number, 94 | categoryId: number, 95 | category: Category 96 | } 97 | 98 | type Category = { 99 | id: number, 100 | name: string 101 | } 102 | 103 | 104 | const filterBuilderProps: ExternalBuilderProps = { autocompleteGroups: ["Job", "Company"], localizationProviderProps: { dateAdapter: AdapterDayjs } }; 105 | 106 | const alwaysFetch = ["Id", "Archived"]; 107 | const columns: ODataGridColumns = [ 108 | { 109 | field: "title", 110 | headerName: "Job Title", 111 | flex: 2, 112 | autocompleteGroup: "Job" 113 | }, 114 | { 115 | field: "location", 116 | headerName: "Location", 117 | flex: 1, 118 | renderCustomFilter: (value, setValue) => ( 119 | 120 | 121 | setValue({ ...value, location: e.target.value })} 124 | size="small" 125 | fullWidth 126 | label="Search Location" 127 | required 128 | /> 129 | 130 | 131 | Distance 132 | setValue({ ...value, distance: val as number })} 135 | step={5} 136 | min={0} 137 | max={50} 138 | valueLabelFormat={(val) => `${val}mi`} 139 | valueLabelDisplay="auto" 140 | size="small" 141 | sx={{padding: 0}} 142 | /> 143 | 144 | 145 | ), 146 | getCustomFilterString: (_, v) => { 147 | const filter = v as LocationFilter; 148 | return { 149 | filter: `Latitude ne null and Longitude ne null and Distance le ${filter.distance ?? 15}`, 150 | compute: { 151 | compute: `geocode('${escapeODataString(filter.location ?? "")}', Latitude, Longitude) as Distance`, 152 | select: ["Distance"] 153 | } 154 | }; 155 | }, 156 | valueGetter: (params) => `${params.row["location"]}${params.row["distance"] ? ` (${params.row["distance"].toFixed(1)} mi away)` : ""}`, 157 | autocompleteGroup: "Job" 158 | }, 159 | { 160 | field: "company/name", 161 | headerName: "Company", 162 | flex: 2, 163 | renderCell: (params) => ( 164 | 165 | 166 | {params.value} 167 | 168 | {params.row["company/recruiter"] && } 169 | {params.row.result.company?.blacklisted && } 170 | 171 | ), 172 | expand: { navigationField: "company", select: "id,name,recruiter,blacklisted,watched" }, 173 | autocompleteGroup: "Company" 174 | }, 175 | { 176 | field: "salary", 177 | headerName: "Salary", 178 | type: "number", 179 | filterField: "avgYearlySalary", 180 | sortField: "avgYearlySalary", 181 | label: "Median Annual Salary", 182 | filterType: "number", 183 | filterOperators: ["eq", "ne", "gt", "lt", "ge", "le", "null", "notnull"], 184 | flex: 1, 185 | autocompleteGroup: "Job" 186 | }, 187 | { 188 | field: "status", 189 | headerName: "Status", 190 | type: "singleSelect", 191 | valueOptions: ["Not Applied", "Awaiting Response", "In Progress", "Rejected", "Dropped Out"], 192 | filterOperators: ["eq", "ne"], 193 | autocompleteGroup: "Job" 194 | }, 195 | { 196 | field: "jobCategories", 197 | headerName: "Categories", 198 | label: "Category", 199 | expand: { 200 | navigationField: "jobCategories", 201 | expand: { 202 | navigationField: "category", 203 | select: "name", 204 | expand: [ 205 | { 206 | navigationField: "companyCategories", 207 | count: true 208 | }, 209 | { 210 | navigationField: "companyCategories", 211 | count: true 212 | }, 213 | { 214 | navigationField: "jobCategories", 215 | count: true 216 | }, 217 | ] 218 | } 219 | }, 220 | sortable: false, 221 | filterable: false, 222 | flex: 1, 223 | renderCell: (params) => params.row.result.jobCategories.map((c) => c.category.name).join(", "), 224 | autocompleteGroup: "Job" 225 | }, 226 | // { 227 | // field: "source/displayName", 228 | // expand: { navigationField: "source", select: "displayName" }, 229 | // headerName: "Source", 230 | // filterable: false, 231 | // sortable: false, 232 | // flex: 1, 233 | // valueGetter: (params) => params.row[params.field] ? params.row[params.field] : "Added Manually", 234 | // autocompleteGroup: "Job" 235 | // }, 236 | { 237 | field: "posted", 238 | select: "posted,seen,archived", 239 | headerName: "Posted", 240 | type: "date", 241 | flex: .9, 242 | autocompleteGroup: "Job" 243 | }, 244 | { 245 | field: "actions", 246 | type: "actions", 247 | getActions: (params) => [ 248 | console.log(params)} /> 249 | ] 250 | }, 251 | 252 | // filter only 253 | { 254 | field: "Company/Recruiter", 255 | label: "Company Type", 256 | filterOnly: true, 257 | filterOperators: ["eq", "ne"], 258 | type: "singleSelect", 259 | valueOptions: [ 260 | { label: "Employer", value: false }, 261 | { label: "Recruiter", value: true } 262 | ], 263 | autocompleteGroup: "Company" 264 | }, 265 | { 266 | field: "Company/Watched", 267 | label: "Company Watched", 268 | filterOnly: true, 269 | filterOperators: ["eq", "ne"], 270 | type: "boolean", 271 | autocompleteGroup: "Company" 272 | }, 273 | { 274 | field: "Company/Blacklisted", 275 | label: "Company Blacklisted", 276 | filterOnly: true, 277 | filterOperators: ["eq", "ne"], 278 | type: "boolean", 279 | autocompleteGroup: "Company" 280 | }, 281 | { 282 | field: "Description", 283 | filterOnly: true, 284 | filterOperators: ["contains"], 285 | autocompleteGroup: "Job" 286 | }, 287 | { 288 | field: "Notes", 289 | filterOnly: true, 290 | filterOperators: ["contains"], 291 | autocompleteGroup: "Job" 292 | } 293 | ]; 294 | 295 | const columnVisibility: ODataColumnVisibilityModel = { 296 | "Company/Name": { xs: false, md: true }, 297 | "Salary": { xs: false, lg: true }, 298 | "Status": false, 299 | "JobCategories": { xs: false, xl: true }, 300 | "Source/DisplayName": true, 301 | "Posted": { xs: false, sm: true }, 302 | } 303 | 304 | const defaultSort: GridSortModel = [{ field: "Posted", sort: "desc" }]; 305 | 306 | export default App; -------------------------------------------------------------------------------- /packages/base/FilterBuilder/components/FilterInputs.tsx: -------------------------------------------------------------------------------- 1 | import React, { Fragment, useMemo } from "react" 2 | import { useRecoilValue } from "recoil"; 3 | import { Autocomplete, FormControl, Grid, InputLabel, MenuItem, Select, TextField } from "@mui/material"; 4 | import { DatePicker, DateTimePicker, LocalizationProvider } from "@mui/x-date-pickers"; 5 | 6 | import { CollectionFieldDef, CollectionOperation, FieldDef, Operation } from "../types"; 7 | 8 | import { propsState, schemaState } from "../state" 9 | import { SelectOption, ValueOption } from "../../types"; 10 | import { getLocaleText, getSelectOption } from "../utils"; 11 | import { allOperators, numericOperators } from "../constants"; 12 | 13 | 14 | type FilterInputsProps = { 15 | clauseId: string, 16 | field: string, 17 | onFieldChange: (oldField: string, currentOp: Operation, newField: string) => void, 18 | op: Operation, 19 | onOpChange: (op: Operation) => void, 20 | value?: string, 21 | onValueChange: (v: any) => void, 22 | collectionOp?: CollectionOperation, 23 | onCollectionOpChange: (op: CollectionOperation) => void, 24 | collectionField?: string, 25 | onCollectionFieldChange: (field: string, oldField: string | undefined, currentOp: Operation, newField: string | undefined) => void, 26 | } 27 | 28 | const FilterInputs = ({ 29 | clauseId, 30 | field, 31 | onFieldChange, 32 | op, 33 | onOpChange, 34 | value, 35 | onValueChange, 36 | collectionOp, 37 | onCollectionOpChange, 38 | collectionField, 39 | onCollectionFieldChange 40 | }: FilterInputsProps) => { 41 | 42 | const schema = useRecoilValue(schemaState); 43 | const builderProps = useRecoilValue(propsState); 44 | 45 | const dateAdapter = useMemo(() => builderProps.localizationProviderProps?.dateAdapter, [builderProps]); 46 | 47 | const fieldDef = useMemo(() => { 48 | if (!field && schema.length < 1) { 49 | return null; 50 | } 51 | 52 | let f: FieldDef; 53 | if (field) { 54 | f = schema.find(c => c.field === field) ?? schema[0]; 55 | } else { 56 | f = schema[0] 57 | } 58 | 59 | if (!f) { 60 | return null; 61 | } 62 | 63 | let filterField = field; 64 | let colField: CollectionFieldDef | undefined; 65 | let type = f.filterType ?? f.type; 66 | let options = f.valueOptions; 67 | let ops = f.filterOperators ?? allOperators; 68 | if (f.collection === true && f.collectionFields) { 69 | if (collectionField) { 70 | colField = f.collectionFields.find(c => c.field === collectionField) ?? f.collectionFields[0]; 71 | } else { 72 | colField = f.collectionFields[0]; 73 | } 74 | 75 | filterField = colField.field; 76 | type = colField.type; 77 | options = colField.valueOptions; 78 | 79 | if (collectionOp !== "count") { 80 | ops = colField.filterOperators ?? allOperators; 81 | } else { 82 | ops = numericOperators; 83 | type = "number" 84 | } 85 | } 86 | 87 | // get value options into a single type 88 | let valueOptions: SelectOption[] | undefined; 89 | if (type === "singleSelect" && typeof options === "function") { 90 | valueOptions = options({ field: filterField }).map((v) => getSelectOption(v)); 91 | } else if (type === "singleSelect" && options) { 92 | valueOptions = (options as ValueOption[]).map((v) => getSelectOption(v)); 93 | } 94 | 95 | return { 96 | ...f, 97 | fieldLabel: f.label ?? f.headerName ?? f.field, 98 | type: type, 99 | ops: ops, 100 | valueOptions: valueOptions, 101 | colField: colField 102 | }; 103 | }, [field, collectionField, collectionOp, schema]); 104 | 105 | const fieldOptions = useMemo(() => schema 106 | .filter(c => c.filterable !== false) 107 | .map(c => ({ label: c.label ?? c.headerName ?? c.field, field: c.field, group: c.autocompleteGroup ?? "" })) 108 | .sort((a, b) => builderProps.autocompleteGroups ? 109 | builderProps.autocompleteGroups.indexOf(a.group) - builderProps.autocompleteGroups.indexOf(b.group) 110 | : a.group.localeCompare(b.group)), 111 | [schema, builderProps] 112 | ); 113 | 114 | if (schema.length < 1 || !fieldDef) { 115 | return null; 116 | } 117 | 118 | return ( 119 | 120 | 121 | } 126 | value={{ label: fieldDef.fieldLabel, field: fieldDef.field, group: fieldDef.autocompleteGroup }} 127 | onChange={(_, val) => onFieldChange(fieldDef.field, op, val.field)} 128 | disableClearable 129 | isOptionEqualToValue={(option, value) => option.field === value.field} 130 | groupBy={(option) => option.group} 131 | /> 132 | 133 | { 134 | fieldDef.collection === true && 135 | 136 | 137 | {getLocaleText("collectionOperation", builderProps.localeText)} 138 | 149 | 150 | 151 | } 152 | { 153 | fieldDef.collection === true && collectionOp !== "count" && 154 | 155 | ({ label: c.label, field: c.field })) ?? []} 159 | renderInput={(params) => } 160 | value={{ label: fieldDef.colField?.label, field: collectionField }} 161 | onChange={(_, val) => onCollectionFieldChange(field, collectionField, op, val.field)} 162 | disableClearable 163 | isOptionEqualToValue={(option, value) => option.field === value.field} 164 | /> 165 | 166 | } 167 | { 168 | fieldDef.renderCustomFilter ? 169 | fieldDef.renderCustomFilter(value, onValueChange) 170 | : 171 | 172 | 173 | Operation 174 | 191 | 192 | 193 | } 194 | { 195 | !fieldDef.renderCustomFilter && 196 | 197 | { 198 | op !== "null" && op !== "notnull" && 199 | (fieldDef.renderCustomInput ? fieldDef.renderCustomInput(value, onValueChange) : 200 | 201 | { 202 | fieldDef.type === "date" && 203 | 204 | } 210 | onChange={(date) => onValueChange(new dateAdapter!().formatByString(date, "YYYY-MM-DD"))} 211 | /> 212 | 213 | } 214 | { 215 | fieldDef.type === "datetime" && 216 | 217 | } 222 | onChange={(date) => onValueChange(new dateAdapter!().toISO(date))} 223 | /> 224 | 225 | } 226 | { 227 | fieldDef.type === "boolean" && 228 | 229 | {fieldDef.selectProps?.label ?? getLocaleText("value", builderProps.localeText)} 230 | 242 | 243 | } 244 | { 245 | fieldDef.type === "singleSelect" && fieldDef.valueOptions && 246 | 247 | {fieldDef.selectProps?.label ?? getLocaleText("value", builderProps.localeText)} 248 | 258 | 259 | } 260 | { 261 | (!fieldDef.type || fieldDef.type === "string" || fieldDef.type === "number") && 262 | ) => onValueChange(fieldDef.type === "number" ? parseFloat(e.target.value) : e.target.value)} 270 | type={fieldDef.type === "number" ? "number" : "text"} 271 | /> 272 | } 273 | ) 274 | } 275 | 276 | } 277 | 278 | ) 279 | }; 280 | 281 | export default React.memo(FilterInputs); 282 | -------------------------------------------------------------------------------- /packages/base/components/ODataGridBase.tsx: -------------------------------------------------------------------------------- 1 | import React, { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react" 2 | import { Box } from "@mui/system"; 3 | 4 | import { ResponsiveValues, useResponsive } from "../hooks"; 5 | 6 | import FilterBuilder from "../FilterBuilder/components/FilterBuilder"; 7 | 8 | import { ODataResponse, ODataGridBaseProps, IGridSortModel, IGridProps, ColumnVisibilityModel, Expand, ODataRowModel } from "../types"; 9 | 10 | import { ExpandToQuery, Flatten, GetPageNumber, GetPageSizeOrDefault } from "../utils"; 11 | 12 | import { defaultPageSize } from "../constants"; 13 | import { QueryStringCollection, FilterParameters } from "../FilterBuilder/types"; 14 | import { GridColumnVisibilityModel } from "@mui/x-data-grid"; 15 | 16 | const ODataGridBase = (props: ODataGridBaseProps) => { 21 | 22 | const [pageNumber, setPageNumber] = useState(GetPageNumber()); 23 | const [pageSize, setPageSize] = useState(GetPageSizeOrDefault(props.defaultPageSize)); 24 | const [rows, setRows] = useState[]>([]) 25 | const [rowCount, setRowCount] = useState(0); 26 | const [loading, setLoading] = useState(true); 27 | const [sortModel, setSortModel] = useState(props.defaultSortModel); 28 | 29 | const [filter, setFilter] = useState(""); 30 | const [filterSelects, setFilterSelects] = useState(); 31 | const [compute, setCompute] = useState(); 32 | const [queryString, setQueryString] = useState(); 33 | 34 | const [visibleColumns, setVisibleColumns] = useState(props.columns 35 | .filter(c => (props.columnVisibilityModel && props.columnVisibilityModel[c.field] !== false) || c.hide !== true) 36 | .map(c => c.field) 37 | ); 38 | const [columnVisibilityOverride, setColumnVisibilityOverride] = useState({}); 39 | 40 | const firstLoad = useRef(true); 41 | const fetchCount = useRef(true); 42 | const pendingFilter = useRef(false); 43 | 44 | const r = useResponsive(); 45 | 46 | const fetchData = useCallback(async () => { 47 | if ( 48 | !filter 49 | && props.disableFilterBuilder !== true 50 | && props.filterBuilderProps?.disableHistory !== true 51 | && window.history.state 52 | && window.history.state.filterBuilder 53 | && window.history.state.filterBuilder.reset !== true 54 | ) { 55 | // stop fetch if there is no filter but there is one in history which will be/has been restored 56 | // this prevents a race condition between the initial data load and the query being restored 57 | return; 58 | } 59 | 60 | setLoading(true); 61 | 62 | // select all fields for visible columns 63 | const fields = new Set( 64 | props.columns 65 | .filter(c => visibleColumns.includes(c.field) && c.expand === undefined && c.filterOnly !== true && c.type !== "actions") 66 | .map(c => c.select ?? c.field) 67 | ); 68 | 69 | if (props.alwaysSelect) { 70 | props.alwaysSelect.forEach((c) => fields.add(c)); 71 | } 72 | 73 | if (filterSelects) { 74 | filterSelects.forEach((s) => fields.add(s)); 75 | } 76 | 77 | const expands = props.columns 78 | .filter(c => visibleColumns.includes(c.field) && c.expand) 79 | .map(c => c.expand!) 80 | .reduce((a: Expand[], b) => Array.isArray(b) ? a.concat(b) : [...a, b], []); 81 | 82 | const query = new URLSearchParams(); 83 | if (fields.size > 0) { 84 | query.append("$select", Array.from(fields).join(",")); 85 | } 86 | 87 | if (expands.length > 0) { 88 | query.append("$expand", ExpandToQuery(expands)); 89 | } 90 | 91 | query.append("$top", pageSize.toString()); 92 | query.append("$skip", (pageNumber * pageSize).toString()); 93 | 94 | if (fetchCount.current) { 95 | query.append("$count", "true"); 96 | } 97 | 98 | if (queryString) { 99 | for (const key in queryString) { 100 | query.append(key, queryString[key]); 101 | } 102 | } 103 | 104 | if (filter) { 105 | query.append("$filter", filter); 106 | } else if (props.$filter) { 107 | query.append("$filter", props.$filter); 108 | } 109 | 110 | if (compute) { 111 | query.append("$compute", compute); 112 | } 113 | 114 | if (sortModel && sortModel.length > 0) { 115 | const sortCols = sortModel 116 | .map(s => ({ col: props.columns.find(c => c.field === s.field), sort: s.sort })) 117 | .filter(c => c.col) 118 | .map(c => `${c.col!.sortField ?? c.col!.field}${c.sort === "desc" ? " desc" : ""}`); 119 | 120 | if (sortCols.length > 0) { 121 | query.append("$orderby", sortCols.join(",")); 122 | } 123 | } 124 | 125 | const response = await fetch(props.url + "?" + query.toString(), props.requestOptions); 126 | if (response.ok) { 127 | const data = await response.json() as ODataResponse; 128 | 129 | // flatten object so that the DataGrid can access all the properties 130 | // i.e. { Person: { name: "John" } } becomes { "Person/name": "John" } 131 | // keep the original object in the "result" property so that it can still be accessed via strong typing 132 | const rows: ODataRowModel[] = data.value.map((v) => ({ result: v, ...Flatten(v, "/") })); 133 | 134 | if (data["@odata.count"]) { 135 | setRowCount(data["@odata.count"]); 136 | } 137 | 138 | setRows(rows); 139 | setLoading(false); 140 | firstLoad.current = false; 141 | pendingFilter.current = false; 142 | fetchCount.current = false; 143 | } else { 144 | console.error(`API request failed: ${response.url}, HTTP ${response.status}`); 145 | } 146 | }, 147 | [ 148 | pageNumber, 149 | pageSize, 150 | visibleColumns, 151 | sortModel, 152 | filter, 153 | filterSelects, 154 | compute, 155 | queryString, 156 | props.url, 157 | props.alwaysSelect, 158 | props.columns, 159 | props.$filter, 160 | props.disableFilterBuilder, 161 | props.filterBuilderProps?.disableHistory, 162 | props.requestOptions 163 | ] 164 | ); 165 | 166 | 167 | const handleBuilderSubmit = useCallback((params: FilterParameters) => { 168 | pendingFilter.current = true; 169 | fetchCount.current = true; 170 | 171 | if (props.filterBuilderProps?.onSubmit) { 172 | props.filterBuilderProps.onSubmit(params); 173 | } 174 | 175 | setCompute(params.compute); 176 | setFilter(params.filter); 177 | setFilterSelects(params.select); 178 | setQueryString(params.queryString); 179 | setPageNumber(0); 180 | 181 | return { oDataGrid: { sortModel: sortModel } }; 182 | }, [props.filterBuilderProps, sortModel]); 183 | 184 | const handleBuilderRestore = useCallback((params: FilterParameters, state: any) => { 185 | fetchCount.current = true; 186 | 187 | if (props.filterBuilderProps?.onRestoreState) { 188 | props.filterBuilderProps.onRestoreState(params, state); 189 | } 190 | 191 | if (props.disableHistory !== true) { 192 | if (state?.oDataGrid?.sortModel) { 193 | setSortModel(state.oDataGrid.sortModel as SortModel); 194 | } else { 195 | setSortModel(props.defaultSortModel); 196 | } 197 | } 198 | 199 | setCompute(params.compute); 200 | setFilter(params.filter); 201 | setFilterSelects(params.select); 202 | setQueryString(params.queryString); 203 | }, [props.filterBuilderProps, props.disableHistory, props.defaultSortModel]); 204 | 205 | useEffect(() => { 206 | fetchData() 207 | }, [fetchData]); 208 | 209 | const { onColumnVisibilityModelChange, onSortModelChange } = props; 210 | 211 | const handleSortModelChange = useCallback((model: SortModel, details) => { 212 | if (onSortModelChange) { 213 | onSortModelChange(model, details); 214 | } 215 | 216 | setSortModel(model); 217 | 218 | if (props.disableHistory !== true) { 219 | window.history.pushState({ ...window.history.state, oDataGrid: { sortModel: model } }, ""); 220 | } 221 | }, [onSortModelChange, props.disableHistory]); 222 | 223 | useEffect(() => { 224 | let changed = false; 225 | 226 | const params = new URLSearchParams(window.location.search); 227 | 228 | // update page query string parameter 229 | const pageStr = params.get("page"); 230 | if (pageStr) { 231 | const page = parseInt(pageStr, 10) - 1; 232 | // update if already exists and is different to settings 233 | if (page !== pageNumber) { 234 | if (pageNumber !== 0) { 235 | params.set("page", (pageNumber + 1).toString()); 236 | } else { 237 | // remove if first page 238 | params.delete("page"); 239 | } 240 | 241 | changed = true; 242 | } 243 | } else if (pageNumber !== 0) { 244 | // add if doesn't already exist and not on first page 245 | params.set("page", (pageNumber + 1).toString()); 246 | changed = true; 247 | } 248 | 249 | // update page-size query string parameter 250 | const sizeStr = params.get("page-size"); 251 | if (sizeStr) { 252 | const size = parseInt(sizeStr, 10); 253 | if (size !== pageSize) { 254 | if (pageSize !== (props.defaultPageSize ?? defaultPageSize)) { 255 | params.set("page-size", pageSize.toString()); 256 | } else { 257 | params.delete("page-size"); 258 | } 259 | 260 | changed = true; 261 | } 262 | } else if (pageSize !== (props.defaultPageSize ?? defaultPageSize)) { 263 | params.set("page-size", pageSize.toString()); 264 | changed = true; 265 | } 266 | 267 | // only run if modified and not the first load 268 | if (changed && !firstLoad.current) { 269 | const search = params.toString(); 270 | const url = search ? `${window.location.pathname}?${search}${window.location.hash}` : `${window.location.pathname}${window.location.hash}`; 271 | 272 | // replace the state instead of pushing if a state has already been pushed by a filter 273 | if (pendingFilter.current) { 274 | window.history.replaceState(window.history.state, "", url); 275 | } else { 276 | window.history.pushState(window.history.state, "", url); 277 | } 278 | } 279 | }, [pageNumber, pageSize, props.defaultPageSize]); 280 | 281 | useEffect(() => { 282 | const handlePopState = (e: PopStateEvent) => { 283 | const params = new URLSearchParams(window.location.search); 284 | 285 | const pageVal = params.get("page"); 286 | if (pageVal) { 287 | const page = parseInt(pageVal, 10) - 1; 288 | setPageNumber(page); 289 | } else if (pageNumber !== 0) { 290 | // reset to first page if not provided and not already on first page 291 | setPageNumber(0); 292 | } 293 | 294 | const sizeVal = params.get("page-size"); 295 | if (sizeVal) { 296 | const size = parseInt(sizeVal, 10) - 1; 297 | setPageSize(size); 298 | } else if (pageSize !== props.defaultPageSize ?? defaultPageSize) { 299 | // reset to default if not provided and not already default 300 | setPageSize(props.defaultPageSize ?? defaultPageSize); 301 | } 302 | 303 | if (props.disableHistory !== true && props.disableFilterBuilder === true) { 304 | // only restore sort model from history if history is enabled and FilterBuilder is disabled 305 | // if FilterBuilder is enabled sort model restoration is handled in handleBuilderRestore 306 | if (e.state?.oDataGrid?.sortModel) { 307 | setSortModel(e.state.oDataGrid.sortModel as SortModel); 308 | } else { 309 | setSortModel(props.defaultSortModel); 310 | } 311 | } 312 | }; 313 | 314 | window.addEventListener("popstate", handlePopState); 315 | return () => window.removeEventListener("popstate", handlePopState); 316 | }, [pageNumber, pageSize, props.defaultPageSize, props.defaultSortModel, props.disableHistory, props.disableFilterBuilder]); 317 | 318 | const handlePageChange = useCallback((page: number) => { 319 | setPageNumber(page); 320 | }, []); 321 | 322 | const handlePageSizeChange = useCallback((size: number) => { 323 | setPageSize(size); 324 | }, []); 325 | 326 | 327 | const visibility = useMemo( 328 | () => { 329 | const v: ColumnVisibilityModel = {}; 330 | if (props.columnVisibilityModel) { 331 | for (const field in props.columnVisibilityModel) { 332 | if (field in columnVisibilityOverride) { 333 | v[field] = columnVisibilityOverride[field]; 334 | } else if (typeof props.columnVisibilityModel[field] === "boolean") { 335 | v[field] = props.columnVisibilityModel[field] as boolean; 336 | } else { 337 | v[field] = r(props.columnVisibilityModel[field] as ResponsiveValues) as boolean; 338 | } 339 | } 340 | } else { 341 | props.columns.filter(c => c.filterOnly !== true).forEach(c => { 342 | if (c.field in columnVisibilityOverride) { 343 | v[c.field] = columnVisibilityOverride[c.field]; 344 | } else if (typeof c.hide === "boolean") { 345 | v[c.field] = !(c.hide as boolean); 346 | } else if (c.hide) { 347 | v[c.field] = !r(c.hide as ResponsiveValues); 348 | } 349 | }) 350 | } 351 | 352 | props.columns.filter(c => c.filterOnly === true).forEach(c => { 353 | v[c.field] = false; 354 | }) 355 | 356 | return v; 357 | }, 358 | [props.columnVisibilityModel, r, props.columns, columnVisibilityOverride] 359 | ); 360 | 361 | const handleColumnVisibilityModelChange = useCallback((model: GridColumnVisibilityModel, details) => { 362 | if (onColumnVisibilityModelChange) { 363 | onColumnVisibilityModelChange(model, details); 364 | } 365 | 366 | // find the field which has been changed 367 | const column = Object.keys(model).find((key) => visibility[key] !== model[key]); 368 | if (column) { 369 | const visible = model[column]; 370 | 371 | setColumnVisibilityOverride((v) => ({ ...v, [column]: visible })); 372 | if (visible) { 373 | setVisibleColumns((v) => [...v, column]); 374 | } 375 | else { 376 | setVisibleColumns((v) => v.filter(c => c !== column)); 377 | } 378 | } 379 | }, [onColumnVisibilityModelChange, visibility]); 380 | 381 | const gridColumns = useMemo(() => props.columns.filter(c => c.filterOnly !== true), [props.columns]); 382 | 383 | const GridComponent = props.component; 384 | 385 | return ( 386 | 387 | { 388 | props.$filter === undefined && props.disableFilterBuilder !== true && 389 | 390 | 396 | 397 | } 398 | 399 | 427 | 428 | ) 429 | }; 430 | 431 | export default ODataGridBase; -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ODataGrid 2 | 3 | [![NPM Version](https://img.shields.io/npm/v/o-data-grid.svg)](https://www.npmjs.com/package/o-data-grid) 4 | [![NPM Downloads](https://img.shields.io/npm/dt/o-data-grid.svg)](https://www.npmjs.com/package/o-data-grid) 5 | [![NPM Bundle Size](https://img.shields.io/bundlephobia/minzip/o-data-grid)](https://www.npmjs.com/package/o-data-grid) 6 | 7 | ODataGrid is an extension to the [MUI DataGrid](https://github.com/mui-org/material-ui-x) React component which implements features such as sorting, pagination, column selection, and filtering using the [OData Standard](https://www.odata.org/). This allows you to quickly create a powerful interface for browsing data with minimal back-end code. 8 | 9 | ![ODataGrid in action](https://raw.githubusercontent.com/jamerst/o-data-grid/main/images/o-data-grid.png) 10 | 11 | ## Features 12 | - Supports DataGrid and DataGridPro 13 | - (Almost) drop-in replacement for DataGrid 14 | - Fully customisable 15 | - Performant & responsive 16 | - Supports sorting, pagination, column selection and filtering (dynamic or static filter) 17 | - Powerful and intuitive filter/query builder built-in 18 | - Supports custom query string parameters for filtering to allow filters which are not natively supported by OData 19 | - Integrates with browser history - sorting, page navigation and filtering all create new browser history states which are restored when navigating back/forward. 20 | - Responsive column visibility - show or hide columns based on screen size 21 | 22 | ## Demo 23 | Coming soon! 24 | 25 | ## Notes 26 | ODataGrid is still in the fairly early stages of development. I'm not aware of any issues currently, but it hasn't been battle-tested. It also utilises [Recoil](https://github.com/facebookexperimental/Recoil) for state management in the filter builder, which is still considered experimental by Facebook. 27 | 28 | Please report any issues that you find, and feel free to make feature requests. This will help to make ODataGrid better. 29 | 30 | ## Installation 31 | ODataGrid can be installed using the appropriate npm package ([`o-data-grid`](https://www.npmjs.com/package/o-data-grid) or [`o-data-grid-pro`](https://www.npmjs.com/package/o-data-grid-pro)). 32 | 33 | The following mui packages must also be installed. I recommend using the latest versions where possible, the minimum required version for the data-grid packages is **v5.8.0**, but any of the other mui packages must be v5.x.x. 34 | 35 | **These are peer dependencies so won't be installed automatically.** 36 | - `@mui/system` 37 | - `@mui/material` 38 | - `@mui/x-date-pickers` 39 | - `@mui/x-data-grid` (minimum `v5.8.0`) for `o-data-grid` 40 | - `@mui/x-data-grid-pro` (minimum `v5.8.0`) for `o-data-grid-pro` 41 | - `@mui/icons-material` 42 | 43 | ## Usage 44 | Usage is very similar to the regular DataGrid. For the most basic scenario simply change the `DataGrid`/`DataGridPro` to the corresponding `ODataGrid`/`ODataGridPro` component, add the `url` property, and remove any unsupported properties. 45 | 46 | From there you can start to customise the grid to your needs using the properties available in the API below. 47 | 48 | **Note: like the DataGrid you should ensure that the `columns` property keeps the same reference between renders.** 49 | 50 | If the same reference is not kept, this may trigger duplicate OData requests. 51 | 52 | ### Helpful Tips 53 | - Nested properties are supported, to define a column for a nested property flatten it as you would for the query string. E.g. to access `Child` from `{ Parent: { Child: "foo" } }`, use `Parent/Child` as the value for `field` in the column definition. Strong typing is still supported through the `result` property of the row. 54 | 55 | ### Examples 56 | The demo site isn't ready yet, but you can see some examples of usage here on my GitHub: 57 | - [Simple example utilising the static `$filter` filter](https://github.com/jamerst/JobHunt/blob/master/jobhunt/client/src/views/Dashboard.tsx#L39) 58 | - More advanced examples utilising the `FilterBuilder`: 59 | - [1](https://github.com/jamerst/JobHunt/blob/master/jobhunt/client/src/views/Jobs.tsx#L63) 60 | - [2](https://github.com/jamerst/JobHunt/blob/master/jobhunt/client/src/views/Companies.tsx#L54) 61 | 62 | ## API 63 | The ODataGrid API is very similar to the standard [DataGrid](https://mui.com/api/data-grid/data-grid/)/[DataGridPro](https://mui.com/api/data-grid/data-grid-pro/) APIs, with a few additions and removals. 64 | 65 | ### ODataGridProps/ODataGridProProps 66 | The props are the same as the standard DataGrid props with the following changes: 67 | 68 | _* = required property_ 69 | #### Modifications 70 | | Name | Change | Description | 71 | | ---- | ------ | ----------- | 72 | | `columns*` | Type | See [`ODataGridColDef`](#ODataGridColDef) | 73 | | `rows` | Removed | Handled internally | 74 | | `autoPageSize` | Removed | Not supported | 75 | | `columnVisibilityModel` | Type | Changed to `{ [key: string]: boolean \| Partial> }` to support responsive column visibility

**Note: providing a value will cause the `hide` property to be ignored on all column definitions.** | 76 | | `disableColumnFilter` | Removed | Not supported - default filtering is replaced | 77 | | `filterMode` | Removed | Not supported - default filtering is replaced | 78 | | `filterModel` | Removed | Not supported - default filtering is replaced | 79 | | `loading` | Removed | Handled internally | 80 | | `onFilterModelChange` | Removed | Not supported - default filtering is replaced | 81 | | `onPageChange` | Removed | Handled internally | 82 | | `onPageSizeChange` | Removed | Handled internally | 83 | | `page` | Removed | Handled internally | 84 | | `pageSize` | Removed | Handled internally | 85 | | `paginationMode` | Removed | Handled internally | 86 | | `rowCount` | Removed | Handled internally | 87 | | `sortingMode` | Removed | Handled internally | 88 | | `sortModel` | Removed | Handled internally | 89 | 90 | #### New Properties 91 | 92 | | Name | Type | Default | Description | 93 | | ---- | ---- | ------- | ----------- | 94 | | `url*` | `string` | | URL of the OData endpoint | 95 | | `alwaysSelect` | `string[]` | | Array of entity properties to add to the `$select` clause of the query, even if a column doesn't exist for that property or the column is not visible.

If you use the `getRowId` prop of the DataGrid, ensure that property is added here as well. | 96 | | `defaultPageSize` | `number` | `10` | The default page size to use. | 97 | | `defaultSortModel` | `GridSortModel` | | The default property/properties to sort by. | 98 | | `disableFilterBuilder` | `boolean` | | Disable the filter/query builder if set to `true` | 99 | | `disableHistory` | `boolean` | | Disable the browser history integration for sorting and pagination if set to `true`.
**Note: this does not disable history integration for the filter builder.** | 100 | | `$filter` | `string` | | Static value to use for the `$filter` clause of the query.

**Note: this also has the effect of setting `disableFilterBuilder` to `true`**. | 101 | | `filterBuilderProps` | [`FilterBuilderProps`](#FilterBuilderProps) | | Props to be passed to the FilterBuilder. | 102 | | `requestOptions` | `RequestInit` | | Options to use in `fetch()` call to OData endpoint. | 103 | 104 | ### ODataGridColDef 105 | The column definition is again similar to the standard [GridColDef](https://mui.com/components/data-grid/columns/). 106 | 107 | Strong typing is supported through the `TRow` generic type. The original unflattened type can be accessed using the `result` property of the grid row parameters. Action columns are also supported using the `ODataGridColumns` type. 108 | 109 | The `TDate` generic type is also available to specify the date provider type for dateTime columns with a date picker filter. 110 | 111 | #### Modifications 112 | | Name | Change | Description | 113 | | ---- | ------ | ----------- | 114 | | `filterOperators` | Type | Type changed to `Operation[]` | 115 | | `hide` | Type | Type changed to `boolean \| Partial>` to support responsive column hiding.

**Note: usage not recommended, use [`columnVisibilityModel`](#columnVisibilityModel) instead. This property will be deprecated in the future.** | 116 | | `sortComparator` | Removed | Not supported | 117 | 118 | #### New Properties 119 | 120 | _* = not applicable to collection fields_ 121 | | Name | Type | Default | Description | 122 | | ---- | ---- | ------- | ----------- | 123 | | `autocompleteGroup` | `string` | | Group the field should be placed under in the field selection dropdown | 124 | | `caseSensitive` | `boolean` | | If set to `true`, all string operations on the field will be case sensitive. Otherwise `tolower()` is called on all string operations. | 125 | | `collection*` | `boolean` | | Indicates this column is a collection, i.e. is an array. Enables the "Any", "All" and "Count" options. | 126 | | `collectionFields` | `ODataGridColDef` | | Column definitions for the subfields of the collection. Any properties marked with * are not supported. | 127 | | `datePickerProps` | [`DatePickerProps`](https://mui.com/api/date-picker/) | | Props to pass to the `DatePicker` component for columns with type `date` | 128 | | `dateTimePickerProps` | [`DateTimePickerProps`](https://mui.com/api/date-time-picker/) | | Props to pass to the `DateTimePicker` component for columns with type `datetime` | 129 | | `expand` | `Expand \| Expand[]` | | Include related entities using the `$expand` clause. | 130 | | `filterable` | `boolean` | | Hides the field and does not allow filtering in the FilterBuilder when set to `false`. | 131 | | `filterField` | `string` | | If the field name is different to the field which should be used for filtering, provide the field for filtering here. See also: `filterType`. | 132 | | `filterOnly` | `boolean` | `false` | Set to true if the field is for filtering only and cannot be displayed as a column in the datagrid. | 133 | | `filterOperators` | `Operation[]` | `["eq", "ne", "gt", "lt", "ge", "le", "contains", "null", "notnull"]` | Array of supported filter operations for the field. | 134 | | `filterType` | `string` | | If the type of the field to be filtered is different to that of the displayed field, provide the type here. See also: `filterField`. | 135 | | `getCustomFilterString` | `(op: Operation, value: any) => string \| FilterCompute \| boolean` | | Function to generate a custom filter string for use in the `$filter` clause. Return `false` to skip and not add it to the `$filter` clause.

Also supports the use of the `$compute` clause by returning a `FilterCompute`. The computed property/properties can also be added to `$select` by returning a `ComputeSelect`. | 136 | | `getCustomQueryString` | `(op: Operation, value: any) => ({ [key: string]: string })` | | Function to generate a custom set of query string values to add to the OData request. | 137 | | `label` | `string` | Defaults to the same value as `headerName` or `field` | Text to be displayed in the field selection dropdown. | 138 | | `nullable` | `boolean` | | Adds an "Unknown" option to the value dropdown for columns with type `boolean` if set to `true`. | 139 | | `select` | `string` | | Additional fields to add to the `$select` clause. | 140 | | `selectProps` | `{ selectProps?: SelectProps, formControlProps?: FormControlProps, label?: string }` | | Props to pass to the `Select`, `FormControl` and `Label` components for this column in the filter. See also: `textFieldProps`. | 141 | | `sortField` | `string` | | If the name of the field to sort by is different to that of the displayed field, provide the name for sorting by here. | 142 | | `textFieldProps` | [`TextFieldProps`](https://mui.com/api/text-field/) | | Props to pass to the `TextField` component in the filter for this column. See also: `selectProps`. | 143 | | `renderCustomInput` | `(value: any, setValue: (v: any) => void) => React.ReactNode` | | Function to render a custom component for the "Value" input of the filter. The component should read the value from `value` and use `setValue` to change the value of the filter. See also: `renderCustomFilter`. | 144 | | `renderCustomFilter` | `(value: any, setValue: (v: any) => void) => React.ReactNode` | | Function to render a custom component for filter. The component should read the value from `value` and use `setValue` to change the value of the filter. This overrides the "Operation" input as well as the "Value" input. See also: `renderCustomInput`. | 145 | 146 | ### FilterBuilderProps 147 | | Name | Type | Default | Description | 148 | | ---- | ---- | ------- | ----------- | 149 | | `autocompleteGroups` | `string[]` | | Array of groups for field selection dropdown (used for setting group order) | 150 | | `autocompleteProps` | [`AutocompleteProps`](https://mui.com/api/autocomplete/#props) | | Props to pass to the `Autocomplete` component used for the field and collection field dropdowns | 151 | | `datePickerProps` | [`DatePickerProps`](https://mui.com/api/date-picker/#props) | | Props to pass to the `DatePicker` component used for the value input for columns of type `date` | 152 | | `datePickerProps` | [`DatePickerProps`](https://mui.com/api/date-time-picker/#props) | | Props to pass to the `DateTimePicker` component used for the value input for columns of type `datetime` | 153 | | `disableHistory` | `boolean` | | Disables browser history integration if set to `true` | 154 | | `filter` | `SerialisedGroup` | | Allows setting the state of the FilterBuilder using a `SerialisedGroup`. You could use this to implement filter saving and restoring.

Changing the value of this property will cause `restoreState` to be called, but with the `state` property undefined. | 155 | | `localeText` | [`FilterBuilderLocaleText`](#FilterBuilderLocaleText) | | Localization strings for `FilterBuilder` (see [Localization](#localization) section) | 156 | | `localizationProviderProps` | [`LocalizationProviderProps`](https://mui.com/components/date-picker/#localization) | | Props to pass to the `LocalizationProvider` component for the `DatePicker` and `DateTimePicker` components | 157 | | `onSubmit` | `(params: FilterParameters) => (void \| any)` | | Function called when FilterBuilder is submitted (e.g. when the search button is clicked). You should use this to trigger the OData request.

`params.filter` is the OData filter string, `params.serialised` is a serialised form of the query which can be used to load the query back into the filter builder. | 158 | | `onRestoreState` | `(params: FilterParameters, state?: any) => void` | | Function called when the state of the FilterBuilder is restored (e.g. from history navigation). You should also use this to trigger the OData request alongside the `onSubmit` callback.

`state` is the the value of `history.state` that the query was restored from. `state` will be undefined if the call is as a result of the `filter` property changing. | 159 | | `searchMenuItems` | `({ label: string, onClick: () => void })[]` | | Array of entries to add to the dropdown menu next to the Search button of the `FilterBuilder` | 160 | 161 | ## Localization 162 | The `FilterBuilder` component supports localization like the `DataGrid` through the `localeText` property. See below for the translation keys and their default values: 163 | ``` 164 | { 165 | and: "And", 166 | or: "Or", 167 | 168 | addCondition: "Add Condition", 169 | addGroup: "Add Group", 170 | 171 | field: "Field", 172 | operation: "Operation", 173 | value: "Value", 174 | collectionOperation: "Operation", 175 | collectionField: "Field", 176 | 177 | search: "Search", 178 | reset: "Reset", 179 | 180 | opAny: "Has at least one", 181 | opAll: "All have", 182 | opCount: "Count", 183 | 184 | opEq: "=", 185 | opNe: "≠", 186 | opGt: ">", 187 | opLt: "<", 188 | opGe: "≥", 189 | opLe: "≤", 190 | opContains: "Contains", 191 | opNull: "Is Blank", 192 | opNotNull: "Is Not Blank" 193 | } 194 | ``` 195 | 196 | ## Development 197 | ODataGrid is developed using [pnpm](https://pnpm.io/). It will probably work fine with npm too, but this hasn't been tested. 198 | 199 | To build and run the packages, you first need to install the development packages by running `pnpm i` in the `packages` directory. Once you have done that you can build or run the relevant package. 200 | 201 | ### Building 202 | Building is simple, just run `pnpm build` in `packages/o-data-grid` or `packages/o-data-grid-pro`. 203 | 204 | The build output is in the `build` directory. 205 | 206 | ### Running Locally 207 | For ease of testing, each package has a basic dev site included. To start it run `pnpm start`. This is only a front-end server, you will have to modify the app in `dev` to point to an OData service of your choice, and add the correct column schema. --------------------------------------------------------------------------------