Skip to content

Commit

Permalink
Image field type (#5396)
Browse files Browse the repository at this point in the history
  • Loading branch information
rohan-deshpande authored Apr 16, 2021
1 parent 11f5bb6 commit be60812
Show file tree
Hide file tree
Showing 30 changed files with 1,093 additions and 15 deletions.
5 changes: 5 additions & 0 deletions .changeset/real-parents-warn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@keystone-next/admin-ui': minor
---

Reflected next/image exports from admin-ui for use in other relevant keystone-next packages.
5 changes: 5 additions & 0 deletions .changeset/rude-parrots-train.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@keystone-next/app-basic': patch
---

Added example of image field support to basic keystone-next example.
5 changes: 5 additions & 0 deletions .changeset/shaggy-dots-appear.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@keystone-next/fields': minor
---

Added new image field type.
5 changes: 5 additions & 0 deletions .changeset/stale-balloons-grin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@keystone-next/types': minor
---

Added types for new images functionality in keystone.
5 changes: 5 additions & 0 deletions .changeset/tough-ravens-help.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@keystone-next/keystone': minor
---

Added create-image-context, logic for parsing, storing and retrieving image data in keystone core.
1 change: 1 addition & 0 deletions examples-next/basic/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
public/
1 change: 1 addition & 0 deletions examples-next/basic/keystone.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export default auth.withAuth(
// path: '/admin',
// isAccessAllowed,
},
images: { upload: 'local' },
lists,
extendGraphqlSchema,
session: withItemData(
Expand Down
11 changes: 3 additions & 8 deletions examples-next/basic/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
timestamp,
select,
virtual,
image,
} from '@keystone-next/fields';
import { document } from '@keystone-next/fields-document';
// import { cloudinaryImage } from '@keystone-next/cloudinary';
Expand Down Expand Up @@ -34,21 +35,15 @@ export const lists = createSchema({
User: list({
ui: {
listView: {
initialColumns: ['name', 'posts'],
initialColumns: ['name', 'posts', 'avatar'],
},
},
fields: {
/** The user's first and last name. */
name: text({ isRequired: true }),
/** Email is used to log into the system. */
email: text({ isRequired: true, isUnique: true }),
// avatar: cloudinaryImage({
// cloudinary: {
// cloudName: '/* TODO */',
// apiKey: '/* TODO */',
// apiSecret: '/* TODO */',
// },
// }),
avatar: image(),
/** Used to log in. */
password: password(),
/** Administrators have more access to various lists and fields. */
Expand Down
4 changes: 4 additions & 0 deletions packages-next/admin-ui/image/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"main": "dist/admin-ui.cjs.js",
"module": "dist/admin-ui.esm.js"
}
3 changes: 2 additions & 1 deletion packages-next/admin-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,8 @@
"apollo.tsx",
"context.tsx",
"router.tsx",
"next-config.ts"
"next-config.ts",
"image.tsx"
]
},
"repository": "https://github.com/keystonejs/keystone/tree/master/packages-next/admin-ui",
Expand Down
2 changes: 2 additions & 0 deletions packages-next/admin-ui/src/image.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from 'next/image';
export { default } from 'next/image';
8 changes: 8 additions & 0 deletions packages-next/admin-ui/src/system/generateAdminUI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,14 @@ export const generateAdminUI = async (
// Nuke any existing files in our target directory
await fs.remove(projectAdminPath);

if (config.images) {
const publicDirectory = Path.join(projectAdminPath, 'public');
await fs.mkdir(publicDirectory, { recursive: true });
const storagePath = Path.resolve(config.images.local?.storagePath ?? './public/images');
await fs.mkdir(storagePath, { recursive: true });
await fs.symlink(storagePath, Path.join(publicDirectory, 'images'), 'junction');
}

// Write out the files configured by the user
const userPages = config.ui?.getAdditionalFiles?.map(x => x(config)) ?? [];
const userFilesToWrite = (await Promise.all(userPages)).flat();
Expand Down
4 changes: 4 additions & 0 deletions packages-next/fields/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"devDependencies": {
"@keystone-next/server-side-graphql-client-legacy": "3.0.1",
"@keystone-next/test-utils-legacy": "16.0.0",
"@types/bytes": "^3.1.0",
"mime": "^2.5.2",
"typescript": "^4.2.3"
},
Expand All @@ -25,6 +26,7 @@
"@keystone-ui/icons": "^2.0.1",
"@keystone-ui/loading": "^2.0.1",
"@keystone-ui/modals": "^2.0.1",
"@keystone-ui/pill": "^2.0.1",
"@keystone-ui/segmented-control": "^2.0.1",
"@keystone-ui/toast": "^2.0.1",
"@keystone-ui/tooltip": "^2.0.1",
Expand All @@ -33,6 +35,8 @@
"@types/react": "^17.0.3",
"apollo-errors": "^1.9.0",
"bcryptjs": "^2.4.3",
"bytes": "^3.1.0",
"copy-to-clipboard": "^3.3.1",
"cuid": "^2.1.8",
"date-fns": "^2.20.2",
"decimal.js": "^10.2.1",
Expand Down
22 changes: 21 additions & 1 deletion packages-next/fields/src/Implementation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,8 +150,28 @@ class Field<P extends string> {
return resolvedData[this.path];
}

/**
* @param {Object} data
* @param {Object} data.resolvedData The incoming item for the mutation with
* relationships and defaults already resolved
* @param {Object} data.existingItem If this is a updateX mutation, this will
* be the existing data in the database
* @param {Object} data.context The graphQL context object of the current
* request
* @param {Object} data.originalInput The raw incoming item from the mutation
* (no relationships or defaults resolved)
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async validateInput(args: any) {}
async validateInput(data: {
resolvedData: Record<P, any>;
existingItem?: Record<string, any>;
context: KeystoneContext;
originalInput: any;
listKey: string;
fieldPath: P;
operation: 'create' | 'update';
addFieldValidationError: (msg: string) => void;
}) {}

async beforeChange() {}

Expand Down
1 change: 1 addition & 0 deletions packages-next/fields/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ export { select } from './types/select';
export { virtual } from './types/virtual';
export { Implementation } from './Implementation';
export type { FieldConfigArgs, FieldExtraArgs } from './Implementation';
export { image } from './types/image';
216 changes: 216 additions & 0 deletions packages-next/fields/src/types/image/Implementation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
import { PrismaFieldAdapter, PrismaListAdapter } from '@keystone-next/adapter-prisma-legacy';
import { getImageRef, SUPPORTED_IMAGE_EXTENSIONS } from '@keystone-next/utils-legacy';
import { ImageData, KeystoneContext, BaseKeystoneList } from '@keystone-next/types';
import { Implementation } from '../../Implementation';
import { handleImageData } from './handle-image-input';

export class ImageImplementation<P extends string> extends Implementation<P> {
get _supportsUnique() {
return false;
}

gqlOutputFields() {
return [`${this.path}: ImageFieldOutput`];
}

getGqlAuxTypes() {
return [
`enum ImageMode {
local
}
input ImageFieldInput {
upload: Upload
ref: String
}
enum ImageExtension {
${SUPPORTED_IMAGE_EXTENSIONS.join('\n')}
}
type ImageFieldOutput {
mode: ImageMode!
id: ID!
filesize: Int!
width: Int!
height: Int!
extension: ImageExtension!
ref: String!
src: String!
}`,
];
}

gqlAuxFieldResolvers() {
return {
ImageFieldOutput: {
src(data: ImageData, _args: any, context: KeystoneContext) {
if (!context.images) {
throw new Error('Image context is undefined');
}
return context.images.getSrc(data.mode, data.id, data.extension);
},
ref(data: ImageData, _args: any, context: KeystoneContext) {
if (!context.images) {
throw new Error('Image context is undefined');
}
return getImageRef(data.mode, data.id, data.extension);
},
},
};
}
// Called on `User.avatar` for example
gqlOutputFieldResolvers() {
return { [`${this.path}`]: (item: Record<P, any>) => item[this.path] };
}

async resolveInput({
resolvedData,
context,
}: {
resolvedData: Record<P, any>;
context: KeystoneContext;
}) {
const data = resolvedData[this.path];
if (data === null) {
return null;
}
if (data === undefined) {
return undefined;
}
const imageData = await handleImageData(data, context);
return imageData;
}

gqlUpdateInputFields() {
return [`${this.path}: ImageFieldInput`];
}
gqlCreateInputFields() {
return [`${this.path}: ImageFieldInput`];
}
getBackingTypes() {
return { [this.path]: { optional: true, type: 'Record<string, any> | null' } };
}
}

export class PrismaImageInterface<P extends string> extends PrismaFieldAdapter<P> {
constructor(
fieldName: string,
path: P,
field: ImageImplementation<P>,
listAdapter: PrismaListAdapter,
getListByKey: (arg: string) => BaseKeystoneList | undefined,
config = {}
) {
super(fieldName, path, field, listAdapter, getListByKey, config);
// Error rather than ignoring invalid config
// We totally can index these values, it's just not trivial. See issue #1297
if (this.config.isIndexed) {
throw new Error(
`The Image field type doesn't support indexes on Prisma. ` +
`Check the config for ${this.path} on the ${this.field.listKey} list`
);
}
}

getPrismaSchema() {
return [
`${this.path}_filesize Int?`,
`${this.path}_extension String?`,
`${this.path}_width Int?`,
`${this.path}_height Int?`,
`${this.path}_mode String?`,
`${this.path}_id String?`,
];
}

getQueryConditions() {
return {};
}

setupHooks({
addPreSaveHook,
addPostReadHook,
}: {
addPreSaveHook: (hook: any) => void;
addPostReadHook: (hook: any) => void;
}) {
const field_path = this.path;
const filesize_field = `${this.path}_filesize`;
const extension_field = `${this.path}_extension`;
const width_field = `${this.path}_width`;
const height_field = `${this.path}_height`;
const mode_field = `${this.path}_mode`;
const id_field = `${this.path}_id`;

addPreSaveHook(
(item: Record<P, any>): Record<string, any> => {
if (!Object.prototype.hasOwnProperty.call(item, field_path)) {
return item;
}
if (item[field_path as P] === null) {
// If the property exists on the field but is null or falsey
// all split fields are null
// delete the original field item
// return the item
const newItem = {
[filesize_field]: null,
[extension_field]: null,
[width_field]: null,
[height_field]: null,
[id_field]: null,
[mode_field]: null,
...item,
};
delete newItem[field_path];
return newItem;
} else {
const { mode, filesize, extension, width, height, id } = item[field_path];

const newItem = {
[filesize_field]: filesize,
[extension_field]: extension,
[width_field]: width,
[height_field]: height,
[id_field]: id,
[mode_field]: mode,
...item,
};

delete newItem[field_path];

return newItem;
}
}
);
addPostReadHook(
(item: Record<string, any>): Record<P, any> => {
if (
!item[filesize_field] ||
!item[extension_field] ||
!item[width_field] ||
!item[height_field] ||
!item[id_field] ||
!item[mode_field]
) {
item[field_path] = null;
return item;
}
item[field_path] = {
filesize: item[filesize_field],
extension: item[extension_field],
width: item[width_field],
height: item[height_field],
id: item[id_field],
mode: item[mode_field],
};

delete item[filesize_field];
delete item[extension_field];
delete item[width_field];
delete item[height_field];
delete item[id_field];
delete item[mode_field];

return item;
}
);
}
}
Loading

1 comment on commit be60812

@vercel
Copy link

@vercel vercel bot commented on be60812 Apr 16, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.