diff --git a/ui/jsconfig.json b/ui/jsconfig.json index 735e8c7..244a67f 100644 --- a/ui/jsconfig.json +++ b/ui/jsconfig.json @@ -32,6 +32,9 @@ ], "styles/*": [ "styles/*" + ], + "fragments/*": [ + "fragments/*" ] } }, diff --git a/ui/src/components/internal/forms/ProjectSearchForm.js b/ui/src/components/internal/forms/ProjectSearchForm.js new file mode 100644 index 0000000..4198170 --- /dev/null +++ b/ui/src/components/internal/forms/ProjectSearchForm.js @@ -0,0 +1,24 @@ +import React from 'react' +import { Field, Form } from 'react-final-form' + +import FilledButton from 'components/buttons/FilledButton' +import FormFooter from 'components/internal/FormFooter' +import TextInput from 'components/inputs/TextInput' + +function ProjectSearchForm(props) { + return ( +
( + + + + + + + )} + {...props} + /> + ) +} + +export default ProjectSearchForm diff --git a/ui/src/components/internal/forms/RecordForm.js b/ui/src/components/internal/forms/RecordForm.js index 6c967aa..ffdde87 100644 --- a/ui/src/components/internal/forms/RecordForm.js +++ b/ui/src/components/internal/forms/RecordForm.js @@ -2,12 +2,14 @@ /* eslint-disable no-use-before-define */ import _ from 'lodash' import arrayMutators from 'final-form-arrays' +import gql from 'graphql-tag' import flat from 'flat' import injectSheet from 'react-jss' import React, { Fragment, useState, useRef, useEffect } from 'react' import { Field, Form } from 'react-final-form' import { FieldArray } from 'react-final-form-arrays' import { ReactSortable } from 'react-sortablejs' +import { withApollo } from 'react-apollo' import * as mixins from 'styles/mixins' import ButtonGroupInput from 'components/internal/inputs/ButtonGroupInput' @@ -29,6 +31,8 @@ import UploadInput from 'components/inputs/UploadInput' import { LoaderText } from 'components/internal/typography' import { SidePaneFormFooter } from 'components/internal/sidePane' +import RECORD_FRAGMENTS from 'fragments/record' + const REFERENCE_OPTIONS = [ { value: 'new', label: 'New Record' }, { value: 'edit', label: 'Existing Record' } @@ -575,9 +579,54 @@ const toggleFormVisibilityMutator = ([ name, selectedRecord, fields ], state, { }) } -function RecordForm({ initialValues, entities, fields, ...other }) { +function RecordForm({ initialValues, fields, client, ...other }) { const newRecord = !initialValues.id + const [ entities, setEntities ] = useState([]) + const [ entitiesLoading, setEntitiesLoading ] = useState(false) + const [ recordLoading, setRecordLoading ] = useState(false) + + const getEntities = async (entityId) => { + setEntitiesLoading(true) + const { data } = await client.query({ + query: RecordForm.ENTITIES_QUERY, + variables: { + entityId + }, + fetchPolicy: 'network-only' + }) + + const newEntities = [ ...entities, ...data.referencedEntities ] + + setEntities(newEntities) + setEntitiesLoading(false) + return newEntities + } + + const getRecord = async (recordId) => { + setRecordLoading(true) + const { data: { record } } = await client.query({ + query: RecordForm.RECORD_QUERY, + variables: { + recordId + }, + fetchPolicy: 'network-only' + }) + + const newEntities = entities + .map((entity) => { + if (entity.id === record.entityId) { + entity.records.push(record) + } + + return entity + }) + + setEntities(newEntities) + setRecordLoading(false) + return newEntities + } + return (
( {renderForm({ - ...other, + getEntities, + getRecord, + entitiesLoading, + recordLoading, initialValues, fields, values, entities, - mutators + mutators, + ...other })} @@ -609,7 +662,7 @@ function RecordForm({ initialValues, entities, fields, ...other }) { ) } -export default injectSheet(({ typography }) => ({ +RecordForm = injectSheet(({ typography }) => ({ inputWrapper: { marginBottom: 50 }, @@ -618,3 +671,42 @@ export default injectSheet(({ typography }) => ({ ...typography.regularSquished } }))(RecordForm) + +RecordForm.ENTITIES_QUERY = gql` + query RecordsPageEntitiesQuery($entityId: ID!) { + referencedEntities(entityId: $entityId) { + id + label + name + label + parentId + + fields { + ...Record_fields + } + + records { + ...Record_records + } + } + } + + ${RECORD_FRAGMENTS.records} + ${RECORD_FRAGMENTS.fields} +` + +RecordForm.RECORD_QUERY = gql` + query RecordsPageRecordQuery($recordId: ID!) { + record(recordId: $recordId) { + entityId + + ...Record_records + } + } + + ${RECORD_FRAGMENTS.records} +` + +RecordForm = withApollo(RecordForm) + +export default RecordForm diff --git a/ui/src/components/internal/modals/RecordModal.js b/ui/src/components/internal/modals/RecordModal.js index ff924b0..809c681 100644 --- a/ui/src/components/internal/modals/RecordModal.js +++ b/ui/src/components/internal/modals/RecordModal.js @@ -5,7 +5,7 @@ import RecordForm from 'components/internal/forms/RecordForm' import Spacer from 'components/Spacer' import { DialogTitle } from 'components/internal/typography' -function RecordModal({ formValues, onFormSubmit, ...other }) { +function RecordModal({ formValues, ...other }) { const action = formValues.id ? 'Edit' : 'New' const title = `${action} Record` @@ -17,7 +17,6 @@ function RecordModal({ formValues, onFormSubmit, ...other }) { ) diff --git a/ui/src/components/internal/sidebars/ProjectSidebar.js b/ui/src/components/internal/sidebars/ProjectSidebar.js index 68b3f2d..9504ffb 100644 --- a/ui/src/components/internal/sidebars/ProjectSidebar.js +++ b/ui/src/components/internal/sidebars/ProjectSidebar.js @@ -20,6 +20,7 @@ function ProjectSidebar({ match, project: { name } = {} }) { + ) diff --git a/ui/src/components/pages/EntityPage.js b/ui/src/components/pages/EntityPage.js index 209ec05..4ed1c7e 100644 --- a/ui/src/components/pages/EntityPage.js +++ b/ui/src/components/pages/EntityPage.js @@ -5,8 +5,10 @@ import React, { Fragment } from 'react' import { Redirect, Route, Switch } from 'react-router-dom' import FieldsPage from 'components/pages/FieldsPage' +import FieldsEditPage from 'components/pages/FieldsEditPage' import Loader from 'components/internal/Loader' import RecordsPage from 'components/pages/RecordsPage' +import RecordsEditPage from 'components/pages/RecordsEditPage' import Spacer from 'components/Spacer' import withConfirmation from 'components/internal/decorators/withConfirmation' import { BackLink, PageSubTitle } from 'components/internal/typography' @@ -47,8 +49,15 @@ function EntityPage({ entity = {}, loading, match }) { - - + + + + + diff --git a/ui/src/components/pages/FieldsEditPage.js b/ui/src/components/pages/FieldsEditPage.js new file mode 100644 index 0000000..7c5344f --- /dev/null +++ b/ui/src/components/pages/FieldsEditPage.js @@ -0,0 +1,119 @@ +import _ from 'lodash' +import gql from 'graphql-tag' +import injectSheet from 'react-jss' +import React, { Fragment } from 'react' + +import FieldForm from 'components/internal/forms/FieldForm' +import Loader from 'components/internal/Loader' +import Spacer from 'components/Spacer' +import withConfirmation from 'components/internal/decorators/withConfirmation' +import { DialogTitle } from 'components/internal/typography' +import { Field } from 'models' +import { MutationResponseModes, withMutation, withQuery } from 'lib/data' +import { showAlertSuccess } from 'client/methods' +import FIELD_FRAGMENTS from 'fragments/fields' + +function FieldsEditPage({ + entities = [], + fields = [], + loading, + match, + updateField, + history +}) { + const handleFormSubmit = values => updateField(values, { onSuccess: () => { + const { teamId, projectId, entityId } = match.params + const path = `/teams/${teamId}/projects/${projectId}/entities/${entityId}/fields` + showAlertSuccess({ message: 'Successfully updated field' }) + history.push(path) + } }) + + if (loading) { + return + } + + const processedFields = Field.process(fields) + const field = processedFields.find(f => f.id === match.params.fieldId) + const title = `Edit Field #${field.label}` + + return ( + + {title} + + + + + ) +} + +FieldsEditPage = injectSheet(({ colors, typography }) => ({ + entityName: { + ...typography.semibold, + + alignItems: 'center', + color: colors.text_dark, + display: 'flex', + lineHeight: 1 + } +}))(FieldsEditPage) + +FieldsEditPage = withMutation(gql` + mutation UpdateFieldMutation($id: ID!, $input: UpdateFieldInput!) { + updateField(id: $id, input: $input) { + ...Field_fields + } + } + + ${FIELD_FRAGMENTS.fields} +`, { + inputFilter: gql` + fragment UpdateFieldInput on UpdateFieldInput { + id + children + dataType + validations + settings + defaultValue + elementType + hint + label + name + position + referencedEntityId + } + `, + mode: MutationResponseModes.CUSTOM, + updateData: ({ cachedData, responseRecords }) => { + const currentRecords = cachedData.fields + cachedData.fields = _.unionWith(currentRecords, responseRecords, _.isEqual) + } +})(FieldsEditPage) + +FieldsEditPage = withQuery(gql` + query GetFieldsAndEntities($entityId: ID!, $projectId: ID!) { + fields(entityId: $entityId) { + ...Field_fields + } + + entities(projectId: $projectId) { + id + label + name + } + } + + ${FIELD_FRAGMENTS.fields} +`, { + options: ({ match }) => ({ + variables: { + entityId: match.params.entityId, + projectId: match.params.projectId + } + }) +})(FieldsEditPage) + +export default withConfirmation()(FieldsEditPage) diff --git a/ui/src/components/pages/FieldsPage.js b/ui/src/components/pages/FieldsPage.js index 4af9fcd..a537c78 100644 --- a/ui/src/components/pages/FieldsPage.js +++ b/ui/src/components/pages/FieldsPage.js @@ -15,45 +15,22 @@ import { Field } from 'models' import { MutationResponseModes, OptimisticResponseModes, withMutation, withQuery } from 'lib/data' import { CellLabel, CellText, CellTitle } from 'components/internal/typography' +import FIELD_FRAGMENTS from 'fragments/fields' + function FieldsPage({ confirm, createField, destroyField, - entities, - fields, + entities = [], + fields = [], loading, match, sortFields, - updateField + history }) { const [ field, isFieldSidePaneOpen, openFieldSidePane, closeFieldSidePane ] = useSidePane() - const rootFields = (fields || []).filter(Field.isRoot) - - const handleFormSubmit = (values) => { - if (values.id) { - return updateField(values, { onSuccess: () => closeFieldSidePane() }) - } - - return createField(values, { onSuccess: () => closeFieldSidePane() }) - } - - const processFields = (entityFields = []) => entityFields.map((entityField) => { - const newField = _.omit(entityField, [ '__typename' ]) - const childFields = fields.filter(f => f.parentId === entityField.id) - - if (childFields.length && entityField.dataType === 'key_value') { - newField.children = processFields(childFields) - } - - if (childFields.length && entityField.dataType === 'array') { - const subChildFields = fields.filter(f => f.parentId === childFields[0].id) - - newField.children = processFields(subChildFields) - } - - return newField - }) + const handleFormSubmit = values => createField(values, { onSuccess: () => closeFieldSidePane() }) const labelRenderer = ({ record: { label } }) => ( @@ -80,7 +57,11 @@ function FieldsPage({ ] const actions = [ - { icon: 'edit', onClick: record => openFieldSidePane(record) }, + { icon: 'edit', + onClick: (clickedField) => { + const path = `${match.url}/${clickedField.id}/edit` + history.push(path) + } }, { icon: 'trash', onClick: record => confirm({ @@ -93,8 +74,8 @@ function FieldsPage({ if (loading) { return } - const processedFields = _.sortBy(processFields(rootFields), [ 'position' ]) + const processedFields = _.sortBy(Field.process(fields), [ 'position' ]) const nextPosition = processedFields.length > 0 ? _.last(processedFields).position + 1 : 0 const formValues = { entityId: match.params.entityId, dataType: 'single_line_text', position: nextPosition, ...field } @@ -146,26 +127,6 @@ FieldsPage = injectSheet(({ colors, typography }) => ({ } }))(FieldsPage) -FieldsPage.fragments = { - fields: gql` - fragment FieldsPage_fields on Field { - id - dataType - validations - settings - defaultValue - elementType - entityId - hint - label - name - parentId - position - referencedEntityId - } - ` -} - FieldsPage = withMutation(gql` mutation SortFieldsMutation($input: SortFieldsInput!) { sortFields(input: $input) { @@ -179,11 +140,11 @@ FieldsPage = withMutation(gql` FieldsPage = withMutation(gql` mutation CreateFieldMutation($input: CreateFieldInput!) { createField(input: $input) { - ...FieldsPage_fields + ...Field_fields } } - ${FieldsPage.fragments.fields} + ${FIELD_FRAGMENTS.fields} `, { inputFilter: gql` fragment CreateFieldInput on CreateFieldInput { @@ -204,38 +165,6 @@ FieldsPage = withMutation(gql` mode: MutationResponseModes.APPEND })(FieldsPage) -FieldsPage = withMutation(gql` - mutation UpdateFieldMutation($id: ID!, $input: UpdateFieldInput!) { - updateField(id: $id, input: $input) { - ...FieldsPage_fields - } - } - - ${FieldsPage.fragments.fields} -`, { - inputFilter: gql` - fragment UpdateFieldInput on UpdateFieldInput { - id - children - dataType - validations - settings - defaultValue - elementType - hint - label - name - position - referencedEntityId - } - `, - mode: MutationResponseModes.CUSTOM, - updateData: ({ cachedData, responseRecords }) => { - const currentRecords = cachedData.fields - cachedData.fields = _.unionWith(currentRecords, responseRecords, _.isEqual) - } -})(FieldsPage) - FieldsPage = withMutation(gql` mutation DestroyFieldMutation($id: ID!) { destroyField(id: $id) { @@ -253,7 +182,7 @@ FieldsPage = withMutation(gql` FieldsPage = withQuery(gql` query FieldsPageQuery($entityId: ID!, $projectId: ID!) { fields(entityId: $entityId) { - ...FieldsPage_fields + ...Field_fields } entity(id: $entityId) { @@ -269,7 +198,7 @@ FieldsPage = withQuery(gql` } } - ${FieldsPage.fragments.fields} + ${FIELD_FRAGMENTS.fields} `, { options: ({ match }) => ({ variables: { diff --git a/ui/src/components/pages/FieldsSearchPage.js b/ui/src/components/pages/FieldsSearchPage.js new file mode 100644 index 0000000..674be4d --- /dev/null +++ b/ui/src/components/pages/FieldsSearchPage.js @@ -0,0 +1,134 @@ +import _ from 'lodash' +import gql from 'graphql-tag' +import React, { Fragment, useState } from 'react' +import { withApollo } from 'react-apollo' + +import Loader from 'components/internal/Loader' +import ProjectSearchForm from 'components/internal/forms/ProjectSearchForm' +import Spacer from 'components/Spacer' +import Tag from 'components/internal/Tag' +import Table from 'components/internal/dataTable/Table' +import { CellLabel, CellText, CellTitle } from 'components/internal/typography' +import { Field } from 'models' + +import FIELD_FRAGMENTS from 'fragments/fields' + +function FieldsSearchPage({ client, match, history }) { + const [ searchData, setSearchData ] = useState({}) + const [ loading, setLoading ] = useState(false) + + const getFields = async (values) => { + if (!values.search || !(values.search && values.search.trim())) { + return + } + setLoading(true) + const { data } = await client.query({ + query: FieldsSearchPage.FIELDS_SEARCH_QUERY, + variables: { + projectId: match.params.projectId, + filter: values.search + }, + fetchPolicy: 'network-only' + }) + + setLoading(false) + setSearchData(data) + } + + const entityNameRenderer = ({ record }) => ( + + Entity + {record.entity.label} + + ) + + const labelRenderer = ({ record: { label } }) => ( + + Label + {label} + + ) + + const nameRenderer = ({ record: { name } }) => ( + + Name + {name} + + ) + + const dataTypeRenderer = ({ record: { dataType } }) => ( + {Field.dataTypeList.find(dt => dt.value === dataType).label} + ) + + const actions = [ + { icon: 'edit', + onClick: (clickedField) => { + const { teamId, projectId } = match.params + const { entityId, id: fieldId } = clickedField + const path = `/teams/${teamId}/projects/${projectId}/entities/${entityId}/fields/${fieldId}/edit` + history.push(path) + } } + ] + + const columns = [ + { dataKey: 'label', bordered: false, flexGrow: 1, cellRenderer: labelRenderer }, + { dataKey: 'entity.name', bordered: false, flexGrow: 1, cellRenderer: entityNameRenderer }, + { dataKey: 'name', flexGrow: 1, cellRenderer: nameRenderer }, + { dataKey: 'dataType', flexGrow: 1, cellRenderer: dataTypeRenderer } + ] + + const processedFields = _.groupBy(_.sortBy(searchData.fields, [ 'position' ]), 'entityId') + + const processedFieldsKeys = Object.keys(processedFields) + return ( + + + + + + + + {processedFieldsKeys.map(key => ( + + + + + ))} + + ) +} + +FieldsSearchPage.FIELDS_SEARCH_QUERY = gql` + query FieldsSearchQuery($projectId: ID, $filter: String) { + fields(projectId: $projectId, filter: $filter) { + ...Field_fields + entity { + id + label + name + } + } + } + + ${FIELD_FRAGMENTS.fields} +` + +FieldsSearchPage = withApollo(FieldsSearchPage) + +export default FieldsSearchPage diff --git a/ui/src/components/pages/ProjectPage.js b/ui/src/components/pages/ProjectPage.js index d31b48c..8220c3c 100644 --- a/ui/src/components/pages/ProjectPage.js +++ b/ui/src/components/pages/ProjectPage.js @@ -8,8 +8,9 @@ import DashboardPage from 'components/pages/DashboardPage' import EntitiesPage from 'components/pages/EntitiesPage' import EntityPage from 'components/pages/EntityPage' import Loader from 'components/internal/Loader' -import ResourcesPage from 'components/pages/ResourcesPage' +import ProjectSearchPage from 'components/pages/ProjectSearchPage' import ProjectSettingsPage from 'components/pages/ProjectSettingsPage' +import ResourcesPage from 'components/pages/ResourcesPage' import { withQuery } from 'lib/data' function ProjectPage({ history, match, project, loading }) { @@ -36,6 +37,7 @@ function ProjectPage({ history, match, project, loading }) { + diff --git a/ui/src/components/pages/ProjectSearchPage.js b/ui/src/components/pages/ProjectSearchPage.js new file mode 100644 index 0000000..af86f3a --- /dev/null +++ b/ui/src/components/pages/ProjectSearchPage.js @@ -0,0 +1,37 @@ +import Helmet from 'react-helmet-async' +import React, { Fragment } from 'react' +import { Redirect, Route, Switch } from 'react-router-dom' + +import FieldsSearchPage from 'components/pages/FieldsSearchPage' +import RecordsSearchPage from 'components/pages/RecordsSearchPage' +import { PageTitle } from 'components/internal/typography' +import { TabLink, TabList } from 'components/internal/tab' + +function ProjectSearchPage({ match }) { + return ( + + + + Search Project + + + + + Search Project + + + + Structure + Content + + + + + + + + + ) +} + +export default ProjectSearchPage diff --git a/ui/src/components/pages/RecordsEditPage.js b/ui/src/components/pages/RecordsEditPage.js new file mode 100644 index 0000000..38a1109 --- /dev/null +++ b/ui/src/components/pages/RecordsEditPage.js @@ -0,0 +1,112 @@ +import gql from 'graphql-tag' +import injectSheet from 'react-jss' +import React, { Fragment } from 'react' + +import Loader from 'components/internal/Loader' +import RecordForm from 'components/internal/forms/RecordForm' +import Record from 'models/Record' +import Spacer from 'components/Spacer' +import { DialogTitle } from 'components/internal/typography' +import { showAlertSuccess } from 'client/methods' +import { withMutation, withQuery } from 'lib/data' +import RECORD_FRAGMENTS from 'fragments/record' + +function RecordsEditPage({ + history, + match, + fields = [], + loading, + record = {}, + updateRecord +}) { + const handleSubmit = (values) => { + updateRecord(values, { + onSuccess: () => { + const { teamId, projectId, entityId } = match.params + const path = `/teams/${teamId}/projects/${projectId}/entities/${entityId}/records` + showAlertSuccess({ message: 'Successfully updated record' }) + history.push(path) + } + }) + } + + if (loading) { + return + } + + const parsedRecord = new Record(fields).process([ record ])[0] + const title = `Edit Record #${record.id}` + + return ( + + {title} + + + + + ) +} + +RecordsEditPage = injectSheet(({ colors, typography }) => ({ + entityName: { + ...typography.semibold, + alignItems: 'center', + color: colors.text_dark, + display: 'flex', + lineHeight: 1 + } +}))(RecordsEditPage) + +RecordsEditPage = withMutation(gql` + mutation UpdateRecordMutation($id: ID!, $input: UpdateRecordInput!) { + updateRecord(id: $id, input: $input) { + ...Record_records + } + } + + ${RECORD_FRAGMENTS.records} +`, { + inputFilter: gql` + fragment UpdateRecordInput on UpdateRecordInput { + id + traits + } + ` +})(RecordsEditPage) + +RecordsEditPage = withQuery(gql` + query GetRecord($recordId: ID!) { + record(recordId: $recordId) { + entityId + ...Record_records + } + } + ${RECORD_FRAGMENTS.records} +`, { + options: ({ match }) => ({ + variables: { + recordId: match.params.recordId + } + }) +})(RecordsEditPage) + +RecordsEditPage = withQuery(gql` + query GetFields($entityId: ID!) { + fields(entityId: $entityId) { + ...Record_fields + } + } + ${RECORD_FRAGMENTS.fields} +`, { + options: ({ match }) => ({ + variables: { + entityId: match.params.entityId + } + }) +})(RecordsEditPage) + +export default RecordsEditPage diff --git a/ui/src/components/pages/RecordsPage.js b/ui/src/components/pages/RecordsPage.js index f66d4eb..5ae54cf 100644 --- a/ui/src/components/pages/RecordsPage.js +++ b/ui/src/components/pages/RecordsPage.js @@ -1,13 +1,10 @@ import _ from 'lodash' import gql from 'graphql-tag' import injectSheet from 'react-jss' -import React, { Fragment, useState } from 'react' +import React, { Fragment } from 'react' import { withApollo } from 'react-apollo' import ActionList from 'components/ActionList' -import ColorTile from 'components/internal/ColorTile' -import Field from 'models/Field' -import FontIcon from 'components/FontIcon' import IconButton from 'components/internal/buttons/IconButton' import Loader from 'components/internal/Loader' import Record from 'models/Record' @@ -17,7 +14,10 @@ import Table from 'components/internal/dataTable/Table' import useModal from 'lib/hooks/useModal' import withConfirmation from 'components/internal/decorators/withConfirmation' import { CellContent } from 'components/internal/typography' +import getRecordColumnFields from 'lib/getRecordColumnFields' +import recordPropertyRenderer from 'lib/recordPropertyRenderer' import { MutationResponseModes, OptimisticResponseModes, withMutation, withQuery } from 'lib/data' +import RECORD_FRAGMENTS from 'fragments/record' function RecordsPage({ confirm, @@ -28,109 +28,25 @@ function RecordsPage({ loading, match, records, - updateRecord, - client + history }) { const [ selectedRecord, isModalOpen, openModal, closeModal ] = useModal() - const [ entities, setEntities ] = useState([]) - const [ entitiesLoading, setEntitiesLoading ] = useState(false) - const [ recordLoading, setRecordLoading ] = useState(false) - - const getEntities = async (entityId) => { - setEntitiesLoading(true) - const { data } = await client.query({ - query: RecordsPage.ENTITIES_QUERY, - variables: { - entityId - }, - fetchPolicy: 'network-only' - }) - - const newEntities = [ ...entities, ...data.referencedEntities ] - - setEntities(newEntities) - setEntitiesLoading(false) - return newEntities - } - - const getRecord = async (recordId) => { - setRecordLoading(true) - const { data: { record } } = await client.query({ - query: RecordsPage.RECORD_QUERY, - variables: { - recordId - }, - fetchPolicy: 'network-only' - }) - - const newEntities = entities - .map((entity) => { - if (entity.id === record.entityId) { - entity.records.push(record) - } - - return entity - }) - - setEntities(newEntities) - setRecordLoading(false) - return newEntities - } - - const handleFormSubmit = (values) => { - if (values.id) { - return updateRecord(values, { - onSuccess: () => { - setEntities([]) // Reset entities so that they are refetched when modal reopens - closeModal() - } - }) - } - - return createRecord(values, { onSuccess: () => closeModal() }) - } - - const makePropertyRenderer = field => ({ record }) => { - let isValid = false - const value = record.traits[field.name] - - if (_.isString(value) || _.isNumber(value)) { - isValid = true - } - - if (field.dataType === 'boolean') { - const isActive = typeof value === 'boolean' ? value : value === 't' - - return ( - - {isActive ? ( - - ) : ( - - )} - - ) - } - - if (field.dataType === 'color' && value) { - return {} - } - - return {isValid ? value : '-'} + const handleSubmit = (values) => { + createRecord(values, { onSuccess: () => { closeModal() } }) } const idRenderer = ({ record }) => {record.id} - const columnFields = _.sortBy((fields || []).filter(Field.isRoot).filter(Field.isColumn).filter(Field.isVisibleColumn), [ 'position' ]) + const columnFields = getRecordColumnFields(fields) const columns = columnFields.map(field => ({ dataKey: `traits.${field.name}`, label: field.label, flexGrow: 1, - cellRenderer: makePropertyRenderer(field) + cellRenderer: recordPropertyRenderer(field) })) columns.unshift({ @@ -147,7 +63,13 @@ function RecordsPage({ openModal(clickedRecord) }, + { + icon: 'edit', + onClick: (clickedRecord) => { + const path = `${match.url}/${clickedRecord.id}/edit` + history.push(path) + } + }, { icon: 'trash', onClick: clickedRecord => confirm({ @@ -205,14 +127,10 @@ function RecordsPage({ ) @@ -229,69 +147,14 @@ RecordsPage = injectSheet(({ colors, typography }) => ({ } }))(RecordsPage) -RecordsPage.fragments = { - fields: gql` - fragment RecordsPage_fields on Field { - id - dataType - validations - settings - elementType - referencedEntityId - defaultValue - elementType - entityId - hint - label - name - parentId - position - referencedEntityId - } - `, - properties: gql` - fragment RecordsPage_properties on Property { - id - value - position - fieldId - linkedRecordId - parentId - asset { - id - fileOriginal - } - field { - id - position - } - } - ` -} - -RecordsPage.fragments.records = gql` - fragment RecordsPage_records on Record { - id - entityId - createdAt - updatedAt - - properties { - ...RecordsPage_properties - } - } - - ${RecordsPage.fragments.properties} -` - RecordsPage = withMutation(gql` mutation CreateRecordMutation($input: CreateRecordInput!) { createRecord(input: $input) { - ...RecordsPage_records + ...Record_records } } - ${RecordsPage.fragments.records} + ${RECORD_FRAGMENTS.records} `, { inputFilter: gql` fragment CreateRecordInput on CreateRecordInput { @@ -303,30 +166,27 @@ RecordsPage = withMutation(gql` })(RecordsPage) RecordsPage = withMutation(gql` - mutation UpdateRecordMutation($id: ID!, $input: UpdateRecordInput!) { - updateRecord(id: $id, input: $input) { - ...RecordsPage_records + mutation DestroyRecordMutation($id: ID!) { + destroyRecord(id: $id) { + id } } - - ${RecordsPage.fragments.records} `, { - inputFilter: gql` - fragment UpdateRecordInput on UpdateRecordInput { - id - traits - } - ` + optimistic: { mode: OptimisticResponseModes.DESTROY, response: { __typename: 'Record' } }, + mode: MutationResponseModes.DELETE, + successAlert: () => ({ + message: 'Successfully deleted record' + }) })(RecordsPage) RecordsPage = withMutation(gql` mutation CloneRecordMutation($id: ID!) { cloneRecord(id: $id) { - ...RecordsPage_records + ...Record_records } } - ${RecordsPage.fragments.records} + ${RECORD_FRAGMENTS.records} `, { mode: MutationResponseModes.APPEND, successAlert: () => ({ @@ -334,24 +194,10 @@ RecordsPage = withMutation(gql` }) })(RecordsPage) -RecordsPage = withMutation(gql` - mutation DestroyRecordMutation($id: ID!) { - destroyRecord(id: $id) { - id - } - } -`, { - optimistic: { mode: OptimisticResponseModes.DESTROY, response: { __typename: 'Record' } }, - mode: MutationResponseModes.DELETE, - successAlert: () => ({ - message: 'Successfully deleted record' - }) -})(RecordsPage) - RecordsPage = withQuery(gql` query RecordsPageQuery($entityId: ID!) { records(entityId: $entityId) { - ...RecordsPage_records + ...Record_records } entity(id: $entityId) { @@ -361,12 +207,12 @@ RecordsPage = withQuery(gql` } fields(entityId: $entityId) { - ...RecordsPage_fields + ...Record_fields } } - ${RecordsPage.fragments.records} - ${RecordsPage.fragments.fields} + ${RECORD_FRAGMENTS.records} + ${RECORD_FRAGMENTS.fields} `, { options: ({ match }) => ({ variables: { @@ -375,40 +221,6 @@ RecordsPage = withQuery(gql` }) })(RecordsPage) -RecordsPage.ENTITIES_QUERY = gql` - query RecordsPageEntitiesQuery($entityId: ID!) { - referencedEntities(entityId: $entityId) { - id - label - name - label - parentId - - fields { - ...RecordsPage_fields - } - - records { - ...RecordsPage_records - } - } - } - - ${RecordsPage.fragments.records} - ${RecordsPage.fragments.fields} -` - -RecordsPage.RECORD_QUERY = gql` - query RecordsPageRecordQuery($recordId: ID!) { - record(recordId: $recordId) { - entityId - - ...RecordsPage_records - } - } - - ${RecordsPage.fragments.records} -` RecordsPage = withApollo(RecordsPage) export default withConfirmation()(RecordsPage) diff --git a/ui/src/components/pages/RecordsSearchPage.js b/ui/src/components/pages/RecordsSearchPage.js new file mode 100644 index 0000000..227464a --- /dev/null +++ b/ui/src/components/pages/RecordsSearchPage.js @@ -0,0 +1,168 @@ +import _ from 'lodash' +import gql from 'graphql-tag' +import React, { Fragment, useState, useCallback } from 'react' +import { withRouter } from 'react-router-dom' +import { withApollo } from 'react-apollo' + +import ActionList from 'components/ActionList' +import getRecordColumnFields from 'lib/getRecordColumnFields' +import Loader from 'components/internal/Loader' +import ProjectSearchForm from 'components/internal/forms/ProjectSearchForm' +import Record from 'models/Record' +import recordPropertyRenderer from 'lib/recordPropertyRenderer' +import Spacer from 'components/Spacer' +import Table from 'components/internal/dataTable/Table' +import { CellContent } from 'components/internal/typography' +import RECORD_FRAGMENTS from 'fragments/record' + +function RecordsSearchPage({ client, match, history }) { + const [ records, setRecords ] = useState([]) + const [ loading, setLoading ] = useState(false) + const getRecords = useCallback(async (values) => { + if (!values.search || !(values.search && values.search.trim())) { + return + } + + setLoading(true) + const { data } = await client.query({ + query: RecordsSearchPage.RECORDS_SEARCH_QUERY, + variables: { + projectId: match.params.projectId, + filter: values.search + }, + fetchPolicy: 'network-only' + }) + setLoading(false) + setRecords(data.records) + }, [ client, match.params.projectId ]) + + const idRenderer = ({ record }) => {record.id} + const entityLabelRenderer = ({ record }) => {record.entity.label} + + const getTablesData = (recordsFromAPI) => { + const groupedRecords = _.groupBy(recordsFromAPI, 'entityId') + const result = {} + + Object.keys(groupedRecords).forEach((id) => { + const entity = _.get(groupedRecords[id], '[0].entity', {}) + const fields = _.get(groupedRecords[id], '[0].entity.fields', []) + const parsedRecords = new Record(fields).process(groupedRecords[id]) + + const columns = getRecordColumnFields(fields).map( + field => ({ + dataKey: `traits.${field.name}`, + label: field.label, + flexGrow: 1, + cellRenderer: recordPropertyRenderer(field) + }) + ) + + columns.unshift({ + dataKey: 'entity.name', + label: 'Entity', + flexGrow: 1, + cellRenderer: entityLabelRenderer + }) + + columns.unshift({ + dataKey: 'id', + label: 'Id', + flexGrow: 0, + cellRenderer: idRenderer + }) + + columns.push({ + dataKey: 'actions', + flexGrow: 0, + cellRenderer: cell => ( + { + const { teamId, projectId } = match.params + const { entityId, id: recordId } = clickedRecord + const path = `/teams/${teamId}/projects/${projectId}/entities/${entityId}/records/${recordId}/edit` + history.push(path) + } + } + ] + } + /> + ) + }) + + result[id] = { + entity, + columns, + records: parsedRecords + } + }) + + return result + } + + const tablesData = getTablesData(records) + const tablesDataKeys = Object.keys(tablesData) + + return ( + + + + + + + + {tablesDataKeys.map(entityId => ( + +
+ + + ))} + + ) +} + +RecordsSearchPage.RECORDS_SEARCH_QUERY = gql` + query RecordsSearchQuery($projectId: ID, $filter: String) { + records(projectId: $projectId, filter: $filter) { + id + entityId + entity { + id + name + label + fields { + ...Record_fields + } + } + + properties { + ...Record_properties + } + } + } + + ${RECORD_FRAGMENTS.fields} + ${RECORD_FRAGMENTS.properties} +` +RecordsSearchPage = withApollo(RecordsSearchPage) + +export default withRouter(RecordsSearchPage) diff --git a/ui/src/fragments/fields.js b/ui/src/fragments/fields.js new file mode 100644 index 0000000..98e4387 --- /dev/null +++ b/ui/src/fragments/fields.js @@ -0,0 +1,23 @@ +import gql from 'graphql-tag' + +const FIELD_FRAGMENTS = { + fields: gql` + fragment Field_fields on Field { + id + dataType + validations + settings + defaultValue + elementType + entityId + hint + label + name + parentId + position + referencedEntityId + } + ` +} + +export default FIELD_FRAGMENTS diff --git a/ui/src/fragments/record.js b/ui/src/fragments/record.js new file mode 100644 index 0000000..55e0be2 --- /dev/null +++ b/ui/src/fragments/record.js @@ -0,0 +1,58 @@ +import gql from 'graphql-tag' + +const RECORD_FRAGMENTS = { + fields: gql` + fragment Record_fields on Field { + id + dataType + validations + settings + elementType + referencedEntityId + defaultValue + elementType + entityId + hint + label + name + parentId + position + referencedEntityId + } + `, + properties: gql` + fragment Record_properties on Property { + id + value + position + fieldId + linkedRecordId + parentId + asset { + id + fileOriginal + } + field { + id + position + } + } + ` +} + +RECORD_FRAGMENTS.records = gql` + fragment Record_records on Record { + id + entityId + createdAt + updatedAt + + properties { + ...Record_properties + } + } + + ${RECORD_FRAGMENTS.properties} +` + +export default RECORD_FRAGMENTS diff --git a/ui/src/lib/getRecordColumnFields.js b/ui/src/lib/getRecordColumnFields.js new file mode 100644 index 0000000..2d58cc5 --- /dev/null +++ b/ui/src/lib/getRecordColumnFields.js @@ -0,0 +1,10 @@ +import _ from 'lodash' +import Field from 'models/Field' + +const getRecordColumnFields = recordFields => _.sortBy((recordFields || []) + .filter(Field.isRoot) + .filter(Field.isColumn) + .filter(Field.isVisibleColumn), +[ 'position' ]) + +export default getRecordColumnFields diff --git a/ui/src/lib/recordPropertyRenderer.js b/ui/src/lib/recordPropertyRenderer.js new file mode 100644 index 0000000..19fc7dd --- /dev/null +++ b/ui/src/lib/recordPropertyRenderer.js @@ -0,0 +1,37 @@ +import _ from 'lodash' +import React from 'react' + +import { CellContent } from 'components/internal/typography' +import ColorTile from 'components/internal/ColorTile' +import FontIcon from 'components/FontIcon' + +const recordPropertyRenderer = field => ({ record }) => { + let isValid = false + const value = record.traits[field.name] + + if (_.isString(value) || _.isNumber(value)) { + isValid = true + } + + if (field.dataType === 'boolean') { + const isActive = typeof value === 'boolean' ? value : value === 't' + + return ( + + {isActive ? ( + + ) : ( + + )} + + ) + } + + if (field.dataType === 'color' && value) { + return {} + } + + return {isValid ? value : '-'} +} + +export default recordPropertyRenderer diff --git a/ui/src/models/Field.js b/ui/src/models/Field.js index 698b363..3ef18dd 100644 --- a/ui/src/models/Field.js +++ b/ui/src/models/Field.js @@ -1,3 +1,4 @@ +import _ from 'lodash' import { object, string } from 'yup' import BaseModel from './BaseModel' @@ -22,6 +23,29 @@ class Field extends BaseModel { static isVisibleColumn(field) { return !(field.settings.visibility === false) } + + static process(fields = []) { + const rootFields = fields.filter(Field.isRoot) + + const processFields = entityFields => entityFields.map((entityField) => { + const newField = _.omit(entityField, [ '__typename' ]) + const childFields = fields.filter(f => f.parentId === entityField.id) + + if (childFields.length && entityField.dataType === 'key_value') { + newField.children = processFields(childFields) + } + + if (childFields.length && entityField.dataType === 'array') { + const subChildFields = fields.filter(f => f.parentId === childFields[0].id) + + newField.children = processFields(subChildFields) + } + + return newField + }) + + return processFields(rootFields) + } } Field.schema = object({ diff --git a/ui/src/models/Record.js b/ui/src/models/Record.js index ab99e24..3005960 100644 --- a/ui/src/models/Record.js +++ b/ui/src/models/Record.js @@ -50,6 +50,8 @@ class Record extends BaseModel { id: record.id, createdAt: record.createdAt, updatedAt: record.updatedAt, + entityId: record.entityId, + entity: record.entity, traits } }) diff --git a/ui/webpack.config.js b/ui/webpack.config.js index 1569818..9395b02 100644 --- a/ui/webpack.config.js +++ b/ui/webpack.config.js @@ -71,6 +71,7 @@ config.common = { components: path.join(__dirname, 'src', 'components'), constants: path.join(__dirname, 'src', 'constants'), fonts: path.join(__dirname, 'src', 'assets', 'fonts'), + fragments: path.join(__dirname, 'src', 'fragments'), images: path.join(__dirname, 'src', 'assets', 'images'), lib: path.join(__dirname, 'src', 'lib'), models: path.join(__dirname, 'src', 'models'),