1
0
Fork 0

feat: Added test view

This commit is contained in:
joostdecock 2025-03-02 13:11:57 +01:00
parent 0dba8dcac0
commit a97f9b6e8a
8 changed files with 470 additions and 2 deletions

View file

@ -41,6 +41,7 @@ import { DesignOptionsMenu } from './menus/DesignOptionsMenu.mjs'
import { CoreSettingsMenu } from './menus/CoreSettingsMenu.mjs'
import { UiPreferencesMenu } from './menus/UiPreferencesMenu.mjs'
import { LayoutSettingsMenu } from './menus/LayoutMenu.mjs'
import { TestOptionsMenu, TestMeasurementsMenu } from './menus/TestMenu.mjs'
import { FlagsAccordionEntries } from './Flag.mjs'
import { UndoStep } from './views/UndosView.mjs'
@ -85,6 +86,54 @@ export const HeaderMenuDraftView = (props) => {
)
}
export const HeaderMenuTestView = (props) => {
const i18n = useDesignTranslation(props.Design.designConfig.data.id)
return (
<>
<HeaderMenuTestViewDesignOptions {...props} i18n={i18n} />
<HeaderMenuTestViewDesignMeasurements {...props} />
<HeaderMenuTestIcons {...props} i18n={i18n} />
</>
)
}
export const HeaderMenuTestViewDesignOptions = (props) => {
return (
<HeaderMenuDropdown
{...props}
id="designOptions"
tooltip="These options are specific to this design. You can use them to customize your pattern in a variety of ways."
toggle={
<>
<HeaderMenuIcon name="options" extraClasses="tw-text-secondary" />
<span className="tw-hidden lg:tw-inline">Test Options</span>
</>
}
>
<TestOptionsMenu {...props} />
</HeaderMenuDropdown>
)
}
export const HeaderMenuTestViewDesignMeasurements = (props) => {
return (
<HeaderMenuDropdown
{...props}
id="designMeasurements"
tooltip="These options are specific to this design. You can use them to customize your pattern in a variety of ways."
toggle={
<>
<HeaderMenuIcon name="options" extraClasses="tw-text-secondary" />
<span className="tw-hidden lg:tw-inline">Test Measurements</span>
</>
}
>
<TestMeasurementsMenu {...props} />
</HeaderMenuDropdown>
)
}
export const HeaderMenuDropdown = (props) => {
const { tooltip, toggle, open, setOpen, id, end = false } = props
/*
@ -381,6 +430,24 @@ export const HeaderMenuUndoIcons = (props) => {
)
}
export const HeaderMenuTestIcons = (props) => {
const { update, state, Design } = props
const Button = HeaderMenuButton
const size = 'tw-w-5 tw-h-5'
const undos = state._?.undos && state._.undos.length > 0 ? state._.undos : false
return (
<div className="tw-flex tw-flex-row tw-flex-wrap tw-items-center tw-justify-center tw-px-0.5 lg:tw-px-1">
<Button
updateHandler={() => update.settings('sample', undefined)}
tooltip="Clear the test so you can select another"
>
Clear Test
</Button>
</div>
)
}
export const HeaderMenuSaveIcons = (props) => {
const { update, state } = props
const backend = useBackend()
@ -498,7 +565,9 @@ export const HeaderMenuViewMenu = (props) => {
toggle={
<>
<HeaderMenuIcon name="right" stroke={3} extraClasses="tw-text-secondary tw-rotate-90" />
<span className="tw-hidden lg:tw-inline">Views</span>
<span className="tw-hidden lg:tw-inline">
{viewLabels[state.view] ? viewLabels[state.view].t : 'Views'}
</span>
</>
}
>
@ -611,6 +680,7 @@ export const HeaderMenuLayoutViewIcons = (props) => {
const headerMenus = {
draft: HeaderMenuDraftView,
test: HeaderMenuTestView,
layout: HeaderMenuLayoutView,
//HeaderMenuDraftViewDesignOptions,
//HeaderMenuDraftViewCoreSettings,

View file

@ -174,6 +174,7 @@ export const MenuItemGroup = ({
i18n,
}) => {
if (!Item) Item = MenuItem
console.log(structure)
// map the entries in the structure
const content = Object.entries(structure).map(([itemName, item]) => {
@ -285,3 +286,79 @@ export const MenuItemTitle = ({ name, current = null, open = false, emoji = '',
<span className="tw-font-bold">{current}</span>
</div>
)
/**
* A component for recursively displaying groups of menu buttons.
*
* This is a lot simpler than the options menu structure
*
* @param {object} props - All the React props
* @param {object} props.structure - The menu structure
* @param {React.Component} props.Button - The component to use for menu items
* @param {freesewing.Design} props.Design - The FreeSewing design
* @param {object} props.i18n - The translations object
*/
export const MenuButtonGroup = ({ structure, Button = false, Design, Icon, i18n }) => {
if (!Button) return null
/*
* Create structure, push groups to the end
*/
const content = []
for (const [itemName, item] of Object.entries(structure)) {
if (item && !item.isGroup && !['isGroup', 'title'].includes(itemName))
content.push(<Button key={itemName} {...{ name: itemName }} />)
}
for (const [itemName, item] of Object.entries(structure)) {
if (item && item.isGroup) {
const ItemIcon = item.icon
? item.icon
: item.isGroup
? GroupIcon
: Icon
? Icon
: () => <span role="img">fixme-icon</span>
const Value = item.isGroup
? () => (
<div className="tw-flex tw-flex-row tw-gap-2 tw-items-center tw-font-medium">
{Object.keys(item).filter((i) => i !== 'isGroup').length}
<OptionsIcon className="tw-w-5 tw-h-5" />
</div>
)
: null
content.push([
<div
className="tw-flex tw-flex-row tw-items-center tw-justify-between tw-w-full tw-pl-0 tw-pr-4 tw-py-2"
key="a"
>
<div className="tw-flex tw-flex-row tw-items-center tw-gap-4 tw-w-full">
<ItemIcon />
<span className="tw-font-medium tw-capitalize">
{item.title ? item.title : getItemLabel(i18n, itemName)}
</span>
</div>
<div className="tw-font-bold">
<Value config={item} Design={Design} />
</div>
</div>,
<MenuButtonGroup
key={itemName}
{...{
structure: item,
Icon,
Button,
Design,
i18n,
}}
/>,
])
}
}
return (
<div className="tw-flex tw-flex-col tw-gap-0.5 tw-ml-4">
{content.filter((item) => item !== null)}
</div>
)
}

View file

@ -0,0 +1,106 @@
// Dependencies
import { menuDesignOptionsStructure } from '../../lib/index.mjs'
import { measurements as measurementsTranslations } from '@freesewing/i18n'
import { orderBy } from '@freesewing/utils'
// Hooks
import React, { useCallback, useMemo } from 'react'
// Components
import { MenuButtonGroup } from './Container.mjs'
import { BeakerIcon, OptionsIcon } from '@freesewing/react/components/Icon'
/**
* The test design options menu
*
* @param {object} props.Design - An object holding the Design instance
* @param {Object} props.state - Object holding state
* @param {Object} props.i18n - Object holding translations loaded from the design
* @param {Object} props.update - Object holding state handlers
*/
export const TestOptionsMenu = ({ Design, state, i18n, update }) => {
const structure = useMemo(
() =>
menuDesignOptionsStructure(
Design.designConfig.data.id,
Design.patternConfig.options,
state.settings
),
[Design.designConfig.data.id, Design.patternConfig, state.settings]
)
return (
<MenuButtonGroup
{...{
structure,
ux: state.ui.ux,
Icon: OptionsIcon,
Button: (props) => <SampleOptionButton {...{ i18n, update }} {...props} />,
name: 'Design Options',
isDesignOptionsGroup: true,
state,
Design,
i18n,
}}
/>
)
}
/**
* The test measurements options menu
*
* @param {object} props.Design - An object holding the Design instance
* @param {Object} props.state - Object holding state
* @param {Object} props.update - Object holding state handlers
*/
export const TestMeasurementsMenu = ({ Design, state, update }) => {
const structure = {}
if (Design.patternConfig.measurements.length > 0)
structure.required = { isGroup: true, title: 'Required Measurements' }
for (const m of Design.patternConfig.measurements) {
structure.required[m] = { isGroup: false, name: m, title: m }
}
if (Design.patternConfig.optionalMeasurements.length > 0)
structure.optional = { isGroup: true, title: 'Optional Measurements' }
for (const m of Design.patternConfig.optionalMeasurements) {
structure.optional[m] = { isGroup: false, name: m, title: m }
}
return (
<MenuButtonGroup
{...{
structure,
Icon: OptionsIcon,
Button: (props) => <SampleMeasurementButton {...{ update }} {...props} />,
name: 'Design Measurments',
isDesignOptionsGroup: true,
state,
Design,
}}
/>
)
}
const SampleOptionButton = ({ name, i18n, update }) => (
<button
className={
'tw-daisy-btn tw-daisy-btn-outline tw-daisy-btn-sm tw-mx-2 ' +
'tw-daisy-btn-secondary tw-flex tw-flex-row tw-items-center tw-justify-between'
}
onClick={() => update.settings('sample', { type: 'option', option: name })}
>
<BeakerIcon className="tw-w-5 tw-h-5" />
<span>{i18n.en.o[name].t}</span>
</button>
)
const SampleMeasurementButton = ({ name, i18n, update }) => (
<button
className={
'tw-daisy-btn tw-daisy-btn-outline tw-daisy-btn-sm tw-mx-2 ' +
'tw-daisy-btn-secondary tw-flex tw-flex-row tw-items-center tw-justify-between'
}
onClick={() => update.settings('sample', { type: 'option', option: name })}
>
<BeakerIcon className="tw-w-5 tw-h-5" />
<span>{measurementsTranslations[name]}</span>
</button>
)

View file

@ -5,7 +5,6 @@ import { draft, missingMeasurements } from '../../lib/index.mjs'
import { Null } from '../Null.mjs'
import { ZoomablePattern } from '../ZoomablePattern.mjs'
import { PatternLayout } from '../PatternLayout.mjs'
import { DraftMenu } from '../menus/DraftMenu.mjs'
/**
* The draft view allows users to tweak their pattern

View file

@ -0,0 +1,181 @@
// Dependencies
import React, { useMemo, useCallback } from 'react'
import { sample, missingMeasurements, menuDesignOptionsStructure } from '../../lib/index.mjs'
import { measurements as measurementsTranslations } from '@freesewing/i18n'
import { orderBy } from '@freesewing/utils'
// Components
import { Null } from '../Null.mjs'
import { ZoomablePattern } from '../ZoomablePattern.mjs'
import { PatternLayout } from '../PatternLayout.mjs'
import { HeaderMenu } from '../HeaderMenu.mjs'
import { H1, H3, H4, H5 } from '@freesewing/react/components/Heading'
/**
* The test view allows users to test options and measurements
*
* @param (object) props - All the props
* @param {function} props.config - The editor configuration
* @param {function} props.Design - The design constructor
* @param {array} props.missingMeasurements - List of missing measurements for the current design
* @param {object} props.state - The ViewWrapper state object
* @param {object} props.state.settings - The current settings
* @param {object} props.update - Helper object for updating the ViewWrapper state
* @return {function} DraftView - React component
*/
export const TestView = ({ Design, state, update, config }) => {
/*
* Don't trust that we have all measurements
*
* We do not need to change the view here. That is done in the central
* ViewWrapper componenet. However, checking the measurements against
* the design takes a brief moment, so this component will typically
* render before that happens, and if measurements are missing it will
* throw and error.
*
* So when measurements are missing, we just return here and the view
* will switch on the next render loop.
*/
if (missingMeasurements(state)) return <Null />
const { settings } = state
if (settings.sample) {
const { pattern } = sample(Design, settings)
const renderProps = pattern.getRenderProps()
const output = (
<ZoomablePattern
renderProps={renderProps}
patternLocale={state.locale || 'en'}
rotate={state.ui.rotate}
/>
)
return <PatternLayout {...{ update, Design, output, state, pattern, config }} />
}
// Translated measurements
const trm = orderBy(
Design.patternConfig.measurements.map((m) => ({ m, t: measurementsTranslations[m] })),
't',
'ASC'
)
const tom = orderBy(
Design.patternConfig.optionalMeasurements.map((m) => ({ m, t: measurementsTranslations[m] })),
't',
'ASC'
)
return (
<>
<HeaderMenu state={state} {...{ config, update, Design }} />
<div className="tw-m-auto tw-mt-8 tw-max-w-4xl tw-px-4 tw-mb-8">
<H1>Test Pattern</H1>
<div className="tw-grid tw-grid-cols-1 lg:tw-grid-cols-2 lg:tw-gap-4">
<div className="tw-flex tw-flex-col tw-gap-4">
<H3>Test Design Options</H3>
<SampleOptionsMenu {...{ Design, state, update }} />
</div>
{trm.length > 0 ? (
<div className="tw-flex tw-flex-col tw-gap-4">
<H3>Test Measurements</H3>
<H4>Required Measurements</H4>
<div className="">
{trm.map(({ t, m }) => (
<button
key={m}
className="tw-my-0.5 tw-block tw-daisy-btn tw-daisy-btn-primary tw-daisy-btn-outline tw-daisy-btn-xs"
onClick={() =>
update.settings(['sample'], { type: 'measurement', measurement: m })
}
>
{t}
</button>
))}
</div>
{tom.length > 0 ? (
<div className="tw-flex tw-flex-col tw-gap-4">
<H4>Optional Measurements</H4>
<div className="">
{tom.map(({ t, m }) => (
<button
key={m}
className="tw-my-0.5 tw-block tw-daisy-btn tw-daisy-btn-primary tw-daisy-btn-outline tw-daisy-btn-xs"
onClick={() =>
update.settings(['sample'], { type: 'measurement', measurement: m })
}
>
{t}
</button>
))}
</div>
</div>
) : null}
</div>
) : null}
</div>
</div>
</>
)
}
const SampleOptionsMenu = ({ Design, state, update }) => {
const structure = useMemo(
() =>
menuDesignOptionsStructure(
Design.designConfig.data.id,
Design.patternConfig.options,
state.settings
),
[Design.designConfig.data.id, Design.patternConfig, state.settings]
)
return <SampleOptionsSubMenu structure={structure} update={update} />
}
const SampleOptionsSubMenu = ({ structure, update, level = 1 }) => {
const output = []
/*
* Show entries alphabetic, but force groups last, and advanced last among them
*/
const titles = Object.keys(structure)
.filter((key) => key !== 'isGroup')
.sort()
const order = [
...titles.filter((key) => key !== 'advanced'),
...titles.filter((key) => key === 'advanced'),
]
// Non-groups first
for (const name of order) {
const struct = structure[name]
if (!struct.isGroup)
output.push(
<button
key={name}
className="tw-my-0.5 tw-block tw-daisy-btn tw-daisy-btn-primary tw-daisy-btn-outline tw-daisy-btn-xs"
onClick={() => update.settings(['sample'], { type: 'option', option: name })}
>
{struct.title}
</button>
)
}
// Then groups
for (const name of order) {
const struct = structure[name]
if (struct.isGroup) {
output.push(
<H5 key={name}>
<span className="tw-capitalize">{name}</span>
</H5>
)
output.push(
<SampleOptionsSubMenu
structure={struct}
update={update}
level={level + 1}
key={name + 's'}
/>
)
}
}
return <div className="tw-pl-2 tw-border-l-2">{output}</div>
}

View file

@ -10,6 +10,7 @@ import { UndosView } from './UndosView.mjs'
import { LayoutView } from './LayoutView.mjs'
import { DocsView } from './DocsView.mjs'
import { LogView } from './LogView.mjs'
import { TestView } from './TestView.mjs'
import { EditSettingsView } from './EditSettingsView.mjs'
import { ErrorIcon } from '@freesewing/react/components/Icon'
import {
@ -67,6 +68,7 @@ export const View = (props) => {
if (view === 'editSettings') return <EditSettingsView {...props} />
if (view === 'inspect') return <InspectView {...props} />
if (view === 'logs') return <LogView {...props} />
if (view === 'test') return <TestView {...props} />
return <h1 className="tw-ext-center tw-my-12">No view component for view {props.view}</h1>
}

View file

@ -46,6 +46,37 @@ export function draft(Design, settings, plugins = []) {
return data
}
/*
* This method samples a pattern option
*
* @param {function} Design - The Design constructor
* @param {object} settings - The settings for the pattern
* @param {array} plugins - Any (extra) plugins to load into the pattern
* @return {object} data - The drafted pattern, along with errors and failure data
*/
export function sample(Design, settings, plugins = []) {
const pattern = new Design(settings)
for (const plugin of plugins) pattern.use(plugin)
const data = {
// The pattern
pattern,
// Any errors logged by the pattern
errors: [],
// If the pattern fails to draft, this will hold the error
failure: false,
}
// Draft the pattern or die trying
try {
data.pattern.sample()
data.errors.push(...data.pattern.store.logs.error)
for (const store of data.pattern.setStores) data.errors.push(...store.logs.error)
} catch (error) {
data.failure = error
}
return data
}
export function flattenFlags(flags) {
const all = {}
for (const type of defaultConfig.flagTypes) {

View file

@ -29,6 +29,7 @@ import {
notEmpty,
nsMerge,
objUpdate,
sample,
settingsValueIsCustom,
settingsValueCustomOrDefault,
statePrefixPath,
@ -89,6 +90,7 @@ export {
notEmpty,
nsMerge,
objUpdate,
sample,
settingsValueIsCustom,
settingsValueCustomOrDefault,
statePrefixPath,