adminjs-typeorm icon indicating copy to clipboard operation
adminjs-typeorm copied to clipboard

Is there a way to add referenceColumnName in adminjs config?

Open pejmanhadavi opened this issue 2 years ago • 2 comments

Hello I have the following codes in my entity:

@ManyToOne(() => OrganizationEntity, (organization) => organization.code)
@JoinColumn({ name: 'organizationCode', referencedColumnName: 'code' })
organization: OrganizationEntity;

@Column({ name: 'organizationCode' })
organizationCode: number;

now in admin dashboard, "Adminjs" does not recognize "referencedColumnName" and it is recognizing it as "organizationId" while listing, creating, editing.

Is there a way to add "referenceColumnName" in "Adminjs" config?

pejmanhadavi avatar Apr 04 '22 06:04 pejmanhadavi

Currently all references relate to primary keys, in one of the projects I've added populateReference feature, but it's for Sequelize. I believe you can modify this to work with Typeorm though. I'll label this issue as enhancement since supporting this in the core makes a lot of sense.

resourceReferenceFeature:

import {
  ActionContext,
  ActionRequest,
  ActionResponse,
  BaseRecord,
  BaseResource,
  buildFeature,
  FeatureType,
  Filter
} from 'adminjs'
import CustomComponents from '../frontend/components/components'

export type ResourceReferenceArgs = {
  foreignKey: string;
  targetKey?: string | null;
  reference: string | ((context: ActionContext) => string | Promise<string>);
}

export type ResourceReferencePopulatorArgs = ResourceReferenceArgs & { targetKey: string }

type FetchAdapterRecordsArgs = {
  resource: BaseResource,
  targetKey: string,
  foreignValues: any[],
}

const getAdapterRecords = async ({
  resource,
  targetKey,
  foreignValues,
}: FetchAdapterRecordsArgs) => {
  const filter = new Filter({
    [targetKey]: foreignValues
  }, resource)

  return resource.find(filter, {})
}

const getSequelizeAdapterRecords = async ({
  resource,
  targetKey,
  foreignValues
}: FetchAdapterRecordsArgs) => {
  // SequelizeModel is specific to sequelize adapter
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  const referencedModel = (resource as any).SequelizeModel

  const referencedRecords = await referencedModel.findAll({
    where: {
      [targetKey]: foreignValues,
    }
  })

  return referencedRecords.map((r) => new BaseRecord(r.toJSON(), resource))
}

export const populateReference = ({
  foreignKey,
  reference,
  targetKey,
}: ResourceReferencePopulatorArgs) => async (
  response: ActionResponse,
  request: ActionRequest,
  context: ActionContext,
): Promise<ActionResponse> => {
  if (!targetKey) return response

  const { _admin, currentAdmin } = context

  const _reference = typeof reference === 'function'
    ? await reference(context)
    : reference

  const referencedResource = _admin.findResource(_reference)
  if (!referencedResource) {
    throw new Error(`Could not find resource with id: ${_reference}`)
  }

  let records
  if (response.records?.length) records = response.records
  if (response.record) records = [response.record]

  if (!records || !records.length) {
    return response
  }

  const foreignValues = records.map(({ params }) => params[foreignKey])

  const getRecords = (referencedResource as any).SequelizeModel
    ? getSequelizeAdapterRecords
    : getAdapterRecords
  
  const referencedRecords = await getRecords({
    resource: referencedResource,
    targetKey,
    foreignValues,
  })
  const referencedRecordsGroupedByTargetKeyId = referencedRecords.reduce((memo, record) => {
    memo[record.params[targetKey]] = record

    return memo
  }, {})

  records.forEach((record) => {
    const foreignId = record.params[foreignKey]
    if (referencedRecordsGroupedByTargetKeyId[foreignId]) {
      const referenceRecord = referencedRecordsGroupedByTargetKeyId[foreignId].toJSON(currentAdmin)
      record.populated[foreignKey] = referenceRecord
    }
  })

  if (response.records?.length) response.records = records
  if (response.record) response.record = records[0]

  return response
}

export const resourceReference = ({
  reference,
  foreignKey,
  targetKey = null,
}: ResourceReferenceArgs): FeatureType => {
  if (!targetKey) {
    return buildFeature({
      properties: {
        [foreignKey]: {
          type: 'reference',
          reference: reference as string,
        }
      },
    })
  }

  return buildFeature({
    properties: {
      [foreignKey]: {
        type: 'string',
        components: {
          edit: CustomComponents.ResourceReferenceSearchEdit,
          show: CustomComponents.ResourceReferenceSearchShow,
        },
        custom: {
          targetKey,
          foreignKey,
          reference,
        }
      }
    },
    actions: {
      show: {
        after: [populateReference({ foreignKey, targetKey, reference })]
      },
      edit: {
        after: [populateReference({ foreignKey, targetKey, reference })]
      },
      new: {
        after: [populateReference({ foreignKey, targetKey, reference })]
      },
      list: {
        after: [populateReference({ foreignKey, targetKey, reference })]
      },
      search: {
        after: [populateReference({ foreignKey, targetKey, reference })]
      },
    }
  })
}

