Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/examples/sqlite/src/admin-ui/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
csv-export-overrides.generated.ts
54 changes: 54 additions & 0 deletions src/examples/sqlite/src/admin-ui/csv-export-overrides.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { gql } from '@apollo/client';

export const csvExportOverrides = {
Genre: {
// When exporting the Genre entity, we also want to include the album on the tracks.
query: gql`
query entityCSVExport($filter: GenresListFilter, $pagination: GenresPaginationInput) {
result: genres(filter: $filter, pagination: $pagination) {
genreId
name
tracks {
value: trackId
label: name

# This is the new part. It allows us to load the album ID for each track.
# You can get as deeply nested as you need here, grab any information you'd like.
album {
albumId
}
}
}
aggregate: genres_aggregate(filter: $filter) {
count
}
}
`,

mapResults: (csvExportRows: any[]) => {
// The export is only going to be looking at keys in the row object, not nested ones,
// so we need to hoist the album titles to the top level. They'll show up in the export
// on a new column called "albums".
return csvExportRows.map((row) => {
delete row.__typename;

const uniqueTracks = new Set<string>();
const uniqueAlbums = new Set<string>();

for (const track of row.tracks) {
uniqueTracks.add(track.value);
uniqueAlbums.add(track.album.albumId);
}

return {
...row,
'Track Count': uniqueTracks.size,
'Album Count': uniqueAlbums.size,

// You can also derive any data you need from the data you've loaded.
'Average Tracks Per Album': uniqueTracks.size / uniqueAlbums.size,
};
});
},
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
declare module 'virtual:graphweaver-admin-ui-csv-export-overrides' {
import type { Entity } from '@exogee/graphweaver-admin-ui';
import type { QueryOptions } from '@apollo/client';

export interface EntitySpecificCsvExportOverride {
// If provided, we'll use this instead of our standard query. Pagination, filtering, sorting, etc. will be supplied as arguments
// to your filter.
// Supplying both `query` and `queryOptions` is not supported. queryOptions gives you more ability to override the export behaviour, but in
// most cases, just overriding the query to get extra fields out, or omit fields you don't want is enough.
// If you return a string, it'll get parsed with gql() before going to the server.
// If the function returns undefined, we'll use the default query for the entity for that page and call you back again for the next one.
//
// The query must have results aliased to be named `result` in the response.
//
// If an `aggregation` field is present in the result, we'll use this to provide a total number of pages. The default query
// aliases an aggregation result there with the shape { count } so a total number of pages can be provided to the user during
// the export.
query?:
| DocumentNode
| Promise<DocumentNode>
| ((
// This is the entity metadata for the entity being exported.
entity: Entity,
// This function can be used to get the entity metadata for other entities as needed.
entityByName: (entityType: string) => Entity
) => Promise<DocumentNode | undefined> | DocumentNode | undefined);

// If provided, we'll pass you the info we have and you can return a different query, map pagination, filtering, sorting, etc.
// This is a low level override and is usually not needed, most likely you'll just want to override the query above.
// Specifying `queryOptions` as well as `query` is an error. Only one can be specified. If you want to override the query while
// also overriding the query options, you can do that by returning the `query` key in the response.
// If the function returns undefined, we'll use the default query for the entity for that page
// and call you back again for the next one.
//
// Note: It's likely you'll want to return `fetchPolicy: 'no-cache'` in your response so that you'll definitely get fresh data on the export.
//
// We have specific behaviour around the `aggregation` field in the result. If you want to provide a total number of pages, you'll need to alias your
// query such that the aggregation field is present the way the default query does.
queryOptions?: (args: {
selectedEntity: Entity;
entityByName: (entityType: string) => Entity;
pageNumber: number;
pageSize: number;
sort: SortEntity;
filters: Filter;
}) => Promise<QueryOptions<any, any> | undefined> | QueryOptions<any, any> | undefined;

// If provided, this will be called after all data is fetched and before passing to the CSV export. This allows you to override the columns and/or
// rows in the resulting CSV export.
mapResults?: (csvExportRows: Row<TData>[]) => Promise<Row<TData>[]> | Row<TData>[];
}

export interface CsvExportOverridesExport {
[entityName: string]: EntitySpecificCsvExportOverride;
}

export const csvExportOverrides: CsvExportOverridesExport;
}
66 changes: 38 additions & 28 deletions src/packages/admin-ui-components/src/export-modal/component.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,17 @@
import { apolloClient } from '../apollo';
import { useEffect, useState, useRef } from 'react';
import { QueryOptions } from '@apollo/client';
import { Row } from '@tanstack/react-table';
import toast from 'react-hot-toast';
import { csvExportOverrides } from 'virtual:graphweaver-admin-ui-csv-export-overrides';

import styles from './styles.module.css';

import { apolloClient } from '../apollo';
import { Button } from '../button';
import { Modal } from '../modal';
import { Spinner } from '../spinner';
import { exportToCSV, useSelectedEntity, useSchema, Filter, SortEntity } from '../utils';

import toast from 'react-hot-toast';

import {
exportToCSV,
useSelectedEntity,
useSchema,
getOrderByQuery,
Filter,
SortEntity,
} from '../utils';
import { listEntityForExport } from './graphql';
import { defaultQuery } from './graphql';
import styles from './styles.module.css';

const DEFAULT_EXPORT_PAGE_SIZE = 200;

Expand All @@ -39,33 +32,46 @@ export const ExportModal = <TData extends object>({

if (!selectedEntity) throw new Error('There should always be a selected entity at this point.');

const csvOverrides = csvExportOverrides[selectedEntity.name];
const pageSize = selectedEntity.attributes.exportPageSize || DEFAULT_EXPORT_PAGE_SIZE;

if (csvOverrides?.query && csvOverrides?.queryOptions) {
throw new Error(
`Both query and queryOptions were specified for the '${selectedEntity.name}' entity CSV export override options. You can specify query, or queryOptions, but not both.`
);
}

const fetchAll = async () => {
try {
let pageNumber = 0;
let hasNextPage = true;

const allResults: Row<TData>[] = [];
let allResults: Row<TData>[] = [];

while (hasNextPage) {
if (abortRef.current) return;

const primaryKeyField = selectedEntity.primaryKeyField;

const { data } = await apolloClient.query({
query: listEntityForExport(selectedEntity, entityByName),
variables: {
pagination: {
offset: pageNumber * pageSize,
limit: pageSize,
orderBy: getOrderByQuery({ primaryKeyField, sort }),
},
...(filters ? { filter: filters } : {}),
},
fetchPolicy: 'no-cache',
let queryOptions: QueryOptions<any, any> | undefined = await csvOverrides?.queryOptions?.({
selectedEntity,
entityByName,
pageNumber,
pageSize,
sort,
filters,
});

queryOptions ??= await defaultQuery({
selectedEntity,
entityByName,
queryOverride: csvOverrides?.query,
pageNumber,
pageSize,
sort,
filters,
});

const { data } = await apolloClient.query(queryOptions);

if (data && data.result.length > 0) allResults.push(...data.result);

hasNextPage = data?.result.length === pageSize;
Expand All @@ -78,6 +84,10 @@ export const ExportModal = <TData extends object>({
}
}

if (csvOverrides?.mapResults) {
allResults = await csvOverrides.mapResults(allResults);
}

exportToCSV(selectedEntity.name, allResults);
} catch (error) {
console.error(error);
Expand Down
64 changes: 62 additions & 2 deletions src/packages/admin-ui-components/src/export-modal/graphql.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
import { gql } from '@apollo/client';
import { AggregationType, Entity, generateGqlSelectForEntityFields } from '../utils';
import { DocumentNode, gql, QueryOptions } from '@apollo/client';
import {
AggregationType,
Entity,
Filter,
generateGqlSelectForEntityFields,
getOrderByQuery,
SortEntity,
} from '../utils';
import { isDocumentNode } from '@apollo/client/utilities';

export const listEntityForExport = (
entity: Entity,
Expand All @@ -18,3 +26,55 @@ export const listEntityForExport = (
}
`;
};

export const defaultQuery = async ({
selectedEntity,
entityByName,
queryOverride,
pageNumber,
pageSize,
sort,
filters,
}: {
selectedEntity: Entity;
entityByName: (entityType: string) => Entity;
queryOverride:
| DocumentNode
| undefined
| ((
entity: Entity,
entityByName: (entityType: string) => Entity
) => Promise<DocumentNode | undefined> | DocumentNode | undefined);
pageNumber: number;
pageSize: number;
sort?: SortEntity;
filters?: Filter;
}): Promise<QueryOptions<any, any>> => {
let query: DocumentNode | undefined;

if (typeof queryOverride === 'function') {
query = await queryOverride(selectedEntity, entityByName);
} else if (isDocumentNode(queryOverride)) {
query = queryOverride;
} else if (typeof queryOverride !== 'undefined' && queryOverride !== null) {
throw new Error('query must be a function or a DocumentNode for CSV export');
}

query ??= listEntityForExport(selectedEntity, entityByName);

return {
query,
variables: {
pagination: {
offset: pageNumber * pageSize,
limit: pageSize,
orderBy: getOrderByQuery({
primaryKeyField: selectedEntity.primaryKeyField,
sort,
}),
},
...(filters ? { filter: filters } : {}),
},
fetchPolicy: 'no-cache',
};
};
58 changes: 58 additions & 0 deletions src/packages/admin-ui/@types/custom-csv-export-overrides.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
declare module 'virtual:graphweaver-admin-ui-csv-export-overrides' {
import type { Entity } from '@exogee/graphweaver-admin-ui';
import type { QueryOptions } from '@apollo/client';

export interface EntitySpecificCsvExportOverride {
// If provided, we'll use this instead of our standard query. Pagination, filtering, sorting, etc. will be supplied as arguments
// to your filter.
// Supplying both `query` and `queryOptions` is not supported. queryOptions gives you more ability to override the export behaviour, but in
// most cases, just overriding the query to get extra fields out, or omit fields you don't want is enough.
// If you return a string, it'll get parsed with gql() before going to the server.
// If the function returns undefined, we'll use the default query for the entity for that page and call you back again for the next one.
//
// The query must have results aliased to be named `result` in the response.
//
// If an `aggregation` field is present in the result, we'll use this to provide a total number of pages. The default query
// aliases an aggregation result there with the shape { count } so a total number of pages can be provided to the user during
// the export.
query?:
| DocumentNode
| Promise<DocumentNode>
| ((
// This is the entity metadata for the entity being exported.
entity: Entity,
// This function can be used to get the entity metadata for other entities as needed.
entityByName: (entityType: string) => Entity
) => Promise<DocumentNode | undefined> | DocumentNode | undefined);

// If provided, we'll pass you the info we have and you can return a different query, map pagination, filtering, sorting, etc.
// This is a low level override and is usually not needed, most likely you'll just want to override the query above.
// Specifying `queryOptions` as well as `query` is an error. Only one can be specified. If you want to override the query while
// also overriding the query options, you can do that by returning the `query` key in the response.
// If the function returns undefined, we'll use the default query for the entity for that page
// and call you back again for the next one.
//
// Note: It's likely you'll want to return `fetchPolicy: 'no-cache'` in your response so that you'll definitely get fresh data on the export.
//
// We have specific behaviour around the `aggregation` field in the result. If you want to provide a total number of pages, you'll need to alias your
// query such that the aggregation field is present the way the default query does.
queryOptions?: (args: {
selectedEntity: Entity;
entityByName: (entityType: string) => Entity;
pageNumber: number;
pageSize: number;
sort: SortEntity;
filters: Filter;
}) => Promise<QueryOptions<any, any> | undefined> | QueryOptions<any, any> | undefined;

// If provided, this will be called after all data is fetched and before passing to the CSV export. This allows you to override the columns and/or
// rows in the resulting CSV export.
mapResults?: (csvExportRows: Row<TData>[]) => Promise<Row<TData>[]> | Row<TData>[];
}

export interface CsvExportOverridesExport {
[entityName: string]: EntitySpecificCsvExportOverride;
}

export const csvExportOverrides: CsvExportOverridesExport;
}
8 changes: 4 additions & 4 deletions src/packages/builder/src/vite-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,11 +92,11 @@ export const viteConfig = async ({
'@tiptap/starter-kit',
],
exclude: [
// This can't be bundled because it's virtual and supplied by
// our vite plugin directly.
'virtual:graphweaver-user-supplied-custom-pages',
'virtual:graphweaver-user-supplied-custom-fields',
// This can't be bundled because it's virtual and supplied by our vite plugin directly.
'virtual:graphweaver-admin-ui-csv-export-overrides',
'virtual:graphweaver-auth-ui-components',
'virtual:graphweaver-user-supplied-custom-fields',
'virtual:graphweaver-user-supplied-custom-pages',
],
},
plugins: [react(), graphweaver()],
Expand Down
6 changes: 4 additions & 2 deletions src/packages/config/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,9 @@ export interface AdminUIAuthOptions {
}

export interface AdminUIOptions {
customPagesPath: string;
customFieldsPath: string;
customPagesPath?: string;
customFieldsPath?: string;
csvExportOverridesPath?: string;
auth?: AdminUIAuthOptions;
}

Expand Down Expand Up @@ -120,6 +121,7 @@ export const defaultConfig = (): ConfigOptions => {
adminUI: {
customPagesPath: 'src/admin-ui/custom-pages',
customFieldsPath: 'src/admin-ui/custom-fields',
csvExportOverridesPath: 'src/admin-ui/csv-export-overrides',
auth: {
password: {
enableForgottenPassword: true,
Expand Down
Loading
Loading