Skip to content

Commit

Permalink
IIIF #5 - Updating to allow defining custom metadata on projects; All…
Browse files Browse the repository at this point in the history
…owing setting custom metadata on resources
  • Loading branch information
dleadbetter committed Jul 15, 2022
1 parent 1adb564 commit e5ee76a
Show file tree
Hide file tree
Showing 15 changed files with 449 additions and 11 deletions.
2 changes: 1 addition & 1 deletion app/models/project.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ class Project < ApplicationRecord
belongs_to :organization

# Resourceable attributes
allow_params :organization_id, :name, :description, :api_key, :avatar
allow_params :organization_id, :name, :description, :api_key, :avatar, :metadata

# ActiveStorage
has_one_attached :avatar
Expand Down
2 changes: 1 addition & 1 deletion app/serializers/projects_serializer.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
class ProjectsSerializer < BaseSerializer
index_attributes :id, :uid, :name, :description, :avatar_thumbnail_url, :organization_id, organization: OrganizationsSerializer
show_attributes :id, :uid, :name, :description, :api_key, :avatar_url, :avatar_preview_url, :organization_id, organization: OrganizationsSerializer
show_attributes :id, :uid, :name, :description, :api_key, :avatar_url, :avatar_preview_url, :metadata, :organization_id, organization: OrganizationsSerializer
end
1 change: 1 addition & 0 deletions client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"classnames": "^2.3.1",
"i18next": "^21.8.0",
"react": "^17.0.2",
"react-calendar": "^3.7.0",
"react-dom": "^17.0.2",
"react-i18next": "^11.16.9",
"react-router-dom": "^6.3.0",
Expand Down
118 changes: 118 additions & 0 deletions client/src/components/MetadataList.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
// @flow

import React, { useCallback, type ComponentType } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Form, List } from 'semantic-ui-react';
import _ from 'underscore';
import Metadata from '../constants/Metadata';
import MetadataOptions from './MetadataOptions';

const MetadataList: ComponentType<any> = (props) => {
const { t } = useTranslation();

/**
* Adds a new item to the list.
*
* @type {(function(): void)|*}
*/
const onAddItem = useCallback(() => {
props.onChange([...props.items, {}]);
}, [props.items]);

/**
* Removes the item at the passed index from the list.
*
* @type {(function(*): void)|*}
*/
const onRemoveItem = useCallback((findIndex) => {
props.onChange(_.reject(props.items, (item, index) => index === findIndex));
}, [props.items]);

/**
* Updates the passed attribute of the item at the passed index.
*
* @type {(function(number, string, ?Event, {value: *}): void)|*}
*/
const onUpdateItem = useCallback((findIndex: number, attribute: string, e: ?Event, { value }) => {
props.onChange(_.map(props.items, (item, index) => (
index !== findIndex ? item : ({ ...item, [attribute]: value })
)));
}, [props.items]);

return (
<Form>
<Button
basic
content={t('Common.buttons.add')}
icon='plus'
onClick={onAddItem.bind(this)}
type='button'
/>
<List
divided
relaxed='very'
>
{ _.map(props.items, (item, index) => (
<List.Item>
<Form.Group
style={{
alignItems: 'center'
}}
>
<Form.Input
onChange={onUpdateItem.bind(this, index, 'name')}
placeholder={t('MetadataList.labels.name')}
value={item.name}
width={7}
/>
<Form.Dropdown
clearable
onChange={onUpdateItem.bind(this, index, 'type')}
options={Metadata.getOptions()}
placeholder={t('MetadataList.labels.type')}
value={item.type}
selectOnBlur={false}
selection
width={6}
/>
<Form.Checkbox
checked={item.required}
label={t('MetadataList.labels.required')}
onChange={(e, { checked }) => onUpdateItem(index, 'required', e, { value: checked })}
width={2}
/>
<Form.Button
color='red'
icon='trash'
onClick={onRemoveItem.bind(this, index)}
type='button'
width={1}
/>
</Form.Group>
{ item.type === 'dropdown' && (
<Form.Group
style={{
alignItems: 'center'
}}
>
<Form.Checkbox
checked={item.multiple}
label={t('MetadataList.labels.multiple')}
onChange={(e, { checked }) => onUpdateItem(index, 'multiple', e, { value: checked })}
/>
<Form.Field>
<MetadataOptions
options={item.options}
onChange={(options) => onUpdateItem(index, 'options', null, { value: options })}
/>
</Form.Field>
</Form.Group>
)}
</List.Item>
))}
</List>
</Form>
);
};

export default MetadataList;
111 changes: 111 additions & 0 deletions client/src/components/MetadataOptions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// @flow

import React, {
useCallback,
useEffect,
useState,
type ComponentType
} from 'react';
import { Button, Input, Label } from 'semantic-ui-react';
import _ from 'underscore';

type Props = {
options: Array<string>,
onChange: (options: Array<string>) => void
};

