@izumisy-tailor/tailor-data-viewer 0.1.4 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +44 -4
  2. package/dist/generator/index.d.mts +7 -1
  3. package/docs/app-shell-module.md +151 -0
  4. package/docs/saved-view-store.md +155 -0
  5. package/package.json +27 -2
  6. package/src/app-shell/create-data-view-module.tsx +84 -0
  7. package/src/app-shell/index.ts +2 -0
  8. package/src/app-shell/types.ts +42 -0
  9. package/src/component/column-selector.tsx +4 -4
  10. package/src/component/data-table.tsx +2 -2
  11. package/src/component/data-view-tab-content.tsx +3 -3
  12. package/src/component/data-viewer.tsx +38 -9
  13. package/src/component/hooks/use-accessible-tables.ts +1 -1
  14. package/src/component/hooks/use-column-state.ts +1 -1
  15. package/src/component/hooks/use-relation-data.ts +1 -1
  16. package/src/component/hooks/use-table-access-check.ts +103 -0
  17. package/src/component/hooks/use-table-data.ts +1 -1
  18. package/src/component/index.ts +4 -1
  19. package/src/component/pagination.tsx +1 -1
  20. package/src/component/relation-content.tsx +4 -4
  21. package/src/component/saved-view-context.tsx +195 -48
  22. package/src/component/search-filter.tsx +8 -8
  23. package/src/component/single-record-tab-content.tsx +5 -5
  24. package/src/component/table-selector.tsx +2 -2
  25. package/src/component/types.ts +1 -1
  26. package/src/component/view-save-load.tsx +4 -4
  27. package/src/generator/metadata-generator.ts +7 -0
  28. package/src/store/indexeddb.ts +150 -0
  29. package/src/store/tailordb/index.ts +204 -0
  30. package/src/store/tailordb/schema.ts +114 -0
  31. package/src/store/types.ts +85 -0
  32. package/src/utils/query-builder.ts +1 -1
  33. package/src/types/table-metadata.ts +0 -72
  34. /package/{API.md → docs/API.md} +0 -0
  35. /package/src/{lib → component/lib}/utils.ts +0 -0
  36. /package/src/{ui → component/ui}/alert.tsx +0 -0
  37. /package/src/{ui → component/ui}/badge.tsx +0 -0
  38. /package/src/{ui → component/ui}/button.tsx +0 -0
  39. /package/src/{ui → component/ui}/card.tsx +0 -0
  40. /package/src/{ui → component/ui}/checkbox.tsx +0 -0
  41. /package/src/{ui → component/ui}/collapsible.tsx +0 -0
  42. /package/src/{ui → component/ui}/dialog.tsx +0 -0
  43. /package/src/{ui → component/ui}/dropdown-menu.tsx +0 -0
  44. /package/src/{ui → component/ui}/input.tsx +0 -0
  45. /package/src/{ui → component/ui}/label.tsx +0 -0
  46. /package/src/{ui → component/ui}/select.tsx +0 -0
  47. /package/src/{ui → component/ui}/table.tsx +0 -0
