diff --git a/package-lock.json b/package-lock.json index b7629eb1..afa6705c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -814,13 +814,13 @@ } }, "node_modules/@fluentui/react-hooks": { - "version": "8.3.1", - "resolved": "https://registry.npmjs.org/@fluentui/react-hooks/-/react-hooks-8.3.1.tgz", - "integrity": "sha512-fgkL4/4m8ds7dK6+o6qfk9Ok1ssbTV3dA7k1w5xZgw/FE4AWTt6zfLx9HhGpxF71l0X+R0DCWWb6W/LqE7+Ylg==", + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/@fluentui/react-hooks/-/react-hooks-8.3.2.tgz", + "integrity": "sha512-mGmDCaUjavYj4Bv/IPoNix4HMXX2ZPnPMfyH5X0QjiFEeSuOFlIM6By0sV6Vf30dsjoHNdUUsU461axtFTRVsg==", "dependencies": { "@fluentui/react-window-provider": "^2.1.4", "@fluentui/set-version": "^8.1.4", - "@fluentui/utilities": "^8.3.1", + "@fluentui/utilities": "^8.3.2", "tslib": "^2.1.0" }, "peerDependencies": { @@ -878,9 +878,9 @@ } }, "node_modules/@fluentui/utilities": { - "version": "8.3.1", - "resolved": "https://registry.npmjs.org/@fluentui/utilities/-/utilities-8.3.1.tgz", - "integrity": "sha512-V/6bokboB7J1di6XWnS2AuT1A2x0N8BvfCbdaTqvrmCarmViaY/3cawO8shV91d+ahiR2ZzN0CqOMkIDvjr4tA==", + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/@fluentui/utilities/-/utilities-8.3.2.tgz", + "integrity": "sha512-XP/NG3jg8LqLzU139SuNzO01nu7IAizlnC9KWNkYxrV5Hc9pGrW/seKloc7F7RVK36Vr5l4gl3DXX9lhUZUP/Q==", "dependencies": { "@fluentui/dom-utilities": "^2.1.4", "@fluentui/merge-styles": "^8.1.5", @@ -9649,6 +9649,7 @@ "name": "@porkbellypro/crm-web", "dependencies": { "@fluentui/react": "^8.28.2", + "@fluentui/react-hooks": "^8.3.2", "@porkbellypro/crm-shared": "file:shared", "@tsconfig/recommended": "^1.0.1", "@types/history": "^4.7.9", @@ -9658,6 +9659,7 @@ "@types/react-test-renderer": "^17.0.1", "argparse": "^2.0.1", "buffer": "^6.0.3", + "color-string": "^1.6.0", "history": "^5.0.1", "html-webpack-plugin": "^5.3.2", "jimp": "^0.16.1", @@ -10338,13 +10340,13 @@ } }, "@fluentui/react-hooks": { - "version": "8.3.1", - "resolved": "https://registry.npmjs.org/@fluentui/react-hooks/-/react-hooks-8.3.1.tgz", - "integrity": "sha512-fgkL4/4m8ds7dK6+o6qfk9Ok1ssbTV3dA7k1w5xZgw/FE4AWTt6zfLx9HhGpxF71l0X+R0DCWWb6W/LqE7+Ylg==", + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/@fluentui/react-hooks/-/react-hooks-8.3.2.tgz", + "integrity": "sha512-mGmDCaUjavYj4Bv/IPoNix4HMXX2ZPnPMfyH5X0QjiFEeSuOFlIM6By0sV6Vf30dsjoHNdUUsU461axtFTRVsg==", "requires": { "@fluentui/react-window-provider": "^2.1.4", "@fluentui/set-version": "^8.1.4", - "@fluentui/utilities": "^8.3.1", + "@fluentui/utilities": "^8.3.2", "tslib": "^2.1.0" } }, @@ -10390,9 +10392,9 @@ } }, "@fluentui/utilities": { - "version": "8.3.1", - "resolved": "https://registry.npmjs.org/@fluentui/utilities/-/utilities-8.3.1.tgz", - "integrity": "sha512-V/6bokboB7J1di6XWnS2AuT1A2x0N8BvfCbdaTqvrmCarmViaY/3cawO8shV91d+ahiR2ZzN0CqOMkIDvjr4tA==", + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/@fluentui/utilities/-/utilities-8.3.2.tgz", + "integrity": "sha512-XP/NG3jg8LqLzU139SuNzO01nu7IAizlnC9KWNkYxrV5Hc9pGrW/seKloc7F7RVK36Vr5l4gl3DXX9lhUZUP/Q==", "requires": { "@fluentui/dom-utilities": "^2.1.4", "@fluentui/merge-styles": "^8.1.5", @@ -11115,6 +11117,7 @@ "version": "file:web", "requires": { "@fluentui/react": "^8.28.2", + "@fluentui/react-hooks": "^8.3.2", "@porkbellypro/crm-shared": "file:shared", "@tsconfig/recommended": "^1.0.1", "@types/history": "^4.7.9", @@ -11127,6 +11130,7 @@ "@typescript-eslint/parser": "^4.29.1", "argparse": "^2.0.1", "buffer": "^6.0.3", + "color-string": "^1.6.0", "eslint": "^7.32.0", "eslint-config-airbnb-typescript": "^12.3.1", "eslint-plugin-import": "^2.24.0", diff --git a/web/package.json b/web/package.json index 552b0af5..c49f9681 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "@porkbellypro/crm-web", - "bin":{ + "bin": { "web-build": "./build.mjs" }, "scripts": { @@ -10,6 +10,7 @@ }, "dependencies": { "@fluentui/react": "^8.28.2", + "@fluentui/react-hooks": "^8.3.2", "@porkbellypro/crm-shared": "file:shared", "@tsconfig/recommended": "^1.0.1", "@types/history": "^4.7.9", @@ -19,6 +20,7 @@ "@types/react-test-renderer": "^17.0.1", "argparse": "^2.0.1", "buffer": "^6.0.3", + "color-string": "^1.6.0", "history": "^5.0.1", "html-webpack-plugin": "^5.3.2", "jimp": "^0.16.1", @@ -35,13 +37,13 @@ "@types/jest": "^27.0.1", "@typescript-eslint/eslint-plugin": "^4.29.1", "@typescript-eslint/parser": "^4.29.1", + "eslint": "^7.32.0", "eslint-config-airbnb-typescript": "^12.3.1", "eslint-plugin-import": "^2.24.0", "eslint-plugin-jsx-a11y": "^6.4.1", "eslint-plugin-react": "^7.24.0", "eslint-plugin-react-hooks": "^4.2.0", - "eslint": "^7.32.0", - "ts-jest": "^27.0.5", - "jest": "^27.0.6" + "jest": "^27.0.6", + "ts-jest": "^27.0.5" } } diff --git a/web/src/__tests__/components/TagEditor.tsx b/web/src/__tests__/components/TagEditor.tsx new file mode 100644 index 00000000..1c059d78 --- /dev/null +++ b/web/src/__tests__/components/TagEditor.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { create } from 'react-test-renderer'; +import { TagEditor } from '../../components/tagSelector/TagEditor'; +import { ITag } from '../../controllers/Tag'; + +import '../disable-icon-warnings.helpers'; + +const demoTag: ITag = { + id: '1', + label: 'Big Boss', + color: '#BF7829', + commit() { throw new Error('Not implemented'); }, + delete() { throw new Error('Not implemented'); }, +}; + +const htmlId = 'TagAnchor'; + +describe('TagEditor render tests', () => { + test('without closingFunction', () => { + const json = create( + , + ).toJSON(); + expect(json).toMatchInlineSnapshot(` + +`); + }); + + test('with closingFunction', () => { + const json = create( + { }} + />, + ).toJSON(); + expect(json).toMatchInlineSnapshot(` + +`); + }); +}); diff --git a/web/src/__tests__/components/TagPicker.tsx b/web/src/__tests__/components/TagPicker.tsx new file mode 100644 index 00000000..69caaf7f --- /dev/null +++ b/web/src/__tests__/components/TagPicker.tsx @@ -0,0 +1,133 @@ +import React from 'react'; +import { create } from 'react-test-renderer'; +import { AppProvider, IAppContext } from '../../AppContext'; +import { TagPicker } from '../../components/tagSelector/TagPicker'; +import { ICard } from '../../controllers/Card'; +import { ITag } from '../../controllers/Tag'; + +import '../disable-icon-warnings.helpers'; + +function notImplemented() { + return new Error('Not Implemented'); +} + +const demoCard: ICard = { + company: 'None', + tags: [], + email: 'noEmail@jmail.com', + favorite: false, + fields: [], + jobTitle: 'Unemployed', + name: 'no name', + phone: '0001', + update() { throw notImplemented(); }, + commit() { throw notImplemented(); }, + delete() { throw notImplemented(); }, +}; + +const demoTag: ITag = { + id: '1', + label: 'Big Boss', + color: '#BF7829', + commit() { throw notImplemented(); }, + delete() { throw notImplemented(); }, +}; + +const demoApp: IAppContext = { + searchQuery: '', + tagQuery: [], + user: { + username: 'username', + settings: {}, + cards: [demoCard], + tags: [demoTag], + }, + update() { throw notImplemented(); }, + showCardDetail() { throw notImplemented(); }, + newCard() { throw notImplemented(); }, + newTag() { throw notImplemented(); }, + login() { return Promise.reject(notImplemented()); }, + logout() { return Promise.reject(notImplemented()); }, +}; + +describe('TagPicker render tests', () => { + test('TagPicker in editing mode', () => { + const json = create( + + + , + ).toJSON(); + expect(json).toMatchInlineSnapshot(` +
+
+ Tags +
+
+
+
+ +
+`); + }); + + test('TagPicker in read-only', () => { + const json = create( + + + , + ).toJSON(); + expect(json).toMatchInlineSnapshot(` +
+
+ Tags +
+
+
+
+
+`); + }); +}); diff --git a/web/src/__tests__/components/TagWrapper.tsx b/web/src/__tests__/components/TagWrapper.tsx new file mode 100644 index 00000000..03bddf3f --- /dev/null +++ b/web/src/__tests__/components/TagWrapper.tsx @@ -0,0 +1,221 @@ +import React from 'react'; +import { create } from 'react-test-renderer'; +import { TagWrapper } from '../../components/tagSelector/TagWrapper'; +import { ICard } from '../../controllers/Card'; +import { ITag } from '../../controllers/Tag'; + +import '../disable-icon-warnings.helpers'; + +const demoTag: ITag = { + id: '1', + label: 'Big Boss', + color: '#BF7829', + commit() { throw new Error('Not implemented'); }, + delete() { throw new Error('Not implemented'); }, +}; + +const demoCard: ICard = { + company: 'None', + tags: [], + email: 'noEmail@jmail.com', + favorite: false, + fields: [], + jobTitle: 'Unemployed', + name: 'no name', + phone: '0001', + update() { throw new Error('Not Implemented'); }, + commit() { throw new Error('Not implemented'); }, + delete() { throw new Error('Not implemented'); }, +}; + +describe('TagWrapper Render Tests', () => { + test('with edit button', () => { + const json = create( + { }} + />, + ).toJSON(); + expect(json).toMatchInlineSnapshot(` +
+
+ +
+ +
+`); + }); + + test('without edit button', () => { + const json = create( + , + ).toJSON(); + expect(json).toMatchInlineSnapshot(` +
+
+ +
+
+`); + }); + + test('with attached card, with edit button', () => { + const json = create( + { }} + />, + ).toJSON(); + expect(json).toMatchInlineSnapshot(` +
+
+ +
+ +
+`); + }); +}); diff --git a/web/src/components/cardDetails/CardDetails.tsx b/web/src/components/cardDetails/CardDetails.tsx index 1fad2733..def55fb9 100644 --- a/web/src/components/cardDetails/CardDetails.tsx +++ b/web/src/components/cardDetails/CardDetails.tsx @@ -8,6 +8,7 @@ import { CardExtraField } from './CardExtraField'; import { CardImageField } from './CardImageField'; import { CardMandatoryField } from './CardMandatoryField'; import { CardNoteField } from './CardNoteField'; +import { TagPicker } from '../tagSelector/TagPicker'; export interface ICardDetailsProps { card: ICard; @@ -69,6 +70,9 @@ export const CardDetails: React.VoidFunctionComponent = ({ ed + + + {mFields.map((field) => ( diff --git a/web/src/components/tagSelector/TagEditor.tsx b/web/src/components/tagSelector/TagEditor.tsx new file mode 100644 index 00000000..82a8e8f0 --- /dev/null +++ b/web/src/components/tagSelector/TagEditor.tsx @@ -0,0 +1,106 @@ +import { + Callout, DefaultButton, ICalloutProps, Stack, TextField, mergeStyles, +} from '@fluentui/react'; +import PropTypes, { Requireable } from 'prop-types'; +import React, { useState } from 'react'; +import { ITag, ITagProperties } from '../../controllers/Tag'; + +// closing Function is called to dismiss callout +export interface ITagEditorProps { + tag: ITag; + anchor: ICalloutProps['target']; + closingFunction?: () => void; +} + +const getSwatchClassNames = (selectedColor: string) => { + const height = 24; + const borderRadius = height / 2; + const width = 24; + // Extracted from Figma + const colors = [ + '#BF7829', + '#127976', + '#D61317', + '#F3B27A', + '#E5E5E5', + '#77A69E', + '#BC8282', + '#78AFB2', + '#84A9C3', + '#5D68A6', + '#662525', + '#FBE900', + ]; + + return colors.map((color) => [color, + mergeStyles([{ + height, + width, + borderRadius, + backgroundColor: color, + }, + // when selectedColor matches then we have feedback on what color is selected + (color === selectedColor) && { + border: '2px solid black', + boxSizing: 'border-box', + }])]); +}; + +export const TagEditor: React.VoidFunctionComponent = ({ + tag, anchor, closingFunction, +}) => { + const [unstagedState, setState] = useState(tag); + const updateLabel = (newLabel: string) => setState({ ...unstagedState, label: newLabel }); + const updateColor = (newColor: string) => setState({ ...unstagedState, color: newColor }); + + const deleteTag = async () => { + tag.delete(); + if (closingFunction) closingFunction(); + }; + + const swatchStyles = getSwatchClassNames(unstagedState.color); + + return ( + { + tag.commit(unstagedState); + if (closingFunction) closingFunction(); + }} + > + updateLabel(ev.currentTarget.value)} + /> + + {/* Can replace with Diaglog */} + + {/* color swatches */} + + {swatchStyles.map(([color, className]) => ( + // Colour swatches +
updateColor(color)} + onKeyDown={undefined} + /> + ))} + + + ); +}; + +TagEditor.propTypes = { + tag: (PropTypes.object as Requireable).isRequired, + anchor: (PropTypes.oneOfType([ + PropTypes.object as Requireable, PropTypes.string, + ])).isRequired, + closingFunction: PropTypes.func, +}; + +TagEditor.defaultProps = { + closingFunction: () => {}, +}; diff --git a/web/src/components/tagSelector/TagPicker.tsx b/web/src/components/tagSelector/TagPicker.tsx new file mode 100644 index 00000000..8c089d49 --- /dev/null +++ b/web/src/components/tagSelector/TagPicker.tsx @@ -0,0 +1,118 @@ +import React, { + Requireable, useState, +} from 'react'; +import PropTypes from 'prop-types'; +import { + Callout, DefaultButton, Stack, TextField, +} from '@fluentui/react'; +import { useId } from '@fluentui/react-hooks'; +import { ICard } from '../../controllers/Card'; +import { useApp } from '../../AppContext'; +import { Tag } from '../Tag'; +import { ITag, ITagProperties } from '../../controllers/Tag'; +import { TagWrapper } from './TagWrapper'; +import { ITagEditorProps, TagEditor } from './TagEditor'; + +export interface ITagPickerProps { + targetCard: ICard; + editing: boolean; +} + +type ITagAnchor = Pick; + +export const TagPicker: React.VoidFunctionComponent = ({ + targetCard, editing, +}) => { + const user = useApp(); + const [pickerActive, setPickerActive] = useState(false); + const [tagSearchString, setTagSearchString] = useState(''); + const [focusedTag, setFocusedTag] = useState(); + const pickerTargetId = useId('picker-target'); + + function getNewTag(): void { + const tagProps: Partial = { + label: tagSearchString || 'New Tag', + }; + // newTag refreshes context and may lead to loss of data. + user.newTag(tagProps); + setTagSearchString(''); + } + + function removeTag(t: ITag): void { + targetCard.update({ tags: targetCard.tags.filter((that) => that.id !== t.id) }); + } + + // user variable is used to access the tags available to the user + return ( + + + Tags + + + {/* could style to fit measurements */} + + {targetCard?.tags.map((t) => ((editing) + ? ( + removeTag(t)} + /> + ) + : ))} + + + + {editing + && setPickerActive((old) => !old)} id={pickerTargetId} />} + {pickerActive + ? ( + { + if (!focusedTag) setPickerActive(false); + }} + > + + + setTagSearchString(event.currentTarget.value)} + /> + + + {/* Tags available to use are listed here */} + {user.user?.tags + .filter((t) => t.label.toUpperCase().startsWith(tagSearchString.toUpperCase())) + .map((t) => ( + + setFocusedTag({ anchor: id, tag: t })} + /> + + ))} + + + ) + : null} + {focusedTag + && ( + setFocusedTag(undefined)} + /> + )} + + ); +}; + +TagPicker.propTypes = { + targetCard: (PropTypes.object as Requireable).isRequired, + editing: PropTypes.bool.isRequired, +}; diff --git a/web/src/components/tagSelector/TagWrapper.tsx b/web/src/components/tagSelector/TagWrapper.tsx new file mode 100644 index 00000000..5cb2d468 --- /dev/null +++ b/web/src/components/tagSelector/TagWrapper.tsx @@ -0,0 +1,49 @@ +import { DefaultButton } from '@fluentui/react'; +import { useId } from '@fluentui/react-hooks'; +import React, { Requireable } from 'react'; +import PropType from 'prop-types'; +import { ICard } from '../../controllers/Card'; +import { ITag } from '../../controllers/Tag'; +import { Tag } from '../Tag'; + +export interface ITagWrapperProps { + tag: ITag; + card?: ICard; + setTagEdit?: (id: string) => void; +} + +export const TagWrapper: React.VoidFunctionComponent = ({ + tag, card, setTagEdit, +}) => { + const targetElemId = useId(); + + const attachTag = () => { + if (card && !card.tags.includes(tag)) { + card.update({ tags: [...card.tags, tag] }); + } + }; + + return ( +
+ + {setTagEdit + && ( + setTagEdit(targetElemId)} + /> + )} +
+ ); +}; + +TagWrapper.propTypes = { + tag: (PropType.object as Requireable).isRequired, + card: (PropType.object as Requireable), + setTagEdit: PropType.func, +}; + +TagWrapper.defaultProps = { + card: undefined, + setTagEdit: undefined, +};