const MetadataOptions: ComponentType<any> = (props: Props) => {
const [options, setOptions] = useState(_.map(props.options, (option) => ({ value: option })));

/**
* Adds a new option to the list.
*
* @type {(function(): void)|*}
*/
const onAddOption = useCallback(() => {
setOptions((prevOptions) => [...prevOptions, { new: true }]);
}, []);

/**
* Deletes the option at the passed index from the list.
*
* @type {(function(*): void)|*}
*/
const onDeleteOption = useCallback((findIndex) => {
setOptions((prevOptions) => _.filter(prevOptions, (option, index) => index !== findIndex));
}, []);

/**
* Removes the "new" indicator from the option at the passed index.
*
* @type {(function(*): void)|*}
*/
const onSaveOption = useCallback((findIndex) => {
setOptions((prevOptions) => _.map(
prevOptions,
(option, index) => (findIndex !== index ? option : ({ ...option, new: false }))
));
}, [options]);

/**
* Updates the value of the option at the passed index.
*
* @type {(function(*, *, {value: *}): void)|*}
*/
const onUpdateOption = useCallback((findIndex, e, { value }) => {
setOptions((prevOptions) => _.map(
prevOptions,
(option, index) => (index !== findIndex ? option : ({ ...option, value }))
));
}, []);

/**
* Calls the onChange prop when the list of options changes.
*/
useEffect(() => {
const savedOptions = _.filter(options, (option) => !option.new);
props.onChange(_.pluck(savedOptions, 'value'));
}, [options]);

return (
<div>
<Button
basic
icon='plus'
onClick={onAddOption}
type='button'
/>
{ _.map(options, (option, index) => (
<>
{ option.new && (
<Label>
<Input
onChange={onUpdateOption.bind(this, index)}
value={option.value}
style={{
width: 'unset'
}}
/>
<Button
basic
color='green'
compact
icon='checkmark'
onClick={onSaveOption.bind(this, index)}
type='button'
/>
</Label>
)}
{ !option.new && (
<Label
content={option.value}
onRemove={onDeleteOption.bind(this, index)}
/>
)}
</>
))}
</div>
);
};

export default MetadataOptions;
125 changes: 125 additions & 0 deletions client/src/components/ResourceMetadata.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
// @flow

import { DatePicker } from '@performant-software/semantic-components';
import React, { useCallback, type ComponentType } from 'react';
import { Form } from 'semantic-ui-react';
import _ from 'underscore';
import Metadata from '../constants/Metadata';

type Item = {
multiple?: boolean,
name: string,
options?: Array<string>,
required?: boolean,
type: string,
value: any
};

type Props = {
items: Array<Item>,
onChange: (item: any) => void,
value: any
};

const ResourceMetadata: ComponentType<any> = (props: Props) => {
/**
* Changes the value for the passed item.
*
* @type {(function(*, *): void)|*}
*/
const onChange = useCallback((item, value) => {
props.onChange({ ...props.value, [item.name]: value });
}, [props.onChange, props.value]);

/**
* Renders the passed item.
*
* @type {function(*): *}
*/
const renderItem = useCallback((item) => {
let rendered;

if (item.type === Metadata.Types.string) {
rendered = (
<Form.Input
label={item.name}
required={item.required}
onChange={(e, { value }) => onChange(item, value)}
value={props.value && props.value[item.name]}
/>
);
}

if (item.type === Metadata.Types.number) {
rendered = (
<Form.Input
label={item.name}
required={item.required}
onChange={(e, { value }) => onChange(item, value)}
value={props.value && props.value[item.name]}
type='number'
/>
);
}

if (item.type === Metadata.Types.dropdown) {
rendered = (
<Form.Dropdown
label={item.name}
multiple={item.multiple}
required={item.required}
options={_.map(item.options, (option) => ({ key: option, value: option, text: option }))}
onChange={(e, { value }) => onChange(item, value)}
selectOnBlur={false}
selection
value={props.value && props.value[item.name]}
/>
);
}

if (item.type === Metadata.Types.text) {
rendered = (
<Form.TextArea
label={item.name}
required={item.required}
onChange={(e, { value }) => onChange(item, value)}
value={props.value && props.value[item.name]}
/>
);
}

if (item.type === Metadata.Types.date) {
rendered = (
<Form.Input
label={item.name}
required={item.required}
>
<DatePicker
onChange={(date) => onChange(item, date && date.toString())}
value={props.value && props.value[item.name] && new Date(props.value[item.name])}
/>
</Form.Input>
);
}

if (item.type === Metadata.Types.checkbox) {
rendered = (
<Form.Checkbox
checked={props.value && props.value[item.name]}
label={item.name}
onChange={(e, { checked }) => onChange(item, checked)}
/>
);
}

return rendered;
}, [props.value]);

return (
<>
{ _.map(props.items, renderItem.bind(this)) }
</>
);
};

export default ResourceMetadata;
Loading

0 comments on commit e5ee76a

Please sign in to comment.