ResourceReferenceSearchEdit.tsx:

import React, { FC, useState, useEffect } from 'react'
import Select from 'react-select/async'
import { FormGroup, FormMessage } from '@adminjs/design-system'
import { ApiClient, EditPropertyProps, SelectRecord, RecordJSON } from 'adminjs'

import PropertyLabel from '../property-label/property-label'

type SelectRecordEnhanced = SelectRecord & {
  record: RecordJSON;
}

const ResourceReferenceSearchEdit: FC<EditPropertyProps > = (props) => {
  const { onChange, property, record } = props
  const { custom = {} } = property
  const { targetKey, reference: resourceId } = custom

  if (!resourceId) {
    throw new Error(`Cannot reference resource in property '${property.path}'`)
  }

  const handleChange = (selected: SelectRecordEnhanced): void => {
    if (selected) {
      onChange(property.path, selected.value, selected.record)
    } else {
      onChange(property.path, null)
    }
  }

  const loadOptions = async (inputValue: string): Promise<SelectRecordEnhanced[]> => {
    const api = new ApiClient()

    const optionRecords = await api.searchRecords({
      resourceId,
      query: inputValue,
    })

    return optionRecords.map((optionRecord: RecordJSON) => {
      const value = targetKey ? optionRecord.params?.[targetKey] : optionRecord.id

      return {
        value,
        label: optionRecord.title,
        record: optionRecord,
      }
    })
  }
  const error = record?.errors[property.path]

  const selectedId = record?.params[property.path] as string | undefined
  const [loadedRecord, setLoadedRecord] = useState<RecordJSON | undefined>()
  const [loadingRecord, setLoadingRecord] = useState(0)
  const selectedValue = record?.populated[property.path] ?? loadedRecord
  const selectedOption = (selectedId && selectedValue) ? {
    value: selectedValue.id,
    label: selectedValue.title,
  } : {
    value: '',
    label: '',
  }

  useEffect(() => {
    if (!selectedValue && selectedId) {
      setLoadingRecord(c => c + 1)
      const api = new ApiClient()
      api.recordAction({
        actionName: 'show',
        resourceId,
        recordId: selectedId,
      }).then(({ data }: any) => {
        setLoadedRecord(data.record)
      }).finally(() => {
        setLoadingRecord(c => c - 1)
      })
    }
  }, [selectedValue, selectedId, resourceId])

  return (
    <FormGroup error={Boolean(error)}>
      <PropertyLabel property={property} />
      <Select
        cacheOptions
        value={selectedOption}
        defaultOptions
        loadOptions={loadOptions}
        onChange={handleChange}
        isClearable
        isDisabled={property.isDisabled}
        isLoading={!!loadingRecord}
        {...property.props}
      />
      <FormMessage>{error?.message}</FormMessage>
    </FormGroup>
  )
}

export default ResourceReferenceSearchEdit

ResourceReferenceSearchShow.tsx:

import React from 'react'
import { ValueGroup } from '@adminjs/design-system'
import { ShowPropertyProps } from 'adminjs'

import ReferenceValue from './reference-value'

const ResourceReferenceSearchShow: React.FC<ShowPropertyProps> = (props) => {
  const { property, record } = props

  return (
    <ValueGroup label={property.label}>
      <ReferenceValue property={property} record={record} />
    </ValueGroup>
  )
}

export default ResourceReferenceSearchShow

ReferenceValue.tsx:

import React from 'react'
import styled from 'styled-components'
import { Link } from 'react-router-dom'
import { ButtonCSS } from '@adminjs/design-system'
import { ViewHelpers, RecordJSON, PropertyJSON } from 'adminjs'

interface Props {
  property: PropertyJSON;
  record: RecordJSON;
}

const StyledLink = styled<any>(Link)`
  ${ButtonCSS};
  padding-left: ${({ theme }): string => theme.space.xs};
  padding-right: ${({ theme }): string => theme.space.xs};
`

const ReferenceValue: React.FC<Props> = (props) => {
  const { property, record } = props
  const { custom = {} } = property
  const { reference, targetKey } = custom

  const h = new ViewHelpers()
  const populated = record.populated[property.path]
  const value = (populated && populated.title)
  const refId = populated?.id ?? populated?.params?.[targetKey]

  if (!reference) {
    throw new Error(`property: "${property.path}" does not have a reference`)
  }

  if (populated && populated.recordActions.find(a => a.name === 'show')) {
    const href = h.recordActionUrl({
      resourceId: reference, recordId: refId, actionName: 'show',
    })
    return (
      <StyledLink variant="text" to={href}>{value}</StyledLink>
    )
  }
  return (
    <span>{value}</span>
  )
}

export default ReferenceValue

Usage in resource:

  ...,
  features: [
    resourceReference({ foreignKey: 'entityUid', targetKey: 'uid', reference: 'entities-entity' }),
  ]

dziraf avatar Apr 05 '22 10:04 dziraf

This feature is urgently needed

zenghj avatar Mar 07 '23 09:03 zenghj