firecms icon indicating copy to clipboard operation
firecms copied to clipboard

[Feature request] Key-Value Map

Open grind-t opened this issue 3 years ago • 6 comments

Hello @fgatti675, I know this issue has already been mentioned (#71, #67), but I would like to open it again. Problem: It is difficult to use key-value data where the exact structure of the object is not defined in advance. Example: A product can have many attributes unknown in advance. For example, it may or may not have a color, size, material, etc. Current solutions:

  1. Use an array for such data, where each element stores a key and a value. This approach has a number of disadvantages: a. It is more difficult to make queries for such an array. For example, selecting all products with a certain color. b. Duplicate keys may appear in such an array by mistake.
  2. Make a custom key-value data field. This solution also has a number of disadvantages: a. It is difficult to implement. b. It is not possible to use validation for such a field (#127).

I think there should be an easier way for such a common data type :face_with_head_bandage:

grind-t avatar Nov 16 '21 21:11 grind-t

Hi @grind-t, definitely adding this as a feature request :) We have a bunch of things in the pipeline, though! You can give us a hand if you implement it as a custom field and then share it with us so we can add it to the core. Thinking about the implementation, the datatype would be map. And we probably need to have a dropdown to select the data type of each of the values, much like the internal Firestore UI works

fgatti675 avatar Nov 16 '21 23:11 fgatti675

@fgatti675 I was thinking of a simpler option where the map works similarly to an array. Since the key is always a string, you can simply specify the value property (similar to an array "of" property). Here is an example of such a map, where the key is an auto-generated id, and the value can be any property:

import {
  Box,
  FormControl,
  FormHelperText,
  Paper,
  Button,
  IconButton,
} from "@mui/material";
import ClearIcon from "@mui/icons-material/Clear";
import {
  buildPropertyField,
  FieldProps,
  FieldDescription,
  LabelWithIcon,
  StringProperty,
  NumberProperty,
  BooleanProperty,
  TimestampProperty,
  GeopointProperty,
  ReferenceProperty,
  ArrayProperty,
  MapProperty,
} from "@camberi/firecms";
import {
  getFirestore,
  collection,
  doc,
  deleteField,
  FieldValue,
} from "firebase/firestore";

type EntityProperty =
  | StringProperty
  | NumberProperty
  | BooleanProperty
  | TimestampProperty
  | GeopointProperty
  | ReferenceProperty
  | ArrayProperty
  | MapProperty;

export interface EntitiesFieldProps {
  entityProperty: (id: string) => EntityProperty;
}

const EntitiesField = ({
  property,
  name,
  value,
  setValue,
  tableMode,
  customProps,
  error,
  showError,
  includeDescription,
  context,
  disabled,
}: FieldProps<object, EntitiesFieldProps>) => {
  const entityProperty = customProps.entityProperty;

  const addEntity = () => {
    const id = doc(collection(getFirestore(), name)).id;
    setValue({ ...value, [id]: {} });
  };

  const removeEntity = (id: string) => {
    setValue({ ...value, [id]: deleteField() });
  };

  return (
    <FormControl fullWidth error={showError}>
      {!tableMode && (
        <FormHelperText filled required={property.validation?.required}>
          <LabelWithIcon property={property} />
        </FormHelperText>
      )}

      <Paper elevation={0}>
        {value &&
          Object.entries(value).map(
            ([id, entity]) =>
              entity instanceof FieldValue || (
                <Box display="flex" key={id} my={2}>
                  <Box flexGrow={1}>
                    {buildPropertyField({
                      name: `${name}.${id}`,
                      property: entityProperty(id),
                      context,
                    })}
                  </Box>
                  <Box width="36px">
                    <IconButton
                      size="small"
                      aria-label="remove"
                      onClick={() => removeEntity(id)}
                    >
                      <ClearIcon fontSize="small" />
                    </IconButton>
                  </Box>
                </Box>
              )
          )}
        <Box p={1} justifyContent="center" textAlign="left">
          <Button
            variant="outlined"
            color="primary"
            disabled={disabled}
            onClick={addEntity}
          >
            Add
          </Button>
        </Box>
      </Paper>

      {includeDescription && <FieldDescription property={property} />}

      {showError && typeof error === "string" && (
        <FormHelperText>{error}</FormHelperText>
      )}
    </FormControl>
  );
};

export default EntitiesField;

However, I don't quite understand how to set up validation correctly, it doesn't work right now :sweat_smile:

grind-t avatar Nov 17 '21 00:11 grind-t

Hi @grind-t I was checking your code. To my understanding this is not a { string => Property } field right? It is mapping Firestore reference => property builder based on id, if I understood it right?

fgatti675 avatar Nov 18 '21 12:11 fgatti675

@fgatti675 Yes you are right. This is an example where the key is an auto-generated identifier (string), and the value is any property. The problem is that validation only works if the properties are specified in the property builder (#127). Therefore, the validation problem will also affect { string => Property } field.

grind-t avatar Nov 18 '21 13:11 grind-t

Heads up for version 2. We are including a schema editor that will allow to update and add properties, so that should allow end users to add properties as they see fit. We will also be allowing property builders at any level of the property tree, not just the root. And also allow tuples in the form of arrays, such as ["string","number"] or any format. Hoping these new features will cover this use case as well!

fgatti675 avatar Apr 27 '22 11:04 fgatti675

For anyone struggling with this issue (or issues https://github.com/Camberi/firecms/issues/71, https://github.com/Camberi/firecms/issues/67), We've got a workaround until version 2 is in full release. What we've done is create a subcollection for the relevant field, and use a triggered function to merge the data into the parent document.

For our case (a map of color names and hexes) we implemented a simple color picker and created a schema for the colors:

const colors = buildSchema({
  name: "Colors", customId: true,
  properties: {
    hex: {
      //Our hex properties
    }
  }
})

Then inserted it as a subcollection:

buildCollection({
  //parent collection definition
  subcollections: [
    buildCollection({ name: "Colors",
      path: "colors", schema: colors
    })
  ]
})

That was it for FireCMS configuration, then we deployed a triggered function for writes to that collection:

functions.firestore.document("path/to/parent/{id}/colors/{color}").onWrite(
  async ({after}, {params: { parent, color }}) => await db.runTransaction(async t => {
    const parent = db.doc(`path/to/parent/${id}`) //after.ref.parent.parent should also work
    const { colors={} } = (await t.get(parent)).data()
    if (after.exists) {
      const { hex } = after.data()
      t.set(parent,
        { colors: { ...colors, [color]: hex } },
        { merge: true })
    }
    else {
      delete colors[color]
      t.set(parent, { colors }, { merge: true })
    }
  })
)

We don't use the subcollection for anything else, and it costs a few extra document operations for content changes, but that isn't a problem in our case.

We hope that this helps someone else move forward with their project prior to 2.0.0 full release~!

Travis-Enright avatar Jul 23 '22 16:07 Travis-Enright

A native implementation has been added in 2.0.0-rc.1 :)

fgatti675 avatar May 31 '23 15:05 fgatti675