diff --git a/shared/src/components/AttributeEditor/AttributeEditor.tsx b/shared/src/components/AttributeEditor/AttributeEditor.tsx index b2932624a..879c76947 100644 --- a/shared/src/components/AttributeEditor/AttributeEditor.tsx +++ b/shared/src/components/AttributeEditor/AttributeEditor.tsx @@ -24,6 +24,7 @@ const SCOPE_OPTIONS = [ { value: 'version', label: 'Version' }, { value: 'representation', label: 'Representation' }, { value: 'user', label: 'User' }, + { value: 'list', label: 'List' }, ] // Define types for constants @@ -37,7 +38,7 @@ const GLOBAL_FIELDS: GlobalFieldEntry[] = [ { value: 'example', scope: null }, // @ts-expect-error - project is not a scope? { value: 'default', scope: ['project'] }, - { value: 'inherit', scope: null }, + { value: 'inherit', scope: ['project', 'folder', 'task', 'product', 'version', 'representation', 'user'] }, ] interface TypeOptionDef { diff --git a/shared/src/components/ListAttributeForm/ListAttributeForm.tsx b/shared/src/components/ListAttributeForm/ListAttributeForm.tsx index 7c81806bc..5f311aa07 100644 --- a/shared/src/components/ListAttributeForm/ListAttributeForm.tsx +++ b/shared/src/components/ListAttributeForm/ListAttributeForm.tsx @@ -5,6 +5,7 @@ import { EntityListModel, useGetProjectQuery, useUpdateEntityListMutation, + useGetAttributeListQuery, } from '@shared/api' import { toast } from 'react-toastify' @@ -40,6 +41,19 @@ export const ListAttributeForm: FC = ({ const { data: project } = useGetProjectQuery({ projectName }) + // Fetch list-scoped attributes + const { data: allAttributes = [] } = useGetAttributeListQuery() + const listScopedAttributes = useMemo( + () => + allAttributes + .filter((attr) => attr.scope?.includes('list')) + .map((attr) => ({ + name: attr.name, + data: attr.data, + })), + [allAttributes], + ) + const fields: AttributeField[] = useMemo( () => [ { name: 'label', data: { type: 'string', title: 'Label' } }, @@ -53,18 +67,20 @@ export const ListAttributeForm: FC = ({ enableSearch: true, }, }, + ...listScopedAttributes, { name: 'active', data: { type: 'boolean', title: 'Active' } }, ...attributes, ], - [project?.tags, attributes], + [project?.tags, listScopedAttributes, attributes], ) useEffect(() => { if (list) { const parsedAttrib = list.attrib || {} - // get the data for the attrib fields only - const attribFieldValues = attributes.reduce((acc, { name }) => { + // get the data for all attrib fields (list-scoped and passed attributes) + const allAttribFields = [...listScopedAttributes, ...attributes] + const attribFieldValues = allAttribFields.reduce((acc, { name }) => { const fieldValue = (parsedAttrib as Record)[name] // Default to empty string for all attrib fields acc[name] = fieldValue || '' @@ -78,12 +94,15 @@ export const ListAttributeForm: FC = ({ ...attribFieldValues, }) } - }, [list, JSON.stringify(attributes)]) + }, [list, JSON.stringify(attributes), JSON.stringify(listScopedAttributes)]) const [updateList] = useUpdateEntityListMutation() const isAttribField = (fieldName: string): boolean => { - return attributes.some((attr) => attr.name === fieldName) + return ( + attributes.some((attr) => attr.name === fieldName) || + listScopedAttributes.some((attr) => attr.name === fieldName) + ) } const handleChange = useCallback( @@ -134,7 +153,7 @@ export const ListAttributeForm: FC = ({ toast.error('Failed to update list attribute: ', error.attrib.detail) } }, - [list?.id, list?.attrib, projectName, updateList], + [list?.id, list?.attrib, projectName, updateList, listScopedAttributes, attributes], ) return ( diff --git a/src/pages/ProjectListsPage/components/ListsFiltersDialog/ListsFiltersDialog.tsx b/src/pages/ProjectListsPage/components/ListsFiltersDialog/ListsFiltersDialog.tsx index eced86022..b6a1bb4fd 100644 --- a/src/pages/ProjectListsPage/components/ListsFiltersDialog/ListsFiltersDialog.tsx +++ b/src/pages/ProjectListsPage/components/ListsFiltersDialog/ListsFiltersDialog.tsx @@ -5,9 +5,96 @@ import { entityTypeOptions } from '../NewListDialog/NewListDialog' import { createPortal } from 'react-dom' import styled from 'styled-components' import { useListsContext } from '@pages/ProjectListsPage/context' +import { AttributeData, AttributeEnumItem, EntityList, useGetAttributeListQuery } from '@shared/api' import { useProjectDataContext } from '@shared/containers/ProjectTreeTable' import { getAttributeIcon } from '@shared/util' +// Helper function to aggregate attribute values from lists +const getAttributeValuesFromLists = ( + lists: EntityList[], + attributeName: string, + enums?: AttributeEnumItem[], + type?: AttributeData['type'], +): Option[] => { + const enumOptions: Option[] = [] + const options: (Option & { count: number })[] = [] + + // add the enum values first + if (enums) { + enums.forEach((enumItem) => { + enumOptions.push({ + id: enumItem.value.toString(), + type: type, + label: enumItem.label, + values: [], + icon: enumItem.icon, + color: enumItem.color, + }) + }) + } + + // aggregate values from all lists + lists.forEach((list) => { + const value = list.attrib?.[attributeName] + + // no value? skip + if (value === null || value === undefined) return + + let text = '' + + // convert value to text + switch (typeof value) { + case 'string': + case 'number': + case 'boolean': + text = value.toString() + break + case 'object': + if (Array.isArray(value)) { + text = value.join(', ') + } else { + text = JSON.stringify(value) + } + break + default: + break + } + + // create id for text value + const id = text + .trim() + .toLowerCase() + .replace(/[^a-z0-9\s]/g, '') + .replace(/\s+/g, '-') + + // check if the option already exists in enums + const existingOption = enumOptions?.find((enumItem) => enumItem.id === id) + if (existingOption) return + + // check if options already has the value, if so, increment the count + const existingValue = options.find((option) => option.id === id) + if (existingValue) { + existingValue.count++ + return + } else { + // add option + options.push({ + id, + type: type, + label: text, + values: [], + count: 1, + }) + } + }) + + // sort options based on count + options.sort((a, b) => b.count - a.count) + + // enum options first, then the rest + return [...enumOptions, ...options] +} + const Dialog = styled.div` position: fixed; inset: 0; @@ -33,20 +120,15 @@ const ListsFiltersDialog: FC = ({}) => { const filtersRef = useRef(null) + // Fetch list-scoped attributes + const { data: allAttributes = [] } = useGetAttributeListQuery() + useEffect(() => { if (listsFiltersOpen && filtersRef.current) { filtersRef.current.open() } }, [listsFiltersOpen, filtersRef]) - // keeps track of the filters whilst adding/removing filters - const [filters, setFilters] = useState(listsFilters) - - // update filters when it changes - useEffect(() => { - setFilters(listsFilters) - }, [listsFilters, setFilters]) - const options = useMemo(() => { const opts: Option[] = [ { @@ -94,8 +176,62 @@ const ListsFiltersDialog: FC = ({}) => { }) } + // Add attribute options + const listScopedAttributes = allAttributes.filter((attr) => attr.scope?.includes('list')) + + const attributeOptions: Option[] = listScopedAttributes.map((attr) => { + const hasEnum = !!attr.data.enum?.length + const option: Option = { + id: `attrib.${attr.name}`, + label: attr.data.title || attr.name, + type: attr.data.type || 'string', + icon: getAttributeIcon(attr.name, attr.data.type, hasEnum), + allowsCustomValues: true, + values: [], + } + + // if the attribute type is boolean, add yes/no options + if (attr.data.type === 'boolean') { + option.singleSelect = true + option.values = [ + { + id: 'true', + label: 'Yes', + icon: 'radio_button_checked', + }, + { + id: 'false', + label: 'No', + icon: 'radio_button_unchecked', + }, + ] + } else { + // Get aggregated values from lists data + const aggregatedValues = getAttributeValuesFromLists( + listsData, + attr.name, + attr.data.enum, + attr.data.type, + ) + + option.values = aggregatedValues + } + + return option + }) + + opts.push(...attributeOptions) + return opts - }, [listsData, projectInfo]) + }, [allAttributes, listsData, projectInfo]) + + // keeps track of the filters whilst adding/removing filters + const [filters, setFilters] = useState(listsFilters) + + // update filters when it changes + useEffect(() => { + setFilters(listsFilters) + }, [listsFilters, setFilters]) // on keydown, close the dialog useEffect(() => { @@ -113,7 +249,7 @@ const ListsFiltersDialog: FC = ({}) => { } }, [setListsFiltersOpen, listsFiltersOpen]) - if (listsFiltersOpen === false) return null + if (!listsFiltersOpen) return null return createPortal(