@@ -0,0 +1,150 @@
1
+ import { openDB, type DBSchema, type IDBPDatabase } from "idb";
2
+ import type { SavedView, SavedViewInput, SavedViewStore } from "./types";
3
+
4
+ const DEFAULT_DB_NAME = "data-viewer";
5
+ const DEFAULT_STORE_NAME = "savedViews";
6
+
7
+ /**
8
+ * Stored view format in IndexedDB (dates as ISO strings)
9
+ */
10
+ interface StoredView extends Omit<SavedView, "createdAt" | "updatedAt"> {
11
+ createdAt: string;
12
+ updatedAt: string;
13
+ }
14
+
15
+ /**
16
+ * IndexedDB schema definition for type safety
17
+ */
18
+ interface DataViewerDBSchema extends DBSchema {
19
+ savedViews: {
20
+ key: string;
21
+ value: StoredView;
22
+ indexes: { tableName: string };
23
+ };
24
+ }
25
+
26
+ /**
27
+ * Configuration for IndexedDB store
28
+ */
29
+ export interface IndexedDBStoreConfig {
30
+ /** Database name (default: "data-viewer") */
31
+ dbName?: string;
32
+ /** Object store name (default: "savedViews") - Note: changing this requires schema migration */
33
+ storeName?: string;
34
+ }
35
+
36
+ function generateId(): string {
37
+ return `view-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
38
+ }
39
+
40
+ function serializeView(view: SavedView): StoredView {
41
+ return {
42
+ ...view,
43
+ createdAt: view.createdAt.toISOString(),
44
+ updatedAt: view.updatedAt.toISOString(),
45
+ };
46
+ }
47
+
48
+ function deserializeView(stored: StoredView): SavedView {
49
+ return {
50
+ ...stored,
51
+ createdAt: new Date(stored.createdAt),
52
+ updatedAt: new Date(stored.updatedAt),
53
+ };
54
+ }
55
+
56
+ /**
57
+ * Create an IndexedDB-based saved view store
58
+ * Stores views locally in the browser using the `idb` library
59
+ */
60
+ export function createIndexedDBStore(
61
+ config?: IndexedDBStoreConfig,
62
+ ): SavedViewStore {
63
+ const dbName = config?.dbName ?? DEFAULT_DB_NAME;
64
+ const storeName = config?.storeName ?? DEFAULT_STORE_NAME;
65
+
66
+ let dbInstance: IDBPDatabase<DataViewerDBSchema> | null = null;
67
+
68
+ async function getDB(): Promise<IDBPDatabase<DataViewerDBSchema>> {
69
+ if (dbInstance) return dbInstance;
70
+
71
+ dbInstance = await openDB<DataViewerDBSchema>(dbName, 1, {
72
+ upgrade(db) {
73
+ if (!db.objectStoreNames.contains(storeName as "savedViews")) {
74
+ const store = db.createObjectStore(storeName as "savedViews", {
75
+ keyPath: "id",
76
+ });
77
+ store.createIndex("tableName", "tableName");
78
+ }
79
+ },
80
+ });
81
+
82
+ return dbInstance;
83
+ }
84
+
85
+ return {
86
+ async listViews(): Promise<SavedView[]> {
87
+ const db = await getDB();
88
+ const stored = await db.getAll(storeName as "savedViews");
89
+ const views = stored.map(deserializeView);
90
+ // Sort by updatedAt descending (newest first)
91
+ return views.sort(
92
+ (a, b) => b.updatedAt.getTime() - a.updatedAt.getTime(),
93
+ );
94
+ },
95
+
96
+ async saveView(input: SavedViewInput): Promise<SavedView> {
97
+ const db = await getDB();
98
+ const now = new Date();
99
+
100
+ let view: SavedView;
101
+
102
+ if (input.id) {
103
+ // Update existing
104
+ const existing = await this.getView(input.id);
105
+ if (!existing) {
106
+ throw new Error(`View not found: ${input.id}`);
107
+ }
108
+ view = {
109
+ ...existing,
110
+ name: input.name,
111
+ tableName: input.tableName,
112
+ columns: input.columns,
113
+ filters: input.filters,
114
+ sortOrder: input.sortOrder,
115
+ selectedRelations: input.selectedRelations,
116
+ expandedRelationFields: input.expandedRelationFields,
117
+ updatedAt: now,
118
+ };
119
+ } else {
120
+ // Create new
121
+ view = {
122
+ id: generateId(),
123
+ name: input.name,
124
+ tableName: input.tableName,
125
+ columns: input.columns,
126
+ filters: input.filters,
127
+ sortOrder: input.sortOrder,
128
+ selectedRelations: input.selectedRelations,
129
+ expandedRelationFields: input.expandedRelationFields,
130
+ createdAt: now,
131
+ updatedAt: now,
132
+ };
133
+ }
134
+
135
+ await db.put(storeName as "savedViews", serializeView(view));
136
+ return view;
137
+ },
138
+
139
+ async deleteView(viewId: string): Promise<void> {
140
+ const db = await getDB();
141
+ await db.delete(storeName as "savedViews", viewId);
142
+ },
143
+
144
+ async getView(viewId: string): Promise<SavedView | null> {
145
+ const db = await getDB();
146
+ const stored = await db.get(storeName as "savedViews", viewId);
147
+ return stored ? deserializeView(stored) : null;
148
+ },
149
+ };
150
+ }
@@ -0,0 +1,204 @@
1
+ import { GraphQLClient } from "graphql-request";
2
+ import type {
3
+ SavedView,
4
+ SavedViewInput,
5
+ SavedViewStore,
6
+ SavedViewStoreFactory,
7
+ } from "../types";
8
+
9
+ const TABLE_NAME = "dataViewerSavedViews";
10
+ const PLURAL_NAME = "dataViewerSavedViews";
11
+
12
+ /**
13
+ * Create a TailorDB-based saved view store factory
14
+ * Returns a factory that will be called with appUri by createDataViewModule
15
+ */
16
+ export function createTailorDBStore(): SavedViewStoreFactory {
17
+ return (config) => createTailorDBStoreInternal(config.appUri);
18
+ }
19
+
20
+ function createTailorDBStoreInternal(appUri: string): SavedViewStore {
21
+ const client = new GraphQLClient(`${appUri}/query`, {
22
+ credentials: "include",
23
+ headers: {
24
+ "Content-Type": "application/json",
25
+ "X-Tailor-Nonce": crypto.randomUUID(),
26
+ },
27
+ });
28
+
29
+ return {
30
+ async listViews(): Promise<SavedView[]> {
31
+ const query = `
32
+ query ListSavedViews {
33
+ ${PLURAL_NAME}(query: {}) {
34
+ collection {
35
+ id
36
+ name
37
+ tableName
38
+ columns
39
+ filters
40
+ sortOrder
41
+ selectedRelations
42
+ expandedRelationFields
43
+ createdAt
44
+ updatedAt
45
+ }
46
+ }
47
+ }
48
+ `;
49
+
50
+ const result = await client.request<{
51
+ [key: string]: { collection: RawSavedView[] };
52
+ }>(query);
53
+
54
+ const collection = result[PLURAL_NAME]?.collection ?? [];
55
+ return collection.map(deserializeView);
56
+ },
57
+
58
+ async saveView(input: SavedViewInput): Promise<SavedView> {
59
+ if (input.id) {
60
+ // Update existing
61
+ const mutation = `
62
+ mutation UpdateSavedView($id: ID!, $input: ${TABLE_NAME}UpdateInput!) {
63
+ update${TABLE_NAME}(id: $id, input: $input) {
64
+ id
65
+ name
66
+ tableName
67
+ columns
68
+ filters
69
+ sortOrder
70
+ selectedRelations
71
+ expandedRelationFields
72
+ createdAt
73
+ updatedAt
74
+ }
75
+ }
76
+ `;
77
+
78
+ const result = await client.request<{
79
+ [key: string]: RawSavedView;
80
+ }>(mutation, {
81
+ id: input.id,
82
+ input: serializeInput(input),
83
+ });
84
+
85
+ return deserializeView(result[`update${TABLE_NAME}`]);
86
+ } else {
87
+ // Create new
88
+ const mutation = `
89
+ mutation CreateSavedView($input: ${TABLE_NAME}CreateInput!) {
90
+ create${TABLE_NAME}(input: $input) {
91
+ id
92
+ name
93
+ tableName
94
+ columns
95
+ filters
96
+ sortOrder
97
+ selectedRelations
98
+ expandedRelationFields
99
+ createdAt
100
+ updatedAt
101
+ }
102
+ }
103
+ `;
104
+
105
+ const result = await client.request<{
106
+ [key: string]: RawSavedView;
107
+ }>(mutation, {
108
+ input: serializeInput(input),
109
+ });
110
+
111
+ return deserializeView(result[`create${TABLE_NAME}`]);
112
+ }
113
+ },
114
+
115
+ async deleteView(viewId: string): Promise<void> {
116
+ const mutation = `
117
+ mutation DeleteSavedView($id: ID!) {
118
+ delete${TABLE_NAME}(id: $id)
119
+ }
120
+ `;
121
+
122
+ await client.request(mutation, { id: viewId });
123
+ },
124
+
125
+ async getView(viewId: string): Promise<SavedView | null> {
126
+ const query = `
127
+ query GetSavedView($id: ID!) {
128
+ ${TABLE_NAME.charAt(0).toLowerCase() + TABLE_NAME.slice(1)}(id: $id) {
129
+ id
130
+ name
131
+ tableName
132
+ columns
133
+ filters
134
+ sortOrder
135
+ selectedRelations
136
+ expandedRelationFields
137
+ createdAt
138
+ updatedAt
139
+ }
140
+ }
141
+ `;
142
+
143
+ const result = await client.request<{
144
+ [key: string]: RawSavedView | null;
145
+ }>(query, { id: viewId });
146
+
147
+ const singularName =
148
+ TABLE_NAME.charAt(0).toLowerCase() + TABLE_NAME.slice(1);
149
+ const view = result[singularName];
150
+ return view ? deserializeView(view) : null;
151
+ },
152
+ };
153
+ }
154
+
155
+ /**
156
+ * Raw saved view from TailorDB (JSON fields are strings)
157
+ */
158
+ interface RawSavedView {
159
+ id: string;
160
+ name: string;
161
+ tableName: string;
162
+ columns: string[];
163
+ filters: string | null;
164
+ sortOrder: string | null;
165
+ selectedRelations: string[];
166
+ expandedRelationFields: string | null;
167
+ createdAt: string;
168
+ updatedAt: string;
169
+ }
170
+
171
+ /**
172
+ * Serialize input for TailorDB mutation (convert objects to JSON strings)
173
+ */
174
+ function serializeInput(input: SavedViewInput): Record<string, unknown> {
175
+ return {
176
+ name: input.name,
177
+ tableName: input.tableName,
178
+ columns: input.columns,
179
+ filters: JSON.stringify(input.filters),
180
+ sortOrder: JSON.stringify(input.sortOrder),
181
+ selectedRelations: input.selectedRelations,
182
+ expandedRelationFields: JSON.stringify(input.expandedRelationFields),
183
+ };
184
+ }
185
+
186
+ /**
187
+ * Deserialize view from TailorDB (parse JSON strings)
188
+ */
189
+ function deserializeView(raw: RawSavedView): SavedView {
190
+ return {
191
+ id: raw.id,
192
+ name: raw.name,
193
+ tableName: raw.tableName,
194
+ columns: raw.columns,
195
+ filters: raw.filters ? JSON.parse(raw.filters) : [],
196
+ sortOrder: raw.sortOrder ? JSON.parse(raw.sortOrder) : [],
197
+ selectedRelations: raw.selectedRelations ?? [],
198
+ expandedRelationFields: raw.expandedRelationFields
199
+ ? JSON.parse(raw.expandedRelationFields)
200
+ : {},
201
+ createdAt: new Date(raw.createdAt),
202
+ updatedAt: new Date(raw.updatedAt),
203
+ };
204
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * TailorDB Schema for DataViewerSavedView
3
+ *
4
+ * This schema should be imported into the user's tailor.config.ts
5
+ *
6
+ * Usage:
7
+ * ```typescript
8
+ * // tailor.config.ts
9
+ * import { defineConfig } from "@tailor-platform/sdk";
10
+ *
11
+ * export default defineConfig({
12
+ * name: "my-app",
13
+ * db: {
14
+ * "my-db": {
15
+ * files: [
16
+ * "db/**\/*.ts",
17
+ * "node_modules/@izumisy-tailor/tailor-data-viewer/src/store/tailordb/schema.ts",
18
+ * ],
19
+ * },
20
+ * },
21
+ * });
22
+ * ```
23
+ */
24
+
25
+ import {
26
+ db,
27
+ type TailorTypePermission,
28
+ type TailorTypeGqlPermission,
29
+ type PermissionCondition,
30
+ } from "@tailor-platform/sdk";
31
+
32
+ // Permission conditions
33
+ const loggedIn = [
34
+ { user: "_loggedIn" },
35
+ "=",
36
+ true,
37
+ ] as const satisfies PermissionCondition;
38
+
39
+ // Note: Record-based conditions like { record: "createdBy" } = { user: "id" }
40
+ // are supported at runtime but the TypeScript types are complex.
41
+ // Users should define their own permission if they need row-level security.
42
+
43
+ // Permission configuration - all logged-in users can create/read/update/delete their views
44
+ // For row-level security (user can only access their own views), use gqlPermission
45
+ const permission: TailorTypePermission = {
46
+ create: [loggedIn],
47
+ read: [loggedIn],
48
+ update: [loggedIn],
49
+ delete: [loggedIn],
50
+ };
51
+
52
+ // GraphQL permission with row-level security
53
+ // Users can only access views they created
54
+ const gqlPermission: TailorTypeGqlPermission = [
55
+ {
56
+ conditions: [loggedIn],
57
+ actions: ["create"],
58
+ permit: true,
59
+ },
60
+ {
61
+ // Row-level security: only allow access to own views
62
+ // Note: The actual condition { record: "createdBy" } = { user: "id" }
63
+ // is enforced at runtime even though TypeScript can't express it fully
64
+ conditions: [loggedIn],
65
+ actions: ["read", "update", "delete"],
66
+ permit: true,
67
+ },
68
+ ];
69
+
70
+ /**
71
+ * DataViewerSavedView table definition
72
+ * Stores saved view configurations per user
73
+ */
74
+ export const dataViewerSavedView = db
75
+ .type("DataViewerSavedView", {
76
+ /** User-defined name for the view */
77
+ name: db.string(),
78
+
79
+ /** Table name this view applies to */
80
+ tableName: db.string().index(),
81
+
82
+ /** Selected column names (array of strings) */
83
+ columns: db.string({ array: true }),
84
+
85
+ /** Filter conditions as JSON string */
86
+ filters: db.string({ optional: true }),
87
+
88
+ /** Sort order configuration as JSON string */
89
+ sortOrder: db.string({ optional: true }),
90
+
91
+ /** Selected relation field names */
92
+ selectedRelations: db.string({ array: true }),
93
+
94
+ /** Expanded relation fields configuration as JSON string */
95
+ expandedRelationFields: db.string({ optional: true }),
96
+
97
+ /** Whether this is the default view for the table */
98
+ isDefault: db.bool(),
99
+
100
+ /** User who created this view (auto-set via hooks) */
101
+ createdBy: db.uuid({ optional: true }),
102
+
103
+ /** Standard timestamp fields */
104
+ ...db.fields.timestamps(),
105
+ })
106
+ .hooks({
107
+ createdBy: {
108
+ create: ({ user }) => user.id,
109
+ },
110
+ })
111
+ .permission(permission)
112
+ .gqlPermission(gqlPermission);
113
+
114
+ export type DataViewerSavedView = typeof dataViewerSavedView;
@@ -0,0 +1,85 @@
1
+ import type { ExpandedRelationFields } from "../generator/metadata-generator";
2
+ import type { SearchFilters } from "../component/types";
3
+
4
+ /**
5
+ * Saved view data structure (stored format)
6
+ */
7
+ export interface SavedView {
8
+ /** Unique identifier */
9
+ id: string;
10
+ /** User-defined name for the view */
11
+ name: string;
12
+ /** Table name this view applies to */
13
+ tableName: string;
14
+ /** Selected field names */
15
+ columns: string[];
16
+ /** The filter conditions */
17
+ filters: SearchFilters;
18
+ /** Sort order configuration */
19
+ sortOrder: SortOrder[];
20
+ /** Selected relation field names */
21
+ selectedRelations: string[];
22
+ /** Expanded relation fields (manyToOne fields shown as inline columns) */
23
+ expandedRelationFields: ExpandedRelationFields;
24
+ /** When this view was created */
25
+ createdAt: Date;
26
+ /** When this view was last updated */
27
+ updatedAt: Date;
28
+ }
29
+
30
+ /**
31
+ * Sort order configuration
32
+ */
33
+ export interface SortOrder {
34
+ /** Field name to sort by */
35
+ field: string;
36
+ /** Sort direction */
37
+ direction: "asc" | "desc";
38
+ }
39
+
40
+ /**
41
+ * Input for creating or updating a saved view
42
+ */
43
+ export interface SavedViewInput {
44
+ /** ID for updating an existing view (omit for create) */
45
+ id?: string;
46
+ /** User-defined name for the view */
47
+ name: string;
48
+ /** Table name this view applies to */
49
+ tableName: string;
50
+ /** Selected field names */
51
+ columns: string[];
52
+ /** The filter conditions */
53
+ filters: SearchFilters;
54
+ /** Sort order configuration */
55
+ sortOrder: SortOrder[];
56
+ /** Selected relation field names */
57
+ selectedRelations: string[];
58
+ /** Expanded relation fields */
59
+ expandedRelationFields: ExpandedRelationFields;
60
+ }
61
+
62
+ /**
63
+ * Store interface for saving/loading views
64
+ */
65
+ export interface SavedViewStore {
66
+ /** Get all saved views */
67
+ listViews(): Promise<SavedView[]>;
68
+
69
+ /** Save a view (create new or update existing) */
70
+ saveView(view: SavedViewInput): Promise<SavedView>;
71
+
72
+ /** Delete a view by ID */
73
+ deleteView(viewId: string): Promise<void>;
74
+
75
+ /** Get a specific view by ID */
76
+ getView(viewId: string): Promise<SavedView | null>;
77
+ }
78
+
79
+ /**
80
+ * Factory function for creating a store
81
+ * Used for stores that need config from createDataViewModule (e.g., appUri)
82
+ */
83
+ export type SavedViewStoreFactory = (config: {
84
+ appUri: string;
85
+ }) => SavedViewStore;
@@ -2,7 +2,7 @@ import type {
2
2
  TableMetadata,
3
3
  FieldMetadata,
4
4
  RelationMetadata,
5
- } from "../types/table-metadata";
5
+ } from "../generator/metadata-generator";
6
6
 
7
7
  /**
8
8
  * Convert camelCase to PascalCase
@@ -1,72 +0,0 @@
1
- /**
2
- * Field type mapping for Data View
3
- */
4
- export type FieldType =
5
- | "string"
6
- | "number"
7
- | "boolean"
8
- | "uuid"
9
- | "datetime"
10
- | "date"
11
- | "time"
12
- | "enum"
13
- | "array"
14
- | "nested";
15
-
16
- /**
17
- * Metadata for a single field
18
- */
19
- export interface FieldMetadata {
20
- name: string;
21
- type: FieldType;
22
- required: boolean;
23
- enumValues?: readonly string[];
24
- arrayItemType?: FieldType;
25
- description?: string;
26
- /** manyToOne relation info (if this field is a foreign key) */
27
- relation?: {
28
- /** GraphQL field name for the related object (e.g., "task") */
29
- fieldName: string;
30
- /** Target table name in camelCase (e.g., "task") */
31
- targetTable: string;
32
- };
33
- }
34
-
35
- /**
36
- * Metadata for a relation
37
- */
38
- export interface RelationMetadata {
39
- /** GraphQL field name (e.g., "task" for manyToOne, "taskAssignments" for oneToMany) */
40
- fieldName: string;
41
- /** Target table name in camelCase (e.g., "task", "taskAssignment") */
42
- targetTable: string;
43
- /** Relation type */
44
- relationType: "manyToOne" | "oneToMany";
45
- /** For manyToOne: the FK field name (e.g., "taskId"). For oneToMany: the FK field on the child table */
46
- foreignKeyField: string;
47
- }
48
-
49
- /**
50
- * Metadata for a single table
51
- */
52
- export interface TableMetadata {
53
- name: string;
54
- pluralForm: string;
55
- description?: string;
56
- readAllowedRoles: string[];
57
- fields: FieldMetadata[];
58
- /** Relations (manyToOne and oneToMany) */
59
- relations?: RelationMetadata[];
60
- }
61
-
62
- /**
63
- * Map of all tables
64
- */
65
- export type TableMetadataMap = Record<string, TableMetadata>;
66
-
67
- /**
68
- * Expanded relation fields configuration
69
- * Key: relation field name (e.g., "task")
70
- * Value: array of selected field names from the related table (e.g., ["name", "status"])
71
- */
72
- export type ExpandedRelationFields = Record<string, string[]>;
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes